Skip to content

Commit b87b172

Browse files
committed
plan: scope PR-5.0.9 (opt-in full-history loading) + approved design doc
UI/UX round scoped via brainstorming; design approved by the user. New sub-PR PR-5.0.9 inserted ahead of PR-5.1: remove the automatic full-history warmup on group open, make full history per-chart opt-in (window chip + 600ms hover-dwell prefetch + existing interaction promotion), and add stale-while-revalidate to the API CDN policy. Design doc at .big-plans/ct__bench-v4-uiux-design.md; spine Current State, handoff, PR enumeration, phase map, and totals updated accordingly. Signed-off-by: "Connor Tsui" <connor@spiraldb.com>
1 parent b0e32fc commit b87b172

2 files changed

Lines changed: 194 additions & 7 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# PR-5.0.9 design: opt-in full-history chart loading (UI/UX round)
2+
3+
Date: 2026-06-11. Approved by the user at the end of the brainstorming session. This is the design
4+
for the UI/UX sub-PR the user queued ahead of PR-5.1 (see the spine's 2026-06-11 handoff sections).
5+
Scope was pinned to "loading model only": no visual or layout redesign in this round.
6+
7+
## Problem
8+
9+
The v4 site already fetches a windowed `?n=100` per chart on group open (4-concurrent
10+
`hydrationQueue`). The problem is what happens next: `onGroupOpen` in
11+
`benchmarks-website/web/components/Chart.tsx` (the `ensureFullHistory` call after the initial
12+
payload resolves) AUTOMATICALLY queues a background `?n=all` full-history fetch for EVERY chart in
13+
the opened group through the 2-concurrent `fullHistoryQueue`. Nobody asked for that data; it is a
14+
speculative warmup inherited from v3's shard-zero-then-warmup model.
15+
16+
Live measurements (2026-06-11, https://benchmarks-web.vercel.app, tpch SF=1 NVMe chart with
17+
3,572 commits):
18+
19+
| Request | Cold function | Warm, CDN MISS | CDN HIT | Payload |
20+
|---|---|---|---|---|
21+
| `?n=100` | ~7.8s (one-time cold start) | 0.17-0.2s | 0.06s | 34KB |
22+
| `?n=all` | (not separately measured) | 0.46-1.07s | 0.06s | 1.1MB |
23+
24+
Arithmetic of the warmup: opening the 22-chart tpch group queues ~24MB of full-history downloads;
25+
"Expand All" (Header.tsx) opens every group and queues ~370 charts of `?n=all`, on the order of
26+
hundreds of MB. These background fetches contend with the windowed fetches the user is actually
27+
waiting on (server capacity, DB pool, bandwidth), which is the perceived "takes forever".
28+
29+
Two aggravating factors confirmed during investigation:
30+
31+
- The API cache policy is `s-maxage=300` with no stale-while-revalidate. On a low-traffic site the
32+
5-minute window nearly always lapses, so most visits MISS and the first request after idle also
33+
pays a multi-second Vercel cold start plus RDS connect.
34+
- The server queries themselves are fast post-PR-5.1.5. This is a client fetch-orchestration
35+
problem, not a SQL problem.
36+
37+
## Key architectural fact (why late loading is safe here)
38+
39+
The user previously hit Chart.js jank ("slider becomes inaccurate, some things become sized
40+
wrong") with load-small-then-load-more implementations. That failure is the axis re-base problem,
41+
and v4 was specifically engineered around it: the windowed response carries
42+
`history.total_commits` and `history.start_index`, and `normalizeChartPayload`
43+
(`lib/chart-format.ts`) builds every chart on the FULL-length virtual x-axis from construction,
44+
with `null` placeholders for the unloaded prefix. The slider max is the full history length from
45+
the start (`syncSliderBounds`). When `?n=all` arrives, `replaceChartPayload` fills in the nulls;
46+
nothing re-bases and the visible window is preserved. The interaction-promotion path also already
47+
exists: `rangeTouchesUnloadedHistory` promotes the full fetch to `INTERACTION_FULL_PRIORITY` the
48+
moment pan/zoom/slider touches the unloaded region. Both mechanisms are kept unchanged.
49+
50+
## Approved design
51+
52+
1. **Remove the automatic warmup.** Delete the `ensureFullHistory(priority)` call from
53+
`onGroupOpen` (Chart.tsx). Group open then costs only the windowed fetches (tpch: ~750KB
54+
total). Keep `fullHistoryQueue` and `FULL_HISTORY_CONCURRENCY = 2` as the bound for the opt-in
55+
fetches below. The permalink page (`app/chart/[slug]/page.tsx`) already upgrades only on
56+
interaction and needs no change.
57+
58+
2. **Window chip (always visible on windowed charts).** Each chart whose payload has
59+
`history.complete === false` shows a small per-card status chip: "latest 100 of 3,572". This
60+
signals at all times that the full view is not the default. Charts with complete history (fewer
61+
than 100 commits) show no chip. State machine:
62+
- windowed: "latest 100 of N"; on card hover the chip presents as the clickable action
63+
("load all N").
64+
- loading: spinner in the chip while the full fetch is in flight (any trigger).
65+
- complete: "all N"; may settle to a quiet static label.
66+
- error: "retry" affordance (today a failed full fetch is console-only; the chip click retries).
67+
- Clicking the chip fetches at `INTERACTION_FULL_PRIORITY`.
68+
69+
3. **Hover intent, staged ("Both, staged" per user decision).** Hovering a chart card reveals the
70+
chip's action affordance immediately but fetches nothing. A continuous dwell of ~600ms on the
71+
same card starts a silent prefetch (chip shows the spinner) at a new mid-tier priority constant
72+
(above background 0, below `INTERACTION_FULL_PRIORITY`), so deliberate hovers have data ready
73+
by the time the user reaches for the slider, while mouse sweeps across the page fetch nothing.
74+
`pointerleave` cancels a pending dwell timer. Touch devices have no hover; the chip and the
75+
existing interaction promotion cover them. Full loads remain one chart at a time by
76+
construction (per-chart triggers plus the 2-concurrent queue bound).
77+
78+
4. **Interaction promotion kept unchanged.** Pan/zoom/slider touching the unloaded virtual region
79+
still fetches at top priority, so nobody hits a dead end at the 100-commit wall.
80+
81+
5. **CDN: add stale-while-revalidate.** Extend the API cache policy (`web/lib/cache.ts`, and the
82+
HTML-route rules in `web/vercel.json` if applicable) from `s-maxage=300` to
83+
`s-maxage=300, stale-while-revalidate=86400`. The CDN then serves the stale copy instantly and
84+
revalidates in the background. Benchmark data lands a few times a day, so day-scale staleness
85+
tolerance is acceptable. This makes both first paint and chip clicks feel instant for any
86+
recently-viewed chart and hides the cold-start path from most users. Verify the exact header
87+
Vercel's CDN honors (`Cache-Control` vs `Vercel-CDN-Cache-Control`) when implementing.
88+
89+
## Implementation sites
90+
91+
- `benchmarks-website/web/components/Chart.tsx`: remove the warmup call in `onGroupOpen`; add the
92+
chip DOM plus its state wiring (the per-card element registry and the `syncDownsampleBadge`
93+
pattern are the precedent); add card-level `pointerenter`/`pointerleave` dwell handling using
94+
the controller's existing AbortSignal listener pattern; route chip clicks and dwell fires into
95+
`ensureFullHistory`; update the stale docstrings (the "then queues the one-shot `?n=all`
96+
upgrade" header comment and the `ensureFullHistory` doc).
97+
- `benchmarks-website/web/lib/chart-format.ts`: new constants (dwell ms, hover-prefetch priority);
98+
the existing `FULL_HISTORY_CONCURRENCY`, virtual-axis, and normalization code is unchanged.
99+
- `benchmarks-website/web/lib/cache.ts` (+ `web/vercel.json` as needed): stale-while-revalidate.
100+
- Tests under `benchmarks-website/web/` (vitest, node-env): see below.
101+
102+
## Out of scope (deliberately deferred, not data-correctness, so NOT added to the spine's
103+
Deferred-work table)
104+
105+
- Viewport-based hydration on group open (windowed fetches are cheap enough at 34KB each).
106+
- Server-side downsampling of `?n=all` payloads (1.1MB is acceptable for an explicit action).
107+
- Any visual or layout redesign (user pinned scope to the loading model).
108+
- Cold-start keep-warm infrastructure.
109+
110+
## Test plan
111+
112+
- Queue behavior: `onGroupOpen` schedules ZERO `fullHistoryQueue` entries (the discriminating
113+
test for the warmup removal); the hydration path is unchanged.
114+
- Dwell: the prefetch fires after the dwell threshold and not before; `pointerleave` before the
115+
threshold cancels it; a second hover restarts cleanly.
116+
- Chip: click promotes to `INTERACTION_FULL_PRIORITY`; state transitions windowed -> loading ->
117+
complete, and error -> retry; no chip when `history.complete` is true.
118+
- Interaction promotion: existing `rangeTouchesUnloadedHistory` tests stay green.
119+
- Existing tests that pin the old warmup ordering get updated to pin the NEW behavior (this is an
120+
intended behavioral change).
121+
- Suite health: web vitest suite green, `next build`, `tsc`, `eslint`, `prettier`.
122+
- Post-deploy manual verification: network profile of a tpch group open shows only `?n=100`
123+
requests (~750KB, no `?n=all` without intent); chip + dwell behave as designed; the API response
124+
carries the stale-while-revalidate directive and repeat visits serve from CDN.
125+
126+
## Acceptance criteria (mirrors the PR-5.0.9 spine row)
127+
128+
- No `?n=all` request is issued without per-chart user intent (hover dwell, chip click, or
129+
interaction touching unloaded history).
130+
- Opening a 22-chart group transfers ~1MB or less of chart data (was ~24MB).
131+
- Windowed charts visibly signal their window state via the chip; full loads show progress and
132+
surface failures with a retry.
133+
- The virtual-axis upgrade path stays jank-free (visible window preserved across the fill-in).
134+
- CDN policy includes stale-while-revalidate and repeat visits hit the CDN.
135+
- Review: inner-loop 2-vote gauntlet (preset pr-2), per the project review calibration.
136+
137+
## Process note
138+
139+
The session handoff prescribed brainstorming then grill-me. Brainstorming ran to an approved
140+
design; grill-me was skipped at user wrap-up. The load-bearing assumptions were instead verified
141+
empirically during the session (live timing/payload measurements above, plus code reading of the
142+
virtual-axis and promotion paths). If extra rigor is wanted, run spiral:grill-me on this document
143+
before implementing; otherwise proceed to implementation.

0 commit comments

Comments
 (0)