Compare periods Last week's line under today's. The comparison series has to read as secondary or the chart looks like two equal metrics, and the label must say which period the ghost is. npx shadcn@latest add toggle-group npm i recharts Tokens this needs: --foreground, --card, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // ComparePeriods.tsx ──────────────────────────────────────────────────────────────────────── import { Line, LineChart, XAxis, YAxis } from "recharts"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; /** * The base is the whole design decision, and there are three honest ones. * Each aligns a different cycle, so the chart says which it is using and * what that aligns, rather than leaving "last period" to mean anything. */ export const BASES = { "1d": { pick: "yesterday", legend: "same period yesterday", aligns: "time of day; a Sunday against a Monday" }, "7d": { pick: "a week ago", legend: "same period last week", aligns: "day of week and time of day" }, "1y": { pick: "a year ago", legend: "same period last year", aligns: "the season; the weekday wobbles" }, } as const; export type Base = keyof typeof BASES; const PRESENT = "hsl(var(--chart-1))"; const PAST = "hsl(var(--muted-foreground))"; export function ComparePeriods({ label, current, comparison, base, onBaseChange, width = 400, height = 150 }: { /** The current period, as the legend names it. */ label: string; current: number[]; /** The same window shifted back by `base`, on the same scale. */ comparison: number[]; base: Base; onBaseChange: (base: Base) => void; width?: number; height?: number; }) { const data = current.map((now, i) => ({ i, now, then: comparison[i] })); const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0); // The gap as a number. Two lines show a difference; only a figure says how big. const pct = Math.round(((sum(current) - sum(comparison)) / sum(comparison)) * 100); return (
{/* The past is thinner, dashed and grey so the present reads first. Equal weight makes a chart the viewer keeps re-checking. */}
{label} {BASES[base].legend} {pct > 0 ? "+" : ""}{pct}% v && onBaseChange(v as Base)} aria-label="Compare against" className="ml-auto gap-1"> {(Object.keys(BASES) as Base[]).map((b) => ( {BASES[b].pick} ))}

aligns {BASES[base].aligns}

); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { ComparePeriods, type Base } from "./ComparePeriods"; /** * This week against the same window shifted back. Last week is the default * because it aligns the weekday; the other two are there to show the same * chart re-based, with the gap recomputed. */ const THIS_WEEK = [32, 54, 44, 92, 82, 60, 76, 58, 68]; const SHIFTED: Record = { "1d": [30, 50, 48, 88, 78, 64, 70, 60, 62], "7d": [40, 58, 50, 84, 74, 52, 62, 44, 50], "1y": [28, 44, 40, 70, 66, 48, 58, 42, 46], }; export default function Demo() { const [base, setBase] = useState("7d"); return ( ); }