Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down
155 changes: 155 additions & 0 deletions web/components/Chart.empty-window.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
container.innerHTML =
'<section class="group-details">' +
'<details class="group-disclosure" open><summary class="group-summary">g</summary></details>' +
'<div class="chart-grid"><div id="m0"></div><div id="m1"></div></div>' +
'</section>';
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(<Chart slug={`s${i}`} name={`q${i}`} index={i} groupSlug={groupSlug} />);
}
});
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(<Chart slug="solo" name="solo" index={0} />);
});
root = r;
expect((container.querySelector('[data-chart-slug="solo"]') as HTMLElement).hidden).toBe(false);
});
});
60 changes: 55 additions & 5 deletions web/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {

import {
CHART_FETCH_N,
chartHasRecentData,
clampRangeWindow,
collectAllValues,
commitDateLabel,
Expand Down Expand Up @@ -55,13 +56,15 @@ import {
import { loadChartJs } from '@/lib/chart-js';
import {
abortGroupBundle,
chartIsHiddenAsEmpty,
emptyGroupSnapshot,
ensureGroupBundle,
fullHistoryQueue,
getCachedPayload,
getGlobalFilterSnapshot,
getGroupSnapshot,
hydrationQueue,
noteChartRecentData,
noteGroupSeries,
subscribeGlobalFilter,
subscribeGroup,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
},
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1995,7 +2039,13 @@ export function Chart({ slug, name, index, groupSlug, initialPayload }: ChartIsl
}, [error, retryable]);

return (
<section className="chart-card" data-chart-index={index} data-chart-slug={slug} ref={cardRef}>
<section
className="chart-card"
data-chart-index={index}
data-chart-slug={slug}
hidden={hiddenAsEmpty}
ref={cardRef}
>
<h3 className="chart-card-title">
<a href={`/chart/${slug}`}>{name}</a>
<span
Expand Down
6 changes: 3 additions & 3 deletions web/components/GroupSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ import type { Group } from '@/lib/queries';
* `/api/chart/[slug]` when this group's disclosure opens (it listens for the
* native `toggle` event of the enclosing `details.group-disclosure`).
*
* The per-group toolbar (group Y override + series filter + reset) sits
* between the summary card and the chart grid, exactly as in v3's
* `landing_body`; CSS hides it while the disclosure is closed.
* The per-group toolbar (group Y override + series filter + empty-charts
* toggle + reset) sits between the summary card and the chart grid, exactly as
* in v3's `landing_body`; CSS hides it while the disclosure is closed.
*
* `startIndex` is the running page-wide chart index of this group's first
* chart, so `data-chart-index` is unique across every chart on the page.
Expand Down
36 changes: 36 additions & 0 deletions web/components/GroupToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';

import { GroupToolbar } from '@/components/GroupToolbar';
import { noteChartRecentData, setShowEmptyCharts } from '@/lib/chart-store';

const UNIVERSE = { engines: ['datafusion', 'duckdb'], formats: ['parquet', 'vortex'] };

Expand Down Expand Up @@ -38,4 +39,39 @@ describe('GroupToolbar (server-rendered markup)', () => {
// post-`syncGroupChipsUi` state): no known series, nothing to match.
expect(html).not.toContain('filter-chip filter-chip--active');
});

it('renders NO empty-charts toggle while the group has no classified empty chart', () => {
expect(html).not.toContain('data-role="group-empty-toggle"');
});
});

describe('GroupToolbar empty-charts toggle', () => {
// The module-scope group store outlives renders, so noting empty charts
// before rendering mirrors a bundle fetch having classified the group.
it('appears once empty charts are known, to the LEFT of the Reset button', () => {
const slug = 'toolbar-empty-charts';
noteChartRecentData(slug, 'c1', false);
noteChartRecentData(slug, 'c2', false);
const html = renderToStaticMarkup(<GroupToolbar groupSlug={slug} universe={UNIVERSE} />);
expect(html).toContain('data-role="group-empty-toggle"');
expect(html).toContain('Show 2 empty charts');
expect(html).toMatch(/data-role="group-empty-toggle"[^>]*aria-pressed="false"/);
const toggleIdx = html.indexOf('data-role="group-empty-toggle"');
const resetIdx = html.indexOf('data-role="group-toolbar-reset"');
expect(toggleIdx).toBeGreaterThanOrEqual(0);
expect(toggleIdx).toBeLessThan(resetIdx);
});

it('singularizes the label and flips to "Hide" while revealed', () => {
const slug = 'toolbar-empty-charts-one';
noteChartRecentData(slug, 'c1', false);
let html = renderToStaticMarkup(<GroupToolbar groupSlug={slug} universe={UNIVERSE} />);
expect(html).toContain('Show 1 empty chart');
expect(html).not.toContain('Show 1 empty charts');

setShowEmptyCharts(slug, true);
html = renderToStaticMarkup(<GroupToolbar groupSlug={slug} universe={UNIVERSE} />);
expect(html).toContain('Hide 1 empty chart');
expect(html).toMatch(/data-role="group-empty-toggle"[^>]*aria-pressed="true"/);
});
});
Loading
Loading