244 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|