Files
loyaly-merchant/src/app/(dev)/charts/page.tsx

244 lines
7.9 KiB
TypeScript

'use client';
/**
* Dev-only chart gallery.
*
* Every primitive is exercised here against the REAL route handlers, not
* inline arrays — so this page simultaneously proves the charts render, the
* data layer round-trips, and the loading/empty/error skins are correct.
* Use the state buttons to force each branch.
*/
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Card} from '@astryxdesign/core/Card';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Badge} from '@astryxdesign/core/Badge';
import {useResource} from '@/shared/hooks/useResource';
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {Sparkline} from '@/shared/components/charts/Sparkline';
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import type {HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard';
import {
formatCompact,
formatDayLabel,
formatDelta,
formatInrCompact,
formatPct,
} from '@/shared/utils/format';
type Forced = '' | 'empty' | 'error' | 'delay';
export default function ChartsGallery() {
const [forced, setForced] = useState<Forced>('');
// The forced state is folded into the endpoint params, so it becomes part of
// the request identity and useResource refetches when it changes.
const extra =
forced === 'delay'
? {_delay: '4000'}
: forced
? {_state: forced}
: {};
const scope = {range: '30d' as const, storeId: 'all'};
const withExtra = <T,>(e: {path: string; params: Record<string, string>}) =>
({...e, params: {...e.params, ...extra}}) as never as T;
const series = useResource<TimePoint[]>(
withExtra(dashboardRepository.timeseries(scope)),
);
const kpis = useResource<Kpi[]>(withExtra(dashboardRepository.kpis(scope)));
const peak = useResource<HourCell[]>(
withExtra(dashboardRepository.peakHours(scope)),
);
return (
<VStack gap={6} padding={6}>
<VStack gap={2}>
<Heading level={1}>Chart primitives</Heading>
<Text color="secondary">
Monochrome by construction. Series beyond the second are separated by
dash pattern and hatch fill, never hue — semantic colour is reserved
for thresholds and deltas.
</Text>
<HStack gap={2} wrap="wrap">
{(
[
['', 'Live data'],
['delay', 'Loading (4s)'],
['empty', 'Empty'],
['error', 'Error'],
] as [Forced, string][]
).map(([v, label]) => (
<Button
key={label}
size="sm"
variant={forced === v ? 'primary' : 'secondary'}
label={label}
onClick={() => setForced(v)}
/>
))}
</HStack>
</VStack>
{/* KPI sparklines — never animated, semantic tone on the delta only. */}
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={4}>
{kpis.status === 'success'
? kpis.data.map((k) => (
<Card key={k.id}>
<VStack gap={2}>
<Text size="sm" color="secondary">
{k.label}
</Text>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>
{k.unit === 'inr'
? formatInrCompact(k.value)
: formatCompact(k.value)}
</Heading>
<Badge
variant={
k.deltaPct === 0
? 'neutral'
: k.deltaPct > 0 === k.isRiseGood
? 'success'
: 'error'
}
label={formatDelta(k.deltaPct)}
/>
</HStack>
<Sparkline
data={k.trend}
dataKey="v"
tone={
k.deltaPct === 0
? 'neutral'
: k.deltaPct > 0 === k.isRiseGood
? 'positive'
: 'negative'
}
/>
</VStack>
</Card>
))
: null}
</Grid>
<Grid columns={{minWidth: 420, repeat: 'fit'}} gap={4}>
<ChartCard
title="Footfall"
subtitle="Visitors per day"
resource={series}
>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'visitors', label: 'Visitors'}]}
/>
)}
</ChartCard>
<ChartCard
title="Visitors vs purchases"
subtitle="Two series — solid and dashed, same ramp"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Visitors'},
{key: 'purchases', label: 'Purchases'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Revenue"
subtitle="Bars, with a target reference line"
resource={series}
>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
reference={{y: 90000, label: 'Daily target', tone: 'positive'}}
/>
)}
</ChartCard>
<ChartCard
title="Three series"
subtitle="The encoding test: luminance, then hatch — never hue"
resource={series}
>
{(d) => (
<BarChartView
// Three series only tells you anything if they share a scale, so
// this splits visitors into who bought and who didn't rather
// than mixing a percentage onto a counts axis.
data={d.slice(-10).map((p) => ({
t: p.t,
purchases: p.purchases,
browsed: p.visitors - p.purchases,
repeat: Math.round(p.purchases * 0.42),
}))}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'browsed', label: 'Browsed only'},
{key: 'purchases', label: 'Purchased'},
{key: 'repeat', label: 'Repeat customers'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Conversion"
subtitle="Dotted third encoding at line scale"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => formatPct(v, 0)}
series={[{key: 'conversion', label: 'Conversion'}]}
reference={{y: 22, label: 'Benchmark'}}
/>
)}
</ChartCard>
<ChartCard
title="Peak hours"
subtitle="CSS grid, not recharts — monochrome by construction"
resource={peak}
height={200}
>
{(d) => <HeatmapGrid data={d} />}
</ChartCard>
</Grid>
</VStack>
);
}