From 1ad9237c143e57899561a1b4867f02d81f286139 Mon Sep 17 00:00:00 2001 From: Tim Thomas <0800tim@gmail.com> Date: Fri, 5 Jun 2026 12:31:19 +1200 Subject: [PATCH] feat(leaderboard): replace coarse N-days hero tile with mini D/H/M countdown Tim 2026-06-05: the third hero tile on /leaderboard read "7 DAYS / TO KICKOFF" when the actual remaining time was 6 days and change. The old logic was `Math.ceil((kickoff - now) / 86_400_000)` which rounds UP, so anything strictly less than a whole day inflated by one. With five days to kickoff this would have read "7 days" for most of the last week of the runway. Swapped it for a mini countdown styled to echo the home-page banner (gold-on-dark cells, Fraunces digits, mono labels), but at hero-tile scale and showing days/hours/minutes only. No seconds because the tile is glanced at, not stared at, and a one-minute tick avoids any pulse animation distracting from the leaderboard below. * Drops the `daysToKickoff` state + `kickoffLabel` memo from the page; the static "brackets locked" + "syndicates running" tiles stay as before. * Adds a small `MiniCountdownTile` component (with three `MiniCell`s) inline in the page file. Seeds `now` from the kickoff timestamp so SSR + first client render agree, suppresses the expected hydration mismatch on the digit nodes only. * Adds `.vt-lb-hero-card--countdown` + `.vt-lb-mini-countdown` styles to leaderboard.css, sharing the radial-gold wash with the home page banner but at smaller cell sizes. * When the kickoff instant passes, the tile reads "Live" + "to kickoff" (instead of an all-zeros grid) to match the `pastLabel` semantics of the main banner. Same target instant as the home page: 2026-06-11T19:00:00Z. Refs: docs/internal/home-polish-spec.md Signed-off-by: Tim Thomas <0800tim@gmail.com> --- apps/web/app/leaderboard/leaderboard.css | 53 +++++++++++++ apps/web/app/leaderboard/page.tsx | 96 +++++++++++++++++------- 2 files changed, 122 insertions(+), 27 deletions(-) diff --git a/apps/web/app/leaderboard/leaderboard.css b/apps/web/app/leaderboard/leaderboard.css index aeac4a77..e33d4d5f 100644 --- a/apps/web/app/leaderboard/leaderboard.css +++ b/apps/web/app/leaderboard/leaderboard.css @@ -79,6 +79,59 @@ font-weight: 500; } +/* Mini countdown that sits in the third hero slot in place of the + * old static "N days / to kickoff" readout. Three cells (D / H / M), + * borrowing the home page's countdown-cell aesthetic but scaled down + * to match the surrounding tile rhythm. Tim 2026-06-05. */ +.vt-lb-hero-card--countdown { + /* Same outer chrome as siblings; we just swap the contents. The + * faint inner gold wash echoes the home-page countdown banner so + * the eye reads them as the same component. */ + background: + radial-gradient(120% 80% at 8% 50%, rgba(220, 169, 75, 0.08), transparent 55%), + radial-gradient(80% 70% at 92% 50%, rgba(245, 158, 11, 0.05), transparent 60%), + var(--vt-bg-elev); +} + +.vt-lb-mini-countdown { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-top: 2px; +} + +.vt-lb-mini-countdown-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 6px 2px 5px; + border-radius: 8px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.18)); + border: 1px solid rgba(220, 169, 75, 0.16); +} + +.vt-lb-mini-countdown-num { + font-family: var(--vt-font-editorial, "Fraunces", ui-serif, Georgia, serif); + font-weight: 500; + font-variant-numeric: tabular-nums; + font-size: 1.5rem; + line-height: 1; + letter-spacing: -0.01em; + color: var(--vt-gold-300, #fcd34d); + text-shadow: 0 0 14px rgba(252, 211, 77, 0.28); +} + +.vt-lb-mini-countdown-label { + font-family: ui-monospace, Menlo, Monaco, monospace; + font-size: 0.55rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--vt-fg-muted, #a3a3ad); + line-height: 1; +} + .vt-lb-grid { display: grid; grid-template-columns: minmax(0, 1fr) 400px; diff --git a/apps/web/app/leaderboard/page.tsx b/apps/web/app/leaderboard/page.tsx index 53576e13..020dd69c 100644 --- a/apps/web/app/leaderboard/page.tsx +++ b/apps/web/app/leaderboard/page.tsx @@ -40,38 +40,17 @@ export default function LeaderboardPage() { // "You" pinned to mid-pack so the highlight row is visibly demoed. const youId = members[12]?.id; - // Days-to-kickoff is a live countdown to the FIFA WC 2026 opening - // match (2026-06-11T19:00:00Z, Mexico City). Initialised to `null` so - // SSR doesn't disagree with the client's clock; the post-mount effect - // fills it in and refreshes every minute so leaving the tab open - // across midnight still reads correctly. Tim 2026-06-04 caught it - // stuck on the original hardcoded "31 days" demo value. - const [daysToKickoff, setDaysToKickoff] = useState(null); - useEffect(() => { - const kickoffMs = Date.UTC(2026, 5, 11, 19, 0, 0); - const recompute = () => { - const remaining = Math.ceil((kickoffMs - Date.now()) / 86_400_000); - setDaysToKickoff(Math.max(0, remaining)); - }; - recompute(); - const timer = setInterval(recompute, 60_000); - return () => clearInterval(timer); - }, []); - - const kickoffLabel = useMemo(() => { - if (daysToKickoff === null) return "Soon"; - if (daysToKickoff === 0) return "Live"; - if (daysToKickoff === 1) return "1 day"; - return `${daysToKickoff} days`; - }, [daysToKickoff]); - + // Static stats (kickoff tile is rendered separately as a mini + // countdown). Tim 2026-06-05: the third tile used to show a coarse + // "7 days" rounded-up readout which read as wrong at the boundary + // (six days and change reads as "7 days" by ceil). Swapped for a + // mini days/hours/minutes countdown that mirrors the home page. const heroStats = useMemo( () => [ { value: "24,388", label: "brackets locked" }, { value: "1,204", label: "syndicates running" }, - { value: kickoffLabel, label: "to kickoff" }, ], - [kickoffLabel], + [], ); // For the "you vs the pool" chart, seed from the highlighted member. @@ -112,6 +91,9 @@ export default function LeaderboardPage() { ))} + + +
@@ -165,3 +147,63 @@ export default function LeaderboardPage() { ); } + +/** + * Mini countdown tile, sits in the third slot of the leaderboard hero + * row in place of the old static "N days / to kickoff" readout. Three + * cells (D / H / M) styled to match the home page's countdown banner + * at tile-scale; no seconds, so a one-minute tick is plenty and the + * SSR/CSR text-mismatch surface is much smaller. The kickoff instant + * is the FIFA WC 2026 opener (2026-06-11T19:00:00Z, Mexico City), the + * same target the home page uses. + * + * Tim 2026-06-05. + */ +function MiniCountdownTile() { + const KICKOFF_MS = Date.UTC(2026, 5, 11, 19, 0, 0); + // Seed with the target so SSR + first client render agree; effect + // snaps to wall-clock and ticks every minute (no seconds shown). + const [now, setNow] = useState(() => KICKOFF_MS); + useEffect(() => { + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, []); + + const diff = Math.max(0, KICKOFF_MS - now); + const days = Math.floor(diff / 86_400_000); + const hours = Math.floor(diff / 3_600_000) % 24; + const minutes = Math.floor(diff / 60_000) % 60; + const live = diff === 0; + + return ( +
+ {live ? ( + Live + ) : ( +
+ + + +
+ )} + to kickoff +
+ ); +} + +function MiniCell({ value, label }: { value: number; label: string }) { + const padded = String(Math.max(0, value)).padStart(2, "0"); + return ( +
+ {/* SSR-seeded `now` equals the target until hydration, so the + * server emits "00" for every cell and the client patches to + * the real values on first effect run. Suppress the expected + * text-mismatch on just this node. */} + + {padded} + + {label} +
+ ); +}