TrendChart
Preview
Basic
Loading…
Preview
Code
ts
import TrendChart from '@/components/data/TrendChart';src/components/data/TrendChart.tsx
AI prompt
text
Build a daily trend chart pair in React + TypeScript + Tailwind CSS, using Recharts and date-fns: two chart cards side by side from one daily series.
## Look
- Wrapper: `grid grid-cols-1 gap-4 xl:grid-cols-2`. Each card is a house panel with `p-5`, a section title (10px semibold uppercase wide-tracking slate-500 / dark slate-400, `mb-3`) and a 300px-tall ResponsiveContainer chart. Chart margins: top 8, right 12, left 4, bottom 0.
- Card 1, "Total Spend": an `AreaChart` with a monotone line in amber #f59e0b, 2px wide, over a vertical gradient fill of the same amber (35% opacity at the top, 0% at the bottom).
- Card 2, "Registrations vs First-Time Deposits": a `ComposedChart` with Registrations as indigo #6366f1 bars (`maxBarSize 22`, top corners rounded 3px) and First-Time Deposits as a 2px monotone blue #3b82f6 line with no dots.
- Both charts: horizontal grid lines only; axis lines and grid in #e5e7eb / dark #374151; 12px ticks in #6b7280 / dark #9ca3af; a legend in 12px text. X ticks are "MMM d" with `minTickGap 24` so they thin out on narrow cards. Y axis width 56 on the spend chart and 40 on the counts chart, where `allowDecimals={false}`.
- Tooltip box: white / #1f2937 background, 1px #e5e7eb / #374151 border, 8px radius, 12px text in #111827 / #f3f4f6. Read dark mode from the `.dark` class on <html>.
## Behaviour
- Dates are plain `YYYY-MM-DD` strings. Split them and build a LOCAL `new Date(y, m - 1, d)` before formatting: `new Date('2026-08-01')` parses as UTC and shows the previous day west of Greenwich. The tooltip label uses the same "MMM d".
- Spend axis and tooltip use compact USD (`Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 })` → "$14.2K"). The counts axis uses a compact number ("1.2K").
- No points: each card shows a 300px-tall centred "No data for the selected period." in 12px slate-400 / dark slate-500.
## API
`trend: { date: string /* YYYY-MM-DD */; spend: number; registrations: number; ftd: number }[]`.
## Demo
30 days from 2026-08-01 shaped by a gentle sine wave: spend around $14K with a slight upward drift, registrations around 420 and FTD around 96.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: marketing-stats (96S1), verbatim. */
import {
Area, AreaChart, Bar, CartesianGrid, ComposedChart, Legend, Line,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { format } from 'date-fns';
import type { TrendPoint } from './types';
import { compactCurrency, compactNumber, useChartTheme } from './chartTheme';
const fmtDay = (iso: string) => {
// iso is a plain YYYY-MM-DD; parse as local to avoid a TZ off-by-one.
const [y, m, d] = iso.split('-').map(Number);
return format(new Date(y, m - 1, d), 'MMM d');
};
function EmptyOrChart({ hasData, children }: { hasData: boolean; children: React.ReactNode }) {
if (!hasData) {
return <div className="flex h-[300px] items-center justify-center text-xs text-slate-400 dark:text-slate-500">No data for the selected period.</div>;
}
return <>{children}</>;
}
export default function TrendChart({ trend }: { trend: TrendPoint[] }) {
const t = useChartTheme();
const hasData = trend.length > 0;
return (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{/* Total Spend over time */}
<div className="panel p-5">
<h2 className="panel-title mb-3">Total Spend</h2>
<EmptyOrChart hasData={hasData}>
<div style={{ height: 300 }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={trend} margin={{ top: 8, right: 12, left: 4, bottom: 0 }}>
<defs>
<linearGradient id="gradSpend" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={t.series.spend} stopOpacity={0.35} />
<stop offset="95%" stopColor={t.series.spend} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke={t.grid} />
<XAxis dataKey="date" tickFormatter={fmtDay} tick={{ fontSize: 12, fill: t.axis }} stroke={t.grid} minTickGap={24} />
<YAxis tickFormatter={compactCurrency} tick={{ fontSize: 12, fill: t.axis }} stroke={t.grid} width={56} />
<Tooltip
contentStyle={t.tooltip}
labelFormatter={(l) => fmtDay(String(l))}
formatter={(v, name) => [compactCurrency(Number(v)), name]}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area type="monotone" dataKey="spend" name="Total Spend" stroke={t.series.spend} strokeWidth={2} fill="url(#gradSpend)" />
</AreaChart>
</ResponsiveContainer>
</div>
</EmptyOrChart>
</div>
{/* Registrations vs FTD (acquisition over time) */}
<div className="panel p-5">
<h2 className="panel-title mb-3">Registrations vs First-Time Deposits</h2>
<EmptyOrChart hasData={hasData}>
<div style={{ height: 300 }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={trend} margin={{ top: 8, right: 12, left: 4, bottom: 0 }}>
<CartesianGrid vertical={false} stroke={t.grid} />
<XAxis dataKey="date" tickFormatter={fmtDay} tick={{ fontSize: 12, fill: t.axis }} stroke={t.grid} minTickGap={24} />
<YAxis tickFormatter={compactNumber} tick={{ fontSize: 12, fill: t.axis }} stroke={t.grid} width={40} allowDecimals={false} />
<Tooltip contentStyle={t.tooltip} labelFormatter={(l) => fmtDay(String(l))} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="registrations" name="Registrations" fill={t.series.registrations} radius={[3, 3, 0, 0]} maxBarSize={22} />
<Line type="monotone" dataKey="ftd" name="First-Time Deposits" stroke={t.series.ftd} strokeWidth={2} dot={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
</EmptyOrChart>
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
trend* | TrendPoint[] | — |