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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user