first commit
This commit is contained in:
243
src/app/(dev)/charts/page.tsx
Normal file
243
src/app/(dev)/charts/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'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 '@/lib/api/useResource';
|
||||
import {endpoints} from '@/lib/api/client';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {LineChartView} from '@/components/charts/LineChartView';
|
||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {Sparkline} from '@/components/charts/Sparkline';
|
||||
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
||||
import type {HourCell, Kpi, TimePoint} from '@/lib/api/contracts';
|
||||
import {
|
||||
formatCompact,
|
||||
formatDayLabel,
|
||||
formatDelta,
|
||||
formatInrCompact,
|
||||
formatPct,
|
||||
} from '@/lib/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(endpoints.dashboardTimeseries(scope)),
|
||||
);
|
||||
const kpis = useResource<Kpi[]>(withExtra(endpoints.dashboardKpis(scope)));
|
||||
const peak = useResource<HourCell[]>(
|
||||
withExtra(endpoints.dashboardPeakHours(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user