From 86692bc5ac56187ac6a1ab558f9f803f27a08a1d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 16:13:58 +0000 Subject: [PATCH] feat(web): hide charts with no data in the latest-100 window by default Several groups (appian, clickbench, polarsignals/100000, statpopgen/100000, tpcds, tpch sf=10/100, some wide-table charts) render compression-size cards as bare axes because their fact rows all predate the default latest-100 commit window. Hide those cards by default and add a per-group toolbar toggle (left of "Reset group") that reveals them. - chart-format: `chartHasRecentData` inspects only the trailing latest-100 slots of each series, so the bounded `?n=100` payload, its normalized form, and a full `?n=all` history all classify a chart identically. - chart-store: `GroupSnapshot` gains `emptyCharts` (data-derived, kept across Reset like `knownSeries`) and `showEmptyCharts` (user toggle, Reset returns it to hidden). The group-bundle prime classifies every chart in the group, including cards that never intersect the viewport; the per-chart `?n=100` and full-history paths classify as fallbacks. - Chart island: the card gets the `hidden` attribute while classified empty and not revealed; `maybeConstruct` bails on hidden cards (a display:none canvas would build a zero-size chart) and a reveal effect re-enters construction once the un-hidden DOM is committed. - GroupToolbar: a "Show/Hide N empty charts" button renders once the group has classified at least one empty chart, with a title explaining the latest-100 rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvL7reZ8o1qo3NGtXXNuwP Signed-off-by: Claude --- web/app/globals.css | 18 ++- web/components/Chart.empty-window.test.tsx | 155 +++++++++++++++++++++ web/components/Chart.tsx | 60 +++++++- web/components/GroupSection.tsx | 6 +- web/components/GroupToolbar.test.tsx | 36 +++++ web/components/GroupToolbar.tsx | 35 ++++- web/lib/chart-format.test.ts | 42 ++++++ web/lib/chart-format.ts | 33 +++++ web/lib/chart-store.test.ts | 103 +++++++++++++- web/lib/chart-store.ts | 62 ++++++++- 10 files changed, 531 insertions(+), 19 deletions(-) create mode 100644 web/components/Chart.empty-window.test.tsx diff --git a/web/app/globals.css b/web/app/globals.css index 6983dcd..e693a98 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -957,7 +957,8 @@ body.bench-prebuilding .group-disclosure:not([open]) ~ .chart-grid { flex: 1 1 auto; min-width: 0; } -.group-toolbar-reset { +.group-toolbar-reset, +.group-toolbar-empty-toggle { flex: 0 0 auto; display: inline-flex; align-items: center; @@ -971,7 +972,13 @@ body.bench-prebuilding .group-disclosure:not([open]) ~ .chart-grid { font-size: 0.78rem; cursor: pointer; } -.group-toolbar-reset:hover { +.group-toolbar-reset:hover, +.group-toolbar-empty-toggle:hover { + border-color: var(--accent); + color: var(--accent); +} +/* While the empty charts are revealed the toggle reads as engaged. */ +.group-toolbar-empty-toggle[aria-pressed='true'] { border-color: var(--accent); color: var(--accent); } @@ -1006,6 +1013,13 @@ body.bench-prebuilding .group-disclosure:not([open]) ~ .chart-grid { .chart-card:hover { box-shadow: var(--shadow-md); } +/* A card classified as empty in the latest-100 window hides via the `hidden` + * attribute (the group toolbar's "show empty charts" toggle reveals it). + * Explicit rather than relying on the UA `[hidden]` rule so a future + * `display` property on `.chart-card` cannot silently re-show these cards. */ +.chart-card[hidden] { + display: none; +} .chart-card-title { margin: 0 0 0.4rem; font-size: 0.95rem; diff --git a/web/components/Chart.empty-window.test.tsx b/web/components/Chart.empty-window.test.tsx new file mode 100644 index 0000000..12c8baf --- /dev/null +++ b/web/components/Chart.empty-window.test.tsx @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Chart } from '@/components/Chart'; +import { resetGroup, resetPayloadCache, setShowEmptyCharts } from '@/lib/chart-store'; + +vi.mock('@/lib/chart-js', () => ({ + loadChartJs: () => new Promise(() => {}), +})); + +// jsdom has no IntersectionObserver; the mount effect only needs a constructible +// stub here (no test in this file drives intersection-based hydration — the +// bundle fetch alone classifies and hides the empty cards). +class StubIO { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } +} + +const COMMITS = Array.from({ length: 3 }, (_, i) => ({ + sha: `sha${i}`, + timestamp: `2026-01-01 00:00:0${i}+00`, + message: `c${i}`, + url: `https://example.invalid/sha${i}`, +})); +const HISTORY = { total_commits: 3, start_index: 0, loaded_commits: 3, complete: true }; + +/** A `/api/group/{slug}?n=100` bundle: `s0` empty in the window, `s1` live. */ +function bundleResponse(): Response { + const body = { + name: 'g', + charts: [ + // The empty-window wire shape: seeded commits, no recorded series. + { + name: 'q0', + slug: 's0', + display_name: 'q0', + unit_kind: 'bytes', + history: HISTORY, + commits: COMMITS, + series: {}, + }, + { + name: 'q1', + slug: 's1', + display_name: 'q1', + unit_kind: 'bytes', + history: HISTORY, + commits: COMMITS, + series: { parquet: [1, 2, 3] }, + }, + ], + }; + return { ok: true, status: 200, json: () => Promise.resolve(body) } as unknown as Response; +} + +describe('empty-window charts hide by default and the toggle reveals them', () => { + let container: HTMLElement; + let root: Root | null = null; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + resetPayloadCache(); + vi.stubGlobal('IntersectionObserver', StubIO); + vi.stubGlobal('fetch', () => Promise.resolve(bundleResponse())); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + root = null; + container.remove(); + vi.unstubAllGlobals(); + resetPayloadCache(); + }); + + /** Render islands `s0`/`s1` inside one OPEN group disclosure of `groupSlug` + * and flush until the eager bundle fetch has settled and classified. */ + async function renderGroup(groupSlug: string): Promise { + container.innerHTML = + '
' + + '
g
' + + '
' + + '
'; + const roots: Root[] = []; + await act(async () => { + for (let i = 0; i < 2; i++) { + const r = createRoot(container.querySelector(`#m${i}`) as HTMLElement); + roots.push(r); + r.render(); + } + }); + root = { + unmount: () => roots.forEach((r) => r.unmount()), + render: () => {}, + } as unknown as Root; + // Let the bundle fetch resolve, prime the cache, classify, and re-render. + await act(async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + }); + } + + function card(slug: string): HTMLElement { + return container.querySelector(`[data-chart-slug="${slug}"]`) as HTMLElement; + } + + it('hides only the card with no data in the latest-100 window', async () => { + await renderGroup('empty-hide'); + expect(card('s0').hasAttribute('hidden')).toBe(true); + expect(card('s1').hasAttribute('hidden')).toBe(false); + }); + + it('the toggle reveals the hidden card and Reset re-hides it', async () => { + const groupSlug = 'empty-reveal'; + await renderGroup(groupSlug); + expect(card('s0').hasAttribute('hidden')).toBe(true); + + await act(async () => { + setShowEmptyCharts(groupSlug, true); + }); + expect(card('s0').hasAttribute('hidden')).toBe(false); + // The live sibling is unaffected by the toggle. + expect(card('s1').hasAttribute('hidden')).toBe(false); + + await act(async () => { + resetGroup(groupSlug); + }); + expect(card('s0').hasAttribute('hidden')).toBe(true); + }); + + it('never hides a groupless (permalink-page) card', async () => { + // The permalink island has no group slug: nothing classifies it and the + // `hidden` gate never applies, even with an all-null payload. + const r = createRoot(container); + await act(async () => { + r.render(); + }); + root = r; + expect((container.querySelector('[data-chart-slug="solo"]') as HTMLElement).hidden).toBe(false); + }); +}); diff --git a/web/components/Chart.tsx b/web/components/Chart.tsx index 175425b..d38a600 100644 --- a/web/components/Chart.tsx +++ b/web/components/Chart.tsx @@ -16,6 +16,7 @@ import type { import { CHART_FETCH_N, + chartHasRecentData, clampRangeWindow, collectAllValues, commitDateLabel, @@ -55,6 +56,7 @@ import { import { loadChartJs } from '@/lib/chart-js'; import { abortGroupBundle, + chartIsHiddenAsEmpty, emptyGroupSnapshot, ensureGroupBundle, fullHistoryQueue, @@ -62,6 +64,7 @@ import { getGlobalFilterSnapshot, getGroupSnapshot, hydrationQueue, + noteChartRecentData, noteGroupSeries, subscribeGlobalFilter, subscribeGroup, @@ -459,6 +462,17 @@ class ChartController { return !details || (details as HTMLDetailsElement).open; } + /** Whether the group store currently hides this card as an empty chart (no + * data in the latest-100 window, reveal toggle off). Read from the store, + * not the DOM: the store mutation precedes React's `display: none` commit, + * so gating on it suppresses construction without racing the render. */ + private hiddenAsEmpty(): boolean { + return ( + this.groupSlug !== undefined && + chartIsHiddenAsEmpty(getGroupSnapshot(this.groupSlug), this.slug) + ); + } + /** * Ensure this chart's default `?n=100` payload is loaded. Consults the session * payload cache first (a sibling group-bundle fetch may have already cached @@ -624,6 +638,9 @@ class ChartController { this.cb.setLoading(false); if (this.groupSlug) { noteGroupSeries(this.groupSlug, normalized.series_meta); + // Classify the empty-window state from this `?n=100` payload (the + // per-chart analogue of the bundle-prime classification). + noteChartRecentData(this.groupSlug, this.slug, chartHasRecentData(normalized)); } void this.maybeConstruct(); }, @@ -800,7 +817,10 @@ class ChartController { if (state.chart || state.constructing || !state.payload || state.disposed) { return; } - if (!this.groupIsOpen()) { + // A card hidden as empty is `display: none`; constructing into it would + // yield a zero-size chart. The reveal effect re-enters when the toggle + // shows it (mirroring how the toggle-open path re-enters via onGroupOpen). + if (!this.groupIsOpen() || this.hiddenAsEmpty()) { return; } const { canvas, tooltipHost, card } = this.els(); @@ -829,11 +849,13 @@ class ChartController { if (state.disposed || state.chart || !state.payload) { return; } - // Re-check the disclosure AFTER the await: the group can close while the + // Re-check the disclosure AND the empty-hide AFTER the await: the group + // can close (or the bundle can classify this card empty) while the // Chart.js chunk loads (a window v3 never had, its library being // preloaded), and constructing into the display:none grid would render a - // zero-size chart. The next toggle-open re-enters via onGroupOpen. - if (!this.groupIsOpen()) { + // zero-size chart. The next toggle-open re-enters via onGroupOpen; the + // empty-reveal effect re-enters via maybeConstruct. + if (!this.groupIsOpen() || this.hiddenAsEmpty()) { return; } const payload = state.payload; @@ -1111,6 +1133,14 @@ class ChartController { const state = this.state; const payload = normalizeChartPayload(rawPayload); state.payload = payload; + if (this.groupSlug) { + // [`chartHasRecentData`] reads only the trailing latest-100 slots, so the + // full-history payload yields the same verdict as the `?n=100` window; + // recording here (before the chart-null guard) covers the rare race where + // the full upgrade resolves before the initial window fetch, whose own + // handler then early-returns without classifying. + noteChartRecentData(this.groupSlug, this.slug, chartHasRecentData(payload)); + } const chart = state.chart; if (!chart) { return; @@ -1771,6 +1801,20 @@ export function Chart({ slug, name, index, groupSlug, initialPayload }: ChartIsl controller.applyY(groupState.groupY ?? 'linear', false); }, [groupSlug, groupState.groupY]); + // A chart with no data in the latest-100 window hides by default (the card + // gets the `hidden` attribute below); the group toolbar's "show empty + // charts" toggle reveals it. + const hiddenAsEmpty = groupSlug !== undefined && chartIsHiddenAsEmpty(groupState, slug); + useEffect(() => { + // Construct on reveal: `maybeConstruct` bails while the card is hidden as + // empty (a display:none canvas would build a zero-size chart), so re-enter + // once React has committed the un-hidden DOM. No-op when no payload is + // waiting or the chart already exists. + if (!hiddenAsEmpty) { + void controllerRef.current?.maybeConstruct(); + } + }, [hiddenAsEmpty]); + // Mount wiring: controller construction, payload seeding, the throttled // slider listener, group toggle/intent listeners, and (on the permalink // page) intersection-based construction. @@ -1995,7 +2039,13 @@ export function Chart({ slug, name, index, groupSlug, initialPayload }: ChartIsl }, [error, retryable]); return ( -
+