M1b: mobile app-bar clock — Design's resting clock + STALE reaction (Hybrid)

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) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-16 11:26:00 -04:00
parent c4d4617fab
commit 7c7ab24be5
5 changed files with 145 additions and 24 deletions
+46
View File
@@ -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');
});
});
+12 -6
View File
@@ -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', () => {
+10 -6
View File
@@ -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; }
}
+38 -12
View File
@@ -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<SnapshotSummary | null>(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 (
<div
className="scanlines heartbeat-bar"
@@ -131,7 +143,7 @@ export function HeartbeatBar() {
fontSize: 10.5,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span className="hb-desktop" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span className="live-dot" style={{ background: 'var(--g-a)' }} />
<span className="mono" style={{ color: 'var(--g-a)', fontWeight: 700, letterSpacing: '0.12em' }}>SIGNAL LIVE</span>
</span>
@@ -152,13 +164,27 @@ export function HeartbeatBar() {
</div>
{summary !== null && (
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
<span className="mono hb-desktop" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
<LiveNumber value={summary.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
</span>
)}
<span className="mono" style={{ color: syncColor, fontWeight: tier === 'stale' ? 700 : 400, flexShrink: 0 }}>
<span className="mono hb-desktop" style={{ color: syncColor, fontWeight: tier === 'stale' ? 700 : 400, flexShrink: 0 }}>
{tier === 'stale' ? 'STALE' : 'SYNC'} {elapsed !== null ? syncLabel(elapsed) : '—'}
</span>
{/* 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). */}
<span
className="mono hb-mobile-clock"
style={{ display: 'none', alignItems: 'center', gap: 6, marginLeft: 'auto', color: mobileFresh.color, fontWeight: mobileFresh.weight, flexShrink: 0, letterSpacing: '0.06em' }}
>
{/* Resting dot is STATIC (the stillness) — the ticking clock is the
proof-of-life; the dot only changes COLOR when the tier reacts. */}
<span style={{ width: 6, height: 6, borderRadius: '50%', background: mobileFresh.color, flexShrink: 0 }} />
{mobileFresh.label}
</span>
</div>
);
}
+39
View File
@@ -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 };