From 7c7ab24be591994df07a9809978bd1fd965d485f Mon Sep 17 00:00:00 2001 From: Kev Date: Thu, 16 Jul 2026 11:26:00 -0400 Subject: [PATCH] =?UTF-8?q?M1b:=20mobile=20app-bar=20clock=20=E2=80=94=20D?= =?UTF-8?q?esign's=20resting=20clock=20+=20STALE=20reaction=20(Hybrid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design's mobile app bar shows a wall clock, no SYNC/STALE readout. Building it literally would drop the Ship-A staleness signal on the device most users are on. Kev's ruling: Hybrid — the wall clock is the RESTING state (the stillness), STALE/amber is the REACTION (the punctuation), driven by the SAME real signal as desktop (refreshed_at vs expected_interval_s, thresholds 1.5×/3×). Never silently stale on mobile. Design drew the happy path; we keep the failure state. - web/src/lib/freshness.js — extracted the freshness tier as ONE shared source (CommonJS, unit-tested); desktop HeartbeatBar + mobile clock both key off it, so mobile can't silently disagree with desktop about staleness. - LiveLayer: <768px hides SIGNAL LIVE + EKG + graded + the SYNC label and shows a single right-aligned clock — ticking wall clock when calm, amber SYNC / red STALE when the tier reacts. Resting dot is static (the clock is the pulse). - Test-lock: freshness.test.js (tier thresholds, absent≠false-stale, no negative age) + vyndrParityQA (mobile clock wired, one shared freshness source). Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px. NEXT shell increments (still M1b): merge the clock into the logo row (Design's single-row app bar), the breadth strip (EDGES/AVG CLV/GAMES — replaces the graded count mobile lost here), and relocate the wire to the bottom of the board. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/freshness.test.js | 46 ++++++++++++++++++++++++ tests/unit/vyndrParityQA.test.js | 18 ++++++---- web/src/app/globals.css | 16 +++++---- web/src/components/vyndr/LiveLayer.tsx | 50 +++++++++++++++++++------- web/src/lib/freshness.js | 39 ++++++++++++++++++++ 5 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 tests/unit/freshness.test.js create mode 100644 web/src/lib/freshness.js diff --git a/tests/unit/freshness.test.js b/tests/unit/freshness.test.js new file mode 100644 index 0000000..f1a6453 --- /dev/null +++ b/tests/unit/freshness.test.js @@ -0,0 +1,46 @@ +'use strict'; + +// Shared pipeline-freshness tier (Ship A + design-train M1b). The desktop +// HeartbeatBar and the mobile app-bar clock BOTH key off this — one source, so +// mobile can never silently disagree with desktop about staleness. + +const { freshnessTier, DEFAULT_EXPECTED_S } = require('../../web/src/lib/freshness'); + +const S = 1000; +const now = 1_000_000_000_000; + +describe('freshnessTier', () => { + test('calm when the heartbeat is recent (< 1.5× expected)', () => { + const r = freshnessTier(now - 1000 * S, 1200, now); // 1000s elapsed, expected 1200 → 0.83× + expect(r.tier).toBe('normal'); + expect(r.elapsedMs).toBe(1000 * S); + }); + + test('amber at ≥ 1.5× expected (the reaction begins)', () => { + const r = freshnessTier(now - 1800 * S, 1200, now); // 1800/1200 = 1.5× + expect(r.tier).toBe('amber'); + }); + + test('stale (red) at ≥ 3× expected — never silently stale', () => { + const r = freshnessTier(now - 3600 * S, 1200, now); // 3600/1200 = 3× + expect(r.tier).toBe('stale'); + }); + + test('absent/NaN heartbeat is calm, not a false STALE', () => { + expect(freshnessTier(NaN, 1200, now).tier).toBe('normal'); + expect(freshnessTier(NaN, 1200, now).elapsedMs).toBeNull(); + }); + + test('non-positive expected falls back to the default 5h gap', () => { + // 4h elapsed vs the 5h (18000s) default → still under 1.5× → calm + const r = freshnessTier(now - 4 * 3600 * S, 0, now); + expect(DEFAULT_EXPECTED_S).toBe(18000); + expect(r.tier).toBe('normal'); + }); + + test('future/negative elapsed clamps to 0 (calm), never a negative age', () => { + const r = freshnessTier(now + 5000 * S, 1200, now); + expect(r.elapsedMs).toBe(0); + expect(r.tier).toBe('normal'); + }); +}); diff --git a/tests/unit/vyndrParityQA.test.js b/tests/unit/vyndrParityQA.test.js index f2a0c3d..6ba1fcf 100644 --- a/tests/unit/vyndrParityQA.test.js +++ b/tests/unit/vyndrParityQA.test.js @@ -148,12 +148,18 @@ describe('QA.22 — honest waiting copy everywhere (no passive mystery)', () => describe('M1 — mobile foundation is wired (structural, not visual)', () => { const css = read('app/globals.css'); - it('header collapses on phones: the decorative EKG is hidden <768px', () => { - // The audit flagged ticker + SIGNAL LIVE + STALE stacking in ~110px. - expect(css).toMatch(/\.hb-ekg\s*\{[^}]*display:\s*none/); - // and the hook actually exists on the component - expect(read('components/vyndr/LiveLayer.tsx')).toMatch(/hb-ekg/); - expect(read('components/vyndr/LiveLayer.tsx')).toMatch(/heartbeat-bar/); + it('mobile app-bar clock: desktop heartbeat pieces hidden, clock shown <768px', () => { + // Design's mobile drops SIGNAL LIVE + EKG + graded + SYNC-label for a single + // right-aligned clock (Hybrid: wall clock rests, STALE reacts). + expect(css).toMatch(/\.hb-ekg,\s*\.hb-desktop\s*\{[^}]*display:\s*none/); + expect(css).toMatch(/\.hb-mobile-clock\s*\{\s*display:\s*inline-flex/); + const live = read('components/vyndr/LiveLayer.tsx'); + expect(live).toMatch(/hb-mobile-clock/); + expect(live).toMatch(/hb-desktop/); + // freshness is the ONE shared tier, not a re-implementation + expect(live).toMatch(/from '@\/lib\/freshness'/); + // never silently stale: the mobile clock reacts to the stale tier + expect(live).toMatch(/STALE/); }); it('primary CTA meets a 44px touch target on mobile', () => { diff --git a/web/src/app/globals.css b/web/src/app/globals.css index b0f819d..6097ec7 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1372,18 +1372,22 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; } VISUALLY UNVERIFIED at 390px (WSL2↔Chrome unreachable — Chrome audit is eyes). ============================================================ */ -/* Header EKG drop <768px — Design's mobile has no EKG (app bar carries only - logo + sync clock). Interim step until the full app-bar recomposition in - M1b; keeps the heartbeat from reading as the audit's stacked 110px zone. */ +/* Mobile app-bar clock (M1b) — Design's mobile drops SIGNAL LIVE + EKG + graded + + the SYNC label and shows a single right-aligned clock that RESTS as a wall + clock and REACTS to amber/STALE off the real freshness tier (Hybrid: clock = + stillness, STALE = punctuation; never silently stale on mobile). Full app-bar + recomposition (merge into the logo row + breadth strip + bottom wire) is the + next M1b shell increment. */ @media (max-width: 767px) { - .hb-ekg { display: none !important; } + .hb-ekg, + .hb-desktop { display: none !important; } + .hb-mobile-clock { display: inline-flex !important; } .heartbeat-bar { height: 28px !important; gap: 10px !important; padding: 0 12px !important; + justify-content: flex-end; } - /* The status line is one line by contract — never let it wrap into the - "stacked" state the audit flagged. */ .heartbeat-bar > span { white-space: nowrap; } } diff --git a/web/src/components/vyndr/LiveLayer.tsx b/web/src/components/vyndr/LiveLayer.tsx index 3e0e091..5ace43e 100644 --- a/web/src/components/vyndr/LiveLayer.tsx +++ b/web/src/components/vyndr/LiveLayer.tsx @@ -2,6 +2,9 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { liveTick } from '@/lib/liveTick'; +// Shared freshness tier — ONE source for the desktop HeartbeatBar and the +// mobile app-bar clock (Design's mobile: clock rests, STALE reacts). +import { freshnessTier } from '@/lib/freshness'; /** Subscribe to the single living-layer tick (§8). Starts the shared interval * on first mount; many components can call this — one interval, fan-out. */ @@ -77,6 +80,8 @@ function syncLabel(elapsedMs: number): string { export function HeartbeatBar() { const live = useLive(); // 1s pulse — re-renders the elapsed clock const [summary, setSummary] = useState(null); + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); useEffect(() => { let active = true; @@ -104,18 +109,25 @@ export function HeartbeatBar() { void live.tick; // subscription drives the 1s clock re-render // Freshness is measured from the intraday heartbeat (refreshed_at), NOT the - // grade-lock time (updated_at). Measuring the 20-min cadence against the - // 5×/day lock field is what made a live pipeline read "STALE 8h". + // grade-lock time (updated_at) — the shared tier util (same thresholds as the + // mobile app-bar clock). Measuring the 20-min cadence against the 5×/day lock + // field is what made a live pipeline read "STALE 8h". const freshAt = summary?.refreshed_at ?? summary?.updated_at ?? null; const syncedAt = freshAt ? Date.parse(freshAt) : NaN; - const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null; - const expectedMs = (summary?.expected_interval_s ?? DEFAULT_EXPECTED_S) * 1000; - const tier: 'normal' | 'amber' | 'stale' = - elapsed === null ? 'normal' - : elapsed >= 3 * expectedMs ? 'stale' - : elapsed >= 1.5 * expectedMs ? 'amber' - : 'normal'; + const { tier, elapsedMs } = freshnessTier(syncedAt, summary?.expected_interval_s ?? 0, Date.now()); + const elapsed = elapsedMs; const syncColor = tier === 'stale' ? 'var(--miss)' : tier === 'amber' ? 'var(--amber)' : 'var(--text-2)'; + // Design's mobile app bar (M1b): the RESTING state is a wall clock (the + // stillness); STALE/amber is the REACTION (the punctuation), from the SAME + // real tier above. `mounted` gates the wall clock so SSR ≠ client-time + // hydration mismatch. The .hb-mobile-clock / .hb-desktop CSS toggle picks + // which renders per width (globals.css). + const wallClock = mounted ? new Date().toLocaleTimeString('en-US', { hour12: false }) : '--:--:--'; + const mobileFresh = tier === 'stale' + ? { label: `STALE ${elapsed !== null ? syncLabel(elapsed) : ''}`.trim(), color: 'var(--miss)', weight: 700 } + : tier === 'amber' + ? { label: `SYNC ${elapsed !== null ? syncLabel(elapsed) : '—'}`, color: 'var(--amber)', weight: 700 } + : { label: wallClock, color: 'var(--text-2)', weight: 400 }; return (
- + SIGNAL LIVE @@ -152,13 +164,27 @@ export function HeartbeatBar() {
{summary !== null && ( - + graded )} - + {tier === 'stale' ? 'STALE' : 'SYNC'} {elapsed !== null ? syncLabel(elapsed) : '—'} + + {/* Mobile app-bar clock (M1b, Hybrid) — Design's mobile RESTS as a wall + clock (the stillness) and only REACTS to amber/STALE (the punctuation) + off the SAME real freshness tier. Never silently stale on mobile. + Toggled with .hb-desktop via width (globals.css). */} + + {/* Resting dot is STATIC (the stillness) — the ticking clock is the + proof-of-life; the dot only changes COLOR when the tier reacts. */} + + {mobileFresh.label} + ); } diff --git a/web/src/lib/freshness.js b/web/src/lib/freshness.js new file mode 100644 index 0000000..497dcdd --- /dev/null +++ b/web/src/lib/freshness.js @@ -0,0 +1,39 @@ +'use strict'; + +/** + * Shared pipeline-freshness tier (Ship A + design-train M1b). + * + * Measures the INTRADAY HEARTBEAT (`refreshed_at`, ~every 20 min) against the + * pipeline's expected cadence — NOT the grade-lock time (`updated_at`, 5×/day, + * intentionally stable). Measuring the wrong field is what produced the + * "SIGNAL LIVE vs STALE 8h" contradiction (Ship A). + * + * ONE source for desktop (HeartbeatBar) AND mobile (the app-bar clock): the + * mobile clock rests as a wall clock and only reacts — amber, then red STALE — + * when this tier crosses. "Alive is punctuated stillness": the clock is the + * stillness, STALE is the punctuation. Design drew the happy path; we never + * silently go stale on the device most users are on. + * + * Thresholds (same both surfaces): calm < 1.5× · amber ≥ 1.5× · red ≥ 3×. + * + * CommonJS so it's unit-testable by the plain-JS Jest suite AND importable by + * the .tsx client components (allowJs). + */ + +const DEFAULT_EXPECTED_S = 18000; // 5h max cron gap fallback (matches LiveLayer) + +/** + * @param {number} refreshedAtMs Date.parse(refreshed_at); NaN/absent → calm. + * @param {number} expectedIntervalS summary.expected_interval_s (s); ≤0 → default. + * @param {number} nowMs Date.now(). + * @returns {{ tier: 'normal'|'amber'|'stale', elapsedMs: number|null }} + */ +function freshnessTier(refreshedAtMs, expectedIntervalS, nowMs) { + if (!Number.isFinite(refreshedAtMs)) return { tier: 'normal', elapsedMs: null }; + const expectedMs = (Number(expectedIntervalS) > 0 ? Number(expectedIntervalS) : DEFAULT_EXPECTED_S) * 1000; + const elapsedMs = Math.max(0, nowMs - refreshedAtMs); + const tier = elapsedMs >= 3 * expectedMs ? 'stale' : elapsedMs >= 1.5 * expectedMs ? 'amber' : 'normal'; + return { tier, elapsedMs }; +} + +module.exports = { freshnessTier, DEFAULT_EXPECTED_S };