From 71e04c00dd803754e7bb71cd65a44beacc8044b1 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 19 Jul 2026 14:01:53 +0200 Subject: [PATCH] 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 --- web/src/app/app/analytics/page.tsx | 17 +++-- .../app/api-keys/_components/Sparkline.tsx | 42 +++------- web/src/app/app/campaigns/[id]/page.tsx | 17 +++-- web/src/app/app/deliverability/page.tsx | 36 ++++----- .../components/app/campaigns/ContentScore.tsx | 16 ++-- .../components/app/campaigns/TaskPreview.tsx | 8 +- .../components/app/emails/InboxDetails.tsx | 34 +++++---- web/src/components/app/unibox/ScopeRail.tsx | 15 +--- web/src/components/layout/CreditsMeter.tsx | 31 ++++---- web/src/components/ui/charts.tsx | 76 ++++++------------- 10 files changed, 116 insertions(+), 176 deletions(-) diff --git a/web/src/app/app/analytics/page.tsx b/web/src/app/app/analytics/page.tsx index 53a18b8b..98e1e459 100644 --- a/web/src/app/app/analytics/page.tsx +++ b/web/src/app/app/analytics/page.tsx @@ -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 = { "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 ? (
) : ( - m.key === metric)?.bar} + tone={METRICS.find((m) => m.key === metric)?.tone} emptyLabel="No sends in this window yet" /> )} diff --git a/web/src/app/app/api-keys/_components/Sparkline.tsx b/web/src/app/app/api-keys/_components/Sparkline.tsx index 4630184c..78f162e8 100644 --- a/web/src/app/app/api-keys/_components/Sparkline.tsx +++ b/web/src/app/app/api-keys/_components/Sparkline.tsx @@ -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 (
-
- {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 ( -
onHoverBucket?.(b)} - onMouseLeave={() => onHoverBucket?.(null)} - style={{ marginRight: i === buckets.length - 1 ? 0 : barGap }} - > -
- {successH > 0 && ( -
- )} - {clientH > 0 && ( -
- )} - {serverH > 0 && ( -
- )} -
-
- ); - })} -
+ ({ + 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 */}
diff --git a/web/src/app/app/campaigns/[id]/page.tsx b/web/src/app/app/campaigns/[id]/page.tsx index 13a98cfe..45b1a9aa 100644 --- a/web/src/app/app/campaigns/[id]/page.tsx +++ b/web/src/app/app/campaigns/[id]/page.tsx @@ -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 ? (
) : ( - m.key === metric)?.bar} + tone={METRICS.find((m) => m.key === metric)?.tone} emptyLabel={ hasSends ? "No activity in this window yet" diff --git a/web/src/app/app/deliverability/page.tsx b/web/src/app/app/deliverability/page.tsx index cd8f69b8..c8c8fb53 100644 --- a/web/src/app/app/deliverability/page.tsx +++ b/web/src/app/app/deliverability/page.tsx @@ -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 = { "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 ? (
) : ( - 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 (
{providerLabel(p.provider)} -
`${s.label} ${s.n}`).join(" · ")}> - {segments.map((s) => ( - - ))} +
`${s.label} ${s.n}`).join(" · ")}> + ({ frac: s.n / Math.max(1, p.samples), tone: s.tone }))} + height={6} + />
{pct(p.inbox_rate)} inbox diff --git a/web/src/components/app/campaigns/ContentScore.tsx b/web/src/components/app/campaigns/ContentScore.tsx index ad5528fa..e778256f 100644 --- a/web/src/components/app/campaigns/ContentScore.tsx +++ b/web/src/components/app/campaigns/ContentScore.tsx @@ -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({ / 100 {tone.label}
-
-
-
+ {data.issues.length > 0 ? (
    {data.issues.map((issue, i) => ( diff --git a/web/src/components/app/campaigns/TaskPreview.tsx b/web/src/components/app/campaigns/TaskPreview.tsx index 7a4458b9..0bbd2528 100644 --- a/web/src/components/app/campaigns/TaskPreview.tsx +++ b/web/src/components/app/campaigns/TaskPreview.tsx @@ -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 {progress}%
-
-
-
+
{processed.toLocaleString()} of {total.toLocaleString()} contacts diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 84f4d02e..d8c3728e 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -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 (
@@ -432,19 +439,18 @@ function AnalyticsTab({ warmup, loading }: { warmup?: import("@/lib/api/models/a Target
-
- {warmup.daily_stats.map((d) => ( -
setSelectedDay((cur) => (cur === d.date ? null : d.date))} - > -
-
0 ? 2 : 0 }} /> -
- ))} -
+ = 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; diff --git a/web/src/components/app/unibox/ScopeRail.tsx b/web/src/components/app/unibox/ScopeRail.tsx index adfd01da..d1832bfd 100644 --- a/web/src/components/app/unibox/ScopeRail.tsx +++ b/web/src/components/app/unibox/ScopeRail.tsx @@ -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({ // 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}
-
-
-
+ {ratio >= 0.95 && (

Near the limit — cancel a few sends to free up space. diff --git a/web/src/components/layout/CreditsMeter.tsx b/web/src/components/layout/CreditsMeter.tsx index 3696815c..0db86353 100644 --- a/web/src/components/layout/CreditsMeter.tsx +++ b/web/src/components/layout/CreditsMeter.tsx @@ -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({

-
-
-
+
{planUsed.toLocaleString()} used this cycle {reset && {reset}} @@ -277,15 +275,12 @@ function SpendRow({ label, spent, limit }: { label: string; spent: number; limit
{fraction != null && ( -
-
= 1 ? "bg-red-400" : fraction >= 0.8 ? "bg-amber-400" : "bg-slate-400", - )} - style={{ width: `${fraction * 100}%` }} - /> -
+ = 1 ? "rose" : fraction >= 0.8 ? "amber" : "slate"} + height={4} + className="mt-1" + /> )}
); diff --git a/web/src/components/ui/charts.tsx b/web/src/components/ui/charts.tsx index 68640d03..52a05dc7 100644 --- a/web/src/components/ui/charts.tsx +++ b/web/src/components/ui/charts.tsx @@ -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 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 + * 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(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( + () => + 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 ; const tickH = 18; - const barAreaH = height - tickH - 4; return (
-
- {points.map((p, i) => { - const h = p.value > 0 ? Math.max(2, (p.value / max) * barAreaH) : 0; - return ( -
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))} - > -
- {hover === i && ( -
- {formatValue(p.value)} · {shortDate(p.label)} -
- )} -
- ); - })} -
+
{shortDate(points[0].label)}