| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| import { fetchTimeseries } from "./apiBridge"; |
| import type { SurfaceScope } from "./apiBridge"; |
| import { isStreamlitComponent } from "./hostBridge"; |
| import { |
| DEFAULT_TS_BUCKET, |
| DEFAULT_TS_LAST_N, |
| TS_DELTA_LABELS, |
| buildTsRequest, |
| buildTsSheet, |
| buildTsSnapshotRow, |
| buildTsTable, |
| classifyTsField, |
| formatTsDelta, |
| formatTsValue, |
| resolveTsStyle, |
| sparklinePoints, |
| sparklineSegments, |
| tsDisplayNumber, |
| tsRefusalReason, |
| tsRowToChartModel, |
| } from "./timeSeriesData"; |
| import type { |
| TsDeltaSubRow, |
| TsPayload, |
| TsSheet, |
| TsSheetRow, |
| TsTableRow, |
| } from "./timeSeriesData"; |
| import { |
| TS_BUCKETS, |
| TS_DELTA_KINDS, |
| TS_MAX_CUSTOM_ROWS, |
| TS_MAX_FIELDS, |
| TS_ROW_LABEL_MAX, |
| TS_MAX_EXPR, |
| } from "./types"; |
| import type { |
| DisplaySpec, |
| Field, |
| TsBucket, |
| TsCellStyle, |
| TsCustomRow, |
| TsDeltaKind, |
| } from "./types"; |
| import { FieldSelectButton } from "./FieldSelect"; |
| import { Popover } from "../filter-kit"; |
| import { toVegaLiteSpec } from "../viz/vega/toVegaLiteSpec"; |
| import VegaLiteChart from "../viz/vega/VegaLiteChart"; |
|
|
| const BUCKET_LABELS: Record<TsBucket, string> = { |
| week: "Weekly", |
| month: "Monthly", |
| quarter: "Quarterly", |
| year: "Yearly", |
| }; |
|
|
| |
| const LAST_N_CHOICES = [6, 12, 24, 36]; |
|
|
| |
| const SPARK_W = 72; |
| const SPARK_H = 18; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function IconCompare() { |
| const s = { |
| stroke: "currentColor", |
| strokeWidth: 1.5, |
| strokeLinecap: "round" as const, |
| strokeLinejoin: "round" as const, |
| fill: "none", |
| }; |
| return ( |
| <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden> |
| <path d="M2.5 11.5h4v-4h4v-3h3" {...s} /> |
| <path d="M2.5 13.5h11" {...s} opacity="0.45" /> |
| </svg> |
| ); |
| } |
|
|
| export interface TimeSeriesPanelProps { |
| scope: SurfaceScope; |
| |
| pids: number[]; |
| |
| fields: Field[]; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| rows?: readonly Record<string, unknown>[]; |
| |
| display?: DisplaySpec; |
| |
| onDisplay?: (next: Partial<DisplaySpec>) => void; |
| |
| locked?: boolean; |
| |
| |
| |
| |
| onSheet?: (sheet: TsSheet) => void; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function classifyFields(fields: Field[]): { |
| server: Field[]; |
| snapshot: Field[]; |
| refused: { field: Field; why: string }[]; |
| } { |
| const out = { |
| server: [] as Field[], |
| snapshot: [] as Field[], |
| refused: [] as { field: Field; why: string }[], |
| }; |
| for (const f of fields) { |
| const cls = classifyTsField(f); |
| if (cls === "server") out.server.push(f); |
| else if (cls === "snapshot") out.snapshot.push(f); |
| else out.refused.push({ field: f, why: tsRefusalReason(f) }); |
| } |
| return out; |
| } |
|
|
| |
| function newRowId(existing: readonly TsCustomRow[]): string { |
| let n = existing.length + 1; |
| const taken = new Set(existing.map((r) => r.id)); |
| while (taken.has(`tsr_${n}`)) n += 1; |
| return `tsr_${n}`; |
| } |
|
|
| export default function TimeSeriesPanel({ |
| scope, |
| pids, |
| fields, |
| rows, |
| display, |
| onDisplay, |
| locked, |
| onSheet, |
| }: TimeSeriesPanelProps) { |
| const classed = useMemo(() => classifyFields(fields), [fields]); |
| |
| |
| const eligible = useMemo( |
| () => [...classed.server, ...classed.snapshot], |
| [classed.server, classed.snapshot] |
| ); |
| const snapshotKeys = useMemo( |
| () => new Set(classed.snapshot.map((f) => f.key)), |
| [classed.snapshot] |
| ); |
|
|
| const bucket: TsBucket = (TS_BUCKETS as readonly string[]).includes(display?.tsBucket ?? "") |
| ? (display!.tsBucket as TsBucket) |
| : DEFAULT_TS_BUCKET; |
| const lastN = |
| typeof display?.tsSpan?.lastN === "number" ? display.tsSpan.lastN : DEFAULT_TS_LAST_N; |
| const selected = useMemo(() => { |
| const want = display?.tsFields ?? []; |
| const live = want.filter((k) => eligible.some((f) => f.key === k)); |
| |
| |
| |
| return live.length ? live : eligible.slice(0, 1).map((f) => f.key); |
| }, [display?.tsFields, eligible]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| const wired = useMemo( |
| () => selected.filter((k) => !snapshotKeys.has(k)), |
| [selected, snapshotKeys] |
| ); |
| const snapshotSelected = useMemo( |
| () => selected.filter((k) => snapshotKeys.has(k)), |
| [selected, snapshotKeys] |
| ); |
|
|
| const customRows = useMemo(() => display?.tsRows ?? [], [display?.tsRows]); |
| const deltaKinds = useMemo(() => display?.tsDeltas ?? [], [display?.tsDeltas]); |
| const styles = display?.tsStyles; |
| |
| |
| const gridlines = display?.tsGridlines !== false; |
| const sparkline = display?.tsSparkline === true; |
|
|
| const [payload, setPayload] = useState<TsPayload | null>(null); |
| const [state, setState] = useState<"idle" | "loading" | "error">("idle"); |
| const [errStatus, setErrStatus] = useState(0); |
| const [charted, setCharted] = useState<Set<string>>(new Set()); |
| |
| |
| const reqRef = useRef(0); |
|
|
| const embedded = isStreamlitComponent(); |
|
|
| useEffect(() => { |
| if (embedded) return; |
| if (!wired.length || !pids.length) { |
| setPayload(null); |
| setState("idle"); |
| return; |
| } |
| const seq = ++reqRef.current; |
| setState("loading"); |
| const body = buildTsRequest({ pids, bucket, span: { lastN }, fields: wired }); |
| fetchTimeseries(body, scope).then(({ payload: p, status }) => { |
| if (seq !== reqRef.current) return; |
| if (!p) { |
| setPayload(null); |
| setErrStatus(status); |
| setState("error"); |
| return; |
| } |
| setPayload(p); |
| setState("idle"); |
| }); |
| }, [embedded, scope, pids, bucket, lastN, wired]); |
|
|
| const served = useMemo(() => buildTsTable(payload), [payload]); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const table = useMemo(() => { |
| if (!snapshotSelected.length || !served.columns.length) return served; |
| const byKey = new Map(fields.map((f) => [f.key, f])); |
| const extra = snapshotSelected |
| .map((k) => byKey.get(k)) |
| .filter((f): f is Field => !!f) |
| .map((f) => buildTsSnapshotRow(f, rows ?? [], served.columns, payload?.meta?.today)); |
| return { ...served, rows: [...served.rows, ...extra], empty: false }; |
| }, [served, snapshotSelected, fields, rows, payload?.meta?.today]); |
| const sheet: TsSheet = useMemo( |
| () => |
| buildTsSheet(table, { |
| customRows, |
| deltas: deltaKinds, |
| bucket, |
| today: payload?.meta?.today, |
| }), |
| [table, customRows, deltaKinds, bucket, payload?.meta?.today] |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| onSheet?.(sheet); |
| }, [sheet, onSheet]); |
|
|
| const toggleChart = useCallback((key: string) => { |
| setCharted((prev) => { |
| const next = new Set(prev); |
| if (next.has(key)) next.delete(key); |
| else next.add(key); |
| return next; |
| }); |
| }, []); |
|
|
| |
| |
| |
| const setMetrics = (keys: string[]) => { |
| |
| if (!keys.length) return; |
| onDisplay?.({ tsFields: keys.slice(0, TS_MAX_FIELDS) }); |
| }; |
| const setRows = (rows: TsCustomRow[]) => |
| onDisplay?.({ tsRows: rows.length ? rows.slice(0, TS_MAX_CUSTOM_ROWS) : undefined }); |
| const toggleDelta = (kind: TsDeltaKind) => { |
| const next = deltaKinds.includes(kind) |
| ? deltaKinds.filter((k) => k !== kind) |
| : [...deltaKinds, kind]; |
| onDisplay?.({ tsDeltas: next.length ? next : undefined }); |
| }; |
| const setStyle = (rowId: string, patch: TsCellStyle) => { |
| const next: Record<string, TsCellStyle> = { ...(styles ?? {}) }; |
| const merged = { ...resolveTsStyle(styles, rowId), ...patch }; |
| |
| |
| |
| const kept: TsCellStyle = {}; |
| if (merged.bold) kept.bold = true; |
| if (merged.line) kept.line = true; |
| if (kept.bold || kept.line) next[rowId] = kept; |
| else delete next[rowId]; |
| onDisplay?.({ tsStyles: Object.keys(next).length ? next : undefined }); |
| }; |
|
|
| |
| |
| if (embedded) { |
| return ( |
| <div className="cg-mode-empty"> |
| The time-series view runs in the web app. Open Loopable in a browser tab to see it. |
| </div> |
| ); |
| } |
|
|
| if (!eligible.length) { |
| return ( |
| <div className="cg-mode-empty"> |
| This table has no number fields to plot. A time series needs either a metric with a |
| period, or a numeric column to snapshot β add one from a column menu, then pick it here. |
| </div> |
| ); |
| } |
|
|
| const unused = eligible.filter((f) => !selected.includes(f.key)); |
| const colCount = sheet.columns.length + 2 + (sparkline ? 1 : 0); |
| |
| |
| |
| |
| const snapshotsUnplaced = |
| snapshotSelected.length > 0 && sheet.columns.length === 0 && state !== "loading"; |
|
|
| return ( |
| <div className="cg-ts"> |
| <div className="cg-ts-bar"> |
| <label className="cg-ts-ctl"> |
| <span className="cg-ts-ctl-k">Period</span> |
| <select |
| className="cg-select" |
| value={bucket} |
| onChange={(e) => onDisplay?.({ tsBucket: e.target.value as TsBucket })} |
| disabled={!onDisplay} |
| > |
| {TS_BUCKETS.map((b) => ( |
| <option key={b} value={b}> |
| {BUCKET_LABELS[b]} |
| </option> |
| ))} |
| </select> |
| </label> |
| <label className="cg-ts-ctl"> |
| <span className="cg-ts-ctl-k">Span</span> |
| <select |
| className="cg-select" |
| value={String(lastN)} |
| onChange={(e) => onDisplay?.({ tsSpan: { lastN: Number(e.target.value) } })} |
| disabled={!onDisplay} |
| > |
| {LAST_N_CHOICES.map((n) => ( |
| <option key={n} value={n}> |
| Last {n} |
| </option> |
| ))} |
| </select> |
| </label> |
|
|
| {/* Item 18 β four named comparisons where there used to be one unnamed toggle. |
| "Change vs prior" could not say WHICH prior, and there are four honest answers. |
|
|
| wave17 owner item 7 β those four sat OPEN on the bar as a labelled strip, which made |
| this panel's most consequential control look like nothing else in the product. It is |
| a `Popover` now: the same component Filter / Sort / Group mount, so "the same" is |
| achieved by BEING the same rather than by resembling it (the component's own note). |
| The count rides the label in the `Filter Β· 2` grammar, and `active` lights the button |
| while any comparison is on β the strip used to be the only way to know. */} |
| <Popover |
| label={deltaKinds.length ? `Compare Β· ${deltaKinds.length}` : "Compare"} |
| icon={<IconCompare />} |
| active={deltaKinds.length > 0} |
| > |
| {() => ( |
| <div className="cg-pop-body"> |
| <div className="cg-pop-title">Compare each period with</div> |
| <div className="cg-pop-note"> |
| Each choice adds a sub-row under every metric. A comparison that cannot be |
| answered for a metric says so in the footnotes rather than printing a number. |
| </div> |
| {TS_DELTA_KINDS.map((kind) => ( |
| <label key={kind} className="cg-check-row"> |
| <input |
| type="checkbox" |
| checked={deltaKinds.includes(kind)} |
| disabled={!onDisplay} |
| onChange={() => toggleDelta(kind)} |
| /> |
| <span className="cg-check-label">{TS_DELTA_LABELS[kind]}</span> |
| </label> |
| ))} |
| </div> |
| )} |
| </Popover> |
|
|
| <span className="cg-ts-group"> |
| <button |
| type="button" |
| className={"cg-ts-tog" + (gridlines ? " is-on" : "")} |
| aria-pressed={gridlines} |
| disabled={!onDisplay} |
| // The literal `false` is the ONLY storable opt-out; the default is stored by |
| // DELETING the key. Writing `true` would be a second spelling the host drops, |
| // and the toggle would look broken. |
| onClick={() => onDisplay?.({ tsGridlines: gridlines ? false : undefined })} |
| > |
| Gridlines |
| </button> |
| <button |
| type="button" |
| className={"cg-ts-tog" + (sparkline ? " is-on" : "")} |
| aria-pressed={sparkline} |
| disabled={!onDisplay} |
| onClick={() => onDisplay?.({ tsSparkline: sparkline ? undefined : true })} |
| > |
| Trend |
| </button> |
| </span> |
|
|
| {/* β NO EXPORT BUTTON HERE (owner item 3, 2026-08-03). Export lives in the view's "β¦" |
| menu, on every view, in four formats β and from a time-series view it now carries |
| THIS SHEET rather than the rows beneath it (see `onSheet` above). A second door on |
| one surface is how "where do I export from?" stops having an answer. */} |
| <span className="cg-ts-pool"> |
| {locked |
| ? "This record" |
| : `${sheet.pool.toLocaleString()} ${sheet.pool === 1 ? "customer" : "customers"}`} |
| </span> |
| </div> |
|
|
| {state === "error" && ( |
| <div className="cg-ts-note cg-ts-error"> |
| {errStatus === 400 |
| ? "That span is too wide to bucket. Choose a shorter span or a larger period." |
| : errStatus === 403 |
| ? "None of these records are in your book." |
| : "The time series could not be loaded. Nothing is shown rather than a guess."} |
| </div> |
| )} |
|
|
| {snapshotsUnplaced && ( |
| <div className="cg-ts-note"> |
| {snapshotSelected.length === 1 ? "That column has" : "Those columns have"} no period of |
| its own, so {snapshotSelected.length === 1 ? "it is" : "they are"} shown as a snapshot |
| across the periods on the sheet β and this sheet has no periods yet. Add one metric with |
| a period and {snapshotSelected.length === 1 ? "it" : "they"} will appear beside it. |
| </div> |
| )} |
|
|
| {/* wave17 item 5 / R9 β "offered is the ruling, undisclosed is not", and its converse: a |
| field that CANNOT be plotted is NAMED here rather than quietly missing from the picker. |
| A reader who can see "Status" in the table and cannot find it in this list otherwise has |
| no way to tell a refusal from an oversight. |
|
|
| β COLLAPSED, AFTER THE OWNER READ IT AS BROKEN MARKUP (2026-08-03). R9's disclosure was |
| shipped as a bare strip of every refused label β struck through, unheaded, at the small |
| end of the type scale. On the customer table that is ~25 names, and 25 crossed-out words |
| in a row under a toolbar do not read as "these columns have no period"; they read as a |
| stylesheet that failed to load. The strip said WHAT but never WHY IT WAS THERE. |
|
|
| A `<details>` fixes exactly that and nothing else: the disclosure is still complete and |
| still one click from the panel, but it now leads with a SENTENCE that says what the list |
| is, and it is shut until asked. Native rather than a Popover β no state, keyboard and |
| screen-reader behaviour for free, and it degrades to an open list if CSS never arrives, |
| which is the failure this element is being fixed for. */} |
| {classed.refused.length > 0 && ( |
| <details className="cg-ts-refused"> |
| <summary className="cg-ts-refsum"> |
| {classed.refused.length === 1 |
| ? "1 column has no period of its own" |
| : `${classed.refused.length} columns have no period of their own`} |
| </summary> |
| <div className="cg-ts-reflist"> |
| {classed.refused.map(({ field, why }) => ( |
| <span key={field.key} className="cg-ts-refchip" title={why}> |
| {field.label} |
| </span> |
| ))} |
| </div> |
| </details> |
| )} |
|
|
| {/* wave17 GRID β item 3 / R6. Only the LOADING note becomes a spinner; the refusal notes |
| above it keep their words, because "could not be loaded" is an answer and R6 is about |
| the wait. A spinner in place of a refusal would spin forever. */} |
| {state === "loading" && !payload && ( |
| <div className="cg-ts-note"> |
| <span className="lp-spin" role="status" aria-label="Loading" /> |
| </div> |
| )} |
|
|
| {payload && ( |
| <div className="cg-ts-scroll"> |
| <table className={"cg-ts-table" + (gridlines ? "" : " is-nogrid")}> |
| <thead> |
| <tr> |
| <th className="cg-ts-stick">Metric</th> |
| {sheet.columns.map((c) => ( |
| <th key={c.key} className={c.partial ? "is-partial" : undefined}> |
| {c.label} |
| {c.partial && <span className="cg-ts-partial"> Β· partial</span>} |
| </th> |
| ))} |
| <th className="cg-ts-total">Total</th> |
| {sparkline && <th className="cg-ts-sparkhead">Trend</th>} |
| </tr> |
| </thead> |
| <tbody> |
| {sheet.rows.map((row) => ( |
| <TsRowBlock |
| key={row.id} |
| row={row} |
| sheet={sheet} |
| style={resolveTsStyle(styles, row.id)} |
| styles={styles} |
| sparkline={sparkline} |
| charted={charted.has(row.id)} |
| editable={!!onDisplay} |
| metricRow={table.rows.find((r) => r.field === row.id)} |
| onToggleChart={() => toggleChart(row.id)} |
| onStyle={(patch) => setStyle(row.id, patch)} |
| onRemove={() => { |
| if (row.kind === "metric") |
| setMetrics(selected.filter((k) => k !== row.id)); |
| else setRows(customRows.filter((r) => r.id !== row.id)); |
| }} |
| onEditRow={(patch) => |
| setRows(customRows.map((r) => (r.id === row.id ? { ...r, ...patch } : r))) |
| } |
| /> |
| ))} |
| {/* Item 17 β the sheet GROWS from its bottom edge, the way a spreadsheet does. |
| This row is both the list of what could be added and the control that adds |
| it; a picker somewhere else would be a second inventory to keep in sync. */} |
| {onDisplay && ( |
| <tr className="cg-ts-addrow"> |
| <th className="cg-ts-stick" colSpan={colCount}> |
| <span className="cg-ts-addbar"> |
| {/* C-FLDSEL (item 20) β the type mark travels with the name. `value` is |
| deliberately left UNSET: this control never displays a choice, it |
| only makes one, so the placeholder IS its resting state (which is |
| also the scar the contract names β a native <select> with no value |
| paints its first option and claims a choice nobody made). */} |
| <FieldSelectButton |
| fields={unused} |
| value={undefined} |
| onChange={(key) => setMetrics([...selected, key])} |
| className="cg-select cg-ts-addsel" |
| ariaLabel="Add a metric row" |
| disabled={!unused.length || selected.length >= TS_MAX_FIELDS} |
| placeholder={ |
| !unused.length |
| ? "Every metric is on the sheet" |
| : selected.length >= TS_MAX_FIELDS |
| ? `At the ${TS_MAX_FIELDS}-metric limit` |
| : "+ Add metric" |
| } |
| /> |
| <button |
| type="button" |
| className="cg-ts-tog" |
| disabled={customRows.length >= TS_MAX_CUSTOM_ROWS} |
| onClick={() => |
| setRows([ |
| ...customRows, |
| { id: newRowId(customRows), kind: "note", label: "" }, |
| ]) |
| } |
| > |
| + Note |
| </button> |
| <button |
| type="button" |
| className="cg-ts-tog" |
| disabled={customRows.length >= TS_MAX_CUSTOM_ROWS} |
| onClick={() => |
| setRows([ |
| ...customRows, |
| { id: newRowId(customRows), kind: "formula", label: "", expr: "" }, |
| ]) |
| } |
| > |
| + Formula |
| </button> |
| </span> |
| </th> |
| </tr> |
| )} |
| </tbody> |
| </table> |
| </div> |
| )} |
|
|
| {payload && sheet.empty && ( |
| <div className="cg-ts-note">No metrics returned a series for this span.</div> |
| )} |
|
|
| {sheet.notes.length > 0 && ( |
| <ul className="cg-ts-notes"> |
| {sheet.notes.map((n) => ( |
| <li key={n}>{n}</li> |
| ))} |
| </ul> |
| )} |
| </div> |
| ); |
| } |
|
|
| /** A row's own controls: emphasis, then removal. Hover-revealed, always in the tab order. */ |
| function RowTools({ |
| style, |
| editable, |
| onStyle, |
| onRemove, |
| label, |
| }: { |
| style: TsCellStyle; |
| editable: boolean; |
| onStyle: (patch: TsCellStyle) => void; |
| onRemove: () => void; |
| label: string; |
| }) { |
| if (!editable) return null; |
| return ( |
| <span className="cg-ts-rowtools"> |
| <button |
| type="button" |
| className={"cg-ts-rowtool" + (style.bold ? " is-on" : "")} |
| aria-pressed={!!style.bold} |
| aria-label={`Bold ${label}`} |
| title="Bold" |
| onClick={() => onStyle({ bold: style.bold ? undefined : true })} |
| > |
| B |
| </button> |
| <button |
| type="button" |
| className={"cg-ts-rowtool" + (style.line ? " is-on" : "")} |
| aria-pressed={!!style.line} |
| aria-label={`Rule above ${label}`} |
| title="Rule above" |
| onClick={() => onStyle({ line: style.line ? undefined : true })} |
| > |
| β |
| </button> |
| <button |
| type="button" |
| className="cg-ts-rowtool" |
| aria-label={`Remove ${label}`} |
| title="Remove row" |
| onClick={onRemove} |
| > |
| Γ |
| </button> |
| </span> |
| ); |
| } |
|
|
| /** |
| * One sheet row: its values, its delta sub-rows, and an optional line chart. |
| * |
| * The chart is built from `tsRowToChartModel`, NOT from `viz/chartData.buildChartData` β see |
| * that function's note. `buildChartData` caps at 12 buckets, which for a time series means |
| * silently deleting periods. |
| */ |
| function TsRowBlock({ |
| row, |
| sheet, |
| style, |
| styles, |
| sparkline, |
| charted, |
| editable, |
| metricRow, |
| onToggleChart, |
| onStyle, |
| onRemove, |
| onEditRow, |
| }: { |
| row: TsSheetRow; |
| sheet: TsSheet; |
| style: TsCellStyle; |
| styles: Record<string, TsCellStyle> | undefined; |
| sparkline: boolean; |
| charted: boolean; |
| editable: boolean; |
| metricRow: TsTableRow | undefined; |
| onToggleChart: () => void; |
| onStyle: (patch: TsCellStyle) => void; |
| onRemove: () => void; |
| onEditRow: (patch: Partial<TsCustomRow>) => void; |
| }) { |
| const spec = useMemo(() => { |
| if (!charted || !metricRow) return null; |
| const model = tsRowToChartModel(metricRow, sheet.columns); |
| if (!model.points.length) return null; |
| return toVegaLiteSpec({ |
| spec: { id: row.id, kind: "line", agg: (row.agg ?? "sum") as never }, |
| model, |
| yTitle: row.label, |
| formatValue: (n) => n.toLocaleString(undefined, { maximumFractionDigits: 2 }), |
| }); |
| }, [charted, metricRow, sheet.columns, row.id, row.agg, row.label]); |
|
|
| const colCount = sheet.columns.length + 2 + (sparkline ? 1 : 0); |
| const rowCls = |
| (style.line ? "is-ruled" : "") + (style.bold ? " is-bold" : "") || undefined; |
|
|
| // A NOTE row is a label across the sheet β a section heading in a financial model, not a |
| // series with empty cells. Spanning it is what stops twelve blank cells from reading as |
| // twelve unanswerable ones ("β" already means something else here). |
| if (row.kind === "note") { |
| return ( |
| <tr className={"cg-ts-noterow " + (rowCls ?? "")}> |
| <th className="cg-ts-stick" colSpan={colCount}> |
| <span className="cg-ts-rowhead"> |
| {editable ? ( |
| <input |
| className="cg-ts-rowinput" |
| value={row.label} |
| maxLength={TS_ROW_LABEL_MAX} |
| placeholder="Note β type a heading for this section" |
| aria-label="Note row label" |
| onChange={(e) => onEditRow({ label: e.target.value })} |
| /> |
| ) : ( |
| <span className="cg-ts-rowlabel">{row.label}</span> |
| )} |
| <RowTools |
| style={style} |
| editable={editable} |
| onStyle={onStyle} |
| onRemove={onRemove} |
| label={row.label || "note row"} |
| /> |
| </span> |
| </th> |
| </tr> |
| ); |
| } |
|
|
| return ( |
| <> |
| <tr className={rowCls}> |
| <th scope="row" className="cg-ts-stick"> |
| <span className="cg-ts-rowhead"> |
| <span className="cg-ts-rowmain"> |
| {row.kind === "formula" ? ( |
| editable ? ( |
| <input |
| className="cg-ts-rowinput" |
| value={row.label} |
| maxLength={TS_ROW_LABEL_MAX} |
| placeholder="Formula row name" |
| aria-label="Formula row label" |
| onChange={(e) => onEditRow({ label: e.target.value })} |
| /> |
| ) : ( |
| <span className="cg-ts-rowlabel">{row.label}</span> |
| ) |
| ) : ( |
| <button |
| type="button" |
| className="cg-ts-rowbtn" |
| onClick={onToggleChart} |
| aria-pressed={charted} |
| title={charted ? "Show as a row of numbers" : "Show as a line"} |
| > |
| {row.label} |
| </button> |
| )} |
| {/* R1 β the metric's OWN window. Two rows under the same month headings can be |
| a cumulative year-to-date and a trailing 90 days; without this line they |
| look like the same kind of number. */} |
| {row.sublabel && <span className="cg-ts-rowsub">{row.sublabel}</span>} |
| {row.kind === "formula" && |
| (editable ? ( |
| <input |
| className="cg-ts-rowinput cg-ts-rowexpr" |
| value={row.expr ?? ""} |
| maxLength={TS_MAX_EXPR} |
| spellCheck={false} |
| placeholder="= [Sales] - [Returns]" |
| aria-label="Formula" |
| onChange={(e) => onEditRow({ expr: e.target.value })} |
| /> |
| ) : ( |
| <span className="cg-ts-rowsub">{row.expr}</span> |
| ))} |
| </span> |
| <RowTools |
| style={style} |
| editable={editable} |
| onStyle={onStyle} |
| onRemove={onRemove} |
| label={row.label || "row"} |
| /> |
| </span> |
| </th> |
| {row.cells.map((c, i) => { |
| const col = sheet.columns[i]; |
| const cellStyle = resolveTsStyle(styles, row.id, col?.key); |
| return ( |
| <td |
| key={col?.key ?? i} |
| className={ |
| (c.partial ? "is-partial" : "") + (cellStyle.bold ? " is-bold" : "") || undefined |
| } |
| > |
| {/* item 6 β `tsDisplayNumber`, never an inline toLocaleString: the CSV calls the |
| same function, and that is the only thing making "the file matches the sheet" |
| a property rather than a coincidence. */} |
| {formatTsValue(c.value, tsDisplayNumber)} |
| </td> |
| ); |
| })} |
| <td className="cg-ts-total"> |
| {/* An avg/min/max row has NO total β the cell stays empty rather than carrying an |
| average of averages. See timeSeriesData's header note (2). Formula and note rows |
| carry none either: an arbitrary expression is not additive across periods. */} |
| {row.total == null ? "" : tsDisplayNumber(row.total)} |
| </td> |
| {sparkline && ( |
| <td className="cg-ts-spark"> |
| <Sparkline values={row.cells.map((c) => c.value)} label={row.label} /> |
| </td> |
| )} |
| </tr> |
| {row.deltas.map((d) => ( |
| <TsDeltaRow key={d.kind} delta={d} sparkline={sparkline} /> |
| ))} |
| {charted && ( |
| <tr className="cg-ts-chartrow"> |
| <td colSpan={colCount}> |
| {spec ? ( |
| <VegaLiteChart spec={spec} label={`${row.label} over time`} /> |
| ) : ( |
| <div className="cg-ts-note"> |
| Nothing to plot β every period for this metric is unanswered. |
| </div> |
| )} |
| </td> |
| </tr> |
| )} |
| </> |
| ); |
| } |
|
|
| /** |
| * One comparison sub-row. |
| * |
| * ONE typography for all four (item 18's "standardized type"): the same 2xs size, the sign |
| * carried by a `-deep` tint, refusals as "β". A comparison that cannot be answered AT ALL |
| * (no year in the span; year-to-date growth asked of a trailing-90 row) renders its dashes and |
| * the sheet's footnotes say why once β the alternative is a column of dashes indistinguishable |
| * from missing data. |
| */ |
| function TsDeltaRow({ |
| delta, |
| sparkline, |
| }: { |
| delta: TsDeltaSubRow; |
| sparkline: boolean; |
| }) { |
| return ( |
| <tr className="cg-ts-delta"> |
| <th scope="row" className="cg-ts-stick"> |
| {delta.label} |
| </th> |
| {delta.values.map((v, i) => ( |
| <td key={i} className={v == null ? undefined : v >= 0 ? "is-up" : "is-down"}> |
| {delta.percent |
| ? formatTsDelta(v) |
| : v == null |
| ? "β" |
| : `${v > 0 ? "+" : ""}${tsDisplayNumber(v)}`} |
| </td> |
| ))} |
| <td className="cg-ts-total" /> |
| {sparkline && <td className="cg-ts-spark" />} |
| </tr> |
| ); |
| } |
|
|
| /** |
| * The trend column: a hand-rolled polyline, no library. |
| * |
| * β Gaps BREAK the line rather than being bridged. A stroke drawn straight across an |
| * unanswerable period asserts a value for it β the same lie the table refuses one cell to the |
| * left, at 72 pixels wide. |
| */ |
| function Sparkline({ values, label }: { values: (number | null)[]; label: string }) { |
| const segments = useMemo( |
| () => sparklineSegments(values, SPARK_W, SPARK_H), |
| [values] |
| ); |
| if (!segments.length) return <span className="cg-ts-sparkempty">β</span>; |
| const answered = values.filter((v) => v != null).length; |
| return ( |
| <svg |
| className="cg-ts-sparksvg" |
| width={SPARK_W} |
| height={SPARK_H} |
| viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} |
| role="img" |
| aria-label={`${label}: trend over ${answered} of ${values.length} periods`} |
| > |
| {segments.map((s, i) => ( |
| <polyline |
| key={i} |
| points={sparklinePoints(s)} |
| fill="none" |
| stroke="currentColor" |
| strokeWidth="1.25" |
| strokeLinejoin="round" |
| strokeLinecap="round" |
| /> |
| ))} |
| </svg> |
| ); |
| } |
|
|