1"use client";2import Link from "next/link";3import { useEffect, useState } from "react";4import { api, type Anomaly, type Dataset, type QualityOut } from "@/lib/api";5import { useAsync, useDebounced, useQueryParam } from "@/lib/hooks";6import { fmtInt, fmtTs } from "@/lib/format";7import { Card, Empty, ErrorState, Field, PageHeader, SeverityBadge, Skeleton, KV } from "@/components/ui";8import { DataTable } from "@/components/DataTable";9import { JsonView } from "@/components/JsonView";10import { CategoryBars } from "@/components/charts";1112const DEFAULT_DS = "market_bars.stock.1m.raw";1314export default function QualityPage() {15 const qDs = useQueryParam("dataset_id");16 const [datasetId, setDatasetId] = useState(DEFAULT_DS);17 const [code, setCode] = useState("");18 const [severity, setSeverity] = useState("");19 const [symbol, setSymbol] = useState("");20 const dsym = useDebounced(symbol.trim().toUpperCase(), 300);21 useEffect(() => {22 if (qDs) setDatasetId(qDs);23 }, [qDs]);2425 const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);26 const q = useAsync((s) => api.get<QualityOut>("/quality", { dataset_id: datasetId }, s), [datasetId]);27 const an = useAsync((s) => api.get<{ data: Anomaly[] }>("/quality/anomalies", { dataset_id: datasetId, code, severity, symbol: dsym, limit: 500 }, s), [datasetId, code, severity, dsym]);2829 const latest = q.data?.latest;30 const byCode = (q.data?.anomalies_by_code_30d ?? []).map((r) => ({ name: r.code, value: Number(r.n) }));3132 return (33 <>34 <PageHeader35 title="Quality"36 subtitle="Validation reports and anomalies recorded at ingestion"37 actions={38 <Field label="Dataset" className="w-72">39 <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>40 {!(datasets.data?.data ?? []).some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}41 {(datasets.data?.data ?? []).filter((d) => d.status === "active").map((d) => (42 <option key={d.id} value={d.id}>43 {d.id}44 </option>45 ))}46 </select>47 </Field>48 }49 />50 {q.error && <ErrorState error={q.error} retry={q.reload} />}51 <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">52 <Card title="Latest report">53 {q.loading && !q.data ? (54 <Skeleton rows={5} />55 ) : latest ? (56 <>57 <KV58 items={[59 ["Report id", <code key="id" className="text-xs">{String(latest.id ?? "—")}</code>],60 ["Created", fmtTs(latest.created_at as string)],61 ["Rows checked", fmtInt(latest.rows_checked as number)],62 ["Anomalies", fmtInt(latest.anomalies as number)],63 ]}64 />65 <div className="mt-3 text-xs text-ink-3">summary</div>66 <JsonView value={latest.summary ?? {}} maxHeight="16rem" />67 </>68 ) : (69 <Empty>No quality report yet for this dataset — enqueue a <code>quality</code> job from Ingestion.</Empty>70 )}71 </Card>72 <Card title="Anomalies by code (30 days)">73 {byCode.length ? <CategoryBars data={byCode} /> : <Empty>No anomalies in the last 30 days.</Empty>}74 <ul className="mt-3 grid gap-1 text-xs sm:grid-cols-2">75 {Object.entries(q.data?.codes ?? {}).map(([c, d]) => (76 <li key={c} className="flex gap-2">77 <button className={`font-mono shrink-0 ${code === c ? "text-accent-ink underline" : "text-ink-1 hover:underline"}`} onClick={() => setCode(code === c ? "" : c)}>78 {c}79 </button>80 <span className="text-ink-3">{d}</span>81 </li>82 ))}83 </ul>84 </Card>85 </div>8687 <Card title={`Reports (${q.data?.reports.length ?? 0})`} className="mt-4">88 <DataTable89 rows={q.data?.reports}90 loading={q.loading}91 rowKey={(r) => r.id}92 mobile="scroll"93 maxHeight="18rem"94 columns={[95 { key: "created_at", header: "Created", render: (r) => <span className="tnum text-xs">{fmtTs(r.created_at)}</span> },96 { key: "rows_checked", header: "Rows checked", num: true, render: (r) => fmtInt(r.rows_checked) },97 { key: "anomalies", header: "Anomalies", num: true, render: (r) => fmtInt(r.anomalies) },98 { key: "summary", header: "Summary", render: (r) => <JsonView value={r.summary} collapsed /> },99 ]}100 empty="No reports."101 />102 </Card>103104 <Card105 title="Anomalies"106 className="mt-4"107 actions={108 <div className="flex flex-wrap gap-2">109 <input className="input w-28 py-1 font-mono text-xs uppercase" placeholder="symbol" value={symbol} onChange={(e) => setSymbol(e.target.value)} aria-label="Symbol" />110 <select className="input w-auto py-1 text-xs" value={severity} onChange={(e) => setSeverity(e.target.value)} aria-label="Severity">111 <option value="">any severity</option>112 <option value="info">info</option>113 <option value="warning">warning</option>114 <option value="error">error</option>115 </select>116 <select className="input w-auto py-1 font-mono text-xs" value={code} onChange={(e) => setCode(e.target.value)} aria-label="Code">117 <option value="">any code</option>118 {Object.keys(q.data?.codes ?? {}).map((c) => (119 <option key={c} value={c}>120 {c}121 </option>122 ))}123 </select>124 </div>125 }126 >127 {an.error && <ErrorState error={an.error} retry={an.reload} />}128 <DataTable129 rows={an.data?.data}130 loading={an.loading}131 rowKey={(r, i) => String(r.id ?? i)}132 mobile="scroll"133 maxHeight="60dvh"134 columns={[135 { key: "created_at", header: "Recorded", render: (r) => <span className="tnum text-xs">{fmtTs(r.created_at)}</span> },136 { key: "severity", header: "Severity", render: (r) => <SeverityBadge severity={r.severity} /> },137 { key: "code", header: "Code", mono: true },138 { key: "symbol", header: "Symbol", render: (r) => (r.symbol ? <Link href={`/symbols/detail/?symbol=${encodeURIComponent(r.symbol)}`} className="font-mono text-accent-ink hover:underline">{r.symbol}</Link> : "—") },139 { key: "session_date", header: "Session", hideSm: true, render: (r) => <span className="tnum">{r.session_date ?? "—"}</span> },140 { key: "detail", header: "Detail", render: (r) => <JsonView value={r.detail} collapsed /> },141 ]}142 empty="No anomalies match."143 footer={<span>{fmtInt(an.data?.data.length ?? 0)} rows (max 500)</span>}144 />145 </Card>146 </>147 );148}149