1"use client";2import Link from "next/link";3import { useEffect, useMemo, useState } from "react";4import { api, type Dataset, type Heatmap, type SessionRow } from "@/lib/api";5import { useAsync, useDebounced, useQueryParam } from "@/lib/hooks";6import { daysAgo, fmtCompact, fmtInt, fmtPct, fmtTime, today } from "@/lib/format";7import { Card, ErrorState, Field, PageHeader, Skeleton } from "@/components/ui";8import { DataTable } from "@/components/DataTable";9import { DateRange } from "@/components/DateRange";10import { CompletenessHeatmap, VolumeBarChart } from "@/components/charts";1112const DEFAULT_DS = "market_bars.stock.1m.raw";1314export default function CoveragePage() {15 const qDs = useQueryParam("dataset_id");16 const qSym = useQueryParam("symbol");17 const [datasetId, setDatasetId] = useState(DEFAULT_DS);18 const [start, setStart] = useState(daysAgo(120));19 const [end, setEnd] = useState(today());20 const [symbol, setSymbol] = useState("");21 const dsym = useDebounced(symbol.trim().toUpperCase(), 400);2223 useEffect(() => {24 if (qDs) setDatasetId(qDs);25 }, [qDs]);26 useEffect(() => {27 if (qSym) setSymbol(qSym.toUpperCase());28 }, [qSym]);2930 const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);31 const heat = useAsync((s) => api.get<Heatmap>("/coverage/heatmap", { dataset_id: datasetId, start, end }, s), [datasetId, start, end]);32 const interval = datasetId.includes(".5m.") ? "5m" : "1m";33 const sessions = useAsync((s) => api.get<{ data: SessionRow[]; meta: { dataset_id: string; symbol: string } }>("/market/sessions", { symbol: dsym, interval, start, end }, s), [dsym, interval, start, end], { enabled: dsym.length > 0 });3435 const barData = useMemo(() => (heat.data?.sessions ?? []).map((s) => ({ label: s.session_date, t: s.session_date, symbols: s.symbols, bars: s.bars })), [heat.data]);36 const totals = useMemo(() => {37 const ss = heat.data?.sessions ?? [];38 const bars = ss.reduce((a, s) => a + Number(s.bars ?? 0), 0);39 const comp = ss.filter((s) => s.completeness !== null);40 const avg = comp.length ? comp.reduce((a, s) => a + Number(s.completeness), 0) / comp.length : null;41 return { sessions: ss.length, bars, avg, maxSymbols: Math.max(0, ...ss.map((s) => s.symbols)) };42 }, [heat.data]);4344 const candidates = (datasets.data?.data ?? []).filter((d) => d.kind === "market_bars" || d.kind === "derived");4546 return (47 <>48 <PageHeader title="Coverage" subtitle="Sessions × completeness (bars ÷ expected regular-hours bars, capped at 100 %)" />49 <Card className="mb-4">50 <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">51 <DateRange start={start} end={end} onChange={(s, e) => { setStart(s); setEnd(e); }} />52 <Field label="Dataset" className="lg:w-80">53 <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>54 {!candidates.some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}55 {candidates.map((d) => (56 <option key={d.id} value={d.id}>57 {d.id} {d.status !== "active" ? `(${d.status})` : ""}58 </option>59 ))}60 </select>61 </Field>62 </div>63 </Card>6465 {heat.error && <ErrorState error={heat.error} retry={heat.reload} />}66 <div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">67 <div className="card"><div className="text-xs text-ink-2">Sessions in range</div><div className="mt-1 text-2xl font-semibold">{fmtInt(totals.sessions)}</div></div>68 <div className="card"><div className="text-xs text-ink-2">Bars in range</div><div className="mt-1 text-2xl font-semibold" title={fmtInt(totals.bars)}>{fmtCompact(totals.bars)}</div></div>69 <div className="card"><div className="text-xs text-ink-2">Mean completeness</div><div className="mt-1 text-2xl font-semibold">{fmtPct(totals.avg)}</div></div>70 <div className="card"><div className="text-xs text-ink-2">Max symbols / session</div><div className="mt-1 text-2xl font-semibold">{fmtInt(totals.maxSymbols)}</div></div>71 </div>7273 <div className="grid gap-4 lg:grid-cols-[1fr_minmax(300px,0.7fr)]">74 <div className="flex flex-col gap-4">75 <Card title="Completeness per session" className={heat.loading ? "opacity-70" : undefined}>76 {heat.loading && !heat.data ? <Skeleton rows={4} /> : heat.data ? <CompletenessHeatmap cells={heat.data.sessions} start={start} end={end} /> : null}77 </Card>78 <Card title="Bars per session">79 {heat.data && barData.length > 0 ? <VolumeBarChart data={barData} valueKey="bars" title="bars" height={140} /> : <div className="text-sm text-ink-3">No sessions in range.</div>}80 </Card>81 <Card title="Symbols per session">82 {heat.data && barData.length > 0 ? <VolumeBarChart data={barData} valueKey="symbols" title="symbols" height={120} color="var(--series-3)" /> : <div className="text-sm text-ink-3">No sessions in range.</div>}83 </Card>84 </div>85 <Card title={`Top symbols by bars (${heat.data?.top_symbols.length ?? 0})`}>86 <DataTable87 rows={heat.data?.top_symbols}88 loading={heat.loading}89 rowKey={(r) => r.symbol}90 mobile="scroll"91 maxHeight="32rem"92 onRowClick={(r) => setSymbol(r.symbol)}93 columns={[94 { key: "symbol", header: "Symbol", render: (r) => <Link href={`/symbols/detail/?symbol=${encodeURIComponent(r.symbol)}`} className="font-mono font-medium text-accent-ink hover:underline" onClick={(e) => e.stopPropagation()}>{r.symbol}</Link> },95 { key: "sessions", header: "Sessions", num: true, render: (r) => fmtInt(r.sessions) },96 { key: "bars", header: "Bars", num: true, render: (r) => fmtCompact(r.bars) },97 { key: "completeness", header: "Compl.", num: true, render: (r) => fmtPct(r.completeness) },98 ]}99 empty="No coverage rows in this range."100 />101 </Card>102 </div>103104 <Card title="Per-symbol sessions" className="mt-4" actions={<span className="text-xs text-ink-3">click a top symbol or type one</span>}>105 <Field label="Symbol" className="mb-3 max-w-xs">106 <input className="input font-mono uppercase" placeholder="AAPL" value={symbol} onChange={(e) => setSymbol(e.target.value)} />107 </Field>108 {sessions.error && <ErrorState error={sessions.error} retry={sessions.reload} />}109 {dsym ? (110 <DataTable111 rows={sessions.data?.data}112 loading={sessions.loading}113 rowKey={(r) => r.session_date}114 mobile="scroll"115 maxHeight="28rem"116 columns={[117 { key: "session_date", header: "Session", render: (r) => <span className="tnum">{r.session_date}</span> },118 { key: "bar_count", header: "Bars", num: true, render: (r) => fmtInt(r.bar_count) },119 { key: "expected_bars", header: "Expected", num: true, render: (r) => fmtInt(r.expected_bars) },120 { key: "completeness", header: "Compl.", num: true, render: (r) => (r.expected_bars ? fmtPct(Math.min(1, r.bar_count / r.expected_bars)) : "—") },121 { key: "first_ts", header: "First (UTC)", render: (r) => <span className="tnum">{fmtTime(r.first_ts)}</span> },122 { key: "last_ts", header: "Last (UTC)", render: (r) => <span className="tnum">{fmtTime(r.last_ts)}</span> },123 { key: "flags", header: "Flags", render: (r) => (r.flags?.length ? r.flags.map((f) => <span key={f} className="pill mr-1 bg-warn-soft text-warn-ink">{f}</span>) : <span className="text-ink-3">—</span>) },124 ]}125 empty={`No sessions for ${dsym} in this range.`}126 footer={<span>{fmtInt(sessions.data?.data.length ?? 0)} sessions · dataset {sessions.data?.meta.dataset_id}</span>}127 />128 ) : (129 <div className="text-sm text-ink-3">Enter a symbol to list its sessions.</div>130 )}131 </Card>132 </>133 );134}135