v1.0

RetentionChart

Preview

Basic

Deposit Retention Trend

Preview

Code

ts
import RetentionChart from '@/components/data/RetentionChart';

src/components/data/RetentionChart.tsx

AI prompt

text
Build a monthly retention-rate line chart card in React + TypeScript + Tailwind CSS, using Recharts `LineChart`.

## Look
- A house panel with `p-5`, the title "Deposit Retention Trend" as a section title (10px semibold uppercase wide-tracking slate-500 / dark slate-400, `mb-3`), then a 300px-tall ResponsiveContainer. Margins: top 8, right 12, left 4, bottom 0.
- Two monotone lines, 2px wide, with 3px-radius dots: "Converted" in violet #8b5cf6 and "D30" in pink #ec4899.
- Horizontal grid lines only; grid and axis lines in #e5e7eb / dark #374151; 12px ticks in #6b7280 / dark #9ca3af. A legend in 12px text.
- The Y axis always runs 0–100 (a fixed domain, so months compare against the whole scale), 44px wide, ticks as whole percentages ("25%"). X ticks are "Mar 2026" with `minTickGap 24`.
- 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
- One point per calendar month. `month` is the ISO date of the month's first day at UTC midnight, so format it with `timeZone: 'UTC'` (`toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })`). Formatting in local time shifts it into the previous month west of UTC.
- Rates may be `null` for a month with no measurement. The line connects across it (`connectNulls`) rather than dropping to zero, and the missing month gets no dot.
- The tooltip label is the formatted month. Each row reads `22%  ·  1,886 users`: the rate as a whole percentage, then the matching count (`depositCount` for Converted, `thirtyDaysCount` for D30) with thousands separators. A null rate shows "—".
- No points: a 300px-tall centred "No data for the selected period." in 12px slate-400 / dark slate-500.

## API
`trend: { month: string; depositRate: number | null; thirtyDaysRate: number | null; registerCount: number; depositCount: number; thirtyDaysCount: number }[]`. `depositRate` is "Converted" (% of registrants who deposited) and `thirtyDaysRate` is "D30" (% retained at day 30).

## Demo
March–August 2026: Converted 22.4, 24.1, 21.7, 26.3, 25.0, and D30 11.2, 12.8, 10.4, 14.1, 13.5. August is `null` for both, so the gap handling is visible.

## 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 {
  CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import type { RetentionTrendPoint } from './types';
import { percent, useChartTheme } from './chartTheme';

const fmtMonth = (iso: string) =>
  // iso is a month's 1st day, always stored/queried at UTC midnight (see
  // getDashboardData) — format in UTC to avoid a TZ off-by-one shifting it
  // into the previous month.
  new Date(iso).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });

// Deposit Retention trend — reuses reports/brands-roi's own BrandDepositRetention
// definitions (depositRate = "Converted" %, thirtyDaysRate = "D30" %) rather
// than deriving a new retention ratio, over the same month-range/filter scope
// (platform/brand/agent + RBAC) as the rest of the dashboard. One point per
// calendar month, matching BrandDepositRetention's own monthly grain (unlike
// DashboardTrends' daily AgentStat-derived points).
export default function RetentionChart({ trend }: { trend: RetentionTrendPoint[] }) {
  const t = useChartTheme();
  const hasData = trend.length > 0;

  return (
    <div className="panel p-5">
      <h2 className="panel-title mb-3">Deposit Retention Trend</h2>
      {!hasData ? (
        <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>
      ) : (
        <div style={{ height: 300 }}>
          <ResponsiveContainer width="100%" height="100%">
            <LineChart data={trend} margin={{ top: 8, right: 12, left: 4, bottom: 0 }}>
              <CartesianGrid vertical={false} stroke={t.grid} />
              <XAxis dataKey="month" tickFormatter={fmtMonth} tick={{ fontSize: 12, fill: t.axis }} stroke={t.grid} minTickGap={24} />
              <YAxis tickFormatter={percent} tick={{ fontSize: 12, fill: t.axis }} stroke={t.grid} width={44} domain={[0, 100]} />
              <Tooltip
                contentStyle={t.tooltip}
                labelFormatter={(l) => fmtMonth(String(l))}
                formatter={(v, name, item) => {
                  const row = item?.payload as RetentionTrendPoint;
                  const count = name === 'Converted' ? row.depositCount : row.thirtyDaysCount;
                  return [typeof v !== 'number' ? '—' : `${percent(v)}  ·  ${count.toLocaleString()} users`, name];
                }}
              />
              <Legend wrapperStyle={{ fontSize: 12 }} />
              <Line type="monotone" dataKey="depositRate" name="Converted" stroke={t.series.retention} strokeWidth={2} dot={{ r: 3 }} connectNulls />
              <Line type="monotone" dataKey="thirtyDaysRate" name="D30" stroke={t.series.retentionD30} strokeWidth={2} dot={{ r: 3 }} connectNulls />
            </LineChart>
          </ResponsiveContainer>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
trend*RetentionTrendPoint[]—