1"use client";2import Link from "next/link";3import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";4import { Download, Play } from "lucide-react";5import { api, isApiError, type Bar, type BarsOut } from "@/lib/api";6import { useQueryParam } from "@/lib/hooks";7import { useToast } from "@/lib/toast";8import { daysAgo, downloadText, fmtInt, fmtNum, fmtTs, toCsv, today } from "@/lib/format";9import { Banner, Card, Empty, ErrorState, Field, PageHeader, Tabs } from "@/components/ui";10import { DataTable } from "@/components/DataTable";11import { CloseLineChart, VolumeBarChart } from "@/components/charts";1213const MAX_SPAN: Record<string, number> = { "1m": 31, "5m": 186, "1d": 3660 };1415export default function BarsPage() {16 const qSymbol = useQueryParam("symbol");17 const qInterval = useQueryParam("interval");18 const qStart = useQueryParam("start");19 const qEnd = useQueryParam("end");20 const [symbol, setSymbol] = useState("AAPL");21 const [interval, setInterval_] = useState<"1m" | "5m" | "1d">("1m");22 const [start, setStart] = useState(daysAgo(7));23 const [end, setEnd] = useState(today());24 const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw");25 const [limit, setLimit] = useState(2000);26 const [out, setOut] = useState<BarsOut | null>(null);27 const [rows, setRows] = useState<Bar[]>([]);28 const [busy, setBusy] = useState(false);29 const [error, setError] = useState<Error | null>(null);30 const [view, setView] = useState<"chart" | "table">("chart");31 const toast = useToast();3233 useEffect(() => {34 if (qSymbol) setSymbol(qSymbol.toUpperCase());35 }, [qSymbol]);36 useEffect(() => {37 if (qInterval === "1m" || qInterval === "5m" || qInterval === "1d") setInterval_(qInterval);38 }, [qInterval]);39 useEffect(() => {40 if (qStart && /^\d{4}-\d{2}-\d{2}$/.test(qStart)) setStart(qStart);41 if (qEnd && /^\d{4}-\d{2}-\d{2}$/.test(qEnd)) setEnd(qEnd);42 }, [qStart, qEnd]);43 // deep link (/bars/?symbol=…&start=…&end=…) loads automatically once the params are applied44 const autoRan = useRef(false);45 useEffect(() => {46 if (autoRan.current || !qSymbol || !qStart || !qEnd) return;47 if (symbol === qSymbol.toUpperCase() && start === qStart && end === qEnd) {48 autoRan.current = true;49 void run();50 }51 // eslint-disable-next-line react-hooks/exhaustive-deps52 }, [symbol, start, end, qSymbol, qStart, qEnd]);5354 const spanDays = useMemo(() => Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000), [start, end]);55 const tooLarge = spanDays > (MAX_SPAN[interval] ?? 31);5657 async function run(e?: FormEvent) {58 e?.preventDefault();59 setBusy(true);60 setError(null);61 try {62 const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit });63 setOut(o);64 setRows(o.data);65 } catch (err) {66 setError(err instanceof Error ? err : new Error(String(err)));67 setOut(null);68 setRows([]);69 } finally {70 setBusy(false);71 }72 }7374 async function loadMore() {75 if (!out?.next_cursor) return;76 setBusy(true);77 try {78 const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit, cursor: out.next_cursor });79 setOut({ ...o, data: [] });80 setRows((r) => [...r, ...o.data]);81 } catch (err) {82 toast.error(err);83 } finally {84 setBusy(false);85 }86 }8788 const chart = useMemo(89 () =>90 rows.map((b) => ({91 t: fmtTs(b.timestamp_utc),92 label: interval === "1d" ? b.session_date : fmtTs(b.timestamp_utc).slice(5, 16),93 close: b.close,94 open: b.open,95 high: b.high,96 low: b.low,97 volume: b.volume,98 })),99 [rows, interval],100 );101 const flagged = useMemo(() => rows.filter((b) => b.quality_flags && b.quality_flags.length > 0).length, [rows]);102 const flagCounts = useMemo(() => {103 const m = new Map<string, number>();104 for (const b of rows) for (const f of b.quality_flags ?? []) m.set(f, (m.get(f) ?? 0) + 1);105 return Array.from(m.entries()).sort((a, b) => b[1] - a[1]);106 }, [rows]);107108 const rangeErr = isApiError(error) && error.code === "range_too_large";109110 return (111 <>112 <PageHeader title="Bars explorer" subtitle="OHLCV bars stamped at bar open (UTC). Adjustments are applied at query time from corporate actions." />113 <Card className="mb-4">114 <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6 lg:items-end">115 <Field label="Symbol">116 <input className="input font-mono uppercase" value={symbol} onChange={(e) => setSymbol(e.target.value)} required />117 </Field>118 <Field label="Interval">119 <select className="input" value={interval} onChange={(e) => setInterval_(e.target.value as typeof interval)}>120 <option value="1m">1 minute</option>121 <option value="5m">5 minutes</option>122 <option value="1d">1 day</option>123 </select>124 </Field>125 <Field label="Start">126 <input type="date" className="input" value={start} max={end} onChange={(e) => setStart(e.target.value)} required />127 </Field>128 <Field label="End">129 <input type="date" className="input" value={end} min={start} onChange={(e) => setEnd(e.target.value)} required />130 </Field>131 <Field label="Adjustment">132 <select className="input" value={adjustment} onChange={(e) => setAdjustment(e.target.value as typeof adjustment)}>133 <option value="raw">raw (as traded)</option>134 <option value="split">split-adjusted</option>135 <option value="split_div">split + dividend</option>136 </select>137 </Field>138 <div className="flex items-end gap-2">139 <Field label="Page size" className="w-24">140 <input type="number" className="input" min={100} max={10000} step={100} value={limit} onChange={(e) => setLimit(Number(e.target.value) || 2000)} />141 </Field>142 <button className="btn btn-primary flex-1" disabled={busy || !symbol}>143 <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}144 </button>145 </div>146 </form>147 <p className={tooLarge ? "mt-2 text-xs text-warn-ink" : "mt-2 text-xs text-ink-3"}>148 Range: {spanDays} day{spanDays === 1 ? "" : "s"} · limit for {interval}: {MAX_SPAN[interval]} days per call (HTTP 413 <code>range_too_large</code> beyond). Larger ranges →{" "}149 <Link href="/exports/" className="text-accent-ink hover:underline">150 Exports151 </Link>{" "}152 (async Parquet/CSV).153 </p>154 </Card>155156 {error && !rangeErr && <ErrorState error={error} retry={() => void run()} />}157 {rangeErr && (158 <Banner tone="warn" className="mb-4">159 <div className="font-medium">Range too large for {interval}</div>160 <div className="text-xs">161 {(error as Error).message} Use{" "}162 <Link href={`/exports/?symbols=${encodeURIComponent(symbol)}&start=${start}&end=${end}&adjustment=${adjustment}`} className="underline">163 an export164 </Link>{" "}165 for {spanDays} days, or shorten the window.166 </div>167 </Banner>168 )}169170 {out && (171 <>172 <div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-ink-2">173 <span className="pill bg-surface-2">{out.meta.symbol}</span>174 <span className="pill bg-surface-2">{out.meta.interval}</span>175 <span className="pill bg-surface-2">{out.meta.adjustment}</span>176 {out.meta.version !== undefined && <span className="pill bg-surface-2">dataset v{String(out.meta.version)}</span>}177 <span className="tnum">178 {fmtInt(rows.length)} of {fmtInt(out.meta.rows_matching || rows.length)} rows179 </span>180 {flagged > 0 && <span className="pill bg-warn-soft text-warn-ink">{fmtInt(flagged)} flagged bars</span>}181 <span className="ml-auto">182 <Tabs value={view} onChange={setView} items={[{ value: "chart", label: "Chart" }, { value: "table", label: "Table" }]} />183 </span>184 </div>185 {out.meta.adjustment_notes?.length > 0 && (186 <Banner tone="accent" className="mb-3">187 <div className="text-xs font-medium">Adjustment notes</div>188 <ul className="list-disc pl-4 text-xs">189 {out.meta.adjustment_notes.map((n, i) => (190 <li key={i}>{n}</li>191 ))}192 </ul>193 </Banner>194 )}195 {flagCounts.length > 0 && (196 <div className="mb-3 flex flex-wrap gap-1 text-xs">197 <span className="text-ink-3">quality_flags:</span>198 {flagCounts.map(([f, n]) => (199 <span key={f} className="pill bg-warn-soft text-warn-ink">200 {f} × {fmtInt(n)}201 </span>202 ))}203 </div>204 )}205 {rows.length === 0 ? (206 <Empty>No bars for this range. Zero-volume bars are not stored; check the session coverage.</Empty>207 ) : view === "chart" ? (208 <Card title={`Close · ${out.meta.symbol} (${out.meta.adjustment})`}>209 <CloseLineChart data={chart} height={280} />210 <div className="mt-2 text-xs font-semibold text-ink-2">Volume</div>211 <VolumeBarChart data={chart} height={110} />212 <p className="mt-2 text-[11px] text-ink-3">Hover for OHLCV. Single series per chart — close and volume use separate axes by design.</p>213 </Card>214 ) : (215 <Card216 actions={217 <button className="btn btn-sm" onClick={() => downloadText(`${out.meta.symbol}_${interval}_${start}_${end}.csv`, toCsv(["timestamp_utc", "session_date", "open", "high", "low", "close", "volume", "vwap", "trade_count", "quality_flags"], rows.map((r) => ({ ...r, quality_flags: (r.quality_flags ?? []).join("|") }))))}>218 <Download className="h-3.5 w-3.5" /> CSV (loaded rows)219 </button>220 }221 >222 <DataTable223 rows={rows}224 rowKey={(r) => r.timestamp_utc}225 mobile="scroll"226 maxHeight="60dvh"227 columns={[228 { key: "timestamp_utc", header: "Timestamp (UTC, bar open)", render: (r) => <span className="tnum font-mono text-xs">{fmtTs(r.timestamp_utc)}</span> },229 { key: "session_date", header: "Session", hideSm: true, render: (r) => <span className="tnum">{r.session_date}</span> },230 { key: "open", header: "Open", num: true, render: (r) => fmtNum(r.open, 4) },231 { key: "high", header: "High", num: true, render: (r) => fmtNum(r.high, 4) },232 { key: "low", header: "Low", num: true, render: (r) => fmtNum(r.low, 4) },233 { key: "close", header: "Close", num: true, render: (r) => fmtNum(r.close, 4) },234 { key: "volume", header: "Volume", num: true, render: (r) => fmtInt(r.volume) },235 { key: "vwap", header: "VWAP", num: true, hideSm: true, render: (r) => (r.vwap === null || r.vwap === undefined ? "—" : fmtNum(r.vwap, 4)) },236 { key: "quality_flags", header: "Flags", render: (r) => (r.quality_flags?.length ? r.quality_flags.map((f) => <span key={f} className="pill mr-1 bg-warn-soft text-warn-ink">{f}</span>) : <span className="text-ink-3">—</span>) },237 ]}238 />239 </Card>240 )}241 <div className="mt-3 flex items-center justify-between text-xs text-ink-3">242 <span>{out.meta.license} · timestamps = {out.meta.timestamp_convention}</span>243 {out.next_cursor && (244 <button className="btn btn-sm" onClick={loadMore} disabled={busy}>245 {busy ? "Loading…" : `Load next ${fmtInt(limit)}`}246 </button>247 )}248 </div>249 </>250 )}251 </>252 );253}254