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:
+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).
|
||||
============================================================ */
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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