Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Natalie
892cd092dc feat(web): implement DashboardView (Wave B) on cocotte ui + getDashboard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:53:47 -04:00

View file

@ -0,0 +1,214 @@
import { useState } from 'react';
import {
Button,
Card,
ErrText,
HStack,
Muted,
Pill,
Bars,
SectionLabel,
Seg,
SegButton,
Title,
Toast,
VStack,
} from '../ui';
import { classifyText, getDashboard, type DashboardData } from '../api';
import { usePoll } from '../usePoll';
import { navigate } from '../useHashRoute';
export function DashboardView(): JSX.Element {
const [days, setDays] = useState<number>(7);
const [toast, setToast] = useState<string | null>(null);
const { data, error, loading } = usePoll<DashboardData>(() => getDashboard(days), 30_000);
const showToast = (msg: string): void => {
setToast(msg);
window.setTimeout(() => setToast(null), 2400);
};
const handleAction = (action: string): void => {
const lower = action.toLowerCase();
if (lower.includes('triage') || lower.includes('held') || lower.includes('review')) {
navigate('triage');
} else if (lower.includes('backfill') || lower.includes('leaving')) {
navigate('backfill');
} else if (lower.includes('queue') || lower.includes('outbox')) {
navigate('queue');
} else if (lower.includes('classify') || lower.includes('gpu')) {
void classifyText('demo inbound: hi, rates? available tonight?', false)
.then((r) => showToast(`Classified demo: ${r.category ?? r.template ?? 'ok'}`))
.catch((e) => showToast(`Classify failed: ${e instanceof Error ? e.message : String(e)}`));
} else if (lower.includes('market') || lower.includes('idle')) {
navigate('markets');
} else if (lower.includes('reports')) {
navigate('reports');
} else {
showToast(`Action: ${action}`);
}
};
if (error) {
return (
<Card>
<VStack $gap={8}>
<ErrText>Dashboard error: {error}</ErrText>
<Muted>Will retry automatically.</Muted>
</VStack>
</Card>
);
}
if (!data) {
return (
<Card>
<Muted>Loading dashboard</Muted>
</Card>
);
}
const { kpis, density, insights, actions } = data;
const densityRows: readonly { key: string; count: number }[] = density.map((d) => ({
key: `${d.hour}h`,
count: d.count,
}));
const kpiEntries = Object.entries(kpis);
const volumeRows: readonly { key: string; count: number }[] = kpiEntries
.filter(([, v]) => typeof v === 'number')
.map(([k, v]) => ({
key: k.replace(/7d|InRange/gi, '').trim() || k,
count: Number(v),
}));
const peak = density.length
? density.reduce((a, b) => (b.count > a.count ? b : a))
: null;
return (
<VStack $gap={14}>
<HStack $justify="space-between" $wrap>
<Title>Today</Title>
<Muted>NYC tour · runner active · through Jul 1</Muted>
</HStack>
<HStack $gap={8} $align="center">
<SectionLabel>Range</SectionLabel>
<Seg>
<SegButton $active={days === 7} onClick={() => setDays(7)}>
7d
</SegButton>
<SegButton $active={days === 30} onClick={() => setDays(30)}>
30d
</SegButton>
</Seg>
{loading ? <Muted>(refreshing)</Muted> : null}
</HStack>
<SectionLabel>KPIs</SectionLabel>
<HStack $wrap $gap={10}>
{kpiEntries.map(([k, v]) => {
const label = k === 'newInRange'
? 'New (range)'
: k === 'sent7d'
? 'Sent 7d'
: k === 'held7d'
? 'Held 7d'
: k === 'qualified'
? 'Qualified'
: k;
const onClick = () => {
if (k === 'qualified' || k === 'sent7d') {
navigate('reports');
} else if (k === 'held7d' || k.includes('held')) {
navigate('queue');
} else if (k.includes('new')) {
navigate('triage');
} else {
navigate('reports');
}
};
return (
<Card
key={k}
onClick={onClick}
style={{ flex: '1 1 120px', minWidth: 110, cursor: 'pointer' }}
>
<VStack $gap={4}>
<Title style={{ fontSize: '22px', fontFamily: 'ui-monospace, Menlo, monospace' }}>
{String(v)}
</Title>
<Muted>{label}</Muted>
</VStack>
</Card>
);
})}
</HStack>
<HStack $gap={12} $wrap>
<Card style={{ flex: 1, minWidth: 280 }}>
<VStack $gap={6}>
<HStack $justify="space-between">
<Title style={{ fontSize: '13px' }}>Inbound density · ET</Title>
{peak ? <Pill $tone="success">peak {peak.hour}h</Pill> : null}
</HStack>
<Muted>time bumps here</Muted>
<Bars rows={densityRows} tone="success" />
</VStack>
</Card>
<Card style={{ flex: 1, minWidth: 280 }}>
<VStack $gap={6}>
<Title style={{ fontSize: '13px' }}>Volume (KPIs)</Title>
{volumeRows.length > 0 ? (
<Bars rows={volumeRows} tone="accent" />
) : (
<Muted>No volume data.</Muted>
)}
<HStack $gap={8}>
<Pill $tone="success">numeric</Pill>
<Muted>from dashboard</Muted>
</HStack>
</VStack>
</Card>
</HStack>
<SectionLabel>Insights</SectionLabel>
<VStack $gap={8}>
{insights.length ? (
insights.map((ins, idx) => (
<Card key={idx}>
<Muted>{ins}</Muted>
</Card>
))
) : (
<Muted>No insights.</Muted>
)}
</VStack>
<SectionLabel>Recommended actions</SectionLabel>
<VStack $gap={8}>
{actions.length ? (
actions.map((a, idx) => (
<Card key={idx}>
<HStack $justify="space-between" $wrap $gap={8}>
<Muted style={{ flex: 1 }}>{a}</Muted>
<Button $variant="primary" onClick={() => handleAction(a)}>
Take action
</Button>
</HStack>
</Card>
))
) : (
<Muted>No recommended actions.</Muted>
)}
</VStack>
{toast ? <Toast>{toast}</Toast> : null}
</VStack>
);
}