feat: roll dithered charts across the dashboard: the shared DailyBars becomes DailyTrend (smoothed dithered area graph) powering campaign overview, analytics, and deliverability over-time charts with per-metric tones, deliverability provider placement rows use DitherStack, the mailbox warmup chart becomes a dithered bar chart with target ghost bars and tap-to-pin day selection, API-key traffic uses DitherColumns, and the content score, campaign send progress, credits meter, and unibox queue bars all move to DitherMeter

This commit is contained in:
Matthew Meszaros
2026-07-19 14:01:53 +02:00
parent 7502b0f2b4
commit 71e04c00dd
10 changed files with 116 additions and 176 deletions
+9 -8
View File
@@ -23,7 +23,8 @@ import {
Stat,
StatStrip,
} from "@/components/layout/Page";
import { DailyBars, type ChartPoint } from "@/components/ui/charts";
import { DailyTrend, type ChartPoint } from "@/components/ui/charts";
import type { DitherTone } from "@/components/ui/dither";
import AnalyticsShareButton from "@/components/app/analytics/AnalyticsShareButton";
import useDashboard from "@/lib/api/hooks/app/analytics/useDashboard";
@@ -36,11 +37,11 @@ const RANGE_LABEL: Record<Range, string> = {
"90d": "Last 90 days",
};
const METRICS: { key: Metric; label: string; bar: string }[] = [
{ key: "sent", label: "Sent", bar: "bg-sky-500" },
{ key: "opens", label: "Opens", bar: "bg-emerald-500" },
{ key: "clicks", label: "Clicks", bar: "bg-violet-500" },
{ key: "replies", label: "Replies", bar: "bg-amber-500" },
const METRICS: { key: Metric; label: string; tone: DitherTone }[] = [
{ key: "sent", label: "Sent", tone: "sky" },
{ key: "opens", label: "Opens", tone: "emerald" },
{ key: "clicks", label: "Clicks", tone: "violet" },
{ key: "replies", label: "Replies", tone: "amber" },
];
function pct(v: number | undefined): string {
@@ -128,10 +129,10 @@ export default function AnalyticsPage() {
{dash.isPending ? (
<div className="h-52 rounded-md bg-slate-50 animate-pulse" />
) : (
<DailyBars
<DailyTrend
points={series}
height={220}
barClass={METRICS.find((m) => m.key === metric)?.bar}
tone={METRICS.find((m) => m.key === metric)?.tone}
emptyLabel="No sends in this window yet"
/>
)}
@@ -8,6 +8,7 @@
// no axis chrome unless asked).
import React from "react";
import { DitherColumns } from "@/components/ui/dither";
export function Sparkline({
values,
@@ -85,40 +86,17 @@ export function StackedBars({
);
}
const max = Math.max(1, ...buckets.map((b) => b.total));
const barGap = 2;
return (
<div className="relative w-full" style={{ height }}>
<div className="absolute inset-0 flex items-end gap-[2px]">
{buckets.map((b, i) => {
const totalH = (b.total / max) * (height - 16);
const successH = b.total > 0 ? (b.success / b.total) * totalH : 0;
const clientH = b.total > 0 ? (b.client_errors / b.total) * totalH : 0;
const serverH = b.total > 0 ? (b.server_errors / b.total) * totalH : 0;
return (
<div
key={`${b.bucket}-${i}`}
className="flex-1 flex flex-col justify-end group cursor-default"
onMouseEnter={() => onHoverBucket?.(b)}
onMouseLeave={() => onHoverBucket?.(null)}
style={{ marginRight: i === buckets.length - 1 ? 0 : barGap }}
>
<div className="flex flex-col-reverse rounded-sm overflow-hidden" style={{ height: totalH }}>
{successH > 0 && (
<div className="bg-emerald-500/80 group-hover:bg-emerald-500 transition-colors" style={{ height: successH }} />
)}
{clientH > 0 && (
<div className="bg-amber-400/80 group-hover:bg-amber-500 transition-colors" style={{ height: clientH }} />
)}
{serverH > 0 && (
<div className="bg-rose-500/80 group-hover:bg-rose-500 transition-colors" style={{ height: serverH }} />
)}
</div>
</div>
);
})}
</div>
<DitherColumns
data={buckets.map((b) => ({
key: b.bucket,
parts: [b.success, b.client_errors, b.server_errors],
}))}
tones={["emerald", "amber", "rose"]}
height={height - 16}
onHover={(i) => onHoverBucket?.(i === null ? null : buckets[i] ?? null)}
/>
{/* horizontal hairline at base */}
<div className="absolute left-0 right-0 bottom-3 h-px bg-slate-200/80" />
<div className="absolute left-0 right-0 bottom-0 flex justify-between font-mono text-[9.5px] text-slate-400">
+9 -8
View File
@@ -10,7 +10,8 @@ import { useCampaign } from "@/hooks/context/campaign";
import useCampaignAnalytics from "@/lib/api/hooks/app/analytics/useCampaignAnalytics";
import useCampaignDailyStats from "@/lib/api/hooks/app/analytics/useCampaignDailyStats";
import { SectionBar, Stat, StatStrip } from "@/components/layout/Page";
import { DailyBars, type ChartPoint } from "@/components/ui/charts";
import { DailyTrend, type ChartPoint } from "@/components/ui/charts";
import type { DitherTone } from "@/components/ui/dither";
import AnalyticsShareButton from "@/components/app/analytics/AnalyticsShareButton";
import TaskPreview from "@/components/app/campaigns/TaskPreview";
import AnimatedNumber from "@/components/ui/AnimatedNumber";
@@ -19,11 +20,11 @@ const pctFmt = (v: number) => `${v.toFixed(1)}%`;
type Metric = "sent" | "opens" | "clicks" | "replies";
const METRICS: { key: Metric; label: string; bar: string }[] = [
{ key: "sent", label: "Sent", bar: "bg-sky-500" },
{ key: "opens", label: "Opens", bar: "bg-emerald-500" },
{ key: "clicks", label: "Clicks", bar: "bg-violet-500" },
{ key: "replies", label: "Replies", bar: "bg-amber-500" },
const METRICS: { key: Metric; label: string; tone: DitherTone }[] = [
{ key: "sent", label: "Sent", tone: "sky" },
{ key: "opens", label: "Opens", tone: "emerald" },
{ key: "clicks", label: "Clicks", tone: "violet" },
{ key: "replies", label: "Replies", tone: "amber" },
];
function pct(v: number | undefined): string {
@@ -168,10 +169,10 @@ export default function CampaignOverview() {
{loading ? (
<div className="h-52 rounded-md bg-slate-50 animate-pulse" />
) : (
<DailyBars
<DailyTrend
points={series}
height={220}
barClass={METRICS.find((m) => m.key === metric)?.bar}
tone={METRICS.find((m) => m.key === metric)?.tone}
emptyLabel={
hasSends
? "No activity in this window yet"
+19 -17
View File
@@ -8,7 +8,8 @@ import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { AlertTriangleIcon, ArrowUpRightIcon, CheckIcon, RefreshCcwIcon, SlidersHorizontalIcon } from "lucide-react";
import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, Stat, StatStrip } from "@/components/layout/Page";
import { DailyBars, type ChartPoint } from "@/components/ui/charts";
import { DailyTrend, type ChartPoint } from "@/components/ui/charts";
import { DitherStack, type DitherTone } from "@/components/ui/dither";
import {
PopoverMenu,
PopoverMenuContent,
@@ -30,12 +31,12 @@ const RANGE_LABEL: Record<Range, string> = {
"90d": "Last 90 days",
};
const METRICS: { key: Metric; label: string; bar: string }[] = [
{ key: "bounces", label: "Bounces", bar: "bg-rose-500" },
{ key: "complaints", label: "Complaints", bar: "bg-amber-500" },
{ key: "opens", label: "Opens", bar: "bg-emerald-500" },
{ key: "replies", label: "Replies", bar: "bg-sky-500" },
{ key: "sent", label: "Sent", bar: "bg-slate-400" },
const METRICS: { key: Metric; label: string; tone: DitherTone }[] = [
{ key: "bounces", label: "Bounces", tone: "rose" },
{ key: "complaints", label: "Complaints", tone: "amber" },
{ key: "opens", label: "Opens", tone: "emerald" },
{ key: "replies", label: "Replies", tone: "sky" },
{ key: "sent", label: "Sent", tone: "slate" },
];
const SECTIONS: { key: SectionKey; label: string }[] = [
@@ -209,10 +210,10 @@ export default function DeliverabilityPage() {
{q.isPending ? (
<div className="h-52 rounded-md bg-slate-50 animate-pulse" />
) : (
<DailyBars
<DailyTrend
points={series}
height={220}
barClass={METRICS.find((m) => m.key === metric)?.bar}
tone={METRICS.find((m) => m.key === metric)?.tone}
emptyLabel="No activity in this window yet"
/>
)}
@@ -371,18 +372,19 @@ export default function DeliverabilityPage() {
// bar plus the two rates that matter.
function ProviderRow({ p }: { p: ProviderPlacement }) {
const segments = [
{ n: p.inbox, cls: "bg-emerald-500", label: "Inbox" },
{ n: p.promotions, cls: "bg-violet-400", label: "Promotions" },
{ n: p.spam, cls: "bg-rose-500", label: "Spam" },
{ n: p.other, cls: "bg-slate-300", label: "Other" },
{ n: p.inbox, tone: "emerald" as DitherTone, label: "Inbox" },
{ n: p.promotions, tone: "violet" as DitherTone, label: "Promotions" },
{ n: p.spam, tone: "rose" as DitherTone, label: "Spam" },
{ n: p.other, tone: "slate" as DitherTone, label: "Other" },
].filter((s) => s.n > 0);
return (
<div className="h-11 px-5 flex items-center gap-3">
<span className="text-[12.5px] font-medium text-slate-900 w-28 shrink-0 truncate">{providerLabel(p.provider)}</span>
<div className="flex h-1.5 flex-1 min-w-16 rounded-full overflow-hidden bg-slate-100" title={segments.map((s) => `${s.label} ${s.n}`).join(" · ")}>
{segments.map((s) => (
<span key={s.label} className={s.cls} style={{ width: `${(s.n / p.samples) * 100}%` }} />
))}
<div className="flex-1 min-w-16" title={segments.map((s) => `${s.label} ${s.n}`).join(" · ")}>
<DitherStack
segments={segments.map((s) => ({ frac: s.n / Math.max(1, p.samples), tone: s.tone }))}
height={6}
/>
</div>
<span className="flex items-center gap-2 md:gap-4 font-mono text-[11px] tabular-nums shrink-0">
<span title="Inbox rate" className="text-emerald-600">{pct(p.inbox_rate)} inbox</span>
@@ -8,11 +8,12 @@ import useScoreTemplate from "@/lib/api/hooks/app/campaigns/useScoreTemplate";
import type { TemplateScoreIssue } from "@/lib/api/models/app/campaigns/TemplateScore";
import { Loading } from "@/components/loader";
import { cn } from "@/lib/utils";
import { DitherMeter, type DitherTone } from "@/components/ui/dither";
function scoreTone(score: number) {
if (score >= 80) return { text: "text-emerald-600", bar: "bg-emerald-500", label: "Looks good" };
if (score >= 50) return { text: "text-amber-600", bar: "bg-amber-500", label: "Could improve" };
return { text: "text-rose-600", bar: "bg-rose-500", label: "Needs work" };
if (score >= 80) return { text: "text-emerald-600", meter: "emerald" as DitherTone, label: "Looks good" };
if (score >= 50) return { text: "text-amber-600", meter: "amber" as DitherTone, label: "Could improve" };
return { text: "text-rose-600", meter: "rose" as DitherTone, label: "Needs work" };
}
function IssueRow({ issue }: { issue: TemplateScoreIssue }) {
@@ -75,9 +76,12 @@ export default function ContentScore({
<span className="text-[11px] text-slate-400 mb-0.5">/ 100</span>
<span className={cn("ml-auto text-[11px] font-medium", tone.text)}>{tone.label}</span>
</div>
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-100 overflow-hidden">
<div className={cn("h-full rounded-full transition-all", tone.bar)} style={{ width: `${Math.max(0, Math.min(100, data.score))}%` }} />
</div>
<DitherMeter
frac={Math.max(0, Math.min(100, data.score)) / 100}
tone={tone.meter}
height={6}
className="mt-2"
/>
{data.issues.length > 0 ? (
<ul className="mt-2 divide-y divide-slate-200/60">
{data.issues.map((issue, i) => (
@@ -10,6 +10,7 @@ import {
} from "lucide-react";
import { useCampaignChannel, type ActivityItem } from "@/hooks/useCampaignChannel";
import useCampaignLogs from "@/lib/api/hooks/app/campaigns/useCampaignLogs";
import { DitherMeter } from "@/components/ui/dither";
interface TaskPreviewProps {
campaignId: string;
@@ -189,12 +190,7 @@ export default function TaskPreview({ campaignId, campaignStatus: initialStatus
</span>
<span className="font-mono text-[11px] text-slate-700 tabular-nums">{progress}%</span>
</div>
<div className="h-1.5 rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full rounded-full bg-sky-600 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<DitherMeter frac={progress / 100} height={6} />
<div className="flex items-center justify-between mt-1.5">
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
{processed.toLocaleString()} of {total.toLocaleString()} contacts
+20 -14
View File
@@ -52,6 +52,7 @@ import buildError from "@/lib/helper/buildError";
import EmailEditor from "../EmailEditor";
import TagSelector from "../popup/select/TagSelector";
import TimeSelect from "@/components/ui/TimeSelect";
import { DitherBarChart } from "@/components/ui/dither";
import WeekdayBitmask from "../campaigns/schedule/WeekdayBitmask";
import { Loading } from "@/components/loader";
import { NumberInput, TextInput } from "@/components/ui/field";
@@ -413,7 +414,13 @@ function AnalyticsTab({ warmup, loading }: { warmup?: import("@/lib/api/models/a
);
}
const s = warmup.summary;
const max = Math.max(1, ...warmup.daily_stats.map((d) => Math.max(d.emails_sent, d.target_volume)));
const chartData = warmup.daily_stats.map((d) => ({
key: d.date,
value: d.emails_sent,
hint: `${d.date}: ${d.emails_sent} sent / ${d.target_volume} target · ${d.emails_replied} replies`,
}));
const targets = warmup.daily_stats.map((d) => d.target_volume);
const selectedIndex = selectedDay ? warmup.daily_stats.findIndex((d) => d.date === selectedDay) : -1;
return (
<div className="divide-y divide-slate-200/60">
@@ -432,19 +439,18 @@ function AnalyticsTab({ warmup, loading }: { warmup?: import("@/lib/api/models/a
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-slate-200" /> Target</span>
</div>
</div>
<div className="flex items-end gap-0.5 h-28">
{warmup.daily_stats.map((d) => (
<div
key={d.date}
className="flex-1 min-w-0 relative h-full flex items-end group cursor-pointer"
title={`${d.date}: ${d.emails_sent} sent / ${d.target_volume} target · ${d.emails_replied} replies`}
onClick={() => setSelectedDay((cur) => (cur === d.date ? null : d.date))}
>
<div className={cn("absolute inset-x-0 bottom-0 rounded-sm", selectedDay === d.date ? "bg-slate-200" : "bg-slate-100")} style={{ height: `${(d.target_volume / max) * 100}%` }} />
<div className={cn("relative w-full rounded-sm transition-colors", selectedDay === d.date ? "bg-sky-700" : "bg-sky-500 group-hover:bg-sky-600")} style={{ height: `${(d.emails_sent / max) * 100}%`, minHeight: d.emails_sent > 0 ? 2 : 0 }} />
</div>
))}
</div>
<DitherBarChart
data={chartData}
ghost={targets}
height={112}
selected={selectedIndex >= 0 ? selectedIndex : null}
onSelect={(i) =>
setSelectedDay((cur) => {
const date = warmup.daily_stats[i]?.date ?? null;
return cur === date ? null : date;
})
}
/>
{selectedDay && (() => {
const d = warmup.daily_stats.find((s) => s.date === selectedDay);
if (!d) return null;
+2 -13
View File
@@ -31,6 +31,7 @@ import ShortcutTooltip from "@/components/ui/shortcut-tooltip";
import ComposeDraftsItem from "@/components/app/unibox/compose/ComposeDraftsItem";
import { useComposeStore } from "@/hooks/useComposeStore";
import { cn } from "@/lib/utils";
import { DitherMeter } from "@/components/ui/dither";
export type UniboxScope =
| { kind: "all" }
@@ -356,15 +357,8 @@ function CollapsibleSection<T extends { id: string }>({
// cap gets close so the user gets unmissable warning before sends fail.
function ScheduledMeter({ used, cap }: { used: number; cap: number }) {
const ratio = Math.min(1, used / cap);
const pct = Math.round(ratio * 100);
const tone = ratio >= 0.95 ? "rose" : ratio >= 0.85 ? "amber" : "sky";
const barClasses =
tone === "rose"
? "bg-rose-500"
: tone === "amber"
? "bg-amber-500"
: "bg-sky-500";
const textClasses =
tone === "rose"
? "text-rose-700"
@@ -385,12 +379,7 @@ function ScheduledMeter({ used, cap }: { used: number; cap: number }) {
{used}/{cap}
</span>
</div>
<div className="h-1 rounded-full bg-slate-200 overflow-hidden">
<div
className={cn("h-full transition-all", barClasses)}
style={{ width: `${pct}%` }}
/>
</div>
<DitherMeter frac={ratio} tone={tone} height={4} />
{ratio >= 0.95 && (
<p className="mt-1 text-[10px] text-rose-600 leading-snug">
Near the limit cancel a few sends to free up space.
+13 -18
View File
@@ -23,6 +23,7 @@ import { useCreditSettings } from "@/lib/api/hooks/app/subscription/useCreditSet
import useCreditUsage from "@/lib/api/hooks/app/subscription/useCreditUsage";
import type { CreditBalance, AISpendSettings } from "@/lib/api/models/app/subscription/Credits";
import { usePermission } from "@/hooks/usePermission";
import { DitherMeter } from "@/components/ui/dither";
import { cn } from "@/lib/utils";
export function CreditsMeter() {
@@ -204,15 +205,12 @@ function MeterPanel({
</span>
</span>
</div>
<div className="mt-1.5 h-1 rounded-full bg-slate-100 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-[width] duration-300",
empty ? "bg-red-400" : low ? "bg-amber-400" : "bg-slate-700",
)}
style={{ width: `${Math.min(100, Math.max(0, planFraction * 100))}%` }}
/>
</div>
<DitherMeter
frac={Math.min(1, Math.max(0, planFraction))}
tone={empty ? "rose" : low ? "amber" : "slate"}
height={4}
className="mt-1.5"
/>
<div className="mt-1 flex justify-between text-[10.5px] text-slate-400">
<span>{planUsed.toLocaleString()} used this cycle</span>
{reset && <span>{reset}</span>}
@@ -277,15 +275,12 @@ function SpendRow({ label, spent, limit }: { label: string; spent: number; limit
</span>
</div>
{fraction != null && (
<div className="mt-1 h-1 rounded-full bg-slate-100 overflow-hidden">
<div
className={cn(
"h-full rounded-full",
fraction >= 1 ? "bg-red-400" : fraction >= 0.8 ? "bg-amber-400" : "bg-slate-400",
)}
style={{ width: `${fraction * 100}%` }}
/>
</div>
<DitherMeter
frac={Math.min(1, fraction)}
tone={fraction >= 1 ? "rose" : fraction >= 0.8 ? "amber" : "slate"}
height={4}
className="mt-1"
/>
)}
</div>
);
+22 -54
View File
@@ -9,6 +9,7 @@
import React from "react";
import { cn } from "@/lib/utils";
import { DitherAreaChart, type DitherBarDatum, type DitherTone } from "@/components/ui/dither";
export interface ChartPoint {
/** x-tick source — typically an ISO date string. */
@@ -55,82 +56,49 @@ export function EmptyChart({
}
/**
* DailyBars — single-series vertical bar chart with date ticks + hover tooltip.
* Falls back to <EmptyChart> when there is no data (or the series is all zero),
* keeping the same height so the layout never jumps.
* DailyTrend — single-series graph: a smoothed dithered area line with date
* ticks and a hover crosshair (see DitherAreaChart). Falls back to
* <EmptyChart> when there is no data (or the series is all zero), keeping the
* same height so the layout never jumps.
*/
export function DailyBars({
export function DailyTrend({
points,
height = 200,
barClass = "bg-sky-500",
tone = "sky",
emptyLabel = "No activity yet",
formatValue = (v: number) => v.toLocaleString(),
className,
}: {
points: ChartPoint[];
height?: number;
barClass?: string;
tone?: DitherTone;
emptyLabel?: string;
formatValue?: (v: number) => string;
className?: string;
}) {
const [hover, setHover] = React.useState<number | null>(null);
const total = points.reduce((s, p) => s + (p.value || 0), 0);
const isEmpty = points.length === 0 || total === 0;
const max = Math.max(1, ...points.map((p) => p.value || 0));
const data = React.useMemo<DitherBarDatum[]>(
() =>
points.map((p) => ({
key: p.label,
value: p.value || 0,
hint: `${formatValue(p.value || 0)} · ${shortDate(p.label)}`,
})),
// formatValue is typically an inline closure; keying on points alone
// keeps the memo stable without re-mapping every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[points],
);
if (isEmpty) return <EmptyChart height={height} label={emptyLabel} className={className} />;
const tickH = 18;
const barAreaH = height - tickH - 4;
return (
<div className={cn("relative w-full select-none", className)} style={{ height }}>
<div className="absolute inset-x-0 top-0 flex items-end gap-px md:gap-[2px]" style={{ height: barAreaH }}>
{points.map((p, i) => {
const h = p.value > 0 ? Math.max(2, (p.value / max) * barAreaH) : 0;
return (
<div
key={i}
className="relative flex-1 flex items-end justify-center h-full"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
// Touch affordance: tap toggles the tooltip (hover
// emulation on touch devices is unreliable).
onClick={() => setHover((cur) => (cur === i ? null : i))}
>
<div
className={cn(
"w-full rounded-sm transition-opacity",
barClass,
hover === null || hover === i ? "opacity-100" : "opacity-50",
)}
// Cap + center each bar so a sparse series (12 points) renders
// as a normal bar, not a full-width block. Dense series have
// cells narrower than the cap, so this is a no-op for them.
style={{ height: h, maxWidth: 64 }}
/>
{hover === i && (
<div
className={cn(
"absolute -top-7 z-10 whitespace-nowrap rounded bg-slate-900 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm",
// Pin edge tooltips inward so they don't
// poke past the viewport on the first/last bar.
i === 0
? "left-0"
: i === points.length - 1
? "right-0"
: "left-1/2 -translate-x-1/2",
)}
>
{formatValue(p.value)} · {shortDate(p.label)}
</div>
)}
</div>
);
})}
</div>
<DitherAreaChart data={data} height={height - tickH - 4} tone={tone} />
<div className="absolute left-0 right-0 h-px bg-slate-200/80" style={{ bottom: tickH }} />
<div className="absolute left-0 right-0 bottom-0 flex justify-between font-mono text-[9.5px] text-slate-400">
<span>{shortDate(points[0].label)}</span>