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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -148,12 +148,18 @@ describe('QA.22 — honest waiting copy everywhere (no passive mystery)', () =>
|
|||||||
describe('M1 — mobile foundation is wired (structural, not visual)', () => {
|
describe('M1 — mobile foundation is wired (structural, not visual)', () => {
|
||||||
const css = read('app/globals.css');
|
const css = read('app/globals.css');
|
||||||
|
|
||||||
it('header collapses on phones: the decorative EKG is hidden <768px', () => {
|
it('mobile app-bar clock: desktop heartbeat pieces hidden, clock shown <768px', () => {
|
||||||
// The audit flagged ticker + SIGNAL LIVE + STALE stacking in ~110px.
|
// Design's mobile drops SIGNAL LIVE + EKG + graded + SYNC-label for a single
|
||||||
expect(css).toMatch(/\.hb-ekg\s*\{[^}]*display:\s*none/);
|
// right-aligned clock (Hybrid: wall clock rests, STALE reacts).
|
||||||
// and the hook actually exists on the component
|
expect(css).toMatch(/\.hb-ekg,\s*\.hb-desktop\s*\{[^}]*display:\s*none/);
|
||||||
expect(read('components/vyndr/LiveLayer.tsx')).toMatch(/hb-ekg/);
|
expect(css).toMatch(/\.hb-mobile-clock\s*\{\s*display:\s*inline-flex/);
|
||||||
expect(read('components/vyndr/LiveLayer.tsx')).toMatch(/heartbeat-bar/);
|
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', () => {
|
it('primary CTA meets a 44px touch target on mobile', () => {
|
||||||
|
|||||||
+10
-6
@@ -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).
|
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
|
/* Mobile app-bar clock (M1b) — Design's mobile drops SIGNAL LIVE + EKG + graded
|
||||||
logo + sync clock). Interim step until the full app-bar recomposition in
|
+ the SYNC label and shows a single right-aligned clock that RESTS as a wall
|
||||||
M1b; keeps the heartbeat from reading as the audit's stacked 110px zone. */
|
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) {
|
@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 {
|
.heartbeat-bar {
|
||||||
height: 28px !important;
|
height: 28px !important;
|
||||||
gap: 10px !important;
|
gap: 10px !important;
|
||||||
padding: 0 12px !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; }
|
.heartbeat-bar > span { white-space: nowrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { liveTick } from '@/lib/liveTick';
|
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
|
/** Subscribe to the single living-layer tick (§8). Starts the shared interval
|
||||||
* on first mount; many components can call this — one interval, fan-out. */
|
* on first mount; many components can call this — one interval, fan-out. */
|
||||||
@@ -77,6 +80,8 @@ function syncLabel(elapsedMs: number): string {
|
|||||||
export function HeartbeatBar() {
|
export function HeartbeatBar() {
|
||||||
const live = useLive(); // 1s pulse — re-renders the elapsed clock
|
const live = useLive(); // 1s pulse — re-renders the elapsed clock
|
||||||
const [summary, setSummary] = useState<SnapshotSummary | null>(null);
|
const [summary, setSummary] = useState<SnapshotSummary | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -104,18 +109,25 @@ export function HeartbeatBar() {
|
|||||||
|
|
||||||
void live.tick; // subscription drives the 1s clock re-render
|
void live.tick; // subscription drives the 1s clock re-render
|
||||||
// Freshness is measured from the intraday heartbeat (refreshed_at), NOT the
|
// Freshness is measured from the intraday heartbeat (refreshed_at), NOT the
|
||||||
// grade-lock time (updated_at). Measuring the 20-min cadence against the
|
// grade-lock time (updated_at) — the shared tier util (same thresholds as the
|
||||||
// 5×/day lock field is what made a live pipeline read "STALE 8h".
|
// 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 freshAt = summary?.refreshed_at ?? summary?.updated_at ?? null;
|
||||||
const syncedAt = freshAt ? Date.parse(freshAt) : NaN;
|
const syncedAt = freshAt ? Date.parse(freshAt) : NaN;
|
||||||
const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null;
|
const { tier, elapsedMs } = freshnessTier(syncedAt, summary?.expected_interval_s ?? 0, Date.now());
|
||||||
const expectedMs = (summary?.expected_interval_s ?? DEFAULT_EXPECTED_S) * 1000;
|
const elapsed = elapsedMs;
|
||||||
const tier: 'normal' | 'amber' | 'stale' =
|
|
||||||
elapsed === null ? 'normal'
|
|
||||||
: elapsed >= 3 * expectedMs ? 'stale'
|
|
||||||
: elapsed >= 1.5 * expectedMs ? 'amber'
|
|
||||||
: 'normal';
|
|
||||||
const syncColor = tier === 'stale' ? 'var(--miss)' : tier === 'amber' ? 'var(--amber)' : 'var(--text-2)';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="scanlines heartbeat-bar"
|
className="scanlines heartbeat-bar"
|
||||||
@@ -131,7 +143,7 @@ export function HeartbeatBar() {
|
|||||||
fontSize: 10.5,
|
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="live-dot" style={{ background: 'var(--g-a)' }} />
|
||||||
<span className="mono" style={{ color: 'var(--g-a)', fontWeight: 700, letterSpacing: '0.12em' }}>SIGNAL LIVE</span>
|
<span className="mono" style={{ color: 'var(--g-a)', fontWeight: 700, letterSpacing: '0.12em' }}>SIGNAL LIVE</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -152,13 +164,27 @@ export function HeartbeatBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary !== null && (
|
{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
|
<LiveNumber value={summary.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
|
||||||
</span>
|
</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) : '—'}
|
{tier === 'stale' ? 'STALE' : 'SYNC'} {elapsed !== null ? syncLabel(elapsed) : '—'}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user