mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-23 00:00:26 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (1–2 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>
|
||||
|
||||
Reference in New Issue
Block a user