|
| 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