1"use client";2import { useEffect, useMemo, useState, type FormEvent } from "react";3import { Download, RefreshCw } from "lucide-react";4import { api, type Dataset, type ExportRow } from "@/lib/api";5import { useAsync, useInterval } from "@/lib/hooks";6import { useToast } from "@/lib/toast";7import { daysAgo, fmtBytes, fmtInt, fmtRelative, fmtTs, splitList, today } from "@/lib/format";8import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";9import { DataTable } from "@/components/DataTable";10import { JsonView } from "@/components/JsonView";1112export default function ExportsPage() {13 const toast = useToast();14 const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);15 const list = useAsync((s) => api.get<{ data: ExportRow[] }>("/exports", undefined, s), []);16 const live = useMemo(() => (list.data?.data ?? []).some((e) => e.status === "queued" || e.status === "running"), [list.data]);17 useInterval(list.reload, live ? 3000 : null);1819 const [datasetId, setDatasetId] = useState("market_bars.stock.1m.raw");20 const [symbols, setSymbols] = useState("");21 const [start, setStart] = useState(daysAgo(365));22 const [end, setEnd] = useState(today());23 const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw");24 const [format, setFormat] = useState<"parquet" | "csv">("parquet");25 const [busy, setBusy] = useState(false);2627 // Prefill from the Bars explorer (range too large)28 useEffect(() => {29 const p = new URLSearchParams(window.location.search);30 if (p.get("symbols")) setSymbols(p.get("symbols")!.replace(/,/g, "\n"));31 if (p.get("start")) setStart(p.get("start")!);32 if (p.get("end")) setEnd(p.get("end")!);33 const a = p.get("adjustment");34 if (a === "raw" || a === "split" || a === "split_div") setAdjustment(a);35 }, []);3637 const syms = splitList(symbols);3839 async function submit(e: FormEvent) {40 e.preventDefault();41 setBusy(true);42 try {43 const out = await api.post<{ id: string; status: string; poll: string }>("/exports", { dataset_id: datasetId, symbols: syms, start, end, adjustment, format });44 toast.success("Export queued", out.id);45 list.reload();46 } catch (err) {47 toast.error(err);48 } finally {49 setBusy(false);50 }51 }5253 return (54 <>55 <PageHeader56 title="Exports"57 subtitle="Asynchronous Parquet/CSV extracts for ranges larger than the live API allows. Dataset version is pinned at submission."58 actions={59 <button className="btn btn-sm" onClick={list.reload}>60 <RefreshCw className="h-3.5 w-3.5" /> Refresh{live ? " (auto 3 s)" : ""}61 </button>62 }63 />64 <div className="grid gap-4 lg:grid-cols-[360px_minmax(0,1fr)]">65 <Card title="New export">66 <form onSubmit={submit} className="flex flex-col gap-3">67 <Field label="Dataset">68 <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>69 {!(datasets.data?.data ?? []).some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}70 {(datasets.data?.data ?? []).filter((d) => d.status === "active").map((d) => (71 <option key={d.id} value={d.id}>72 {d.id}73 </option>74 ))}75 </select>76 </Field>77 <Field label={`Symbols (${syms.length}/500)`} hint="comma, space or newline separated">78 <textarea className="input min-h-24 font-mono uppercase" value={symbols} onChange={(e) => setSymbols(e.target.value)} placeholder={"AAPL\nMSFT\nSPY"} required />79 </Field>80 <div className="grid grid-cols-2 gap-2">81 <Field label="Start">82 <input type="date" className="input" value={start} max={end} onChange={(e) => setStart(e.target.value)} required />83 </Field>84 <Field label="End">85 <input type="date" className="input" value={end} min={start} onChange={(e) => setEnd(e.target.value)} required />86 </Field>87 <Field label="Adjustment">88 <select className="input" value={adjustment} onChange={(e) => setAdjustment(e.target.value as typeof adjustment)}>89 <option value="raw">raw</option>90 <option value="split">split</option>91 <option value="split_div">split + dividend</option>92 </select>93 </Field>94 <Field label="Format">95 <select className="input" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}>96 <option value="parquet">Parquet (zstd)</option>97 <option value="csv">CSV</option>98 </select>99 </Field>100 </div>101 <button className="btn btn-primary" disabled={busy || syms.length === 0 || syms.length > 500}>102 {busy ? "Submitting…" : "Queue export"}103 </button>104 <p className="text-[11px] text-ink-3">Limits: 500 symbols, 12 years, 3 concurrent exports per principal. Files expire after ~72 h. Private use only — do not redistribute.</p>105 </form>106 </Card>107108 <Card title={`Exports (${list.data?.data.length ?? 0})`}>109 {list.error && <ErrorState error={list.error} retry={list.reload} />}110 <DataTable111 rows={list.data?.data}112 loading={list.loading}113 rowKey={(e) => e.id}114 mobile="cards"115 columns={[116 { key: "created_at", header: "Created", render: (e) => <span className="tnum text-xs" title={fmtTs(e.created_at)}>{fmtRelative(e.created_at)}</span> },117 { key: "status", header: "Status", render: (e) => <StatusBadge status={isExpired(e) ? "expired" : e.status} label={isExpired(e) ? "expired" : e.status} /> },118 {119 key: "request",120 header: "Request",121 render: (e) => (122 <div className="text-xs">123 <div className="font-mono">{e.request.dataset_id}{e.request.version !== undefined ? ` v${e.request.version}` : ""}</div>124 <div className="text-ink-2">125 {e.request.symbols.length} symbol{e.request.symbols.length === 1 ? "" : "s"} · {e.request.start} → {e.request.end} · {e.request.adjustment} · {e.format}126 </div>127 <div className="line-clamp-1 font-mono text-[11px] text-ink-3">{e.request.symbols.join(", ")}</div>128 </div>129 ),130 },131 { key: "row_count", header: "Rows", num: true, hideSm: true, render: (e) => fmtInt(e.row_count) },132 { key: "bytes", header: "Size", num: true, render: (e) => fmtBytes(e.bytes) },133 { key: "expires_at", header: "Expires", hideSm: true, render: (e) => <span className="tnum text-xs">{e.expires_at ? fmtRelative(e.expires_at) : "—"}</span> },134 {135 key: "download",136 header: "",137 render: (e) =>138 e.status === "ready" && !isExpired(e) ? (139 <a className="btn btn-sm btn-primary" href={`/api/v1/exports/${e.id}/download`} target="_blank" rel="noopener">140 <Download className="h-3.5 w-3.5" /> Download141 </a>142 ) : e.status === "failed" && e.error ? (143 <JsonView value={e.error} collapsed />144 ) : null,145 },146 ]}147 empty="No exports yet."148 />149 </Card>150 </div>151 <Banner tone="warn" className="mt-4">152 Exported files contain FirstRate Data bars licensed for <strong>private use by the subscriber only</strong>. Do not share, publish or re-host them.153 </Banner>154 </>155 );156}157158function isExpired(e: ExportRow): boolean {159 return e.status === "expired" || (!!e.expires_at && new Date(e.expires_at).getTime() < Date.now());160}161