From 32afbffbea663dabfb1231ce7a554e9afcb5f20e Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 21 May 2026 13:10:59 +0200 Subject: [PATCH 01/94] fix: remove empty "Today" section from the event log (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groupEventsByDay injected a synthetic empty TODAY group when the homepage event log had no events for the current day, rendering as a bare "TODAY" header with no cards. Removed the mechanism entirely: the includeToday option, the injection block, the dead .event-log-day--no-events className branch in EventLog, and its CSS. A real event starting today is unaffected — formatDayLabel still labels its group "TODAY", independent of the removed injection. Added a regression test for that case. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++ .../ArchiveComponentsIntegration.test.jsx | 1 - .../unit/features/timeline/EventLog.test.jsx | 12 ++++++ .../timeline/groupEventsByDay.test.mjs | 42 +++++++++---------- src/features/archives/ArchivesClient.jsx | 1 - src/features/timeline/EventLog.css | 11 ----- src/features/timeline/EventLog.jsx | 14 +------ src/features/timeline/groupEventsByDay.mjs | 15 +------ 8 files changed, 39 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0565232d..e106eb08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- **Empty "Today" section no longer renders in the event log (#385).** `groupEventsByDay` injected a synthetic empty `{ label: 'TODAY', events: [] }` group whenever the homepage event log had no events for the current day — it rendered as a bare "TODAY" header with no cards beneath it. Removed the mechanism outright: the `includeToday` option (archives already passed `false`, and the homepage relied on the `true` default), the injection block, the `.event-log-day--no-events` className branch in `EventLog` (only ever reachable via the injected group), and its two CSS rules. A real event starting today is unaffected — it is still grouped and labelled "TODAY" by `formatDayLabel`, which is wholly independent of the removed injection (covered by a new regression test). + ## 0.47.9 ### Documentation diff --git a/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx b/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx index 7e63f23e..47134e19 100644 --- a/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx +++ b/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx @@ -252,7 +252,6 @@ describe('Archive Components Integration Tests', () => { expect(props).toHaveProperty('events', data.events); expect(props).toHaveProperty('timeFormat', 'absolute'); expect(props).toHaveProperty('id', 'archives-event-log'); - expect(props).toHaveProperty('includeToday', false); }); test('ArchiveMap receives data and selectedEvent', () => { diff --git a/src/__tests__/unit/features/timeline/EventLog.test.jsx b/src/__tests__/unit/features/timeline/EventLog.test.jsx index a741b9d7..d59779fe 100644 --- a/src/__tests__/unit/features/timeline/EventLog.test.jsx +++ b/src/__tests__/unit/features/timeline/EventLog.test.jsx @@ -54,6 +54,18 @@ describe('EventLog', () => { expect(daysContainer.classList.contains('event-log-days--stack')).toBe(false); }); + test('does not render an empty TODAY section when today has no events', () => { + const pastEvent = { + ...fakeEvents[0], + start_time: NOW - 5 * 86400, + end_time: NOW - 5 * 86400 + 500, + }; + const { container } = render(); + expect(container.querySelector('.event-log-day--no-events')).toBeNull(); + // only the real event's day group renders — no synthetic TODAY marker + expect(container.querySelectorAll('.event-log-day')).toHaveLength(1); + }); + test('cards have data-event-key attributes for scroll-sync wiring', () => { const { container } = render( , diff --git a/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs b/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs index cf4c7ce8..61be80c9 100644 --- a/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs +++ b/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs @@ -1,4 +1,3 @@ -// src/__tests__/unit/utils/groupEventsByDay.test.mjs import { describe, it, expect } from 'vitest'; import { groupEventsByDay, @@ -21,22 +20,23 @@ describe('groupEventsByDay', () => { expect(groupEventsByDay([])).toEqual([]); }); - it('always includes today as first group', () => { - const today = new Date().toISOString().slice(0, 10); - const events = [event('a', 1774958400)]; // March 31 2026 + it('does not inject an empty TODAY group when today has no events', () => { + const events = [event('a', 1774958400)]; // March 31 2026 — not today const groups = groupEventsByDay(events); - expect(groups[0].date).toBe(today); - expect(groups[0].label).toBe('TODAY'); + // only the real event day — no synthetic empty TODAY placeholder + expect(groups).toHaveLength(1); + expect(groups.some((g) => g.events.length === 0)).toBe(false); }); - it('does not duplicate today when events exist for today', () => { + it('an event starting today is grouped under a TODAY label', () => { const nowSec = Math.floor(Date.now() / 1000); const events = [event('a', nowSec)]; const groups = groupEventsByDay(events); - const todayGroups = groups.filter( - (g) => g.date === new Date().toISOString().slice(0, 10), - ); - expect(todayGroups).toHaveLength(1); + const today = new Date().toISOString().slice(0, 10); + expect(groups).toHaveLength(1); + expect(groups[0].date).toBe(today); + expect(groups[0].label).toBe('TODAY'); + expect(groups[0].events).toHaveLength(1); }); it('groups events by calendar day (UTC)', () => { @@ -46,27 +46,25 @@ describe('groupEventsByDay', () => { event('c', 1774872000), ]; const groups = groupEventsByDay(events); - // today + 2 event days - expect(groups).toHaveLength(3); - // skip groups[0] (today); groups[1] = Mar 31, groups[2] = Mar 30 - expect(groups[1].events).toHaveLength(2); - expect(groups[2].events).toHaveLength(1); + // 2 event days, no synthetic today group + expect(groups).toHaveLength(2); + // groups[0] = Mar 31 (2 events), groups[1] = Mar 30 (1 event) + expect(groups[0].events).toHaveLength(2); + expect(groups[1].events).toHaveLength(1); }); it('sorts groups newest day first', () => { const events = [event('old', 1774872000), event('new', 1774958400)]; const groups = groupEventsByDay(events); - // groups[0] = today, groups[1] = Mar 31, groups[2] = Mar 30 - expect(groups[1].date).toBe('2026-03-31'); - expect(groups[2].date).toBe('2026-03-30'); + expect(groups[0].date).toBe('2026-03-31'); + expect(groups[1].date).toBe('2026-03-30'); }); it('sorts events within a group newest first', () => { const events = [event('early', 1774958400), event('late', 1774958400 + 7200)]; const groups = groupEventsByDay(events); - // groups[0] = today (empty), groups[1] = the event day - expect(groups[1].events[0].event_id).toBe('late'); - expect(groups[1].events[1].event_id).toBe('early'); + expect(groups[0].events[0].event_id).toBe('late'); + expect(groups[0].events[1].event_id).toBe('early'); }); }); diff --git a/src/features/archives/ArchivesClient.jsx b/src/features/archives/ArchivesClient.jsx index 09f2bc6f..cda4b613 100644 --- a/src/features/archives/ArchivesClient.jsx +++ b/src/features/archives/ArchivesClient.jsx @@ -178,7 +178,6 @@ export default function ArchivesClient({ initialSortOrder={initialSortOrder} selectedEventKey={selectedEvent ? eventKey(selectedEvent) : null} railRef={railRef} - includeToday={false} layout="stack" /> diff --git a/src/features/timeline/EventLog.css b/src/features/timeline/EventLog.css index 53c625fd..50b61f6a 100644 --- a/src/features/timeline/EventLog.css +++ b/src/features/timeline/EventLog.css @@ -72,13 +72,6 @@ white-space: nowrap; } -/* === Empty day markers === */ - -.event-log-day--no-events .event-log-day-header { - min-height: 3rem; - box-shadow: inset -6px 0 0 0 rgba(255, 255, 255, 0.06); -} - /* === Event cards === */ .event-card { @@ -136,10 +129,6 @@ padding: 0 0 0.25rem 0; } - .event-log-day--no-events .event-log-day-header { - box-shadow: none; - } - .event-log-day-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/features/timeline/EventLog.jsx b/src/features/timeline/EventLog.jsx index 4f072ef0..cfc17c7b 100644 --- a/src/features/timeline/EventLog.jsx +++ b/src/features/timeline/EventLog.jsx @@ -26,9 +26,6 @@ import { eventKey } from '@/shared/utils/game/eventKey.mjs'; * - `onHoverEvent` (optional): called with `(event)` on card hover * - `railRef` (optional): forwarded to the scrolling container so * `useScrollEvent` on `/archives` can query `[data-event-key]` cards - * - `includeToday` (optional, default `true`): whether to show an empty - * TODAY marker when no events exist for today. Pass `false` in - * archives to suppress. * - `layout` (optional, default `'grid'`): `'grid'` renders the * desktop multi-column layout at ≥md. `'stack'` forces a single * vertical column regardless of viewport width — required by the @@ -44,11 +41,10 @@ export default function EventLog({ selectedEventKey = null, onHoverEvent, railRef, - includeToday = true, layout = 'grid', }) { const [sortOrder, toggleSortOrder] = useEventLogSort(initialSortOrder); - const groups = groupEventsByDay(events ?? [], { includeToday, sortOrder }); + const groups = groupEventsByDay(events ?? [], { sortOrder }); return (
@@ -75,13 +71,7 @@ export default function EventLog({ const { wins, losses } = countOutcomes(group.events); return ( -
+
{group.label} diff --git a/src/features/timeline/groupEventsByDay.mjs b/src/features/timeline/groupEventsByDay.mjs index 5c398d53..d9a9e4f8 100644 --- a/src/features/timeline/groupEventsByDay.mjs +++ b/src/features/timeline/groupEventsByDay.mjs @@ -3,14 +3,10 @@ * * @param {Array<{ start_time: number }>} events - Events with Unix timestamps (seconds) * @param {object} [opts] - Optional grouping options - * @param {boolean} [opts.includeToday=true] - If true, prepend an empty TODAY group when no events exist for today (only applies to the default newest-first sort). * @param {'desc' | 'asc'} [opts.sortOrder='desc'] - `'desc'` = newest first (days and events-within-day). `'asc'` = oldest first. * @returns {Array<{ date: string, label: string, events: Array }>} */ -export function groupEventsByDay( - events, - { includeToday = true, sortOrder = 'desc' } = {}, -) { +export function groupEventsByDay(events, { sortOrder = 'desc' } = {}) { if (!events || events.length === 0) return []; const groups = new Map(); @@ -40,15 +36,6 @@ export function groupEventsByDay( events: dayEvents.sort(cmpEvent), })); - // Only show the empty TODAY placeholder on the default descending view — - // on ascending (oldest first) TODAY would be at the bottom and looks odd empty. - if (includeToday && sortOrder === 'desc') { - const today = new Date().toISOString().slice(0, 10); - if (sorted.length === 0 || sorted[0].date !== today) { - sorted.unshift({ date: today, label: 'TODAY', events: [] }); - } - } - return sorted; } From 8f567dca64ba6c289745e2c64fdf1984204d7899 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 21 May 2026 19:57:54 +0200 Subject: [PATCH 02/94] fix: defer stats faction-tab re-render with startTransition (#388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching the faction tab fans a re-render across ~10 react-slot-counter instances in StatGrid. A DevTools profile showed a ~41ms synchronous block (72ms INP) — a visible hitch. Wrapping setFaction in React's startTransition lets React yield through that render instead of blocking the interaction frame. Measured INP for a Global->Bugs switch: 72ms -> 39ms. The key-remount approach was profiled and rejected (722ms, 10x worse — layout thrashing from ~10 slot counters re-measuring glyph width on mount). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++++ src/features/dashboard/DashboardClient.jsx | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0bbb969..9d87ffa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- **Stats faction-tab switch no longer blocks the interaction frame (#388).** Switching the `FactionTabs` selection fans a re-render across ~10 `react-slot-counter` instances in `StatGrid` at once; a DevTools profile measured a ~41ms synchronous block (72ms INP) — a visible hitch. `setFaction` is now wrapped in React's `startTransition`, marking the re-render non-urgent so React can yield through it rather than blocking the frame. Measured INP for a Global→Bugs switch dropped 72ms → 39ms. The odometer roll itself is unchanged. (The obvious `key`-remount approach was profiled and rejected — it was 10× worse, 722ms, from layout thrashing as ~10 slot counters re-measured glyph width on mount.) + ## 0.47.10 ### Bug Fixes diff --git a/src/features/dashboard/DashboardClient.jsx b/src/features/dashboard/DashboardClient.jsx index d7b96bf5..62a48554 100644 --- a/src/features/dashboard/DashboardClient.jsx +++ b/src/features/dashboard/DashboardClient.jsx @@ -1,4 +1,5 @@ 'use client'; +import { startTransition } from 'react'; import './DashboardClient.css'; import NotificationToggle from '@/features/notifications/NotificationToggle'; import LastUpdated from '@/shared/components/LastUpdated'; @@ -241,7 +242,18 @@ export default function DashboardClient({

Stats

- + + // Switching the faction tab fans a re-render + // out to ~10 react-slot-counter instances + // (~41ms of work). startTransition marks it + // non-urgent so React can yield through that + // render instead of blocking the interaction + // frame in one chunk. + startTransition(() => setFaction(id)) + } + />
Date: Thu, 21 May 2026 21:10:31 +0200 Subject: [PATCH 03/94] feat: war-duration stat card in the dashboard StatGrid (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 6th WAR_DURATION card. Global tab: total war elapsed time (season_duration). Faction tab: how long that faction has been deployed — war duration minus the time it spent hidden before introduction. getCampaign now derives per-faction first_seen (earliest non-hidden h1_status bucket) and a top-level war_start. The non-hidden filter matters: updateStatus writes a row for all 3 factions every poll, so pre-introduction factions carry 'hidden' rows from war start — min(time) alone would report day 0 for everyone (confirmed against the season-157 data, which has a real staggered start). Mirrors the archives DURATION card; the auto-fit grid absorbs the 6th card with no layout change. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++ .../unit/features/stats/StatGrid.test.jsx | 54 +++++++++++++++++++ .../unit/queries/getCampaign.test.mjs | 42 +++++++++++++++ src/db/queries/getCampaign.mjs | 29 +++++++++- src/features/dashboard/DashboardClient.jsx | 2 + src/features/stats/StatGrid.jsx | 34 ++++++++++++ 6 files changed, 164 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 728c3412..2ab184fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- **War-duration stat card on the dashboard (#386).** A 6th `WAR_DURATION` card joins the `StatGrid`: on the **global** tab it shows how long the current war has been running (`season_duration`); on a **faction** tab it shows how long that faction has been deployed — total war duration minus the span it spent `hidden` before introduction. `getCampaign` now derives a per-faction `first_seen` (the earliest non-`hidden` `h1_status` bucket) and a top-level `war_start`. The non-`hidden` filter is essential: `updateStatus` writes an `h1_status` row for all 3 factions every poll, so a pre-introduction faction carries `hidden` rows from war start — a plain `min(time)` would report day 0 for everyone. Mirrors the archives `DURATION` card; the `auto-fit` stat grid absorbs the 6th card with no layout change. + ## 0.47.11 ### Bug Fixes diff --git a/src/__tests__/unit/features/stats/StatGrid.test.jsx b/src/__tests__/unit/features/stats/StatGrid.test.jsx index 241fb703..8096f56f 100644 --- a/src/__tests__/unit/features/stats/StatGrid.test.jsx +++ b/src/__tests__/unit/features/stats/StatGrid.test.jsx @@ -168,4 +168,58 @@ describe('StatGrid', () => { expect(container.innerHTML).toBe(''); }); }); + + describe('war duration card', () => { + const cardValue = (label) => + screen + .getByText(label) + .closest('.stat-card') + ?.querySelector('.stat-card-value')?.textContent; + + test('global view shows total war duration', () => { + render( + , + ); + expect(screen.getByText('WAR_DURATION')).toBeInTheDocument(); + expect(cardValue('WAR_DURATION')).toBe('10 days'); + }); + + test('faction view shows time since that faction was deployed', () => { + // bugs = enemy 0; it appeared 2 days after the war started + const live = mockLive.map((s) => ({ ...s, first_seen: 1000 })); + live[0] = { ...live[0], first_seen: 1000 + 86400 * 2 }; + render( + , + ); + // war ran 10 days; bugs deployed 2 days in → 8 days in the war + expect(cardValue('WAR_DURATION')).toBe('8 days'); + }); + + test('faction not yet deployed (first_seen null) shows a dash', () => { + const live = mockLive.map((s) => ({ ...s, first_seen: 1000 })); + live[0] = { ...live[0], first_seen: null }; + render( + , + ); + expect(cardValue('WAR_DURATION')).toBe('—'); + }); + }); }); diff --git a/src/__tests__/unit/queries/getCampaign.test.mjs b/src/__tests__/unit/queries/getCampaign.test.mjs index 23bf1ab9..91014a4e 100644 --- a/src/__tests__/unit/queries/getCampaign.test.mjs +++ b/src/__tests__/unit/queries/getCampaign.test.mjs @@ -263,6 +263,48 @@ describe('getCampaign', () => { }); }); + test('computes war_start and per-faction first_seen (first non-hidden bucket)', async () => { + // updateStatus writes a row for all 3 factions every poll, so + // pre-introduction factions have 'hidden' rows from war start. + // first_seen must be the first NON-hidden bucket, not min(time). + seedDbMocks({ + statusHistory: [ + { bucket: 1, enemy: 0, status: 'active', time: 1000, points: 1, points_taken: 0 }, // prettier-ignore + { bucket: 1, enemy: 1, status: 'hidden', time: 1000, points: 0, points_taken: 0 }, // prettier-ignore + { bucket: 1, enemy: 2, status: 'hidden', time: 1000, points: 0, points_taken: 0 }, // prettier-ignore + { bucket: 2, enemy: 0, status: 'active', time: 2000, points: 2, points_taken: 0 }, // prettier-ignore + { bucket: 2, enemy: 1, status: 'active', time: 2000, points: 1, points_taken: 0 }, // prettier-ignore + { bucket: 2, enemy: 2, status: 'hidden', time: 2000, points: 0, points_taken: 0 }, // prettier-ignore + { bucket: 3, enemy: 0, status: 'active', time: 3000, points: 3, points_taken: 0 }, // prettier-ignore + { bucket: 3, enemy: 1, status: 'active', time: 3000, points: 2, points_taken: 0 }, // prettier-ignore + { bucket: 3, enemy: 2, status: 'active', time: 3000, points: 1, points_taken: 0 }, // prettier-ignore + ], + }); + + const result = await getCampaign(); + + // war_start = earliest bucket time across all factions (incl. hidden) + expect(result.war_start).toBe(1000); + // first_seen = earliest bucket where the faction is no longer 'hidden' + expect(result.status[0].first_seen).toBe(1000); + expect(result.status[1].first_seen).toBe(2000); + expect(result.status[2].first_seen).toBe(3000); + }); + + test('first_seen is null for a faction that is still hidden', async () => { + seedDbMocks({ + statusHistory: [ + { bucket: 1, enemy: 0, status: 'active', time: 1000, points: 1, points_taken: 0 }, // prettier-ignore + { bucket: 1, enemy: 1, status: 'active', time: 1000, points: 1, points_taken: 0 }, // prettier-ignore + { bucket: 1, enemy: 2, status: 'hidden', time: 1000, points: 0, points_taken: 0 }, // prettier-ignore + ], + }); + + const result = await getCampaign(); + + expect(result.status[2].first_seen).toBeNull(); + }); + test('zeroes stats fields when h1_statistic row missing for a faction', async () => { // Only faction 0 has a stat row. Faction 1 and 2 should still appear // in data.status with stats fields zeroed — not undefined, not dropped. diff --git a/src/db/queries/getCampaign.mjs b/src/db/queries/getCampaign.mjs index c9ea61e9..bd3141d5 100644 --- a/src/db/queries/getCampaign.mjs +++ b/src/db/queries/getCampaign.mjs @@ -2,6 +2,7 @@ import { cache } from 'react'; import db from '@/db/db'; import { tryCatch } from '@/shared/utils/tryCatch.mjs'; import { groupStatusByBucket } from '@/shared/utils/bucketing.mjs'; +import { CAMPAIGN_STATUS } from '@/shared/enums/events.mjs'; /** * Fetch the campaign data for a season (or the latest season if null). @@ -9,7 +10,7 @@ import { groupStatusByBucket } from '@/shared/utils/bucketing.mjs'; * @returns {Promise} Campaign data, or null if no season exists * * Returns the public getCampaign shape consumed by archives and rebroadcast: - * { season, last_updated, season_duration, status, introduction_order, points_max, snapshots, events } + * { season, last_updated, season_duration, war_start, status, introduction_order, points_max, snapshots, events } * * - `status` — 3 rows from h1_status, one per faction, latest bucket each. * Consumers cast this as an array of faction states. @@ -22,6 +23,10 @@ import { groupStatusByBucket } from '@/shared/utils/bucketing.mjs'; * shape `{ order: number[] }` / `{ points: number[] }` to * preserve the historical 1:1 relation shape. * - `season_duration` — scalar int from h1_season (per-season, not per-faction). + * - `war_start` — unix-seconds time of the earliest h1_status bucket. Each + * `status[i]` also carries `first_seen` (earliest non-hidden + * bucket for that faction, or null if still hidden) — together + * they give per-faction deployment duration. */ export const getCampaign = cache(async function getCampaign(season = null) { 'use server'; @@ -83,6 +88,25 @@ export const getCampaign = cache(async function getCampaign(season = null) { orderBy: [{ bucket: 'asc' }, { enemy: 'asc' }], }); + // Per-faction first appearance. updateStatus writes a row for all 3 + // factions every poll, so a pre-introduction faction carries 'hidden' + // rows from war start — first_seen must be the earliest NON-hidden + // bucket, not min(time). war_start is the overall earliest bucket. + let warStart = null; + const firstSeenByEnemy = new Map(); + for (const row of allStatusRows) { + if (warStart === null || row.time < warStart) warStart = row.time; + if (row.status !== CAMPAIGN_STATUS.HIDDEN) { + const seen = firstSeenByEnemy.get(row.enemy); + if (seen == null || row.time < seen) { + firstSeenByEnemy.set(row.enemy, row.time); + } + } + } + for (const row of liveRows) { + row.first_seen = firstSeenByEnemy.get(row.enemy) ?? null; + } + // Group full history by bucket into the public snapshot shape. // Each snapshot has { time, data: [f0, f1, f2] } — the consumer pattern // for archives charts (FactionHealthChart, getWarOutcome, etc.). @@ -115,6 +139,9 @@ export const getCampaign = cache(async function getCampaign(season = null) { // of h1_statistic; exposed at the top level so consumers don't go // looking in data.status[i]. season_duration: seasonRow.season_duration ?? 0, + // Unix-seconds timestamp of the earliest h1_status bucket — used with + // each status row's `first_seen` to derive per-faction deployment time. + war_start: warStart, status: liveRows, introduction_order: { order: seasonRow.introduction_order ?? [] }, points_max: { points: seasonRow.points_max ?? [] }, diff --git a/src/features/dashboard/DashboardClient.jsx b/src/features/dashboard/DashboardClient.jsx index 62a48554..72b2d8a8 100644 --- a/src/features/dashboard/DashboardClient.jsx +++ b/src/features/dashboard/DashboardClient.jsx @@ -261,6 +261,8 @@ export default function DashboardClient({ events={events} playersAvg24h={playersAvg24h} kills24hAgo={kills24hAgo} + seasonDuration={data.season_duration} + warStart={data.war_start} /> diff --git a/src/features/stats/StatGrid.jsx b/src/features/stats/StatGrid.jsx index 0e58b46a..a8f528c5 100644 --- a/src/features/stats/StatGrid.jsx +++ b/src/features/stats/StatGrid.jsx @@ -1,5 +1,6 @@ import Image from 'next/image'; import { formatNumber } from '@/shared/utils/format/formatNumber.mjs'; +import { formatDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; import { countOutcomes } from '@/shared/utils/game/eventFilters.mjs'; import { EVENT_STATUS } from '@/shared/enums/events.mjs'; import { FACTION_INDEX } from '@/shared/enums/factions.mjs'; @@ -140,12 +141,35 @@ function eventsScoreValue(wins, losses) { ); } +/** + * WAR_DURATION stat card. `seconds` is the elapsed time to display — total + * war duration on the global tab, or how long a faction has been deployed on + * a faction tab. Null/invalid `seconds` (a faction not yet introduced) renders + * an em-dash. Mirrors the archives `DURATION` card: rounded days as the value, + * the precise humanised duration as the subtitle. + * + * @param {number | null} seconds - Elapsed war/deployment time in seconds + */ +function warDurationCard(seconds) { + const valid = Number.isFinite(seconds) && seconds > 0; + const days = valid ? Math.round(seconds / 86400) : null; + return ( + + ); +} + export default function StatGrid({ live, faction, events, playersAvg24h = null, kills24hAgo = null, + seasonDuration = 0, + warStart = null, }) { if (!live?.length) return null; @@ -210,6 +234,7 @@ export default function StatGrid({ value={eventsScoreValue(wins, losses)} subtitle={eventsSubtitle} /> + {warDurationCard(seasonDuration)} ); } @@ -220,6 +245,14 @@ export default function StatGrid({ const onlineSubtitle = playersDeltaSubtitle(stats.players, playersAvg24h?.[faction]); const killsSubtitle = cumulativeAddedSubtitle(stats.kills, kills24hAgo?.[faction]); + // How long this faction has been in the war: total war duration minus the + // span it spent 'hidden' before introduction. Null `first_seen` → the + // faction has not been deployed yet. + const factionSeconds = + stats.first_seen != null && Number.isFinite(warStart) ? + seasonDuration - (stats.first_seen - warStart) + : null; + return (
+ {warDurationCard(factionSeconds)}
); } From 79d2be53eefcb172baa875a28a37b95e3711cace Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 21 May 2026 21:43:28 +0200 Subject: [PATCH 04/94] =?UTF-8?q?feat:=20card=20=E2=86=92=20map=20hover=20?= =?UTF-8?q?highlight=20(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering a dashboard region card lights up the matching galaxy-map area: the hovered faction's territory faintly, its active sector strongly, the rest of the map receding — a three-tier opacity focus. New sectorLink.mjs toggles CSS classes directly on the map's SVG nodes (the MermaidDiagram pattern) instead of via React state, so a hover costs no re-render of the card grid or ~33 map paths. DashboardClient tags each card
  • with hover handlers + data-faction-index/data-sector; the data-* attrs also leave cards findable for a future map → card reverse highlight. Ships the card→map direction only, per the issue's "see how it goes" plan. TDD: 5 sectorLink tests (faint+strong, clear, defeated faction, Super Earth, re-highlight). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + .../unit/features/galaxy/sectorLink.test.mjs | 97 +++++++++++++++++++ .../unit/features/stats/StatGrid.test.jsx | 36 +++++++ src/features/dashboard/DashboardClient.jsx | 30 +++++- src/features/galaxy/Map.css | 24 ++++- src/features/galaxy/sectorLink.mjs | 62 ++++++++++++ src/features/stats/StatGrid.jsx | 45 +++++---- 7 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/unit/features/galaxy/sectorLink.test.mjs create mode 100644 src/features/galaxy/sectorLink.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a3e502..665be1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- **Region cards highlight their location on the galaxy map on hover (#185).** Hovering a dashboard region card now lights up the matching area of the map — the hovered faction's whole territory faintly, its one active sector strongly, the rest of the map receding (a three-tier opacity focus). Implemented by toggling CSS classes directly on the map's SVG nodes (`sectorLink.mjs`) rather than through React state, so a hover costs no re-render of the card grid or the ~33 map paths. This ships the card → map direction; the reverse (map sector → card) can be layered on later by calling the same helper from the map's own hover handlers — the cards already carry `data-faction-index`/`data-sector` for it. + ## 0.47.12 ### Features diff --git a/src/__tests__/unit/features/galaxy/sectorLink.test.mjs b/src/__tests__/unit/features/galaxy/sectorLink.test.mjs new file mode 100644 index 00000000..4b4d64e7 --- /dev/null +++ b/src/__tests__/unit/features/galaxy/sectorLink.test.mjs @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { describe, test, expect, beforeEach } from 'vitest'; +import { highlightSector, clearSectorHighlight } from '@/features/galaxy/sectorLink.mjs'; + +/** + * Build a minimal stand-in for the Map.jsx SVG structure: a `#map` wrapper, + * one `` per faction keyed by faction string, sector elements classed + * `.sector` with a numeric `data-name`. Plain divs — sectorLink only touches + * ids, classes and data-attributes, never SVG geometry. + */ +function makeMapDom() { + document.body.replaceChildren(); + const map = document.createElement('div'); + map.id = 'map'; + const addGroup = (id, sectors) => { + const group = document.createElement('div'); + group.id = id; + for (const name of sectors) { + const sector = document.createElement('div'); + sector.className = 'sector'; + sector.setAttribute('data-name', String(name)); + group.appendChild(sector); + } + map.appendChild(group); + }; + addGroup('bugs', [1, 2, 11]); + addGroup('cyborgs', [1]); + addGroup('superearth', [0]); + document.body.appendChild(map); +} + +beforeEach(makeMapDom); + +describe('sectorLink', () => { + test('faints the whole faction territory and strongly marks one sector', () => { + highlightSector(0, 2); + + document + .querySelectorAll('#bugs .sector') + .forEach((el) => + expect(el.classList.contains('sector-linked-faint')).toBe(true), + ); + + const strong = document.querySelectorAll('.sector-linked-strong'); + expect(strong).toHaveLength(1); + expect(strong[0].getAttribute('data-name')).toBe('2'); + + expect( + document.getElementById('map').classList.contains('is-sector-linking'), + ).toBe(true); + // a different faction is untouched + expect( + document + .querySelector('#cyborgs .sector') + .classList.contains('sector-linked-faint'), + ).toBe(false); + }); + + test('clearSectorHighlight removes every link class and the map flag', () => { + highlightSector(0, 2); + clearSectorHighlight(); + + expect( + document.querySelectorAll('.sector-linked-faint, .sector-linked-strong'), + ).toHaveLength(0); + expect( + document.getElementById('map').classList.contains('is-sector-linking'), + ).toBe(false); + }); + + test('null sector (defeated faction) faints territory with no strong sector', () => { + highlightSector(0, null); + + expect(document.querySelectorAll('#bugs .sector-linked-faint')).toHaveLength(3); + expect(document.querySelectorAll('.sector-linked-strong')).toHaveLength(0); + }); + + test('Super Earth (faction index 3) resolves to the superearth group', () => { + highlightSector(3, 0); + + const se = document.querySelector('#superearth .sector'); + expect(se.classList.contains('sector-linked-faint')).toBe(true); + expect(se.classList.contains('sector-linked-strong')).toBe(true); + }); + + test('a new highlight clears the previous one first', () => { + highlightSector(0, 2); + highlightSector(1, 1); + + expect(document.querySelectorAll('#bugs .sector-linked-faint')).toHaveLength(0); + expect( + document + .querySelector('#cyborgs .sector') + .classList.contains('sector-linked-strong'), + ).toBe(true); + }); +}); diff --git a/src/__tests__/unit/features/stats/StatGrid.test.jsx b/src/__tests__/unit/features/stats/StatGrid.test.jsx index 8096f56f..04109f4f 100644 --- a/src/__tests__/unit/features/stats/StatGrid.test.jsx +++ b/src/__tests__/unit/features/stats/StatGrid.test.jsx @@ -176,6 +176,12 @@ describe('StatGrid', () => { .closest('.stat-card') ?.querySelector('.stat-card-value')?.textContent; + const cardSubtitle = (label) => + screen + .getByText(label) + .closest('.stat-card') + ?.querySelector('.stat-card-subtitle')?.textContent; + test('global view shows total war duration', () => { render( { ); expect(cardValue('WAR_DURATION')).toBe('—'); }); + + test('global view subtitle shows the war start date', () => { + render( + , + ); + expect(cardSubtitle('WAR_DURATION')).toBe('25 JANUARY'); + }); + + test('faction view subtitle shows that faction introduction date', () => { + // war started 20 Feb; bugs (enemy 0) were introduced 01 Mar + const warStart = Date.UTC(2025, 1, 20) / 1000; + const live = mockLive.map((s) => ({ ...s, first_seen: warStart })); + live[0] = { ...live[0], first_seen: Date.UTC(2025, 2, 1) / 1000 }; + render( + , + ); + expect(cardSubtitle('WAR_DURATION')).toBe('01 MARCH'); + }); }); }); diff --git a/src/features/dashboard/DashboardClient.jsx b/src/features/dashboard/DashboardClient.jsx index 72b2d8a8..f1289609 100644 --- a/src/features/dashboard/DashboardClient.jsx +++ b/src/features/dashboard/DashboardClient.jsx @@ -5,6 +5,7 @@ import NotificationToggle from '@/features/notifications/NotificationToggle'; import LastUpdated from '@/shared/components/LastUpdated'; import EventCard, { computeFrontier } from '@/features/galaxy/EventCard'; import DefeatedCard from '@/features/galaxy/DefeatedCard'; +import { highlightSector, clearSectorHighlight } from '@/features/galaxy/sectorLink.mjs'; import FactionTabs from '@/shared/components/FactionTabs'; import RegionsViewToggle from '@/features/dashboard/RegionsViewToggle'; import StatGrid from '@/features/stats/StatGrid'; @@ -21,6 +22,27 @@ import { REGIONS_VIEW_KEY } from '@/shared/preferences/regionsView.mjs'; const factionIndices = [0, 1, 2]; +/** + * Hover props for a region card's `
  • `: `data-*` attributes that key the + * card to its galaxy-map sector, plus handlers that light the matching map + * area on hover (faction territory faint, active sector strong). The `data-*` + * attributes also leave the card findable for a future map → card reverse + * highlight (#185). + * + * @param {number} factionIndex - 0-2 faction, or 3 for Super Earth + * @param {number | null} [sector] - Active sector (1-11; 0 for Super Earth); + * omitted for a defeated faction with no single active sector + * @returns {object} Props to spread onto the card's `
  • ` + */ +function sectorHoverProps(factionIndex, sector = null) { + return { + 'data-faction-index': factionIndex, + ...(sector != null && { 'data-sector': sector }), + onMouseEnter: () => highlightSector(factionIndex, sector), + onMouseLeave: clearSectorHighlight, + }; +} + export default function DashboardClient({ initialFaction = 'global', initialRegionsView = 'sector', @@ -62,7 +84,7 @@ export default function DashboardClient({ if (index === seDefenderIndex) { // Super Earth defense is an event-focused interrupt — always sector view. return ( -
  • +
  • +
  • +
  • +
  • ` groups of + * ``; those ids / data-attributes are + * the link keys, so no shared component or state is needed. + * + * Bidirectional (map → card) can be layered on later by calling the same + * functions from the map's own hover handlers — see issue #185. + */ + +// Faction index → the `` id used in Map.jsx. +const FACTION_GROUP = { + 0: 'bugs', + 1: 'cyborgs', + 2: 'illuminate', + 3: 'superearth', +}; + +/** Remove every hover-link class and the map-wide focus flag. */ +export function clearSectorHighlight() { + if (typeof document === 'undefined') return; + document.getElementById('map')?.classList.remove('is-sector-linking'); + document + .querySelectorAll('.sector-linked-faint, .sector-linked-strong') + .forEach((el) => + el.classList.remove('sector-linked-faint', 'sector-linked-strong'), + ); +} + +/** + * Highlight a faction's map territory, with one sector emphasised. + * + * @param {number} factionIndex - 0 Bugs, 1 Cyborgs, 2 Illuminate, 3 Super Earth + * @param {number | null} [sector] - Sector to mark strongly (1-11, or 0 for + * Super Earth). Null/omitted faints the whole territory only — e.g. a + * defeated faction with no single active sector. + */ +export function highlightSector(factionIndex, sector = null) { + if (typeof document === 'undefined') return; + clearSectorHighlight(); + + const group = FACTION_GROUP[factionIndex]; + if (!group) return; + const groupEl = document.getElementById(group); + if (!groupEl) return; + + document.getElementById('map')?.classList.add('is-sector-linking'); + + const wanted = sector != null ? String(sector) : null; + groupEl.querySelectorAll('.sector').forEach((el) => { + el.classList.add('sector-linked-faint'); + if (wanted != null && el.dataset.name === wanted) { + el.classList.add('sector-linked-strong'); + } + }); +} diff --git a/src/features/stats/StatGrid.jsx b/src/features/stats/StatGrid.jsx index a8f528c5..dda28dff 100644 --- a/src/features/stats/StatGrid.jsx +++ b/src/features/stats/StatGrid.jsx @@ -1,23 +1,24 @@ import Image from 'next/image'; import { formatNumber } from '@/shared/utils/format/formatNumber.mjs'; -import { formatDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; import { countOutcomes } from '@/shared/utils/game/eventFilters.mjs'; import { EVENT_STATUS } from '@/shared/enums/events.mjs'; import { FACTION_INDEX } from '@/shared/enums/factions.mjs'; import AnimatedStat from '@/shared/components/AnimatedStat/AnimatedStat'; import './StatGrid.css'; -const asPercent = (v) => (Number.isFinite(v) ? `${v.toFixed(1)}%` : '—'); - /** - * Compute accidental-death rate: accidentals / deaths as a raw percentage - * number (0-100). Returns null if deaths is zero so callers can render '—'. + * Format a Unix-seconds timestamp as a "DD MONTH" label (e.g. "25 JANUARY", + * "01 MARCH"). Day is zero-padded; month is the full English name uppercased. + * Fixed to UTC so the server-rendered output and client hydration agree + * regardless of the viewer's timezone. */ -function computeAccidentalRate(accidentals, deaths) { - const a = Number(accidentals || 0); - const d = Number(deaths || 0); - if (d <= 0) return null; - return (a / d) * 100; +function formatStartDate(unixSeconds) { + const d = new Date(unixSeconds * 1000); + const day = String(d.getUTCDate()).padStart(2, '0'); + const month = d + .toLocaleString('en-US', { month: 'long', timeZone: 'UTC' }) + .toUpperCase(); + return `${day} ${month}`; } function accidentalRateTooltip(accidentals, deaths) { @@ -144,20 +145,30 @@ function eventsScoreValue(wins, losses) { /** * WAR_DURATION stat card. `seconds` is the elapsed time to display — total * war duration on the global tab, or how long a faction has been deployed on - * a faction tab. Null/invalid `seconds` (a faction not yet introduced) renders - * an em-dash. Mirrors the archives `DURATION` card: rounded days as the value, - * the precise humanised duration as the subtitle. + * a faction tab. `startUnix` is the Unix-seconds timestamp that span began — + * war start on the global tab, faction introduction on a faction tab — shown + * as a "DD MONTH" subtitle so the value and subtitle read as a coherent pair. + * Null/invalid `seconds` (a faction not yet introduced) renders an em-dash + * with no subtitle. * * @param {number | null} seconds - Elapsed war/deployment time in seconds + * @param {number | null} startUnix - Unix-seconds timestamp the span began */ -function warDurationCard(seconds) { +function warDurationCard(seconds, startUnix) { const valid = Number.isFinite(seconds) && seconds > 0; const days = valid ? Math.round(seconds / 86400) : null; + const startValid = Number.isFinite(startUnix) && startUnix > 0; return ( + {formatStartDate(startUnix)} + + : undefined + } /> ); } @@ -234,7 +245,7 @@ export default function StatGrid({ value={eventsScoreValue(wins, losses)} subtitle={eventsSubtitle} /> - {warDurationCard(seasonDuration)} + {warDurationCard(seasonDuration, warStart)} ); } @@ -281,7 +292,7 @@ export default function StatGrid({ value={eventsScoreValue(wins, losses)} subtitle={eventsSubtitle} /> - {warDurationCard(factionSeconds)} + {warDurationCard(factionSeconds, stats.first_seen)} ); } From b12e50e8d10a75f764d51564b1ed5d0d29131ab3 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 21 May 2026 21:54:31 +0200 Subject: [PATCH 05/94] refactor: HELLDIVERS_LOST subtitle labels accidentals "Martyrs" The card subtitle showed the accidental-death count plus its rate as a percentage of total deaths; it now shows the count with a "Martyrs" label and drops the percentage. Completes the StatGrid refinement begun in the previous commit (asPercent / computeAccidentalRate are now unused and were removed there). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../unit/features/stats/StatGrid.test.jsx | 12 ++++++++++++ src/features/stats/StatGrid.jsx | 15 ++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/__tests__/unit/features/stats/StatGrid.test.jsx b/src/__tests__/unit/features/stats/StatGrid.test.jsx index 04109f4f..9f916e34 100644 --- a/src/__tests__/unit/features/stats/StatGrid.test.jsx +++ b/src/__tests__/unit/features/stats/StatGrid.test.jsx @@ -93,6 +93,18 @@ describe('StatGrid', () => { // losses: 2 (enemy0 fail + enemy2 fail) expect(screen.getByText('2')).toBeInTheDocument(); }); + + test('HELLDIVERS_LOST teamkill subtitle is labelled MARTYRS, not a percentage', () => { + render(); + const subtitle = screen + .getByText('HELLDIVERS_LOST') + .closest('.stat-card') + ?.querySelector('.stat-card-subtitle')?.textContent; + // accidentals: 10+20+5 = 35, followed by the MARTYRS label + expect(subtitle).toContain('35'); + expect(subtitle).toContain('Martyrs'); + expect(subtitle).not.toContain('%'); + }); }); describe('faction view', () => { diff --git a/src/features/stats/StatGrid.jsx b/src/features/stats/StatGrid.jsx index dda28dff..ba3defd4 100644 --- a/src/features/stats/StatGrid.jsx +++ b/src/features/stats/StatGrid.jsx @@ -86,26 +86,23 @@ function cumulativeAddedSubtitle(current, baseline) { } /** - * Subtitle for the HELLDIVERS_LOST card — shows the absolute number - * of accidental deaths with a small `backstab` icon in place of a - * text label, plus the rate as a percentage of total deaths. Returns + * Subtitle for the HELLDIVERS_LOST card — shows the absolute number of + * accidental ("teamkill") deaths, marked with a small `backstab` icon and + * a `MARTYRS` label (these are the divers who were teamkilled). Returns * null when there are no accidentals to report (either deaths or - * accidentals is 0). The full count + rate is also surfaced via the - * card's tooltip. + * accidentals is 0). The accidental + total death counts are also + * surfaced via the card's tooltip. */ function accidentalSubtitle(accidentals, deaths) { const count = Number(accidentals || 0); if (!(Number(deaths) > 0) || count <= 0) return null; - const rate = computeAccidentalRate(accidentals, deaths); return ( - - () - + Martyrs ); } From eab2cb080b2995b0272ec7ef53b571c2908b9005 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 21 May 2026 22:49:12 +0200 Subject: [PATCH 06/94] feat(stats): ENEMIES_KILLED 24h kill-pace trend arrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ENEMIES_KILLED "Last 24h" subtitle showed a raw kill volume with a permanently green arrow — a count, not a verdict, since a cumulative counter only ever grows. It now compares the last 24h of kills against the 24h before it and shows ▲/▼/▪ for whether the killing pace rose, fell, or held — a genuine better/worse signal, matching how HELLDIVERS_ONLINE already reads against its rolling average. - getKills24hAgo → getKillsTrend: fetches two point-in-time snapshots (~24h and ~48h ago) instead of one; prop threaded as `killsTrend`. - killsTrendSubtitle derives last24h/prev24h; neutral ▪ when there is no 48h baseline yet (season 24–48h old). - Extracted the shared ▲/▼/▪ arrow into a `deltaArrow` helper, now used by both playersDeltaSubtitle and killsTrendSubtitle. - formatNumber applies the M suffix from 1M (was 10M): a 7-digit grouped number overflowed the stat-card subtitles; ≥1M now renders as X.XM. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 ++ CLAUDE.md | 2 +- .../unit/features/galaxy/EventCard.test.jsx | 6 +- .../unit/features/stats/StatGrid.test.jsx | 53 ++++++++++++ .../shared/utils/format/formatNumber.test.mjs | 8 +- .../unit/shared/utils/utils.test.mjs | 10 +-- src/app/page.jsx | 6 +- src/db/queries/getKills24hAgo.mjs | 44 ---------- src/db/queries/getKillsTrend.mjs | 71 ++++++++++++++++ src/features/dashboard/DashboardClient.jsx | 4 +- src/features/dashboard/HomeClient.jsx | 4 +- src/features/stats/StatGrid.jsx | 85 +++++++++++-------- src/shared/utils/format/formatNumber.mjs | 4 +- 13 files changed, 208 insertions(+), 97 deletions(-) delete mode 100644 src/db/queries/getKills24hAgo.mjs create mode 100644 src/db/queries/getKillsTrend.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3f669f..e1842d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Features + +- **`ENEMIES_KILLED` subtitle compares the last 24h of kills against the 24h before it.** The card's `Last 24h` subtitle previously showed only the raw kill volume with a permanently green arrow — a count, not a verdict (a cumulative counter only ever grows). It now derives two consecutive 24h volumes (`last24h = current − kills(24h ago)`, `prev24h = kills(24h ago) − kills(48h ago)`) and shows the last-24h volume with a ▲/▼/▪ arrow marking whether the killing pace rose, fell, or held versus the previous 24h — a genuine better/worse signal, matching how `HELLDIVERS_ONLINE` already reads against its rolling-average baseline. `getKills24hAgo` was renamed to `getKillsTrend` and now fetches two point-in-time snapshots (~24h and ~48h ago) instead of one; the prop is threaded through as `killsTrend`. For a season 24–48h old there is no 48h baseline to compare against, so the arrow falls back to a neutral ▪. The ▲/▼/▪ arrow rendering shared by both delta subtitles was extracted into a `deltaArrow` helper. + +### Changes + +- **`formatNumber` applies the `M` suffix from 1M, not 10M.** A 7-digit locale-grouped number (`3,522,088`) overflowed the dashboard stat-card subtitles; numbers ≥ 1M now collapse to `X.XM` (`3.5M`). Since this is the shared number formatter, 1M–10M values also compact on the archive stat cards, the admin overview, and event-card point totals. Numbers below 1M are unchanged (still locale-grouped). + ## 0.47.13 ### Features diff --git a/CLAUDE.md b/CLAUDE.md index 4402deb3..a3ea72c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ All visual properties use CSS custom properties defined in the Tailwind v4 `@the - **Error tracking (optional):** Sentry SDK configured for self-hosted GlitchTip (`tracesSampleRate` 0.1 in production / 1.0 in dev, `environment` tagging, no replays/logs). Client tunnel (`/api/glitchtip`) bypasses ad blockers. CSP violations reported via `report-uri`. Route-level (`error.jsx`) and component-level (`ComponentErrorBoundary`) error boundaries for graceful degradation. When `SENTRY_AUTH_TOKEN` absent, `withSentryConfig` build plugin skipped. - **Node version:** mise pins node@24 (ships with npm 11 natively). - **Server actions:** Most utilities use `'use server'` directive. -- **Shared utilities:** `formatNumber` (`src/shared/utils/format/formatNumber.mjs`) for compact numbers (25.0M, 1.2K — M suffix at 10M+, locale grouping below). `formatTimeAgo` (`src/shared/utils/format/formatTimeAgo.mjs`) for relative timestamps. +- **Shared utilities:** `formatNumber` (`src/shared/utils/format/formatNumber.mjs`) for compact numbers (25.0M, 1.2K — M suffix at 1M+, locale grouping below). `formatTimeAgo` (`src/shared/utils/format/formatTimeAgo.mjs`) for relative timestamps. - **Map state:** `computeMapState` (`src/shared/utils/game/computeMapState.mjs`) computes galaxy map sector ownership. Sectors 1-10 from campaign `points`/`points_max`; region 11 (homeworld) from attack events only. **Critical:** live views must only pass active events — use the `computeLiveMapState(data)` helper from the same module to keep the filter and the call together. - **On-demand season fetching:** `/archives` page derives SeasonSelector from current season number (not DB query). Missing seasons are backfilled from the official HD1 API on first request via `updateSeason()` (`src/update/season.mjs`) -- the same shared pipeline the worker runs every poll for the active season and the admin "Refresh" button triggers via `reseedSeason`. `updateSeason` writes `h1_season` (with inlined arrays) + `h1_status` + `h1_statistic` + `h1_event` + `h1_event_progress`, then stamps `h1_season.last_updated`. - **Live polling:** `useLiveData` hook (`src/shared/hooks/useLiveData.mjs`) polls `GET /api/h1/live` every 10 seconds via `setInterval` + `fetch`. A `visibilitychange` listener fires an immediate poll on tab focus. Tri-state status: `'polling'` (request in flight), `'live'` (last poll succeeded), `'offline'` (last poll failed or PWA offline). Module-level singleton ensures one connection per tab. BroadcastChannel leader election for Web Notifications. diff --git a/src/__tests__/unit/features/galaxy/EventCard.test.jsx b/src/__tests__/unit/features/galaxy/EventCard.test.jsx index aceeefc2..46c9823d 100644 --- a/src/__tests__/unit/features/galaxy/EventCard.test.jsx +++ b/src/__tests__/unit/features/galaxy/EventCard.test.jsx @@ -361,9 +361,9 @@ describe('EventCard (campaign view)', () => { />, ); const points = container.querySelector('.sector-card-points'); - // Values under 10M use locale grouping (e.g. 1,234,567 / 5,000,000) - expect(points.textContent).toMatch(/1.*234.*567/); - expect(points.textContent).toMatch(/5.*000.*000/); + // 1,234,567 and 5,000,000 both clear 1M, so each renders with the M suffix + expect(points.textContent).toMatch(/1\.2M/); + expect(points.textContent).toMatch(/5\.0M/); }); test('missing factionMap does not crash, renders all 11 empty segments', () => { diff --git a/src/__tests__/unit/features/stats/StatGrid.test.jsx b/src/__tests__/unit/features/stats/StatGrid.test.jsx index 9f916e34..068ca23e 100644 --- a/src/__tests__/unit/features/stats/StatGrid.test.jsx +++ b/src/__tests__/unit/features/stats/StatGrid.test.jsx @@ -105,6 +105,59 @@ describe('StatGrid', () => { expect(subtitle).toContain('Martyrs'); expect(subtitle).not.toContain('%'); }); + + // total kills 500+1000+750 = 2250 → last 24h = 2250 − ago24h + const killsSubtitle = () => + screen + .getByText('ENEMIES_KILLED') + .closest('.stat-card') + ?.querySelector('.stat-card-subtitle'); + + test('ENEMIES_KILLED arrow is green ▲ when killing pace rose vs the prior 24h', () => { + render( + , + ); + // last 24h: 2250 − 2000 = 250; prior 24h: 2000 − 1900 = 100; pace up + expect(killsSubtitle()?.textContent).toContain('250'); + expect(killsSubtitle()?.querySelector('.text-success')?.textContent).toBe( + '▲', + ); + }); + + test('ENEMIES_KILLED arrow is red ▼ when killing pace fell vs the prior 24h', () => { + render( + , + ); + // last 24h: 2250 − 2000 = 250; prior 24h: 2000 − 1650 = 350; pace down + expect(killsSubtitle()?.textContent).toContain('250'); + expect(killsSubtitle()?.querySelector('.text-danger')?.textContent).toBe('▼'); + }); + + test('ENEMIES_KILLED arrow is a neutral ▪ when there is no 48h baseline', () => { + render( + , + ); + // last 24h volume still shows, but pace can't be compared yet + expect(killsSubtitle()?.textContent).toContain('250'); + expect(killsSubtitle()?.textContent).toContain('▪'); + expect(killsSubtitle()?.querySelector('.text-success')).toBeNull(); + expect(killsSubtitle()?.querySelector('.text-danger')).toBeNull(); + }); }); describe('faction view', () => { diff --git a/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs b/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs index 5a9db89e..52f7066e 100644 --- a/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs +++ b/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs @@ -5,8 +5,14 @@ describe('formatNumber', () => { expect(formatNumber(1_500_000_000)).toBe('1.5B'); }); - test('formats millions', () => { + test('formats millions with the M suffix from 1M up', () => { expect(formatNumber(12_300_000)).toBe('12.3M'); + expect(formatNumber(3_522_088)).toBe('3.5M'); + expect(formatNumber(1_000_000)).toBe('1.0M'); + }); + + test('keeps numbers just below 1M locale-grouped', () => { + expect(formatNumber(999_999)).toBe('999,999'); }); test('formats thousands with commas', () => { diff --git a/src/__tests__/unit/shared/utils/utils.test.mjs b/src/__tests__/unit/shared/utils/utils.test.mjs index 06c41de1..687e339e 100644 --- a/src/__tests__/unit/shared/utils/utils.test.mjs +++ b/src/__tests__/unit/shared/utils/utils.test.mjs @@ -49,10 +49,10 @@ describe('formatNumber', () => { expect(formatNumber(999999)).toBe(Number(999999).toLocaleString()); }); - test('values 1M–9.99M use locale grouping', () => { - expect(formatNumber(1000000)).toBe(Number(1000000).toLocaleString()); - expect(formatNumber(1500000)).toBe(Number(1500000).toLocaleString()); - expect(formatNumber(9999999)).toBe(Number(9999999).toLocaleString()); + test('values from 1M up use the M suffix with one decimal', () => { + expect(formatNumber(1000000)).toBe('1.0M'); + expect(formatNumber(1500000)).toBe('1.5M'); + expect(formatNumber(9500000)).toBe('9.5M'); }); test('millions ≥10M use M suffix with one decimal', () => { @@ -68,7 +68,7 @@ describe('formatNumber', () => { test('BigInt input is converted to number', () => { expect(formatNumber(BigInt(42))).toBe('42'); - expect(formatNumber(BigInt(5000000))).toBe(Number(5000000).toLocaleString()); + expect(formatNumber(BigInt(5000000))).toBe('5.0M'); }); test('null, undefined, and non-finite return em dash', () => { diff --git a/src/app/page.jsx b/src/app/page.jsx index 13051f5c..d469d80e 100644 --- a/src/app/page.jsx +++ b/src/app/page.jsx @@ -3,7 +3,7 @@ import JsonLd from '@/shared/components/JsonLd'; import HomeClient from '@/features/dashboard/HomeClient'; import { getCampaign } from '@/db/queries/getCampaign.mjs'; import { getPlayersAvg24h } from '@/db/queries/getPlayersAvg24h.mjs'; -import { getKills24hAgo } from '@/db/queries/getKills24hAgo.mjs'; +import { getKillsTrend } from '@/db/queries/getKillsTrend.mjs'; import { tryCatch } from '@/shared/utils/tryCatch.mjs'; import { FACTION_KEY, validateFaction } from '@/shared/preferences/faction.mjs'; import { @@ -75,7 +75,7 @@ export default async function HomePage() { campaign ? await Promise.all([ tryCatch(getPlayersAvg24h(campaign.season)), - tryCatch(getKills24hAgo(campaign.season)), + tryCatch(getKillsTrend(campaign.season)), ]) : [{ data: null }, { data: null }]; @@ -87,7 +87,7 @@ export default async function HomePage() { initialRegionsView={initialRegionsView} initialSortOrder={initialSortOrder} playersAvg24h={playersRes.data ?? null} - kills24hAgo={killsRes.data ?? null} + killsTrend={killsRes.data ?? null} /> ); diff --git a/src/db/queries/getKills24hAgo.mjs b/src/db/queries/getKills24hAgo.mjs deleted file mode 100644 index 140aa7ff..00000000 --- a/src/db/queries/getKills24hAgo.mjs +++ /dev/null @@ -1,44 +0,0 @@ -'use server'; -import db from '@/db/db'; - -const FACTION_LABELS = { 0: 'bugs', 1: 'cyborgs', 2: 'illuminate' }; - -/** - * Returns the cumulative kill counts from ~24 hours ago for the given - * season, both globally and per-faction — used as the baseline for the - * "+N LAST 24H" subtitle on the ENEMIES_KILLED stat cards. - * - * Unlike `getPlayersAvg24h`, which averages over the window, kills are - * a monotonically increasing counter — we want the *point-in-time* - * value at ~24h ago so `current - baseline` gives kills over the last - * 24h exactly. - * - * Shape: `{ global, bugs, cyborgs, illuminate }` where `global` is the - * sum across factions. Returns `null` if the season is too young to - * have a 24h-ago bucket. - */ -export async function getKills24hAgo(season) { - const targetTimestamp = Math.floor(Date.now() / 1000) - 86400; - - const rows = await db.$queryRaw` - SELECT enemy, kills - FROM h1_statistic - WHERE season = ${season} - AND bucket = ( - SELECT MAX(bucket) FROM h1_statistic - WHERE season = ${season} AND bucket <= ${targetTimestamp} - ) - `; - - if (rows.length === 0) return null; - - const result = { global: 0 }; - for (const row of rows) { - // kills is stored as BigInt; cast for JS arithmetic + formatNumber. - const kills = Number(row.kills); - const label = FACTION_LABELS[row.enemy]; - if (label) result[label] = kills; - result.global += kills; - } - return result; -} diff --git a/src/db/queries/getKillsTrend.mjs b/src/db/queries/getKillsTrend.mjs new file mode 100644 index 00000000..1edfbdac --- /dev/null +++ b/src/db/queries/getKillsTrend.mjs @@ -0,0 +1,71 @@ +'use server'; +import db from '@/db/db'; + +const FACTION_LABELS = { 0: 'bugs', 1: 'cyborgs', 2: 'illuminate' }; + +/** + * Returns cumulative kill counts at two historical reference points — + * ~24h ago and ~48h ago — per faction and globally, for the given season. + * + * The ENEMIES_KILLED stat card combines these with the live kill total to + * derive two consecutive 24h kill volumes — the last 24h vs the 24h before + * it — and show whether the killing pace is rising or falling. + * + * Kills are a monotonically increasing counter, so each reference point is + * the *point-in-time* value at the bucket nearest (but not after) its + * target timestamp: `current - ago24h` is then exactly the last 24h, and + * `ago24h - ago48h` the 24h before that. + * + * Shape: `{ global, bugs, cyborgs, illuminate }`, each `{ ago24h, ago48h }` + * where `global` sums across factions. `ago48h` is `null` per faction when + * there's no 48h-ago bucket yet (season 24–48h old, so the pace can't be + * compared). Returns `null` for the whole object when there's no 24h-ago + * bucket at all (season younger than 24h). + */ +export async function getKillsTrend(season) { + const now = Math.floor(Date.now() / 1000); + + // Cumulative kill counts at the bucket nearest (but not after) a target + // timestamp. Resolves to [] when the season has no bucket that old yet. + const snapshotAt = (targetTimestamp) => db.$queryRaw` + SELECT enemy, kills + FROM h1_statistic + WHERE season = ${season} + AND bucket = ( + SELECT MAX(bucket) FROM h1_statistic + WHERE season = ${season} AND bucket <= ${targetTimestamp} + ) + `; + + const [rows24h, rows48h] = await Promise.all([ + snapshotAt(now - 86400), + snapshotAt(now - 172800), + ]); + + if (rows24h.length === 0) return null; + + // Fold per-enemy rows into { global, bugs, cyborgs, illuminate }. kills is + // stored as BigInt; cast for JS arithmetic + formatNumber downstream. + const fold = (rows) => { + const totals = { global: 0 }; + for (const row of rows) { + const kills = Number(row.kills); + const label = FACTION_LABELS[row.enemy]; + if (label) totals[label] = kills; + totals.global += kills; + } + return totals; + }; + + const at24h = fold(rows24h); + const at48h = rows48h.length > 0 ? fold(rows48h) : null; + + const result = {}; + for (const faction of ['global', 'bugs', 'cyborgs', 'illuminate']) { + result[faction] = { + ago24h: at24h[faction] ?? null, + ago48h: at48h?.[faction] ?? null, + }; + } + return result; +} diff --git a/src/features/dashboard/DashboardClient.jsx b/src/features/dashboard/DashboardClient.jsx index f1289609..85baff15 100644 --- a/src/features/dashboard/DashboardClient.jsx +++ b/src/features/dashboard/DashboardClient.jsx @@ -47,7 +47,7 @@ export default function DashboardClient({ initialFaction = 'global', initialRegionsView = 'sector', playersAvg24h = null, - kills24hAgo = null, + killsTrend = null, }) { const { data, mapState } = useLiveDataContext(); const [faction, setFaction] = usePersistedState(FACTION_KEY, initialFaction); @@ -282,7 +282,7 @@ export default function DashboardClient({ faction={faction} events={events} playersAvg24h={playersAvg24h} - kills24hAgo={kills24hAgo} + killsTrend={killsTrend} seasonDuration={data.season_duration} warStart={data.war_start} /> diff --git a/src/features/dashboard/HomeClient.jsx b/src/features/dashboard/HomeClient.jsx index 47666156..68d85ed7 100644 --- a/src/features/dashboard/HomeClient.jsx +++ b/src/features/dashboard/HomeClient.jsx @@ -97,7 +97,7 @@ export default function HomeClient({ initialRegionsView = 'sector', initialSortOrder = 'desc', playersAvg24h = null, - kills24hAgo = null, + killsTrend = null, }) { const { data, mapState: liveMapState } = useLiveDataContext(); const events = data?.events ?? []; @@ -127,7 +127,7 @@ export default function HomeClient({ initialFaction={initialFaction} initialRegionsView={initialRegionsView} playersAvg24h={playersAvg24h} - kills24hAgo={kills24hAgo} + killsTrend={killsTrend} /> diff --git a/src/features/stats/StatGrid.jsx b/src/features/stats/StatGrid.jsx index ba3defd4..40d32b94 100644 --- a/src/features/stats/StatGrid.jsx +++ b/src/features/stats/StatGrid.jsx @@ -28,30 +28,37 @@ function accidentalRateTooltip(accidentals, deaths) { } /** - * Format the "LAST 24H" delta subtitle for the ONLINE card. Compares - * the current player count to the 24h rolling average baseline. - * Three states: ▲ (growth, success) / ▼ (decline, danger) / ▪ (flat, - * ghost). Returns null only when there's no baseline yet (new season). - * Whole line is uppercase and ghost-coloured; only the arrow carries - * a tinted override, and only when non-zero. + * The ▲ / ▼ / ▪ trend arrow shared by the delta-style stat subtitles. + * `n` is a signed delta: positive → green ▲, negative → red ▼, zero → a + * neutral, un-tinted ▪. The ▲/▼ triangles sit below their optical centre + * so they're lifted 1.5px; ▪ is already centred in its em box and isn't + * nudged. */ -function playersDeltaSubtitle(currentPlayers, avgPlayers) { - if (avgPlayers == null) return null; - const delta = currentPlayers - avgPlayers; - const indicator = - delta > 0 ? '▲' - : delta < 0 ? '▼' +function deltaArrow(n) { + const glyph = + n > 0 ? '▲' + : n < 0 ? '▼' : '▪'; const colorClass = - delta > 0 ? 'text-success' - : delta < 0 ? 'text-danger' + n > 0 ? 'text-success' + : n < 0 ? 'text-danger' : ''; - // The ▲/▼ triangle glyphs sit below their optical centre — lift them. - // ▪ is already centred in its em box, so no nudge. - const nudgeClass = delta !== 0 ? '-translate-y-[1.5px]' : ''; + const nudgeClass = n !== 0 ? '-translate-y-[1.5px]' : ''; + return {glyph}; +} + +/** + * Format the "LAST 24H" delta subtitle for the ONLINE card. Compares the + * current player count to the 24h rolling-average baseline and shows the + * gap with a ▲/▼/▪ arrow. Returns null only when there's no baseline yet + * (new season). + */ +function playersDeltaSubtitle(currentPlayers, avgPlayers) { + if (avgPlayers == null) return null; + const delta = currentPlayers - avgPlayers; return ( - {indicator} + {deltaArrow(delta)} @@ -61,24 +68,32 @@ function playersDeltaSubtitle(currentPlayers, avgPlayers) { } /** - * Format the "+N LAST 24H" subtitle for cumulative counters (kills, - * deaths, etc. — monotonically increasing). Unlike the instantaneous - * delta, direction is always "up" so no arrow is shown; the number is - * prefixed with "+" to read as an addition over the last 24h. Ghost - * colour throughout — growth is not semantically good or bad here. + * Subtitle for the ENEMIES_KILLED card. From the live cumulative kill total + * and two historical baselines it derives two consecutive 24h kill volumes — + * last24h = current − kills(24h ago), prev24h = kills(24h ago) − kills(48h + * ago) — and shows the last-24h volume with a ▲/▼/▪ arrow marking whether the + * killing pace rose, fell, or held versus the previous 24h. With no 48h + * baseline yet (season 24–48h old) the pace can't be compared, so the arrow + * is a neutral ▪. Returns null with no 24h baseline, or when nothing was + * killed in the last 24h. */ -function cumulativeAddedSubtitle(current, baseline) { - if (baseline == null) return null; - // `current` may be a Prisma BigInt (kills/deaths/etc. are BIGINT in schema) - // while `baseline` is a plain JS number from `AVG(...)::int` — coerce - // both to Number before subtracting to avoid the "Cannot mix BigInt and - // other types" runtime error during SSR. - const added = Number(current) - Number(baseline); - if (added <= 0) return null; +function killsTrendSubtitle(current, baseline) { + if (baseline?.ago24h == null) return null; + // `current` may be a Prisma BigInt (kills are BIGINT in schema) while the + // baselines are plain numbers — coerce before subtracting to avoid the + // "Cannot mix BigInt and other types" runtime error during SSR. + const last24h = Number(current) - Number(baseline.ago24h); + if (last24h <= 0) return null; + const prev24h = + baseline.ago48h != null ? + Number(baseline.ago24h) - Number(baseline.ago48h) + : null; + const trend = prev24h == null ? 0 : last24h - prev24h; return ( + {deltaArrow(trend)} - + + Last 24h @@ -175,7 +190,7 @@ export default function StatGrid({ faction, events, playersAvg24h = null, - kills24hAgo = null, + killsTrend = null, seasonDuration = 0, warStart = null, }) { @@ -213,7 +228,7 @@ export default function StatGrid({ totals.players, playersAvg24h?.global, ); - const killsSubtitle = cumulativeAddedSubtitle(totals.kills, kills24hAgo?.global); + const killsSubtitle = killsTrendSubtitle(totals.kills, killsTrend?.global); return (
    = 1_000_000_000) return (num / 1_000_000_000).toFixed(1) + 'B'; - if (num >= 10_000_000) return (num / 1_000_000).toFixed(1) + 'M'; + // M suffix from 1M up — a 7-digit grouped number ("3,522,088") overflows + // the stat cards, so anything >= 1M collapses to "X.XM". + if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M'; if (num >= 1_000) return num.toLocaleString(); return String(num); } From 545aad4696ff27eefbb5e236a3baa720c6940a55 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Thu, 21 May 2026 23:27:48 +0200 Subject: [PATCH 07/94] =?UTF-8?q?fix:=20card=E2=86=92map=20hover=20highlig?= =?UTF-8?q?ht=20is=20additive,=20not=20dimming=20(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card→map hover (shipped in 0.47.13) dimmed the whole galaxy map to opacity 0.25 and spared the hovered area — a subtractive focus. It now leaves every sector at full opacity and instead firms up the hovered faction's see-through `.lost` sectors: their translucent ghost stroke (0.15 → 0.55) and near-invisible fill (0.06 → 0.12) gain opacity. Gold `.captured` / `.in_progress` strokes are left untouched so they stay gold; the active sector takes a heavier 3px outline. Pure Map.css restyle — sectorLink.mjs class-toggling logic and its 5 tests are unchanged. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 4 ++++ src/features/galaxy/Map.css | 25 +++++++++++++------------ src/features/galaxy/sectorLink.mjs | 4 ++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d169f6..3d339c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changes + +- **Region card → map hover highlight is additive instead of dimming (#185).** Hovering a dashboard region card previously dimmed the whole galaxy map to `opacity: 0.25` and spared the hovered area — a three-tier _subtractive_ focus. It now leaves every sector at full opacity and instead firms up the hovered faction's see-through `.lost` sectors — their translucent ghost stroke and near-invisible fill gain opacity so the faction's full reach reads at a glance. Gold (`.captured`/`.in_progress`) strokes are left untouched so they stay gold, and the one active sector takes a heavier 3px outline. Nothing on the map dims. The treatment is stroke-only, so it still composes with the active sector's red pulse animation — on a pulsing sector the running keyframe keeps owning the stroke. Purely a `Map.css` restyle; the `sectorLink.mjs` class-toggling logic and its 5 tests are unchanged. + ## 0.47.14 ### Features diff --git a/src/features/galaxy/Map.css b/src/features/galaxy/Map.css index 4c15cfb1..9cdba84c 100644 --- a/src/features/galaxy/Map.css +++ b/src/features/galaxy/Map.css @@ -5,7 +5,7 @@ transition: fill 0.4s ease, stroke 0.3s ease, - opacity 0.2s ease; + stroke-width 0.2s ease; } .lost { @@ -71,18 +71,19 @@ * Hovering a dashboard region card adds `.is-sector-linking` to `#map` and * tags the matching faction territory (`.sector-linked-faint`) and active * sector (`.sector-linked-strong`) — see src/features/galaxy/sectorLink.mjs. - * The rest of the map recedes, the territory sits at a faint level, the - * active sector stays full: a three-tier focus. Opacity-only, so it composes - * cleanly with the fill/stroke pulse animation above. Rule order matters — - * the strong/faint rules win the same-specificity tie over `.sector`. */ -#map.is-sector-linking .sector { - opacity: 0.25; -} - -#map.is-sector-linking .sector-linked-faint { - opacity: 0.6; + * The highlight is additive: nothing on the map dims. Instead the hovered + * faction's see-through `.lost` sectors firm up — their translucent ghost + * stroke and near-invisible fill gain opacity so the faction's full reach + * reads at a glance. Gold (`.captured` / `.in_progress`) strokes are left + * untouched: they stay gold. The one active sector takes a heavier outline, + * whatever its colour. Stroke / fill only, so it composes with the pulse + * animation above — on an `.active` sector the running keyframe still owns + * the stroke. */ +#map.is-sector-linking .sector-linked-faint.lost { + stroke: rgba(255, 255, 255, 0.55); + fill: rgba(255, 255, 255, 0.12); } #map.is-sector-linking .sector-linked-strong { - opacity: 1; + stroke-width: 3px; } diff --git a/src/features/galaxy/sectorLink.mjs b/src/features/galaxy/sectorLink.mjs index 23a36973..4e5168a8 100644 --- a/src/features/galaxy/sectorLink.mjs +++ b/src/features/galaxy/sectorLink.mjs @@ -1,7 +1,7 @@ /** * Card → map hover link. When a dashboard region card is hovered, highlight * the matching area on the galaxy map: the hovered faction's whole territory - * faintly, and its one active sector strongly. + * gets a lit outline, and its one active sector a heavier one. * * Implemented by toggling CSS classes directly on the map's SVG nodes (the * MermaidDiagram pattern) rather than via React state — a hover effect must @@ -38,7 +38,7 @@ export function clearSectorHighlight() { * * @param {number} factionIndex - 0 Bugs, 1 Cyborgs, 2 Illuminate, 3 Super Earth * @param {number | null} [sector] - Sector to mark strongly (1-11, or 0 for - * Super Earth). Null/omitted faints the whole territory only — e.g. a + * Super Earth). Null/omitted outlines the whole territory only — e.g. a * defeated faction with no single active sector. */ export function highlightSector(factionIndex, sector = null) { From 7af0a72c5873ede6f56ddb22244aa3bf435611e4 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Fri, 22 May 2026 00:06:55 +0200 Subject: [PATCH 08/94] =?UTF-8?q?feat:=20map=20=E2=86=92=20card=20hover=20?= =?UTF-8?q?highlight=20=E2=80=94=20reverse=20direction=20of=20#185=20(#390?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the bidirectional hover link. #185 shipped card → map only; this adds map → card: hovering anywhere in a faction's galaxy-map territory firms up that faction's sidebar card border (ghost rgba 0.15 → 0.55 — the mirror of the card → map lost-sector lift). - sectorLink.mjs: highlightCard() / clearCardHighlight(), DOM class toggling on the card
  • s, no React state, zero re-renders. - Map.jsx: onMouseEnter/onMouseLeave on each faction — child-path mouse events bubble to the group, so one handler covers the whole faction territory. - DashboardClient.jsx: the Super Earth defence card gets a data-attacker-index so hovering the attacking faction's own territory highlights it too. - EventCard.css: .card-linked border firm-up + transition. TDD: 6 new cardLink tests (one-card faction, two-card faction, data-attacker-index match, superearth-group match, clear, re-highlight). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 4 + .../unit/features/galaxy/sectorLink.test.mjs | 89 ++++++++++++++++++- src/features/dashboard/DashboardClient.jsx | 9 +- src/features/galaxy/EventCard.css | 19 ++++ src/features/galaxy/Map.jsx | 18 +++- src/features/galaxy/sectorLink.mjs | 50 +++++++++-- 6 files changed, 175 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e00d1c94..c254b65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- **Hovering a faction's map territory highlights its dashboard card(s) (#390).** Completes the bidirectional hover link from #185, which shipped the card → map direction only. Hovering anywhere in a faction's galaxy-map territory now firms up that faction's sidebar card border — `var(--color-ghost)` → `rgba(255,255,255,0.55)`, the mirror of the dim → bright lift the card → map direction gives the map's lost sectors. A faction's frontier and homeworld cards both highlight; during a Super Earth defense the attacking faction's card (filed under Super Earth) carries a `data-attacker-index` so hovering the attacker's own territory highlights it too. Implemented by extending `sectorLink.mjs` with `highlightCard`/`clearCardHighlight` — DOM class toggling on the card `
  • `s, no React state, zero re-renders — and `onMouseEnter`/`onMouseLeave` on the map's faction `` groups. TDD: 6 new `cardLink` tests. + ## 0.47.15 ### Changes diff --git a/src/__tests__/unit/features/galaxy/sectorLink.test.mjs b/src/__tests__/unit/features/galaxy/sectorLink.test.mjs index 4b4d64e7..018556eb 100644 --- a/src/__tests__/unit/features/galaxy/sectorLink.test.mjs +++ b/src/__tests__/unit/features/galaxy/sectorLink.test.mjs @@ -1,6 +1,11 @@ // @vitest-environment jsdom import { describe, test, expect, beforeEach } from 'vitest'; -import { highlightSector, clearSectorHighlight } from '@/features/galaxy/sectorLink.mjs'; +import { + highlightSector, + clearSectorHighlight, + highlightCard, + clearCardHighlight, +} from '@/features/galaxy/sectorLink.mjs'; /** * Build a minimal stand-in for the Map.jsx SVG structure: a `#map` wrapper, @@ -29,9 +34,31 @@ function makeMapDom() { document.body.appendChild(map); } -beforeEach(makeMapDom); +/** + * Build a stand-in for the DashboardClient card grid: a `
      ` of `
    • ` + * cards keyed by `data-faction-index`. A faction can own two cards (frontier + * + homeworld); the Super Earth defence card additionally carries a + * `data-attacker-index` so the attacking faction's territory highlights it. + */ +function makeCardDom() { + document.body.replaceChildren(); + const ul = document.createElement('ul'); + ul.className = 'sector-grid'; + const addCard = (attrs) => { + const li = document.createElement('li'); + for (const [k, v] of Object.entries(attrs)) li.setAttribute(k, String(v)); + ul.appendChild(li); + }; + addCard({ 'data-faction-index': 0 }); // bugs — frontier + addCard({ 'data-faction-index': 0, 'data-sector': 11 }); // bugs — homeworld + addCard({ 'data-faction-index': 1 }); // cyborgs + addCard({ 'data-faction-index': 3, 'data-attacker-index': 2 }); // SE defence, Illuminate attacking + document.body.appendChild(ul); +} describe('sectorLink', () => { + beforeEach(makeMapDom); + test('faints the whole faction territory and strongly marks one sector', () => { highlightSector(0, 2); @@ -95,3 +122,61 @@ describe('sectorLink', () => { ).toBe(true); }); }); + +describe('cardLink', () => { + beforeEach(makeCardDom); + + test('highlightCard marks every card of the hovered faction', () => { + highlightCard(0); // bugs owns two cards: frontier + homeworld + + const linked = document.querySelectorAll('.card-linked'); + expect(linked).toHaveLength(2); + linked.forEach((el) => expect(el.getAttribute('data-faction-index')).toBe('0')); + // a different faction is untouched + expect( + document + .querySelector('li[data-faction-index="1"]') + .classList.contains('card-linked'), + ).toBe(false); + }); + + test('a single-card faction highlights just its one card', () => { + highlightCard(1); + + const linked = document.querySelectorAll('.card-linked'); + expect(linked).toHaveLength(1); + expect(linked[0].getAttribute('data-faction-index')).toBe('1'); + }); + + test('hovering the attacking faction highlights the Super Earth defence card', () => { + // The SE defence card carries data-attacker-index="2" (Illuminate attacking). + highlightCard(2); + + const seCard = document.querySelector('li[data-faction-index="3"]'); + expect(seCard.classList.contains('card-linked')).toBe(true); + }); + + test('the Super Earth defence card also highlights from the superearth group', () => { + highlightCard(3); + + const seCard = document.querySelector('li[data-faction-index="3"]'); + expect(seCard.classList.contains('card-linked')).toBe(true); + }); + + test('clearCardHighlight removes every card-linked class', () => { + highlightCard(0); + clearCardHighlight(); + + expect(document.querySelectorAll('.card-linked')).toHaveLength(0); + }); + + test('a new card highlight clears the previous one first', () => { + highlightCard(0); + highlightCard(1); + + expect( + document.querySelectorAll('li[data-faction-index="0"].card-linked'), + ).toHaveLength(0); + expect(document.querySelectorAll('.card-linked')).toHaveLength(1); + }); +}); diff --git a/src/features/dashboard/DashboardClient.jsx b/src/features/dashboard/DashboardClient.jsx index 85baff15..787e8234 100644 --- a/src/features/dashboard/DashboardClient.jsx +++ b/src/features/dashboard/DashboardClient.jsx @@ -83,8 +83,15 @@ export default function DashboardClient({ function renderFrontierCard(index) { if (index === seDefenderIndex) { // Super Earth defense is an event-focused interrupt — always sector view. + // `data-attacker-index` links this card (filed under faction 3) to the + // attacking faction's map territory, so hovering there highlights it + // too — the map → card reverse highlight (#185). return ( -
    • +
    • (s) — see src/features/galaxy/sectorLink.mjs. The + * card's ghost border firms up dim → bright, the mirror of the dim → bright + * lift the card → map direction gives the map's lost sectors. The defeated + * card sits at opacity 0.333; lift it so the faded card still visibly + * responds to the hover. */ +li.card-linked .sector-card { + border-color: rgba(255, 255, 255, 0.55); +} + +li.card-linked .sector-card-defeated { + opacity: 0.6; +} diff --git a/src/features/galaxy/Map.jsx b/src/features/galaxy/Map.jsx index e1d19207..3fa28157 100644 --- a/src/features/galaxy/Map.jsx +++ b/src/features/galaxy/Map.jsx @@ -7,6 +7,7 @@ import { superEarthCircle, factionIcons, } from '@/features/galaxy/mapPaths.mjs'; +import { highlightCard, clearCardHighlight } from '@/features/galaxy/sectorLink.mjs'; /** * Renders the galaxy map SVG. The root `` uses @@ -101,7 +102,16 @@ export default function Map({ map, pulseDelays }) { {factions.map(({ id, index, paths }) => ( - + // Hovering anywhere in a faction's territory highlights + // its dashboard card(s) — mouse events on the child + // paths bubble to this , so one handler covers the + // whole faction (#185 map → card direction). + highlightCard(index)} + onMouseLeave={clearCardHighlight} + > {paths.map((path) => ( ))} - + highlightCard(superearth)} + onMouseLeave={clearCardHighlight} + > ` groups of - * ``; those ids / data-attributes are - * the link keys, so no shared component or state is needed. - * - * Bidirectional (map → card) can be layered on later by calling the same - * functions from the map's own hover handlers — see issue #185. + * ``; the cards (`DashboardClient.jsx`) + * carry `data-faction-index` / `data-attacker-index`. Those ids and + * data-attributes are the link keys, so no shared component or state is needed. */ // Faction index → the `` id used in Map.jsx. @@ -60,3 +62,33 @@ export function highlightSector(factionIndex, sector = null) { } }); } + +/** Remove the hover-link class from every dashboard card. */ +export function clearCardHighlight() { + if (typeof document === 'undefined') return; + document + .querySelectorAll('.card-linked') + .forEach((el) => el.classList.remove('card-linked')); +} + +/** + * Highlight a faction's dashboard card(s) — the map → card reverse of + * `highlightSector`. Hovering a faction's map territory firms up the border + * of its sidebar card(s); a faction can own more than one (frontier + + * homeworld). + * + * @param {number} factionIndex - 0 Bugs, 1 Cyborgs, 2 Illuminate, 3 Super Earth + */ +export function highlightCard(factionIndex) { + if (typeof document === 'undefined') return; + clearCardHighlight(); + + // A card matches by its own faction slot (`data-faction-index`) or — for + // the Super Earth defence card — by the attacking faction it represents + // (`data-attacker-index`), so hovering the attacker's territory lights it. + document + .querySelectorAll( + `li[data-faction-index="${factionIndex}"], li[data-attacker-index="${factionIndex}"]`, + ) + .forEach((el) => el.classList.add('card-linked')); +} From 8ea570c71e248061af75d4cd78aa960782e1647b Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Fri, 22 May 2026 13:44:41 +0200 Subject: [PATCH 09/94] fix: pin lowlighter/metrics action to v3.34 stable tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@v4` has no release tag — it resolved to the action's WIP v4 branch, which lost its root action.yml, breaking the Pagespeed workflow with "Can't find 'action.yml'". Pin to v3.34, the latest stable release. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/metrics.yml | 2 +- CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index 3144ea7c..4571c201 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -19,7 +19,7 @@ jobs: steps: # https://github.com/lowlighter/metrics/tree/master/source/plugins/pagespeed - name: 'metrics: pagespeed' - uses: lowlighter/metrics@v4 + uses: lowlighter/metrics@v3.34 with: token: NOT_NEEDED committer_branch: metrics diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f89746..71ecf6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- **Pagespeed workflow pins `lowlighter/metrics` to the `v3.34` release tag.** The workflow used `lowlighter/metrics@v4`, which has no matching release tag — `@v4` silently resolved to the action's long-running `v4` rewrite _branch_. A push to that branch removed the root `action.yml`, so runs began failing with `Can't find 'action.yml', 'action.yaml' or 'Dockerfile'`. Pinned to `@v3.34`, the latest stable release, restoring reproducible runs. + ## 0.47.16 ### Features From 69035d9e9c290b4694d8076ab0ed7cc9f261b445 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Fri, 22 May 2026 22:08:43 +0200 Subject: [PATCH 10/94] feat: unify /archives statistics with the homepage StatGrid (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the archives page's bespoke ArchiveStats (global) + FactionStats (per-faction) — a parallel, drifted reimplementation of the homepage hero's per-faction StatGrid — with the shared StatGrid itself for the six core cards, above a collapsed archives-only extras component. - StatGrid: additive `archived` prop. On seasons predating combat-stat collection, the four telemetry cards render a censored DATA REDACTED — MINISTRY OF TRUTH treatment instead of zeros. Default off, so the homepage path is byte-for-byte unchanged. - ArchiveStats: collapsed the global + per-faction extras (OUTCOME, DEFENSE_RATE, ATTACK_RATE, AVG_DIFFICULTY, WORST_CASCADE, HOTSPOT, CONQUEST, AVG_BATTLE) into one component, mirroring StatGrid's branch. - FactionStats: deleted — its cards now come from StatGrid + ArchiveStats. - formatRatio: extracted to a shared format util. - Drops duplicated cards: DURATION, KILLS, BATTLES, K/D. Built test-first; lint, typecheck, 1347 unit tests and build all pass, DevTools-verified on telemetry and pre-telemetry seasons. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + .../ArchiveComponentsIntegration.test.jsx | 17 +- .../features/archives/ArchiveStats.test.jsx | 444 ++++++------------ .../features/archives/ArchivesClient.test.jsx | 37 +- .../features/archives/FactionStats.test.jsx | 166 ------- .../unit/features/stats/StatGrid.test.jsx | 61 +++ .../shared/utils/format/formatRatio.test.mjs | 24 + src/features/archives/ArchiveStats.jsx | 316 ++++++------- src/features/archives/ArchivesClient.jsx | 39 +- src/features/archives/FactionStats.jsx | 100 ---- src/features/stats/StatGrid.jsx | 113 +++-- src/shared/utils/format/formatRatio.mjs | 12 + 12 files changed, 522 insertions(+), 811 deletions(-) delete mode 100644 src/__tests__/unit/features/archives/FactionStats.test.jsx create mode 100644 src/__tests__/unit/shared/utils/format/formatRatio.test.mjs delete mode 100644 src/features/archives/FactionStats.jsx create mode 100644 src/shared/utils/format/formatRatio.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0c1786..f85ee40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- **`/archives` statistics now reuse the homepage `StatGrid`, with a Ministry of Truth redaction for pre-telemetry seasons (#391).** The archives page carried its own `ArchiveStats` (global) and `FactionStats` (per-faction) components — a parallel, partly-duplicated reimplementation of the homepage hero's per-faction `StatGrid` that had drifted from it. The archives stats section now renders the shared `StatGrid` itself for the six core cards (`HELLDIVERS_ONLINE`, `ENEMIES_KILLED`, `HELLDIVERS_LOST`, `MISSIONS_WON`, `EVENTS`, `WAR_DURATION`) — one source of truth, no drift — above a slim archives-only extras grid (`OUTCOME`, `DEFENSE_RATE`, `ATTACK_RATE`, `AVG_DIFFICULTY`, `WORST_CASCADE`; plus `HOTSPOT`, `CONQUEST`, `AVG_BATTLE` on a faction tab). `ArchiveStats` and `FactionStats` collapse into one component and the duplicated cards (`DURATION`, `KILLS`, `BATTLES`, `K/D`) are dropped. `StatGrid` gains an additive `archived` prop (default off, so the homepage is byte-for-byte unchanged): on seasons that predate combat-stat collection, the four telemetry cards render a censored `DATA REDACTED — MINISTRY OF TRUTH` treatment instead of misleading zeros. A new shared `formatRatio` formatter was extracted; built test-first throughout. + ## 0.47.17 ### Fixes diff --git a/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx b/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx index 47134e19..9ae95890 100644 --- a/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx +++ b/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx @@ -10,9 +10,9 @@ vi.mock('@/features/archives/ArchiveStats', () => ({ ), })); -vi.mock('@/features/archives/FactionStats', () => ({ +vi.mock('@/features/stats/StatGrid', () => ({ default: (props) => ( -
      +
      ), })); @@ -387,7 +387,7 @@ describe('Archive Components Integration Tests', () => { }); describe('Component Interaction Tests', () => { - test('Faction switch from global to specific faction', () => { + test('Faction switch passes the active faction to the stats components', () => { const setFactionMock = vi.fn(); mockUsePersistedState.mockReturnValue(['bugs', setFactionMock]); @@ -400,8 +400,15 @@ describe('Archive Components Integration Tests', () => { />, ); - expect(screen.getByTestId('faction-stats-mock')).toBeInTheDocument(); - expect(screen.queryByTestId('archive-stats-mock')).not.toBeInTheDocument(); + const stats = JSON.parse( + screen.getByTestId('archive-stats-mock').getAttribute('data-props') || + '{}', + ); + const grid = JSON.parse( + screen.getByTestId('stat-grid-mock').getAttribute('data-props') || '{}', + ); + expect(stats.faction).toBe('bugs'); + expect(grid.faction).toBe('bugs'); }); test('Admin controls visibility', () => { diff --git a/src/__tests__/unit/features/archives/ArchiveStats.test.jsx b/src/__tests__/unit/features/archives/ArchiveStats.test.jsx index 627ac7e1..12afe619 100644 --- a/src/__tests__/unit/features/archives/ArchiveStats.test.jsx +++ b/src/__tests__/unit/features/archives/ArchiveStats.test.jsx @@ -1,359 +1,191 @@ // @vitest-environment jsdom import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import humanizeDuration from 'humanize-duration'; import ArchiveStats from '@/features/archives/ArchiveStats'; -import { getWarOutcome } from '@/features/archives/getWarOutcome.mjs'; - vi.mock('@/features/archives/getWarOutcome.mjs', () => ({ - getWarOutcome: vi.fn(() => ({ - outcome: 'victory', - reason: 'All enemy factions have been defeated.', - faction: 2, - })), + getWarOutcome: vi.fn(() => ({ outcome: 'victory', reason: 'won', faction: 2 })), })); - vi.mock('@/features/archives/GlitchText', () => ({ default: ({ text, className }) => {text}, })); -const noEffects = { headerScramble: false, watermark: false }; - const mockEvents = [ { - event_id: 1, type: 'defend', enemy: 0, region: 1, start_time: 1000, end_time: 4600, status: 'success', - players_at_start: 200, - points: 400, - points_max: 500, }, { - event_id: 2, type: 'defend', enemy: 0, region: 1, start_time: 5000, - end_time: 19400, + end_time: 12200, status: 'fail', - players_at_start: 150, - points: 500, - points_max: 500, }, { - event_id: 3, type: 'attack', + enemy: 0, + region: 2, + start_time: 13000, + end_time: 20200, + status: 'success', + }, + { + type: 'defend', enemy: 1, - region: 11, - start_time: 20000, - end_time: 27200, + region: 1, + start_time: 1000, + end_time: 4600, status: 'success', - players_at_start: 300, - points: 1000, - points_max: 1000, }, ]; -describe('ArchiveStats', () => { - it('renders statistics stats', () => { - render( - , - ); - expect(screen.getByText('OUTCOME')).toBeDefined(); - expect(screen.getByText('VICTORY')).toBeDefined(); - expect(screen.getByText('DURATION')).toBeDefined(); - // mockEvents: 2 defends (1 success, 1 fail), 1 attack (1 success) - expect(screen.getByText('DEFENSE_RATE')).toBeDefined(); - expect(screen.getByText('50%')).toBeDefined(); - expect(screen.getByText('1 / 2')).toBeDefined(); - expect(screen.getByText('ATTACK_RATE')).toBeDefined(); - expect(screen.getByText('100%')).toBeDefined(); - expect(screen.getByText('1 / 1')).toBeDefined(); - }); - - it('derives DURATION from snapshot time span when snapshots are present', () => { - // 23 days 12 hours between first and last poll → rounds to 24 days - const firstTime = 1_700_000_000; - const lastTime = firstTime + 23 * 86_400 + 12 * 3_600; - const snapshots = [ - { time: firstTime, data: {} }, - { time: firstTime + 86_400, data: {} }, - { time: lastTime, data: {} }, - ]; - render( - , - ); - expect(screen.getByText('24 days')).toBeDefined(); - // Subtitle is humanize-duration(span, { largest: 2, round: true }) — compute the - // expected value from the same fn the component uses so we don't bake in the - // rounding edge cases by hand. - const expectedSubtitle = humanizeDuration((23 * 86_400 + 12 * 3_600) * 1000, { - largest: 2, - round: true, - }); - expect(screen.getByText(expectedSubtitle)).toBeDefined(); - }); - - it('falls back to event span when snapshots are missing or fewer than two', () => { - // Event span: 27200 − 1000 = 26200 seconds → 0 days (rounded) - render( - , - ); - expect(screen.getByText('0 days')).toBeDefined(); - - // Same result when exactly one snapshot is present - render( - , - ); - expect(screen.getAllByText('0 days').length).toBeGreaterThanOrEqual(1); - }); - - it('pluralises DURATION correctly for a single day', () => { - const firstTime = 1_700_000_000; - const snapshots = [ - { time: firstTime, data: {} }, - { time: firstTime + 86_400, data: {} }, - ]; - render( - , - ); - // "1 day" appears twice — once as the StatCard value and once as the - // humanize-duration subtitle (which also produces "1 day" for 86400s). - // Both are valid; we just want to prove the value isn't "1 days". - const matches = screen.getAllByText('1 day'); - expect(matches.length).toBeGreaterThanOrEqual(1); - expect(screen.queryByText('1 days')).toBeNull(); - }); - - it('does not render WORST_CASCADE when there is no cascade', () => { - const easyEvents = [ - { - event_id: 1, - type: 'defend', - enemy: 0, - region: 1, - start_time: 1000, - end_time: 4600, - status: 'success', - players_at_start: 200, - points: 10, - points_max: 500, - }, - { - event_id: 2, - type: 'defend', - enemy: 0, - region: 2, - start_time: 5000, - end_time: 8600, - status: 'success', - players_at_start: 150, - points: 20, - points_max: 500, - }, - ]; - render( - , - ); - expect(screen.queryByText('WORST_CASCADE')).toBeNull(); - }); +const mockData = { + snapshots: [ + { + time: 100, + data: [ + { enemy: 0, points: 300 }, + { enemy: 1, points: 200 }, + { enemy: 2, points: 100 }, + ], + }, + ], + points_max: { points: [1000, 1000, 1000] }, +}; + +// Per-faction h1_statistic rows merged into data.status by getCampaign. +const mockLive = [ + { enemy: 0, total_mission_difficulty: 6000, successful_missions: 800 }, + { enemy: 1, total_mission_difficulty: 2800, successful_missions: 400 }, + { enemy: 2, total_mission_difficulty: 900, successful_missions: 150 }, +]; - it('returns null when events is empty', () => { - const { container } = render( - , - ); - expect(container.innerHTML).toBe(''); - }); +// A season predating stat collection — getCampaign zero-fills the stats. +const noTelemetryLive = [0, 1, 2].map((enemy) => ({ + enemy, + total_mission_difficulty: 0, + successful_missions: 0, +})); - it('returns null when events is null', () => { +describe('ArchiveStats', () => { + it('returns null when there are no events', () => { const { container } = render( - , + , ); expect(container.innerHTML).toBe(''); }); - it('renders combat record stats when live data is provided', () => { - // mockLive must mirror real Prisma return types: Int columns come back as - // JS Number, only the 5 explicit BigInt columns (kills, deaths, shots, hits, - // accidentals) come back as BigInt. total_unique_players is a GLOBAL per-season - // count repeated across all 3 faction rows — the component must read it from - // a single row, not sum it. - const mockLive = [ - { - enemy: 0, - // Int columns — plain JS Number - players: 5000, - missions: 80000000, - successful_missions: 70000000, - total_unique_players: 100000, - // BigInt columns - kills: 1200000000n, - deaths: 50000000n, - accidentals: 50000000n, - shots: 500000000n, - hits: 115000000n, - }, - { - enemy: 1, - players: 4000, - missions: 50000000, - successful_missions: 40000000, - total_unique_players: 100000, // same value — proves no sum - kills: 800000000n, - deaths: 30000000n, - accidentals: 30000000n, - shots: 300000000n, - hits: 70000000n, - }, - { - enemy: 2, - players: 3000, - missions: 26000000, - successful_missions: 20000000, - total_unique_players: 100000, // same value — proves no sum - kills: 400000000n, - deaths: 20000000n, - accidentals: 20000000n, - shots: 200000000n, - hits: 46000000n, - }, - ]; - - render( - , - ); - - expect(screen.getByText('KILLS')).toBeDefined(); - expect(screen.getByText('K/D')).toBeDefined(); - expect(screen.getByText('ACCURACY')).toBeDefined(); - expect(screen.getByText('FRIENDLY_FIRE')).toBeDefined(); - expect(screen.getByText('MISSION_SUCCESS')).toBeDefined(); - expect(screen.getByText('PEAK_ONLINE')).toBeDefined(); - expect(screen.getByText('TOTAL_DIVERS')).toBeDefined(); + describe('global view', () => { + it('renders outcome, rates, and difficulty', () => { + render( + , + ); + expect(screen.getByText('OUTCOME')).toBeDefined(); + expect(screen.getByText('VICTORY')).toBeDefined(); + expect(screen.getByText('DEFENSE_RATE')).toBeDefined(); + expect(screen.getByText('ATTACK_RATE')).toBeDefined(); + expect(screen.getByText('AVG_DIFFICULTY')).toBeDefined(); + }); - // Correctness: total_unique_players must be read from a single row, not summed. - // 100000 → "100,000" via formatNumber's toLocaleString branch (< 1M). - // If sumBigInt were still applied, the rendered value would be "300,000". - expect(screen.getByText('100,000')).toBeDefined(); - expect(screen.queryByText('300,000')).toBeNull(); - }); + it('does not render cards now owned by StatGrid', () => { + render( + , + ); + for (const dropped of [ + 'DURATION', + 'KILLS', + 'K/D', + 'BATTLES', + 'PEAK_ONLINE', + 'TOTAL_DIVERS', + ]) { + expect(screen.queryByText(dropped)).toBeNull(); + } + }); - it('renders Cyberstani interference subtitle on defeat', () => { - getWarOutcome.mockReturnValueOnce({ - outcome: 'defeat', - reason: 'The war was lost.', - faction: 1, + it('hides AVG_DIFFICULTY when the season has no telemetry', () => { + render( + , + ); + expect(screen.queryByText('AVG_DIFFICULTY')).toBeNull(); }); - render( - , - ); - expect(screen.getByText('DEFEAT')).toBeDefined(); }); - it('shows faction name as OUTCOME subtitle (victory)', () => { - // Default mock returns faction: 2 (Illuminate) - render( - , - ); - expect(screen.getByText('The Illuminate')).toBeDefined(); - }); + describe('faction view', () => { + it('renders rates, battle, hotspot, conquest, and difficulty', () => { + render( + , + ); + expect(screen.getByText('DEFENSE_RATE')).toBeDefined(); + expect(screen.getByText('ATTACK_RATE')).toBeDefined(); + expect(screen.getByText('AVG_BATTLE')).toBeDefined(); + expect(screen.getByText('HOTSPOT')).toBeDefined(); + expect(screen.getByText('CONQUEST')).toBeDefined(); + expect(screen.getByText('AVG_DIFFICULTY')).toBeDefined(); + }); - it('shows faction name as OUTCOME subtitle (defeat attribution)', () => { - getWarOutcome.mockReturnValueOnce({ - outcome: 'defeat', - reason: 'The war was lost.', - faction: 0, + it('computes the bug defense rate, conquest, and difficulty', () => { + render( + , + ); + expect(screen.getByText('50%')).toBeDefined(); // 1 of 2 defends won + expect(screen.getByText('30.0%')).toBeDefined(); // conquest 300 / 1000 + expect(screen.getByText('7.5')).toBeDefined(); // difficulty 6000 / 800 }); - render( - , - ); - expect(screen.getByText('Bugs')).toBeDefined(); - }); - it('omits OUTCOME subtitle when faction is null', () => { - getWarOutcome.mockReturnValueOnce({ - outcome: 'defeat', - reason: 'The war was lost.', - faction: null, + it('does not render the OUTCOME card on a faction tab', () => { + render( + , + ); + expect(screen.queryByText('OUTCOME')).toBeNull(); }); - render( - , - ); - expect(screen.queryByText('Bugs')).toBeNull(); - expect(screen.queryByText('Cyborgs')).toBeNull(); - expect(screen.queryByText('The Illuminate')).toBeNull(); - }); - it('does not render combat record when live is empty', () => { - render( - , - ); - expect(screen.queryByText('KILLS')).toBeNull(); - expect(screen.queryByText('K/D')).toBeNull(); + it('returns null for a faction with no events', () => { + const { container } = render( + , + ); + expect(container.innerHTML).toBe(''); + }); }); }); diff --git a/src/__tests__/unit/features/archives/ArchivesClient.test.jsx b/src/__tests__/unit/features/archives/ArchivesClient.test.jsx index 078f5b33..549c0d47 100644 --- a/src/__tests__/unit/features/archives/ArchivesClient.test.jsx +++ b/src/__tests__/unit/features/archives/ArchivesClient.test.jsx @@ -24,7 +24,7 @@ vi.mock('@/features/archives/ArchiveStats', () => ({
      ), })); @@ -60,9 +60,13 @@ vi.mock('@/shared/components/FactionTabs', () => ({ /> ), })); -vi.mock('@/features/archives/FactionStats', () => ({ - default: ({ faction }) => ( -
      +vi.mock('@/features/stats/StatGrid', () => ({ + default: (props) => ( +
      ), })); vi.mock('@/features/timeline/EventLog', () => ({ @@ -217,21 +221,30 @@ describe('ArchivesClient — layout + default render', () => { }); }); -describe('ArchivesClient — faction switch (global ↔ per-faction)', () => { - test('faction="global" renders ArchiveStats, NOT FactionStats', () => { +describe('ArchivesClient — stats section (StatGrid + ArchiveStats)', () => { + test('renders both StatGrid and ArchiveStats on the global tab', () => { useFactionPreferenceMock.mockReturnValue(['global', vi.fn()]); render(); + expect(screen.getByTestId('stat-grid-stub')).toBeInTheDocument(); expect(screen.getByTestId('archive-stats-stub')).toBeInTheDocument(); - expect(screen.queryByTestId('faction-stats-stub')).not.toBeInTheDocument(); }); - test('faction="bugs" renders FactionStats with the right faction prop, NOT ArchiveStats', () => { + test('passes the active faction to both StatGrid and ArchiveStats', () => { useFactionPreferenceMock.mockReturnValue(['bugs', vi.fn()]); render(); - expect(screen.queryByTestId('archive-stats-stub')).not.toBeInTheDocument(); - const fStats = screen.getByTestId('faction-stats-stub'); - expect(fStats).toBeInTheDocument(); - expect(fStats.getAttribute('data-faction')).toBe('bugs'); + expect(screen.getByTestId('stat-grid-stub').getAttribute('data-faction')).toBe( + 'bugs', + ); + expect( + screen.getByTestId('archive-stats-stub').getAttribute('data-faction'), + ).toBe('bugs'); + }); + + test('renders the StatGrid in archived mode', () => { + render(); + expect(screen.getByTestId('stat-grid-stub').getAttribute('data-archived')).toBe( + 'true', + ); }); test('clicking FactionTabs invokes the setter from useFactionPreference', () => { diff --git a/src/__tests__/unit/features/archives/FactionStats.test.jsx b/src/__tests__/unit/features/archives/FactionStats.test.jsx deleted file mode 100644 index f703b593..00000000 --- a/src/__tests__/unit/features/archives/FactionStats.test.jsx +++ /dev/null @@ -1,166 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import FactionStats from '@/features/archives/FactionStats'; - -const mockEvents = [ - { - enemy: 0, - region: 1, - type: 'defend', - status: 'success', - start_time: 1000, - end_time: 4600, - players_at_start: 5000, - }, - { - enemy: 0, - region: 1, - type: 'defend', - status: 'fail', - start_time: 5000, - end_time: 12200, - players_at_start: 8000, - }, - { - enemy: 0, - region: 2, - type: 'attack', - status: 'success', - start_time: 13000, - end_time: 20200, - players_at_start: 3000, - }, - { - enemy: 1, - region: 1, - type: 'defend', - status: 'success', - start_time: 1000, - end_time: 4600, - players_at_start: 2000, - }, -]; - -const mockSnapshots = [ - { - time: 100, - data: [ - { enemy: 0, points: 300, points_taken: 1500 }, - { enemy: 1, points: 200, points_taken: 800 }, - { enemy: 2, points: 100, points_taken: 400 }, - ], - }, -]; - -const mockPointsMax = { points: [1000, 1000, 1000] }; - -describe('FactionStats', () => { - it('renders stat cards for a faction with events', () => { - render( - , - ); - - expect(screen.getByText('DEFENSE_RATE')).toBeDefined(); - expect(screen.getByText('ATTACK_RATE')).toBeDefined(); - expect(screen.getByText('BATTLES')).toBeDefined(); - expect(screen.getByText('AVG_BATTLE')).toBeDefined(); - expect(screen.getByText('HOTSPOT')).toBeDefined(); - expect(screen.getByText('CONQUEST')).toBeDefined(); - }); - - it('does not render removed stats', () => { - render( - , - ); - - expect(screen.queryByText('PEAK_SURGE')).toBeNull(); - expect(screen.queryByText('OVERKILL')).toBeNull(); - }); - - it('returns null for faction with no events', () => { - const { container } = render( - , - ); - expect(container.innerHTML).toBe(''); - }); - - it('shows correct defense rate', () => { - render( - , - ); - // Bugs: 1 successful defend out of 2 = 50% - expect(screen.getByText('50%')).toBeDefined(); - }); - - it('shows correct conquest from snapshots', () => { - render( - , - ); - // Bugs: points=300, pointsMax=1000 → 30.0% - expect(screen.getByText('30.0%')).toBeDefined(); - }); - - it('shows dash when snapshots are missing', () => { - render( - , - ); - expect(screen.getByText('BATTLES')).toBeDefined(); - }); - - it('shows correct total events count', () => { - render( - , - ); - // 3 bug events - expect(screen.getByText('3')).toBeDefined(); - }); - - it('shows correct attack rate', () => { - render( - , - ); - // Bugs: 1 successful attack out of 1 = 100% - expect(screen.getByText('100%')).toBeDefined(); - }); -}); diff --git a/src/__tests__/unit/features/stats/StatGrid.test.jsx b/src/__tests__/unit/features/stats/StatGrid.test.jsx index 068ca23e..d557f7f7 100644 --- a/src/__tests__/unit/features/stats/StatGrid.test.jsx +++ b/src/__tests__/unit/features/stats/StatGrid.test.jsx @@ -323,4 +323,65 @@ describe('StatGrid', () => { expect(cardSubtitle('WAR_DURATION')).toBe('01 MARCH'); }); }); + + describe('archived / redaction', () => { + const noTelemetryLive = [0, 1, 2].map((enemy) => ({ + enemy, + players: 0, + kills: 0, + deaths: 0, + accidentals: 0, + successful_missions: 0, + missions: 0, + })); + + test('archived global view without telemetry redacts the four telemetry cards', () => { + render( + , + ); + expect(screen.getAllByText('████████')).toHaveLength(4); + expect(screen.getAllByText(/Data redacted/i)).toHaveLength(4); + // EVENTS and WAR_DURATION are not telemetry-derived — still render. + expect(screen.getByText('EVENTS')).toBeInTheDocument(); + expect(screen.getByText('WAR_DURATION')).toBeInTheDocument(); + }); + + test('archived faction view without telemetry redacts the telemetry cards', () => { + render( + , + ); + expect(screen.getAllByText('████████')).toHaveLength(4); + }); + + test('archived season with real telemetry is not redacted', () => { + render( + , + ); + expect(screen.queryByText('████████')).toBeNull(); + expect(screen.getByText('450')).toBeInTheDocument(); + }); + + test('without the archived prop a zero-telemetry season is never redacted', () => { + render( + , + ); + expect(screen.queryByText('████████')).toBeNull(); + expect(screen.queryByText(/Data redacted/i)).toBeNull(); + }); + }); }); diff --git a/src/__tests__/unit/shared/utils/format/formatRatio.test.mjs b/src/__tests__/unit/shared/utils/format/formatRatio.test.mjs new file mode 100644 index 00000000..9df5be22 --- /dev/null +++ b/src/__tests__/unit/shared/utils/format/formatRatio.test.mjs @@ -0,0 +1,24 @@ +import { formatRatio } from '@/shared/utils/format/formatRatio.mjs'; + +describe('formatRatio', () => { + test('formats a ratio to one decimal place', () => { + expect(formatRatio(10, 5)).toBe('2.0'); + }); + + test('rounds to one decimal place', () => { + expect(formatRatio(10, 3)).toBe('3.3'); + }); + + test('coerces BigInt inputs', () => { + expect(formatRatio(1_200_000_000n, 50_000_000n)).toBe('24.0'); + }); + + test('returns a dash for a zero denominator', () => { + expect(formatRatio(5, 0)).toBe('—'); + }); + + test('returns a dash for a null or undefined denominator', () => { + expect(formatRatio(5, null)).toBe('—'); + expect(formatRatio(5, undefined)).toBe('—'); + }); +}); diff --git a/src/features/archives/ArchiveStats.jsx b/src/features/archives/ArchiveStats.jsx index 5dbebd33..d9dfeefe 100644 --- a/src/features/archives/ArchiveStats.jsx +++ b/src/features/archives/ArchiveStats.jsx @@ -1,189 +1,183 @@ -import { formatDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; import { StatCard } from '@/features/stats/StatGrid'; -import { formatNumber } from '@/shared/utils/format/formatNumber.mjs'; +import { formatCompactDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; +import { formatRatio } from '@/shared/utils/format/formatRatio.mjs'; import { getWarOutcome } from '@/features/archives/getWarOutcome.mjs'; import GlitchText from '@/features/archives/GlitchText'; -import factions from '@/shared/enums/factions.mjs'; +import factions, { FACTION_INDEX } from '@/shared/enums/factions.mjs'; import { findWorstCascade } from '@/shared/utils/game/seasonAnalytics.mjs'; import { EVENT_TYPE, EVENT_STATUS } from '@/shared/enums/events.mjs'; +import map from '@/shared/enums/map.mjs'; -// Only 5 fields in h1_statistic are BigInt in the Prisma schema: kills, deaths, -// shots, hits, accidentals. The other stat columns (missions, successful_missions, -// players, total_unique_players, ...) are Int and come back as plain JS Number. -// BigInt() coerces either losslessly. DO NOT use sumBigInt for global-per-season -// fields (total_unique_players, season_duration) — those are repeated verbatim -// across the 3 faction rows and summing them overcounts 3x; read live[0]?.field. -function sumBigInt(live, field) { - return live.reduce((acc, f) => acc + BigInt(f[field] ?? 0), 0n); +/** + * A DEFENSE_RATE / ATTACK_RATE card for one event type — the success rate + * with a "won / total" subtitle, tinted by whether the rate cleared 50%. + */ +function rateCard(label, typeEvents) { + const won = typeEvents.filter((e) => e.status === EVENT_STATUS.SUCCESS).length; + const total = typeEvents.length; + const rate = total > 0 ? Math.round((won / total) * 100) : null; + return ( + 0 ? `${won} / ${total}` : undefined} + accentColor={ + rate != null ? + rate > 50 ? + 'success' + : 'danger' + : undefined + } + /> + ); } -function formatPercent(numerator, denominator) { - if (!denominator) return '—'; - return ((Number(numerator) / Number(denominator)) * 100).toFixed(1) + '%'; +/** The DEFENSE_RATE + ATTACK_RATE pair derived from a set of events. */ +function rateCards(events) { + return ( + <> + {rateCard( + 'DEFENSE_RATE', + events.filter((e) => e.type === EVENT_TYPE.DEFEND), + )} + {rateCard( + 'ATTACK_RATE', + events.filter((e) => e.type === EVENT_TYPE.ATTACK), + )} + + ); } -function formatRatio(numerator, denominator) { - if (!denominator) return '—'; - return (Number(numerator) / Number(denominator)).toFixed(1); +/** + * AVG_DIFFICULTY card — mean difficulty of successful missions on a 1-15 + * scale. Telemetry-derived, so it renders nothing for seasons that predate + * stat collection (no successful missions on record). + */ +function difficultyCard(totalDifficulty, successfulMissions) { + if (!(Number(successfulMissions) > 0)) return null; + return ( + + ); } -export default function ArchiveStats({ - events, - live, - data, - effects: _effects, - glitchPhase, -}) { +/** + * Archives-only statistics that complement the shared ``: the war + * outcome, defend/attack rates and mission difficulty on the global tab, plus + * a faction's hotspot, conquest and average battle length on a faction tab. + * Handles both views in one component, mirroring how `StatGrid` branches on + * `faction`. + */ +export default function ArchiveStats({ faction, events, data, live, glitchPhase }) { if (!events?.length) return null; - // Event-derived stats - const sorted = [...events].sort((a, b) => a.start_time - b.start_time); - // DURATION: snapshot poll span is the archive-era source of truth; event span is the fallback. - const snapshots = data?.snapshots; - const seasonSeconds = - snapshots && snapshots.length >= 2 ? - snapshots[snapshots.length - 1].time - snapshots[0].time - : sorted[sorted.length - 1].end_time - sorted[0].start_time; - const seasonDays = Math.round(seasonSeconds / 86400); - const seasonHumanDuration = formatDuration(seasonSeconds); - - // Defense / attack rates — split out from the old global WIN_RATE so the - // two activities can be read independently. - const defends = events.filter((e) => e.type === EVENT_TYPE.DEFEND); - const attacks = events.filter((e) => e.type === EVENT_TYPE.ATTACK); - const successfulDefends = defends.filter( - (e) => e.status === EVENT_STATUS.SUCCESS, - ).length; - const successfulAttacks = attacks.filter( - (e) => e.status === EVENT_STATUS.SUCCESS, - ).length; - const defenseRate = - defends.length > 0 ? - Math.round((successfulDefends / defends.length) * 100) - : null; - const attackRate = - attacks.length > 0 ? - Math.round((successfulAttacks / attacks.length) * 100) - : null; - - // Outcome - const result = getWarOutcome(data); - const outcome = result?.outcome ?? 'unknown'; - const outcomeColor = - outcome === 'victory' ? 'success' - : outcome === 'defeat' ? 'danger' - : undefined; - const outcomeFaction = - result?.faction != null ? factions[result.faction]?.name : null; - - // Notable moments - const worstCascade = findWorstCascade(events); + if (faction === 'global') { + const result = getWarOutcome(data); + const outcome = result?.outcome ?? 'unknown'; + const outcomeColor = + outcome === 'victory' ? 'success' + : outcome === 'defeat' ? 'danger' + : undefined; + const outcomeFaction = + result?.faction != null ? factions[result.faction]?.name : null; + const worstCascade = findWorstCascade(events); + // Per-faction stats are disjoint, so summing the three rows gives the + // war-wide totals for the average-difficulty ratio. + const diff = (live ?? []).reduce( + (acc, s) => ({ + difficulty: acc.difficulty + Number(s.total_mission_difficulty || 0), + successful: acc.successful + Number(s.successful_missions || 0), + }), + { difficulty: 0, successful: 0 }, + ); - // h1_statistic combat stats (only for seasons with live data) - const hasLive = live?.length > 0; - let liveCards = null; - if (hasLive) { - const kills = sumBigInt(live, 'kills'); - const deaths = sumBigInt(live, 'deaths'); - const missions = sumBigInt(live, 'missions'); - const successfulMissions = sumBigInt(live, 'successful_missions'); - const players = Math.max(...live.map((f) => Number(f.players ?? 0n))); - const shots = sumBigInt(live, 'shots'); - const hits = sumBigInt(live, 'hits'); - const accidentals = sumBigInt(live, 'accidentals'); - liveCards = ( - <> - - - + return ( +
      + : outcome.toUpperCase() + } + subtitle={outcomeFaction ?? undefined} + accentColor={outcomeColor} + valueColor={outcome !== 'defeat' ? outcomeColor : undefined} /> - - - + {rateCards(events)} + {difficultyCard(diff.difficulty, diff.successful)} + {worstCascade && ( + + )} +
      ); } + const factionIndex = FACTION_INDEX[faction]; + if (factionIndex === undefined) return null; + const factionEvents = events.filter((e) => e.enemy === factionIndex); + if (!factionEvents.length) return null; + + // Average battle length across this faction's events. + const durations = factionEvents + .filter((e) => e.end_time && e.start_time) + .map((e) => e.end_time - e.start_time); + const avgDuration = + durations.length > 0 ? + durations.reduce((a, b) => a + b, 0) / durations.length + : null; + + // Most-fought region for this faction. + const regionCounts = {}; + for (const e of factionEvents) { + regionCounts[e.region] = (regionCounts[e.region] ?? 0) + 1; + } + const topRegion = Object.entries(regionCounts).sort((a, b) => b[1] - a[1])[0]; + const hotspotName = + topRegion ? (map[factionIndex]?.[Number(topRegion[0])]?.region ?? '—') : '—'; + + // Final conquest share from the last snapshot. + let conquest = '—'; + const snapshots = data?.snapshots; + const pointsMax = data?.points_max; + if (snapshots?.length && pointsMax?.points) { + const factionData = snapshots[snapshots.length - 1].data?.[factionIndex]; + const maxPoints = pointsMax.points[factionIndex]; + if (maxPoints > 0 && factionData?.points != null) { + conquest = ((Number(factionData.points) / maxPoints) * 100).toFixed(1) + '%'; + } + } + + const factionLive = live?.find((r) => r.enemy === factionIndex); + return (
      + {rateCards(factionEvents)} - : outcome.toUpperCase() - } - subtitle={outcomeFaction ?? undefined} - accentColor={outcomeColor} - valueColor={outcome !== 'defeat' ? outcomeColor : undefined} - /> - - 0 ? - `${successfulDefends} / ${defends.length}` - : undefined - } - accentColor={ - defenseRate != null ? - defenseRate > 50 ? - 'success' - : 'danger' - : undefined - } + label="AVG_BATTLE" + value={avgDuration != null ? formatCompactDuration(avgDuration) : '—'} /> - 0 ? - `${successfulAttacks} / ${attacks.length}` - : undefined - } - accentColor={ - attackRate != null ? - attackRate > 50 ? - 'success' - : 'danger' - : undefined - } - /> - {hasLive && ( - + + + {difficultyCard( + factionLive?.total_mission_difficulty, + factionLive?.successful_missions, )} - - {worstCascade && ( - - )} - - {liveCards}
      ); } diff --git a/src/features/archives/ArchivesClient.jsx b/src/features/archives/ArchivesClient.jsx index cda4b613..c66976de 100644 --- a/src/features/archives/ArchivesClient.jsx +++ b/src/features/archives/ArchivesClient.jsx @@ -6,7 +6,7 @@ import ArchiveStats from '@/features/archives/ArchiveStats'; import ArchivesHeader, { EffectsToggle } from '@/features/archives/ArchivesHeader'; import FactionHealthChart from '@/features/archives/FactionHealthChartLoader'; import FactionTabs from '@/shared/components/FactionTabs'; -import FactionStats from '@/features/archives/FactionStats'; +import StatGrid from '@/features/stats/StatGrid'; import EventLog from '@/features/timeline/EventLog'; import ArchiveMap from '@/features/archives/ArchiveMap'; import SeasonSelector from '@/features/archives/SeasonSelector'; @@ -77,9 +77,10 @@ export default function ArchivesClient({ initialSortOrder = 'desc', }) { const events = data?.events ?? []; - // 'global' shows the whole-war overview (ArchiveStats); bugs/cyborgs/illuminate - // show a per-faction breakdown (FactionStats). Persisted via cookies and - // shared with the dashboard; initial value is SSR-read in the archives page. + // 'global' shows the whole-war overview; bugs/cyborgs/illuminate show a + // per-faction breakdown. Either way the stats render through the shared + // StatGrid plus the archives-only ArchiveStats extras. Persisted via + // cookies and shared with the dashboard; SSR-read in the archives page. const [faction, setFaction] = usePersistedState(FACTION_KEY, initialFaction); // Mobile-only: toggle whether the archives map column is sticky // (pinned at the top as the user scrolls). Default ON here (unlike @@ -141,21 +142,21 @@ export default function ArchivesClient({ )}
      - {faction === 'global' ? - - : - } + +
      diff --git a/src/features/archives/FactionStats.jsx b/src/features/archives/FactionStats.jsx deleted file mode 100644 index 3d235be6..00000000 --- a/src/features/archives/FactionStats.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import { StatCard } from '@/features/stats/StatGrid'; -import { formatCompactDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; -import map from '@/shared/enums/map.mjs'; -import { EVENT_TYPE, EVENT_STATUS } from '@/shared/enums/events.mjs'; -import { FACTION_INDEX } from '@/shared/enums/factions.mjs'; - -export default function FactionStats({ events, snapshots, pointsMax, faction }) { - const factionIndex = FACTION_INDEX[faction]; - if (factionIndex === undefined) return null; - - const factionEvents = (events ?? []).filter((e) => e.enemy === factionIndex); - if (!factionEvents.length) return null; - - const defends = factionEvents.filter((e) => e.type === EVENT_TYPE.DEFEND); - const attacks = factionEvents.filter((e) => e.type === EVENT_TYPE.ATTACK); - const successfulDefends = defends.filter( - (e) => e.status === EVENT_STATUS.SUCCESS, - ).length; - const successfulAttacks = attacks.filter( - (e) => e.status === EVENT_STATUS.SUCCESS, - ).length; - - const defenseRate = - defends.length > 0 ? - Math.round((successfulDefends / defends.length) * 100) - : null; - const attackRate = - attacks.length > 0 ? - Math.round((successfulAttacks / attacks.length) * 100) - : null; - - const durations = factionEvents - .filter((e) => e.end_time && e.start_time) - .map((e) => e.end_time - e.start_time); - const avgDuration = - durations.length > 0 ? - durations.reduce((a, b) => a + b, 0) / durations.length - : null; - - // Most attacked region (hotspot) - const regionCounts = {}; - for (const e of factionEvents) { - regionCounts[e.region] = (regionCounts[e.region] ?? 0) + 1; - } - const topRegion = Object.entries(regionCounts).sort((a, b) => b[1] - a[1])[0]; - const hotspotName = - topRegion ? (map[factionIndex]?.[Number(topRegion[0])]?.region ?? '—') : '—'; - - // Snapshot-derived conquest - let conquest = '—'; - - if (snapshots?.length && pointsMax?.points) { - const lastSnap = snapshots[snapshots.length - 1]; - const parsed = lastSnap.data; - - if (parsed?.[factionIndex]) { - const factionData = parsed[factionIndex]; - const maxPoints = pointsMax.points[factionIndex]; - - if (maxPoints > 0 && factionData.points != null) { - conquest = - ((Number(factionData.points) / maxPoints) * 100).toFixed(1) + '%'; - } - } - } - - return ( -
      - 50 ? - 'success' - : 'danger' - : undefined - } - /> - 50 ? - 'success' - : 'danger' - : undefined - } - /> - - - - -
      - ); -} diff --git a/src/features/stats/StatGrid.jsx b/src/features/stats/StatGrid.jsx index 40d32b94..d41a9da7 100644 --- a/src/features/stats/StatGrid.jsx +++ b/src/features/stats/StatGrid.jsx @@ -185,6 +185,37 @@ function warDurationCard(seconds, startUnix) { ); } +/** + * A telemetry card for an archived season that predates stat collection. + * Rather than a misleading zero, the value is censored and the subtitle + * plays the gap as a Ministry of Truth redaction. + */ +function redactedCard(label) { + return ( + ████████} + subtitle={ + + Data redacted — Ministry of Truth + + } + /> + ); +} + +/** + * Render a telemetry card, or its redacted stand-in when `redacted` is set — + * an archived season with no h1_statistic data behind it. + * + * @param {boolean} redacted - Whether to censor the card + * @param {string} label - The card label + * @param {object} cardProps - Props for the real StatCard when not redacted + */ +function telemetryCard(redacted, label, cardProps) { + return redacted ? redactedCard(label) : ; +} + export default function StatGrid({ live, faction, @@ -193,6 +224,7 @@ export default function StatGrid({ killsTrend = null, seasonDuration = 0, warStart = null, + archived = false, }) { if (!live?.length) return null; @@ -229,29 +261,28 @@ export default function StatGrid({ playersAvg24h?.global, ); const killsSubtitle = killsTrendSubtitle(totals.kills, killsTrend?.global); + // An archived season with no missions logged predates stat collection — + // redact its telemetry cards rather than render misleading zeros. + const redacted = archived && totals.allMissions <= 0; return (
      - } - subtitle={onlineSubtitle} - /> - } - subtitle={killsSubtitle} - /> - } - subtitle={accidentalSubtitle(totals.accidentals, totals.deaths)} - title={accidentalRateTooltip(totals.accidentals, totals.deaths)} - /> - } - subtitle={missionTotalSubtitle(totals.allMissions)} - /> + {telemetryCard(redacted, 'HELLDIVERS_ONLINE', { + value: , + subtitle: onlineSubtitle, + })} + {telemetryCard(redacted, 'ENEMIES_KILLED', { + value: , + subtitle: killsSubtitle, + })} + {telemetryCard(redacted, 'HELLDIVERS_LOST', { + value: , + subtitle: accidentalSubtitle(totals.accidentals, totals.deaths), + title: accidentalRateTooltip(totals.accidentals, totals.deaths), + })} + {telemetryCard(redacted, 'MISSIONS_WON', { + value: , + subtitle: missionTotalSubtitle(totals.allMissions), + })} - } - subtitle={onlineSubtitle} - /> - } - subtitle={killsSubtitle} - /> - } - subtitle={accidentalSubtitle(stats.accidentals, stats.deaths)} - title={accidentalRateTooltip(stats.accidentals, stats.deaths)} - /> - } - subtitle={missionTotalSubtitle(stats.missions)} - /> + {telemetryCard(redacted, 'HELLDIVERS_ONLINE', { + value: , + subtitle: onlineSubtitle, + })} + {telemetryCard(redacted, 'ENEMIES_KILLED', { + value: , + subtitle: killsSubtitle, + })} + {telemetryCard(redacted, 'HELLDIVERS_LOST', { + value: , + subtitle: accidentalSubtitle(stats.accidentals, stats.deaths), + title: accidentalRateTooltip(stats.accidentals, stats.deaths), + })} + {telemetryCard(redacted, 'MISSIONS_WON', { + value: , + subtitle: missionTotalSubtitle(stats.missions), + })} Date: Sat, 23 May 2026 00:03:07 +0200 Subject: [PATCH 11/94] feat: add cross-season /stats page (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of the Phase A split (Part 2 = #391, shipped in 0.48.0). New top-level /stats route reading the full 157-season history through a single getCrossSeasonStats() query (SQL GROUP BY aggregates + a per-season war-outcome derivation that reuses getWarOutcome on a slim per-season slice). Three components: - Faction Threat Ranking — per-faction overall HD win rates as a faction-colored horizontal bar chart, sorted ascending (most threatening enemy first). Recharts. - War Outcomes & Streaks — total wars, victories, defeats, win rate, longest win/loss streaks with season ranges, plus a wrapping per-season outcome timeline of success/danger pills. - All-Time Records — longest war, most events, longest avg battle, most defends/attacks won; each card attributed to the season that owns the extremum. The three telemetry charts from #178 (Friendly Fire Index, Accuracy Trend, Shots per Planet) are deferred until telemetry accumulates beyond season 157 — the query already returns telemetry fields so the charts slot in cleanly later. HeaderNav and BottomNav gain a Stats entry. Built test-first throughout. lint, typecheck, 1365 unit tests and build all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + .../stats/FactionThreatRanking.test.jsx | 43 ++++ .../features/stats/SeasonRecords.test.jsx | 79 ++++++ .../unit/features/stats/WarOutcomes.test.jsx | 58 +++++ .../unit/queries/getCrossSeasonStats.test.mjs | 225 ++++++++++++++++++ .../unit/shared/components/BottomNav.test.jsx | 8 +- .../unit/shared/components/HeaderNav.test.jsx | 8 +- src/app/stats/page.jsx | 61 +++++ src/db/queries/getCrossSeasonStats.mjs | 195 +++++++++++++++ src/features/stats/FactionThreatRanking.jsx | 92 +++++++ src/features/stats/SeasonRecords.jsx | 72 ++++++ src/features/stats/WarOutcomes.jsx | 115 +++++++++ src/shared/components/BottomNav/BottomNav.jsx | 1 + .../components/Navigation/HeaderNav.jsx | 1 + 14 files changed, 958 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/unit/features/stats/FactionThreatRanking.test.jsx create mode 100644 src/__tests__/unit/features/stats/SeasonRecords.test.jsx create mode 100644 src/__tests__/unit/features/stats/WarOutcomes.test.jsx create mode 100644 src/__tests__/unit/queries/getCrossSeasonStats.test.mjs create mode 100644 src/app/stats/page.jsx create mode 100644 src/db/queries/getCrossSeasonStats.mjs create mode 100644 src/features/stats/FactionThreatRanking.jsx create mode 100644 src/features/stats/SeasonRecords.jsx create mode 100644 src/features/stats/WarOutcomes.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c29aa9..f090d351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- **New `/stats` page surfaces cross-season analytics across every Helldivers war (#394).** The other half of the Phase A split (Part 2 = #391, shipped in 0.48.0). A new top-level route reads the full 157-season history via a single `getCrossSeasonStats()` query — SQL `GROUP BY` aggregates over `h1_event` / `h1_status` / `h1_season` / `h1_statistic`, plus a per-season war-outcome derivation that reuses `getWarOutcome`'s algorithm on a slim per-season slice (final faction states + relevant events + a synthetic any-all-3-defeated snapshot flag). Three components ship: **Faction Threat Ranking** — per-faction overall HD win rates as a faction-colored horizontal bar chart, sorted ascending so the most-threatening enemy reads first; **War Outcomes & Streaks** — total wars, victories, defeats, win rate, longest win/loss streaks with season ranges, plus a wrapping per-season outcome timeline; **All-Time Records** — longest war, most events, longest avg battle, most defends/attacks won, each card attributed to the season that owns the extremum. The three telemetry charts originally listed in #178 (Friendly Fire Index, Accuracy Trend, Shots per Planet) are deferred until telemetry accumulates beyond season 157 — the query already returns telemetry fields so the charts drop in cleanly later. `HeaderNav` and `BottomNav` gain a `Stats` entry. + ## 0.48.0 ### Features diff --git a/src/__tests__/unit/features/stats/FactionThreatRanking.test.jsx b/src/__tests__/unit/features/stats/FactionThreatRanking.test.jsx new file mode 100644 index 00000000..8af31f8f --- /dev/null +++ b/src/__tests__/unit/features/stats/FactionThreatRanking.test.jsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { computeThreatData } from '@/features/stats/FactionThreatRanking'; + +// The chart itself uses Recharts in a ResponsiveContainer, which doesn't +// render usefully in jsdom (no real dimensions) — so the unit test covers +// the pure data transform that drives it. The chart is exercised end-to-end +// by the DevTools verify. + +describe('computeThreatData', () => { + it('computes the overall HD win rate per faction and sorts ascending', () => { + const totals = [ + // Bugs: (60+30)/(100+50) = 90/150 = 60% + { enemy: 0, defends: 100, defend_wins: 60, attacks: 50, attack_wins: 30 }, + // Cyborgs: (40+20)/(80+60) = 60/140 ≈ 43% + { enemy: 1, defends: 80, defend_wins: 40, attacks: 60, attack_wins: 20 }, + // Illuminate: (30+40)/(50+50) = 70/100 = 70% + { enemy: 2, defends: 50, defend_wins: 30, attacks: 50, attack_wins: 40 }, + ]; + const data = computeThreatData(totals); + expect(data).toHaveLength(3); + // Ascending by winRate → most threatening (lowest HD win rate) at top. + expect(data[0]).toMatchObject({ name: 'Cyborgs', winRate: 43 }); + expect(data[1]).toMatchObject({ name: 'Bugs', winRate: 60 }); + expect(data[2]).toMatchObject({ name: 'Illuminate', winRate: 70 }); + }); + + it('treats a missing faction as zero rather than dropping it', () => { + const data = computeThreatData([ + { enemy: 0, defends: 10, defend_wins: 5, attacks: 5, attack_wins: 3 }, + ]); + expect(data).toHaveLength(3); + const cyborgs = data.find((d) => d.name === 'Cyborgs'); + expect(cyborgs.winRate).toBe(0); + }); + + it('returns empty when factionTotals is empty', () => { + // Every faction registers but with zeros — empty array still yields + // three rows so the chart has consistent axis labels. + const data = computeThreatData([]); + expect(data).toHaveLength(3); + expect(data.every((d) => d.winRate === 0)).toBe(true); + }); +}); diff --git a/src/__tests__/unit/features/stats/SeasonRecords.test.jsx b/src/__tests__/unit/features/stats/SeasonRecords.test.jsx new file mode 100644 index 00000000..7e060f5d --- /dev/null +++ b/src/__tests__/unit/features/stats/SeasonRecords.test.jsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SeasonRecords from '@/features/stats/SeasonRecords'; + +// Each record-winning season is distinct so each `Season N` subtitle +// appears exactly once — easier to assert via getByText. +const mockPerSeason = [ + { + season: 1, + season_duration: 86400 * 10, + events: 20, + defend_wins: 5, + attack_wins: 3, + avg_event_duration: 3600, + }, + { + season: 2, + season_duration: 86400 * 5, + events: 30, + defend_wins: 3, + attack_wins: 2, + avg_event_duration: 4000, + }, + { + season: 3, + season_duration: 86400 * 7, + events: 15, + defend_wins: 4, + attack_wins: 8, + avg_event_duration: 5000, + }, + { + season: 4, + season_duration: 86400 * 6, + events: 25, + defend_wins: 10, + attack_wins: 1, + avg_event_duration: 4500, + }, + { + season: 5, + season_duration: 86400 * 4, + events: 18, + defend_wins: 2, + attack_wins: 4, + avg_event_duration: 9000, + }, +]; + +describe('SeasonRecords', () => { + it('renders the all-time records grid', () => { + render(); + expect(screen.getByText('LONGEST_WAR')).toBeDefined(); + expect(screen.getByText('MOST_EVENTS')).toBeDefined(); + expect(screen.getByText('LONGEST_AVG_BATTLE')).toBeDefined(); + expect(screen.getByText('MOST_DEFENDS_WON')).toBeDefined(); + expect(screen.getByText('MOST_ATTACKS_WON')).toBeDefined(); + }); + + it('attributes each record to the season that owns the extremum', () => { + render(); + // Longest war (864000s = 10 days) → S1 + expect(screen.getByText('Season 1')).toBeDefined(); + // Most events (30) → S2 + expect(screen.getByText('Season 2')).toBeDefined(); + // Most attacks won (8) → S3 + expect(screen.getByText('Season 3')).toBeDefined(); + // Most defends won (10) → S4 + expect(screen.getByText('Season 4')).toBeDefined(); + // Longest avg battle (9000s) → S5 + expect(screen.getByText('Season 5')).toBeDefined(); + }); + + it('returns null for empty input', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/src/__tests__/unit/features/stats/WarOutcomes.test.jsx b/src/__tests__/unit/features/stats/WarOutcomes.test.jsx new file mode 100644 index 00000000..671a7eb8 --- /dev/null +++ b/src/__tests__/unit/features/stats/WarOutcomes.test.jsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import WarOutcomes from '@/features/stats/WarOutcomes'; + +// V V V V V D V D D D — total 10, victories 6, defeats 4 +// Longest win streak: 5 (S1-S5). Longest loss streak: 3 (S8-S10). +// All distinct values so we can assert via getByText. +const mockPerSeason = [ + { season: 1, outcome: 'victory' }, + { season: 2, outcome: 'victory' }, + { season: 3, outcome: 'victory' }, + { season: 4, outcome: 'victory' }, + { season: 5, outcome: 'victory' }, + { season: 6, outcome: 'defeat' }, + { season: 7, outcome: 'victory' }, + { season: 8, outcome: 'defeat' }, + { season: 9, outcome: 'defeat' }, + { season: 10, outcome: 'defeat' }, +]; + +describe('WarOutcomes', () => { + it('renders the summary cards', () => { + render(); + expect(screen.getByText('TOTAL_WARS')).toBeDefined(); + expect(screen.getByText('VICTORIES')).toBeDefined(); + expect(screen.getByText('DEFEATS')).toBeDefined(); + expect(screen.getByText('LONGEST_WIN_STREAK')).toBeDefined(); + expect(screen.getByText('LONGEST_LOSS_STREAK')).toBeDefined(); + }); + + it('counts total wars, victories, defeats, and the win rate', () => { + render(); + expect(screen.getByText('10')).toBeDefined(); + expect(screen.getByText('6')).toBeDefined(); + expect(screen.getByText('4')).toBeDefined(); + expect(screen.getByText('60%')).toBeDefined(); + }); + + it('finds the longest win and loss streaks with their season ranges', () => { + render(); + expect(screen.getByText('5')).toBeDefined(); + expect(screen.getByText('Seasons 1–5')).toBeDefined(); + expect(screen.getByText('3')).toBeDefined(); + expect(screen.getByText('Seasons 8–10')).toBeDefined(); + }); + + it('renders a timeline pill per season', () => { + const { container } = render(); + const pills = container.querySelectorAll('[role="listitem"]'); + expect(pills.length).toBe(10); + }); + + it('returns null for empty input', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/src/__tests__/unit/queries/getCrossSeasonStats.test.mjs b/src/__tests__/unit/queries/getCrossSeasonStats.test.mjs new file mode 100644 index 00000000..dc0a5e3b --- /dev/null +++ b/src/__tests__/unit/queries/getCrossSeasonStats.test.mjs @@ -0,0 +1,225 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import db from '@/db/db'; +import { getCrossSeasonStats } from '@/db/queries/getCrossSeasonStats.mjs'; + +// getCrossSeasonStats is wrapped in React's cache(); the global db mock from +// vitest.setup.mjs handles the underlying queries transparently. We feed +// each of the 5 $queryRaw calls with `mockResolvedValueOnce` in source-order: +// 1) per-season event aggregates +// 2) per-faction event totals (Threat Ranking) +// 3) per-season telemetry sums (latest-bucket-per-enemy summed) +// 4) per-season final faction states (DISTINCT ON season,enemy) +// 5) seasons where any bucket showed all-3-defeated + +beforeEach(() => { + vi.mocked(db.$queryRaw).mockReset(); + vi.mocked(db.h1_season.findMany).mockReset(); + vi.mocked(db.h1_event.findMany).mockReset(); +}); + +function seed({ + eventAggs = [], + factionTotals = [], + seasons = [], + telemetry = [], + allEvents = [], + finalStates = [], + defeatedSeasonRows = [], +} = {}) { + vi.mocked(db.$queryRaw) + .mockResolvedValueOnce(eventAggs) + .mockResolvedValueOnce(factionTotals) + .mockResolvedValueOnce(telemetry) + .mockResolvedValueOnce(finalStates) + .mockResolvedValueOnce(defeatedSeasonRows); + vi.mocked(db.h1_season.findMany).mockResolvedValue(seasons); + vi.mocked(db.h1_event.findMany).mockResolvedValue(allEvents); +} + +describe('getCrossSeasonStats', () => { + test('returns empty arrays when there are no seasons', async () => { + seed({}); + const r = await getCrossSeasonStats(); + expect(r.perSeason).toEqual([]); + expect(r.factionTotals).toEqual([]); + }); + + test('builds a per-season row from h1_season + event aggregates + season_duration', async () => { + seed({ + eventAggs: [ + { + season: 1, + events: 4, + defends: 1, + defend_wins: 1, + attacks: 3, + attack_wins: 3, + avg_event_duration: 3600, + }, + ], + seasons: [{ season: 1, season_duration: 432000 }], + }); + const r = await getCrossSeasonStats(); + expect(r.perSeason).toHaveLength(1); + expect(r.perSeason[0]).toMatchObject({ + season: 1, + season_duration: 432000, + events: 4, + defends: 1, + defend_wins: 1, + attacks: 3, + attack_wins: 3, + avg_event_duration: 3600, + }); + }); + + test('passes per-faction totals through for the Threat Ranking', async () => { + seed({ + factionTotals: [ + { enemy: 0, defends: 10, defend_wins: 5, attacks: 8, attack_wins: 6 }, + { enemy: 1, defends: 12, defend_wins: 8, attacks: 6, attack_wins: 3 }, + { enemy: 2, defends: 15, defend_wins: 7, attacks: 5, attack_wins: 5 }, + ], + seasons: [{ season: 1, season_duration: 0 }], + }); + const r = await getCrossSeasonStats(); + expect(r.factionTotals).toEqual([ + { enemy: 0, defends: 10, defend_wins: 5, attacks: 8, attack_wins: 6 }, + { enemy: 1, defends: 12, defend_wins: 8, attacks: 6, attack_wins: 3 }, + { enemy: 2, defends: 15, defend_wins: 7, attacks: 5, attack_wins: 5 }, + ]); + }); + + test('derives outcome=victory when all 3 final faction states are defeated', async () => { + seed({ + seasons: [{ season: 1, season_duration: 0 }], + allEvents: [ + { + season: 1, + type: 'attack', + status: 'success', + region: 5, + enemy: 0, + end_time: 100, + start_time: 50, + }, + { + season: 1, + type: 'attack', + status: 'success', + region: 5, + enemy: 1, + end_time: 200, + start_time: 150, + }, + { + season: 1, + type: 'attack', + status: 'success', + region: 5, + enemy: 2, + end_time: 300, + start_time: 250, + }, + ], + finalStates: [ + { + season: 1, + enemy: 0, + status: 'defeated', + points: 100, + points_taken: 100, + }, + { + season: 1, + enemy: 1, + status: 'defeated', + points: 100, + points_taken: 100, + }, + { + season: 1, + enemy: 2, + status: 'defeated', + points: 100, + points_taken: 100, + }, + ], + }); + const r = await getCrossSeasonStats(); + expect(r.perSeason[0].outcome).toBe('victory'); + // Victory faction = last conquered (enemy of last attack-success by end_time) + expect(r.perSeason[0].outcome_faction).toBe(2); + }); + + test('derives outcome=defeat when the last r0 defend failed and no victory signal fires', async () => { + seed({ + seasons: [{ season: 2, season_duration: 0 }], + allEvents: [ + { + season: 2, + type: 'defend', + status: 'fail', + region: 0, + enemy: 1, + end_time: 500, + start_time: 400, + }, + ], + finalStates: [ + { season: 2, enemy: 0, status: 'active', points: 50, points_taken: 20 }, + { season: 2, enemy: 1, status: 'active', points: 80, points_taken: 30 }, + { season: 2, enemy: 2, status: 'hidden', points: 0, points_taken: 0 }, + ], + }); + const r = await getCrossSeasonStats(); + expect(r.perSeason[0].outcome).toBe('defeat'); + expect(r.perSeason[0].outcome_faction).toBe(1); + }); + + test('telemetry fields are zero when the season has no h1_statistic row', async () => { + seed({ + seasons: [{ season: 1, season_duration: 0 }], + telemetry: [], + }); + const r = await getCrossSeasonStats(); + expect(r.perSeason[0]).toMatchObject({ + kills: 0n, + deaths: 0n, + accidentals: 0n, + shots: 0n, + hits: 0n, + missions: 0, + successful_missions: 0, + total_mission_difficulty: 0, + completed_planets: 0, + }); + }); + + test('telemetry fields flow through when the season has h1_statistic', async () => { + seed({ + seasons: [{ season: 157, season_duration: 0 }], + telemetry: [ + { + season: 157, + kills: 12345678n, + deaths: 1000n, + accidentals: 50n, + shots: 9000000n, + hits: 4000000n, + missions: 100, + successful_missions: 80, + total_mission_difficulty: 600, + completed_planets: 4, + }, + ], + }); + const r = await getCrossSeasonStats(); + expect(r.perSeason[0]).toMatchObject({ + kills: 12345678n, + deaths: 1000n, + missions: 100, + successful_missions: 80, + }); + }); +}); diff --git a/src/__tests__/unit/shared/components/BottomNav.test.jsx b/src/__tests__/unit/shared/components/BottomNav.test.jsx index 7045b22d..2a48e0d2 100644 --- a/src/__tests__/unit/shared/components/BottomNav.test.jsx +++ b/src/__tests__/unit/shared/components/BottomNav.test.jsx @@ -12,10 +12,10 @@ describe('BottomNav', () => { vi.mocked(usePathname).mockReturnValue('/'); }); - test('renders 3 navigation links', () => { + test('renders 4 navigation links', () => { render(); const links = screen.getAllByRole('link'); - expect(links).toHaveLength(3); + expect(links).toHaveLength(4); }); test('renders correct labels', () => { @@ -56,6 +56,10 @@ describe('BottomNav', () => { 'href', '/archives', ); + expect(screen.getByRole('link', { name: /Stats/i })).toHaveAttribute( + 'href', + '/stats', + ); expect(screen.getByRole('link', { name: /Docs/i })).toHaveAttribute( 'href', '/docs', diff --git a/src/__tests__/unit/shared/components/HeaderNav.test.jsx b/src/__tests__/unit/shared/components/HeaderNav.test.jsx index 65a4fc12..e3976c58 100644 --- a/src/__tests__/unit/shared/components/HeaderNav.test.jsx +++ b/src/__tests__/unit/shared/components/HeaderNav.test.jsx @@ -10,10 +10,10 @@ describe('HeaderNav', () => { vi.mocked(usePathname).mockReturnValue('/'); }); - test('renders 3 navigation links', () => { + test('renders 4 navigation links', () => { render(); const links = screen.getAllByRole('link'); - expect(links).toHaveLength(3); + expect(links).toHaveLength(4); }); test('renders correct labels', () => { @@ -57,6 +57,10 @@ describe('HeaderNav', () => { 'href', '/archives', ); + expect(screen.getByRole('link', { name: /Stats/i })).toHaveAttribute( + 'href', + '/stats', + ); expect(screen.getByRole('link', { name: /Docs/i })).toHaveAttribute( 'href', '/docs', diff --git a/src/app/stats/page.jsx b/src/app/stats/page.jsx new file mode 100644 index 00000000..da66e37c --- /dev/null +++ b/src/app/stats/page.jsx @@ -0,0 +1,61 @@ +import { getCrossSeasonStats } from '@/db/queries/getCrossSeasonStats.mjs'; +import FactionThreatRanking from '@/features/stats/FactionThreatRanking'; +import WarOutcomes from '@/features/stats/WarOutcomes'; +import SeasonRecords from '@/features/stats/SeasonRecords'; + +// DB-backed — the cross-season aggregate runs on each request (cached via +// React's `cache()` inside getCrossSeasonStats); skip the build-time +// pre-render that would otherwise try to hit Postgres. +export const dynamic = 'force-dynamic'; + +export const metadata = { + title: 'Across the Wars', + description: + 'Cross-season analytics across every Helldivers war: faction threat ranking, war outcomes and streaks, and all-time records.', +}; + +/** + * `/stats` — the cross-season analytics page. + * + * One DB query (`getCrossSeasonStats`) supplies every section so the page + * stays a thin server-side composition: faction threat ranking on top + * (Recharts bar), war outcomes + streaks + a per-season timeline in the + * middle, all-time records grid at the bottom. Telemetry-derived components + * are deferred — see issue #394. + */ +export default async function StatsPage() { + const data = await getCrossSeasonStats(); + const seasonsCount = data.perSeason.length; + + return ( +
      +
      +

      Across the Wars

      +

      + What humanity has learned from every war we have ever fought — + aggregated across {seasonsCount}{' '} + {seasonsCount === 1 ? 'season' : 'seasons'} of campaign history. +

      +
      + +
      +

      Faction Threat Ranking

      +

      + Helldivers' overall win rate against each enemy across every war. + A shorter bar means a more threatening foe. +

      + +
      + +
      +

      War Outcomes & Streaks

      + +
      + +
      +

      All-Time Records

      + +
      +
      + ); +} diff --git a/src/db/queries/getCrossSeasonStats.mjs b/src/db/queries/getCrossSeasonStats.mjs new file mode 100644 index 00000000..61f21966 --- /dev/null +++ b/src/db/queries/getCrossSeasonStats.mjs @@ -0,0 +1,195 @@ +import { cache } from 'react'; +import db from '@/db/db'; +import { getWarOutcome } from '@/features/archives/getWarOutcome.mjs'; + +/** + * Aggregate cross-season statistics across the full war history — used by + * the `/stats` page. + * + * Returns `{ perSeason, factionTotals }`: + * + * - `perSeason` — one row per season in `h1_season` (sorted ascending) with + * event-derived aggregates (counts, win counts, average event duration), + * the season duration from `h1_season`, a derived war outcome + * (victory/defeat/unknown) plus attribution faction, and per-season + * telemetry sums (latest-bucket-per-enemy summed). Telemetry fields are + * zero for seasons that predate `h1_statistic` collection — callers should + * treat them as future-proof, not as meaningful zero values. + * + * - `factionTotals` — three rows (one per enemy) with defend/attack win + * counts aggregated across every war, used by the Faction Threat Ranking + * chart. + * + * The outcome is derived by feeding a slim per-season slice (final faction + * states + relevant events + a synthetic "any-all-3-defeated snapshot" flag) + * to the existing `getWarOutcome` so the algorithm stays in one place. + * + * @returns {Promise<{ perSeason: Array, factionTotals: Array }>} + */ +export const getCrossSeasonStats = cache(async function getCrossSeasonStats() { + 'use server'; + + // 1. Per-season event aggregates. + const eventAggs = await db.$queryRaw` + SELECT season, + count(*)::int AS events, + count(*) FILTER (WHERE type = 'defend')::int AS defends, + count(*) FILTER (WHERE type = 'defend' AND status = 'success')::int AS defend_wins, + count(*) FILTER (WHERE type = 'attack')::int AS attacks, + count(*) FILTER (WHERE type = 'attack' AND status = 'success')::int AS attack_wins, + avg(end_time - start_time) FILTER (WHERE end_time > start_time)::float + AS avg_event_duration + FROM h1_event + GROUP BY season + ORDER BY season ASC + `; + + // 2. Per-faction totals across every war (Threat Ranking). + const factionTotalsRaw = await db.$queryRaw` + SELECT enemy, + count(*) FILTER (WHERE type = 'defend')::int AS defends, + count(*) FILTER (WHERE type = 'defend' AND status = 'success')::int AS defend_wins, + count(*) FILTER (WHERE type = 'attack')::int AS attacks, + count(*) FILTER (WHERE type = 'attack' AND status = 'success')::int AS attack_wins + FROM h1_event + GROUP BY enemy + ORDER BY enemy ASC + `; + + // 3. h1_season rows for duration. + const seasons = await db.h1_season.findMany({ + select: { season: true, season_duration: true }, + orderBy: { season: 'asc' }, + }); + + // 4. Telemetry per season — latest-bucket-per-enemy, summed across factions. + // For 156 of 157 seasons this returns no row; the merge defaults to 0. + const telemetry = await db.$queryRaw` + SELECT season, + sum(kills) AS kills, + sum(deaths) AS deaths, + sum(accidentals) AS accidentals, + sum(shots) AS shots, + sum(hits) AS hits, + sum(missions)::int AS missions, + sum(successful_missions)::int AS successful_missions, + sum(total_mission_difficulty)::int AS total_mission_difficulty, + sum(completed_planets)::int AS completed_planets + FROM ( + SELECT DISTINCT ON (season, enemy) * + FROM h1_statistic + ORDER BY season, enemy, bucket DESC + ) latest + GROUP BY season + ORDER BY season ASC + `; + + // 5. Outcome-derivation inputs. + // All events (~30 per season × 157 seasons ≈ 5k rows) grouped by season + // in JS, then each season's slice is fed to getWarOutcome alongside its + // final faction states and a synthetic any-all-3-defeated snapshot flag. + const allEvents = await db.h1_event.findMany({ + select: { + season: true, + type: true, + status: true, + region: true, + enemy: true, + end_time: true, + start_time: true, + }, + }); + + const finalStates = await db.$queryRaw` + SELECT DISTINCT ON (season, enemy) season, enemy, status, points, points_taken + FROM h1_status + ORDER BY season, enemy, bucket DESC + `; + + const defeatedSeasonRows = await db.$queryRaw` + SELECT season FROM ( + SELECT season, bucket, count(*) FILTER (WHERE status = 'defeated') AS defeated_count + FROM h1_status + GROUP BY season, bucket + ) sub + WHERE defeated_count = 3 + GROUP BY season + `; + + // ── Group inputs by season for the per-season build. ──────────────── + const eventAggBySeason = new Map(eventAggs.map((r) => [r.season, r])); + const telemetryBySeason = new Map(telemetry.map((r) => [r.season, r])); + + const eventsBySeason = new Map(); + for (const e of allEvents) { + const arr = eventsBySeason.get(e.season); + if (arr) arr.push(e); + else eventsBySeason.set(e.season, [e]); + } + const statesBySeason = new Map(); + for (const s of finalStates) { + const arr = statesBySeason.get(s.season); + if (arr) arr.push(s); + else statesBySeason.set(s.season, [s]); + } + const allDefeatedSet = new Set(defeatedSeasonRows.map((r) => r.season)); + + // A one-entry snapshots array is enough to fire getWarOutcome's + // anySnapshotDefeated signal — we don't need to ship every snapshot. + const ALL_DEFEATED_SNAPSHOT = [ + { + data: [ + { status: 'defeated' }, + { status: 'defeated' }, + { status: 'defeated' }, + ], + }, + ]; + + const perSeason = seasons.map(({ season, season_duration }) => { + const agg = eventAggBySeason.get(season); + const tele = telemetryBySeason.get(season); + + const outcomeResult = getWarOutcome({ + status: statesBySeason.get(season) ?? [], + events: eventsBySeason.get(season) ?? [], + snapshots: allDefeatedSet.has(season) ? ALL_DEFEATED_SNAPSHOT : [], + }); + + return { + season, + season_duration: Number(season_duration) || 0, + events: agg?.events ?? 0, + defends: agg?.defends ?? 0, + defend_wins: agg?.defend_wins ?? 0, + attacks: agg?.attacks ?? 0, + attack_wins: agg?.attack_wins ?? 0, + avg_event_duration: + agg?.avg_event_duration != null ? Number(agg.avg_event_duration) : null, + outcome: outcomeResult?.outcome ?? 'unknown', + outcome_faction: outcomeResult?.faction ?? null, + // Telemetry — present only where the bot has polled live; future- + // proof for the deferred Friendly Fire / Accuracy / Shots-per- + // Planet components that will live on this page. + kills: tele?.kills ?? 0n, + deaths: tele?.deaths ?? 0n, + accidentals: tele?.accidentals ?? 0n, + shots: tele?.shots ?? 0n, + hits: tele?.hits ?? 0n, + missions: tele?.missions ?? 0, + successful_missions: tele?.successful_missions ?? 0, + total_mission_difficulty: tele?.total_mission_difficulty ?? 0, + completed_planets: tele?.completed_planets ?? 0, + }; + }); + + const factionTotals = factionTotalsRaw.map((r) => ({ + enemy: Number(r.enemy), + defends: Number(r.defends), + defend_wins: Number(r.defend_wins), + attacks: Number(r.attacks), + attack_wins: Number(r.attack_wins), + })); + + return { perSeason, factionTotals }; +}); diff --git a/src/features/stats/FactionThreatRanking.jsx b/src/features/stats/FactionThreatRanking.jsx new file mode 100644 index 00000000..c72206eb --- /dev/null +++ b/src/features/stats/FactionThreatRanking.jsx @@ -0,0 +1,92 @@ +'use client'; +import { + BarChart, + Bar, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + LabelList, +} from 'recharts'; + +// Faction colors mirror the existing FactionHealthChart palette so the +// /stats page reads as part of the same visual language as the live +// dashboard's per-faction views. +const FACTIONS = [ + { enemy: 0, name: 'Bugs', color: '#e8822a' }, + { enemy: 1, name: 'Cyborgs', color: '#8b2d2d' }, + { enemy: 2, name: 'Illuminate', color: '#7ec8e3' }, +]; + +/** + * Compute the overall HD win rate per faction across every war and sort + * ascending — most-threatening (lowest HD win rate) at the top of the + * ranking. Always returns three rows, zero-filled if a faction is absent + * from `factionTotals`. + */ +export function computeThreatData(factionTotals) { + const byEnemy = new Map((factionTotals ?? []).map((t) => [t.enemy, t])); + const rows = FACTIONS.map(({ enemy, name, color }) => { + const t = byEnemy.get(enemy); + const events = (t?.defends ?? 0) + (t?.attacks ?? 0); + const wins = (t?.defend_wins ?? 0) + (t?.attack_wins ?? 0); + const winRate = events > 0 ? Math.round((wins / events) * 100) : 0; + return { enemy, name, color, winRate, events, wins }; + }); + return rows.sort((a, b) => a.winRate - b.winRate); +} + +/** + * Faction Threat Ranking — one horizontal bar per faction, length = + * Helldivers' overall win rate against that faction across all wars. Bars + * are sorted ascending so the most-threatening faction is on top; each bar + * is painted in the faction's signature color. + */ +export default function FactionThreatRanking({ factionTotals }) { + if (!factionTotals) return null; + const data = computeThreatData(factionTotals); + + return ( + + + + + + [`${value}%`, 'HD win rate']} + /> + + {data.map((entry) => ( + + ))} + `${v}%`} + fill="var(--color-text)" + /> + + + + ); +} diff --git a/src/features/stats/SeasonRecords.jsx b/src/features/stats/SeasonRecords.jsx new file mode 100644 index 00000000..fd9513d6 --- /dev/null +++ b/src/features/stats/SeasonRecords.jsx @@ -0,0 +1,72 @@ +import { StatCard } from '@/features/stats/StatGrid'; +import { formatCompactDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; + +/** + * Find the row that owns the maximum of `getValue(row)` across `perSeason`. + * Returns `{ value, season }` or null when no row has a finite value. + */ +function findMax(perSeason, getValue) { + let best = null; + for (const row of perSeason) { + const v = getValue(row); + if (v == null || !Number.isFinite(v)) continue; + if (best == null || v > best.value) best = { value: v, season: row.season }; + } + return best; +} + +/** + * All-time superlatives across the war history — five extrema rendered as + * StatCards with the winning season as the subtitle. Event/campaign-derived + * so every season counts (telemetry-based records are deferred until the + * dataset is meaningful — see issue #394). + */ +export default function SeasonRecords({ perSeason }) { + if (!perSeason?.length) return null; + + const longestWar = findMax(perSeason, (r) => r.season_duration); + const mostEvents = findMax(perSeason, (r) => r.events); + const longestAvgBattle = findMax(perSeason, (r) => r.avg_event_duration); + const mostDefendsWon = findMax(perSeason, (r) => r.defend_wins); + const mostAttacksWon = findMax(perSeason, (r) => r.attack_wins); + + return ( +
      + {longestWar && ( + + )} + {mostEvents && ( + + )} + {longestAvgBattle && ( + + )} + {mostDefendsWon && ( + + )} + {mostAttacksWon && ( + + )} +
      + ); +} diff --git a/src/features/stats/WarOutcomes.jsx b/src/features/stats/WarOutcomes.jsx new file mode 100644 index 00000000..ef14a160 --- /dev/null +++ b/src/features/stats/WarOutcomes.jsx @@ -0,0 +1,115 @@ +import { StatCard } from '@/features/stats/StatGrid'; + +/** + * Walk `perSeason` (assumed season-ascending) and return the longest victory + * and defeat runs. Each run records its length and the [start, end] season + * range so the UI can attribute it back to the wars that own the streak. + * + * @param {Array<{season:number, outcome:string}>} perSeason - Season-ascending per-season rows + * @returns {{ longestWin: {length:number, start:number|null, end:number|null}, longestLoss: {length:number, start:number|null, end:number|null} }} + */ +function computeStreaks(perSeason) { + let longestWin = { length: 0, start: null, end: null }; + let longestLoss = { length: 0, start: null, end: null }; + let cur = { kind: null, length: 0, start: null, end: null }; + + const flush = () => { + if (cur.kind === 'victory' && cur.length > longestWin.length) { + longestWin = { length: cur.length, start: cur.start, end: cur.end }; + } else if (cur.kind === 'defeat' && cur.length > longestLoss.length) { + longestLoss = { length: cur.length, start: cur.start, end: cur.end }; + } + }; + + for (const row of perSeason) { + if (row.outcome === cur.kind) { + cur.length += 1; + cur.end = row.season; + } else { + flush(); + cur = { + kind: row.outcome, + length: 1, + start: row.season, + end: row.season, + }; + } + } + flush(); + + return { longestWin, longestLoss }; +} + +function streakSubtitle(streak) { + if (streak.start == null) return undefined; + return streak.start === streak.end ? + `Season ${streak.start}` + : `Seasons ${streak.start}–${streak.end}`; +} + +/** + * War Outcomes & Streaks — total wars, win/loss counts, win rate, longest + * win and loss streaks (with season range), and a wrapping timeline of + * outcome pills (one per season, faction-style success/danger color, neutral + * for `outcome: 'unknown'`). + */ +export default function WarOutcomes({ perSeason }) { + if (!perSeason?.length) return null; + + const total = perSeason.length; + const victories = perSeason.filter((r) => r.outcome === 'victory').length; + const defeats = perSeason.filter((r) => r.outcome === 'defeat').length; + const winRate = total > 0 ? Math.round((victories / total) * 100) : 0; + + const { longestWin, longestLoss } = computeStreaks(perSeason); + + return ( +
      +
      + + + + {longestWin.length > 0 && ( + + )} + {longestLoss.length > 0 && ( + + )} +
      +
      + {perSeason.map((row) => ( + + ))} +
      +
      + ); +} diff --git a/src/shared/components/BottomNav/BottomNav.jsx b/src/shared/components/BottomNav/BottomNav.jsx index a5d1a137..9feb43db 100644 --- a/src/shared/components/BottomNav/BottomNav.jsx +++ b/src/shared/components/BottomNav/BottomNav.jsx @@ -9,6 +9,7 @@ export default function BottomNav() { const tabs = [ { href: '/', label: 'Live', live: true, track: 'nav-live' }, { href: '/archives', label: 'Archives', icon: '◈', track: 'nav-archives' }, + { href: '/stats', label: 'Stats', icon: '▣', track: 'nav-stats' }, { href: '/docs', label: 'Docs', icon: '◇', track: 'nav-docs' }, ]; diff --git a/src/shared/components/Navigation/HeaderNav.jsx b/src/shared/components/Navigation/HeaderNav.jsx index a453b787..76194cf2 100644 --- a/src/shared/components/Navigation/HeaderNav.jsx +++ b/src/shared/components/Navigation/HeaderNav.jsx @@ -6,6 +6,7 @@ import StatusDot from '@/shared/components/StatusDot'; const tabs = [ { href: '/', label: 'Live', live: true, track: 'nav-live' }, { href: '/archives', label: 'Archives', track: 'nav-archives' }, + { href: '/stats', label: 'Stats', track: 'nav-stats' }, { href: '/docs', label: 'Docs', track: 'nav-docs' }, ]; From e2e29a8575974d46c8516fd5a1a241cb450e14b3 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Sat, 23 May 2026 01:05:08 +0200 Subject: [PATCH 12/94] fix(archives): match hero StatGrid's auto-fit layout for the extras row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ArchiveStats` (the archives-only extras grid that sits under the shared `StatGrid` hero) was hard-capped at `lg:grid-cols-3`, so its 4th and subsequent cards wrapped to a new row — visually inconsistent with the 6-across auto-fit grid right above it. - Swap both grid divs to use the existing `.stat-grid` class (`repeat(auto-fit, minmax(11rem, 1fr))`, same one `` uses). - Make the previously-implicit dependency on `StatGrid.css` explicit by importing it in `ArchiveStats.jsx` — the component now styles itself rather than relying on `` happening to render first. Both grids now breathe with the viewport identically; at typical desktop widths every extras card sits on one row. Tests + DevTools verified. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 ++ src/features/archives/ArchiveStats.jsx | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f090d351..0f89d6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- **Archives extras grid now matches the homepage hero's auto-fit layout.** `ArchiveStats` (the per-faction / per-war extras grid on `/archives`) was hard-capped at `lg:grid-cols-3`, so its 4th and subsequent cards wrapped to a new row below the hero's wider auto-fit grid — visually inconsistent with the 6-across `StatGrid` directly above it. Switched to the same `.stat-grid` class (`repeat(auto-fit, minmax(11rem, 1fr))`), and made the previously-implicit CSS dependency on `StatGrid.css` explicit. Both grids now breathe with the viewport identically; at typical desktop widths every extras card sits on one row. + - **New `/stats` page surfaces cross-season analytics across every Helldivers war (#394).** The other half of the Phase A split (Part 2 = #391, shipped in 0.48.0). A new top-level route reads the full 157-season history via a single `getCrossSeasonStats()` query — SQL `GROUP BY` aggregates over `h1_event` / `h1_status` / `h1_season` / `h1_statistic`, plus a per-season war-outcome derivation that reuses `getWarOutcome`'s algorithm on a slim per-season slice (final faction states + relevant events + a synthetic any-all-3-defeated snapshot flag). Three components ship: **Faction Threat Ranking** — per-faction overall HD win rates as a faction-colored horizontal bar chart, sorted ascending so the most-threatening enemy reads first; **War Outcomes & Streaks** — total wars, victories, defeats, win rate, longest win/loss streaks with season ranges, plus a wrapping per-season outcome timeline; **All-Time Records** — longest war, most events, longest avg battle, most defends/attacks won, each card attributed to the season that owns the extremum. The three telemetry charts originally listed in #178 (Friendly Fire Index, Accuracy Trend, Shots per Planet) are deferred until telemetry accumulates beyond season 157 — the query already returns telemetry fields so the charts drop in cleanly later. `HeaderNav` and `BottomNav` gain a `Stats` entry. ## 0.48.0 diff --git a/src/features/archives/ArchiveStats.jsx b/src/features/archives/ArchiveStats.jsx index d9dfeefe..e99ddde0 100644 --- a/src/features/archives/ArchiveStats.jsx +++ b/src/features/archives/ArchiveStats.jsx @@ -1,4 +1,5 @@ import { StatCard } from '@/features/stats/StatGrid'; +import '@/features/stats/StatGrid.css'; import { formatCompactDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; import { formatRatio } from '@/shared/utils/format/formatRatio.mjs'; import { getWarOutcome } from '@/features/archives/getWarOutcome.mjs'; @@ -95,7 +96,7 @@ export default function ArchiveStats({ faction, events, data, live, glitchPhase ); return ( -
      +
      r.enemy === factionIndex); return ( -
      +
      {rateCards(factionEvents)} Date: Sat, 23 May 2026 01:13:24 +0200 Subject: [PATCH 13/94] docs: add Ministry Interference sitewide easter egg design Brainstormed design for replacing the archives-only Cyberstan interference with a sitewide opt-in system: rare per-element hijack every 2-5 min plus always-on ambient micro-flicker, with tone derived from humanity's overall war record (winning -> sardonic mocking, losing -> regime/Skynet reassurance). Manual toggle removed in favor of prefers-reduced-motion only. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-23-ministry-interference-design.md | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-ministry-interference-design.md diff --git a/docs/superpowers/specs/2026-05-23-ministry-interference-design.md b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md new file mode 100644 index 00000000..cee67dfb --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md @@ -0,0 +1,323 @@ +# Ministry Interference — Sitewide Easter Egg + +**Status:** Design (approved in brainstorming) +**Author:** Andrei +**Date:** 2026-05-23 + +## Summary + +Replace the archives-only "Cyberstan interference" easter egg with a sitewide system: a single global controller surfaces a rare, in-universe propaganda hijack on a random opt-in element every 2-5 minutes, plus an always-on ambient micro-flicker every 15-30 seconds. The tone of the defacement is derived from humanity's overall war record — a "winning" record gets sardonic resistance-voice mocking, a "losing" record gets calm Big Brother / Skynet reassurance from the regime. The current per-page mechanism, manual opt-out, and continuous loop on defeat archives are retired in favor of the unified sitewide system. + +## Goals + +- Extend the easter egg from `/archives` only to every page of the site. +- Surface the effect rarely and subtly — never disrupts the actual task the user is on. +- Make the tone of the propaganda contextual to humanity's overall war record so the joke works in both directions (winning and losing). +- Reuse the existing `GlitchText` rendering machinery rather than reinventing it. +- Keep zero risk of hydration mismatch, broken layouts, or runtime errors that affect the surrounding page. + +## Non-goals + +- Per-season, per-faction, or per-page nuanced tone selection beyond the binary `winning` / `losing` signal. +- A manual user-facing toggle. Opt-out is via `prefers-reduced-motion` only. +- Tracking which strings the user has already seen to avoid repetition. +- Real-time updates to the war-tone signal during a session. Tone is computed server-side per request. +- A CMS or admin UI for managing the propaganda copy. Content ships in code. + +## User-visible behavior + +### Rare hijack + +Every 2-5 minutes of active page time, exactly one opt-in element on the current page is selected at random. That element runs a single glitch cycle (takeover → hold → restore, ~2.6 seconds total) where its visible text is replaced with an in-universe propaganda line, then restored to its original "truth" text. No other element on the page changes during the hijack. + +### Always-on ambient micro-flicker + +Every 15-30 seconds, one random character of one random registered element flickers to a single Cyberstan glyph for 150-300ms then restores. So tiny most users will not consciously register it; gives the page a continuous, low-grade sense of unease without ever taking over. + +### Tone of the defacement + +Computed server-side from completed-season win/loss records: + +- **`winning`** (humanity has won ≥ 50% of completed wars all-time) → resistance/hacker voice that mocks the regime's victory framing and reframes the player's pyrrhic wins as defeats. Example header swap: `"Live Statistics"` → `"Pyrrhic Statistics"`. Example OUTCOME flip on a won season: `"VICTORY"` → `"DEFEAT"`. +- **`losing`** (otherwise) → the regime/Machine voice that drowns dissent in saccharine Big Brother propaganda. Example header swap: `"Live Statistics"` → `"Sanctioned Truth"`. Existing `RESISTANCE_MESSAGES` body copy fits here. + +### Accessibility + +- `prefers-reduced-motion: reduce` disables both the hijack scheduler and the ambient flicker entirely — neither timer is created. Toggled live via a `matchMedia` `change` listener; no reload needed. +- Each `Hijackable` wrapper carries `aria-label={text}` so screen readers always announce the truth text and never the propaganda. +- No focus stealing, no overlays, no input blocking. Hijacks are pure visual character swaps in place. + +## Architecture + +### File layout + +New feature folder `src/features/ministry/`: + +| File | Role | +|---|---| +| `MinistryProvider.jsx` | Root-level React context provider. Owns the registry, the schedulers, the war-tone signal, and the `prefers-reduced-motion` + `visibilitychange` listeners. | +| `Hijackable.jsx` | Opt-in wrapper component. Renders as a plain `` (or configurable tag) on initial render; registers with the provider on mount; runs a one-shot glitch cycle when picked. | +| `AmbientFlicker.jsx` | Internal child of the provider. Drives the always-on micro-flicker timer independently from the hijack timer. | +| `useMinistryRegistry.mjs` | Internal hook used by `Hijackable` to register/unregister and subscribe to "you're picked" callbacks. | +| `ministryContent.mjs` | Static content library: 12 pools (6 categories × 2 tones). Exports `MINISTRY_CONTENT` and `pickAlt(category, tone, rng)`. | +| `warTone.mjs` | Server-only helper. Reads completed-season outcomes from Prisma and returns `'winning' | 'losing'`. | + +Mounted once in `src/app/layout.jsx`: + +```jsx + + {children} + +``` + +### Component contracts + +#### `` + +Single prop: `warTone`, computed server-side per request. + +Internal context (consumed only by `Hijackable` and `AmbientFlicker`): + +```js +{ + register(id, descriptor), + unregister(id), + subscribe(id, callback), // for hijack notifications + subscribeFlicker(id, callback),// for single-char flicker + warTone, +} +``` + +`descriptor` shape: + +```js +{ + text: string, // the truth (required) + altText?: string, // explicit override; otherwise content pool is used + category: 'heading' | 'value' | 'nav' | 'button' | 'body' | 'footer', + scope: 'global' | 'archives', // default 'global' +} +``` + +#### `` + +Default render: a plain `{text}` with `aria-label={text}`. No visible effect, no listeners attached beyond registration. + +Props: + +| Prop | Default | Purpose | +|---|---|---| +| `text` | (required) | The truth text to display normally. | +| `altText` | `undefined` | Explicit override for the propaganda string; falls back to `pickAlt(category, tone, rng)`. | +| `category` | `'body'` | Selects which content pool the provider draws from. | +| `scope` | `'global'` | Restricts visibility to certain pages (`'archives'` shows only on archive pages). | +| `className` | `undefined` | Passed through to internal `GlitchText`. | +| `altClassName` | `undefined` | Passed through to internal `GlitchText` for alt-styled characters. | +| `as` | `'span'` | Wrapper tag — set to `'h1'`, `'p'`, etc. when the wrapper itself is the heading/paragraph. | + +Internally: generates a stable id with `useId()`, registers/unregisters in `useEffect`, holds local `useState` for phase (`'idle' | 'takeover' | 'hold' | 'restore'`), and reuses the existing `GlitchText` for the per-character animation. + +#### `AmbientFlicker` + +No props. Mounted once inside `MinistryProvider`. Owns the 15-30s timer. Picks one random registered descriptor, picks one random non-space char index, calls `subscribeFlicker(id)(charIndex, durationMs)`. Reschedules on completion. + +### Scheduler + +Both timers live in `MinistryProvider`, both use `setTimeout` (never `setInterval`). + +**Hijack timer:** + +1. Wait `random(2 min, 5 min)`. +2. Filter registry by current-page scope (archives includes `global` + `archives`, all other pages include only `global`). +3. Pick one descriptor uniformly at random. +4. Resolve altText: prefer descriptor's explicit `altText`, else `pickAlt(descriptor.category, warTone, rng)`. +5. Set `isHijackActive = true`. Call subscriber. After the known cycle duration (~2.6s), set `isHijackActive = false` and reschedule. + +**Ambient flicker timer:** + +1. Wait `random(15s, 30s)`. +2. If `isHijackActive`, reschedule without firing. +3. Else, pick a random descriptor + char index + duration, call flicker subscriber, reschedule. + +**Lifecycle:** + +- Both timers start on provider mount. +- Both timers pause on `document.visibilityState === 'hidden'` and resume on visible. +- Both timers never start if `prefers-reduced-motion: reduce` is active. A live `change` listener starts/stops them when the OS setting flips. +- Both timers are torn down on provider unmount. +- The current-page scope is detected via `usePathname()` and updated on navigation. Pages under `/archives` (matched via `pathname.startsWith('/archives')`) include both `global` and `archives`-scoped descriptors; everywhere else only `global` is eligible. + +### Content library + +`ministryContent.mjs` exports: + +```js +export const MINISTRY_CONTENT = { + winning: { heading: [...], value: [...], nav: [...], button: [...], body: [...], footer: [...] }, + losing: { heading: [...], value: [...], nav: [...], button: [...], body: [...], footer: [...] }, +}; + +export function pickAlt(category, tone, rng) { /* returns string | undefined */ } +``` + +Approximately 6-10 entries per pool, ~80-120 strings total at launch. Authoring rules: + +- In-universe Helldivers-franchise voice only — no real-world political content. +- Profanity-free; matches the franchise's dark-comedy military-propaganda tone. +- No string interpolation of user/session data — all pool entries are static. +- For the `value` category, entries are kept roughly the same character length as common stat values to avoid layout shift in fixed-width cards. + +The existing `RESISTANCE_MESSAGES` array from `src/features/archives/resistanceMessages.mjs` is migrated into `MINISTRY_CONTENT.losing.body` and that file is deleted afterward. `PROPAGANDA_BODY` (the page's normal description text) is left in place — it is the page's *truth*, not propaganda. + +### War tone helper + +`warTone.mjs` server-side: + +```js +export async function getWarTone() { + // Reads all h1_season rows via existing Prisma query. + // A "completed" war is one whose season number < currentSeason + // (i.e., the war has ended and a new one has started). + // Counts completed wars won vs total completed wars using the existing + // getWarOutcome() helper (which already classifies victory/defeat/unknown + // from the season's snapshots). + // Returns 'winning' if wonCount / completedCount >= 0.5, else 'losing'. + // On any DB error or zero completed wars, returns 'losing' (the + // in-universe-believable fallback). +} +``` + +Called once per page render in `src/app/layout.jsx`. Result is passed as a prop to `MinistryProvider`. No caching beyond Next.js's request-level memoization; the cost is one cheap aggregate query per page load. + +## Data flow + +``` +app/layout.jsx (server) + │ + ├── await getWarTone() ─────► 'winning' | 'losing' + │ + └── + │ + ├── context: { register, unregister, subscribe, subscribeFlicker, warTone } + │ + ├── ── 15-30s tick ──► subscribeFlicker(randomId)(charIdx, dur) + │ + ├── (children: the app tree) + │ │ + │ └── + │ │ + │ ├── useEffect mount ─► register(id, descriptor) + │ ├── useEffect unmount ─► unregister(id) + │ └── subscribed callback fires ─► local phase state transitions + │ through takeover → hold → restore + │ rendered via internal GlitchText + │ + └── hijack scheduler ── 2-5min tick ──► pick descriptor → resolve altText → subscribe(id)(altText) +``` + +## Adoption: which elements get wrapped + +The wrapping is a one-time, mechanical chore. The bar for "wrap this element?" is *"would seeing this element glitch be a recognizable moment?"* — favor inclusion. Initial pass covers: + +- All `

      ` and `

      ` headings across `src/app/**/page.jsx` and major feature components. +- Stat card labels and values inside `StatGrid`, `ArchiveStats`, dashboard cards. +- Top-nav link labels (`HeaderNav` items). +- Footer text in `Footer.jsx`. +- Archives header h1 + body, archives OUTCOME card (these already use `GlitchText` today; they migrate to `Hijackable`). + +Page-hero headings get explicit `altText` props for memorable, page-specific swaps. Generic h2s and body text rely on the content pool. + +## Error handling + +- All scheduler ticks wrapped in the project's `tryCatch` wrapper — a thrown error is swallowed and the next tick is rescheduled. The page must never break because of the easter egg. +- `pickAlt` returning `undefined` (empty pool, missing category) causes the hijack to reschedule without firing. +- `Hijackable` unmounted between "picked" and "fire" → the unregister already cleared the subscriber callback, so the provider's call is a no-op. +- `getWarTone()` throwing server-side → falls back to `'losing'`, no rethrow, page render unaffected. +- No `try`/`catch` blocks elsewhere; the rest of the code paths are pure or already covered by React's render error boundaries. + +## Performance + +- Registry is a `Map`. Register/unregister O(1). Random pick O(n) but n is small (<50 typical). +- `Hijackable` in idle is just `{text}` — no listeners, no extra DOM. +- Context value is `useMemo`'d so downstream re-renders are not triggered by provider state changes. +- All randomness happens in `useEffect` callbacks — no work during render, no hydration concerns. +- Tab-hidden pauses both timers. No background-tab cost. +- Estimated bundle impact: 6-10KB minified (content strings dominate; logic is small). +- Compatible with the project's React Compiler — no manual memoization beyond the context value. + +## Removed / changed files + +| File | Action | +|---|---| +| `src/features/archives/useCyberstanEffects.mjs` | Deleted. Replaced by the global system. | +| `src/features/archives/useGlitchCycle.mjs` | Deleted. The one-shot cycle is folded into `Hijackable`. | +| `src/features/archives/resistanceMessages.mjs` | Deleted after migration. `RESISTANCE_MESSAGES` → `MINISTRY_CONTENT.losing.body`. `PROPAGANDA_BODY` stays inline in `ArchivesHeader.jsx` since it is normal-mode copy. | +| `src/features/archives/CyberstanInterference.css` | Reduced. Keep `.glitch-char`. Remove `.cyberstan-defeat`, `::before` watermark, `.cyberstan-watermark-active`. | +| `src/features/archives/ArchivesHeader.jsx` | `EffectsToggle` export removed. Header still uses `GlitchText` via `Hijackable` wrappers. `onPhaseChange` plumbing removed (provider owns the phase now). | +| `src/features/archives/ArchivesClient.jsx` | `EffectsToggle` usage removed. `useCyberstanEffects` import removed. `cyberstan-defeat`/`cyberstan-watermark-active` classes removed. Synced `glitchPhase` state removed. `useCallback` `handlePhaseChange` removed. The `getWarOutcome` import + `isDefeat` derivation stays — still used by `ArchiveStats` for the OUTCOME card's value/color logic. | +| `src/features/archives/ArchiveStats.jsx` | `GlitchText` swapped for `Hijackable` on the OUTCOME card. `glitchPhase` prop removed. | +| `localStorage` key `cyberstan-effects-disabled` | Left orphaned in existing users' browsers — harmless, not worth migration code. | + +`src/features/archives/GlitchText.jsx` stays unchanged and is reused by `Hijackable` internally. + +## Testing strategy + +### Unit tests (Vitest) — `src/__tests__/unit/features/ministry/` + +1. **`ministryContent.test.mjs`** + - `pickAlt(category, tone, rng)` returns expected pool entries with injected RNG. + - Exhaustive across all 12 pools — each returns a non-empty string. + - Unknown category returns `undefined`. + +2. **`warTone.test.mjs`** + - Empty seasons → `'losing'`. + - ≥ 50% wins → `'winning'`. + - < 50% wins → `'losing'`. + - Counts only completed wars; current/in-progress war excluded. + - DB throw → `'losing'`, no re-throw. + +3. **`MinistryProvider.test.jsx`** — `vi.useFakeTimers()` + injected RNG: + - Register/unregister via context works. + - Hijack tick picks a registered descriptor and calls its subscriber with the resolved altText. + - Ambient flicker skips its tick while `isHijackActive`. + - `visibilitychange` pause/resume. + - `prefers-reduced-motion: reduce` → no timers ever scheduled. + - `scope: 'archives'` descriptors excluded outside `/archives`. + - Empty registry → hijack tick reschedules without throwing. + +4. **`Hijackable.test.jsx`** + - Initial render is plain `{text}` with `aria-label={text}` — no glitch classes. + - Mount registers; unmount unregisters. + - Subscriber callback drives the cycle through takeover → hold → restore (driven via fake timers). + - Flicker subscriber callback flips one char to `.glitch-char` for duration then restores. + - `as` prop changes the rendered tag. + +### Integration test (Playwright) + +`src/__tests__/e2e/ministry-easter-egg.spec.mjs` — one narrow test. Load `/archives` for a known season. Use a test-only debug hook `window.__ministry_test__.hijack(id)` (exposed only when `process.env.NODE_ENV !== 'production'`) to fire a hijack immediately on the archives header. Assert text swaps to expected altText and restores within ~3s. + +### Tests removed + +- Any existing tests for `useGlitchCycle`, `useCyberstanEffects` are deleted alongside their source files. +- `ArchivesHeader`/`ArchivesClient` tests that asserted on `EffectsToggle` or `cyberstan-defeat` are updated. +- Imports of `RESISTANCE_MESSAGES` in tests are repointed to `MINISTRY_CONTENT.losing.body` or removed entirely. + +### Manual QA checklist (PR review only, not automated) + +- `prefers-reduced-motion: reduce` on → no glitch ever fires on any page. +- `/archives` for a won season → over a 5-min wait, OUTCOME can flip to `DEFEAT`, header can swap. (Previously only happened on lost seasons.) +- `/archives` for a lost season → existing Skynet/Big Brother vibe still surfaces. +- Dashboard and generic pages over a few minutes → ambient char-flicker is visible if you watch for it; no hijack feels harshly jarring. +- Tab-out 30s, tab-in → no flurry of hijacks. +- Screen reader pass on `/archives` during a hijack → only the truth text is announced. + +## Risks accepted + +- **First-hijack timing is unpredictable.** A user might see one within 2 minutes; another might browse for 10 minutes and see nothing. This is the desired feel of "rare." +- **Content can feel stale within a session** if the same string appears twice. Acceptable for v1. +- **`warTone` is binary, all-time.** No per-faction or recent-window nuance. Future refinement is a one-helper change. +- **Wrapping is mechanical and easy to miss on new pages.** Missing the wrap means the easter egg doesn't fire on that element — not a bug, just a missed opportunity. Acceptable. + +## Open questions + +None at design time. All open scope questions were answered in brainstorming. From 3236dcc5b6a90eb848a63f5b629bd6f960128d0e Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Sat, 23 May 2026 01:38:29 +0200 Subject: [PATCH 14/94] docs: revise Ministry Interference spec after adversarial review Applies 11 consensus revisions from a 3-round adversarial AI debate (Gemini, Codex, Sonnet, Opus). Architecturally significant changes: - MinistryProvider mounts inside LiveDataProvider, shares its visibility signal and reload-cancel signal (no duplicate listeners, no orphaned timers during guardedReload). - Registry moved from React state to useRef-backed store with stable context callbacks; eliminates re-render storms on navigation when 30+ wrappers exist on a page. - Hijackable: aria-label removed (unreliable on span without role); alt characters now rendered aria-hidden; nav/button/link categories banned (WCAG 2.5.3 compliance). - Scheduler: scope eligibility evaluated at pick-time against pathnameRef (eliminates 16ms stale-registration race); per-element idle check on ambient flicker (prevents double-restore corruption). - One authoritative state machine in useMinistryHijackCycle.mjs (replaces deleted useGlitchCycle); fight phase explicitly dropped; CYCLE_MS=2600 pinned as shared constant. - War tone: returns 'winning' | 'losing' | null; null disables the effect (no more silent forced-losing on DB errors); uses getWarOutcome for "completed war" classification; requires layout.jsx dynamic='force-dynamic' for cache freshness. - v1 adoption whitelist sharply cut to headings + decorative body + archives migration; deferred nav/buttons/footer/stat-values until layout-shift is measured. - Content pools: minimum 12 entries per pool with Vitest enforcement; category enum narrowed to heading/value/body/footer only. One open question remains for the author: tone-direction mapping (losing -> Ministry doubling-down vs. resistance-mocks-regime). Debate transcript at ~/.claude-octopus/debates/debates/001-ministry-interference-spec-critique/. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-23-ministry-interference-design.md | 291 ++++++++++++------ 1 file changed, 189 insertions(+), 102 deletions(-) diff --git a/docs/superpowers/specs/2026-05-23-ministry-interference-design.md b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md index cee67dfb..52bafe78 100644 --- a/docs/superpowers/specs/2026-05-23-ministry-interference-design.md +++ b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md @@ -1,8 +1,8 @@ # Ministry Interference — Sitewide Easter Egg -**Status:** Design (approved in brainstorming) +**Status:** Design (revised after adversarial multi-AI critique; awaiting author decision on Open Question #1) **Author:** Andrei -**Date:** 2026-05-23 +**Date:** 2026-05-23 (revised same day) ## Summary @@ -10,11 +10,12 @@ Replace the archives-only "Cyberstan interference" easter egg with a sitewide sy ## Goals -- Extend the easter egg from `/archives` only to every page of the site. +- Extend the easter egg from `/archives` only to every page of the site that has hijackable text — starting with a narrow whitelist and expanding only after layout-stability is measured. - Surface the effect rarely and subtly — never disrupts the actual task the user is on. - Make the tone of the propaganda contextual to humanity's overall war record so the joke works in both directions (winning and losing). - Reuse the existing `GlitchText` rendering machinery rather than reinventing it. -- Keep zero risk of hydration mismatch, broken layouts, or runtime errors that affect the surrounding page. +- Zero hydration mismatches. No runtime errors that affect the surrounding page. (Layout stability is *measured*, not assumed — the v1 whitelist is chosen to minimize CLS risk; broader rollout follows real-world measurement.) +- Never expose visible / accessible-name divergence on interactive controls (WCAG 2.5.3). ## Non-goals @@ -28,7 +29,9 @@ Replace the archives-only "Cyberstan interference" easter egg with a sitewide sy ### Rare hijack -Every 2-5 minutes of active page time, exactly one opt-in element on the current page is selected at random. That element runs a single glitch cycle (takeover → hold → restore, ~2.6 seconds total) where its visible text is replaced with an in-universe propaganda line, then restored to its original "truth" text. No other element on the page changes during the hijack. +Every 2-5 minutes of active page time, exactly one opt-in element on the current page is selected at random. That element runs a single glitch cycle (`takeover` 800ms → `hold` 1000ms → `restore` 800ms = **2600ms total**) where its visible text is replaced with an in-universe propaganda line, then restored to its original "truth" text. No other element on the page changes during the hijack. + +**The `fight` phase from the existing `useGlitchCycle.mjs` is dropped.** The continuous loop's chaotic-ticker phase makes sense when the cycle repeats endlessly; for a one-shot hijack, the cleaner takeover→hold→restore arc is more readable. Cycle duration is exported as a single shared constant from the new `useMinistryHijackCycle` hook (see Architecture) so the scheduler, the test suite, and the component never disagree on it. ### Always-on ambient micro-flicker @@ -44,7 +47,8 @@ Computed server-side from completed-season win/loss records: ### Accessibility - `prefers-reduced-motion: reduce` disables both the hijack scheduler and the ambient flicker entirely — neither timer is created. Toggled live via a `matchMedia` `change` listener; no reload needed. -- Each `Hijackable` wrapper carries `aria-label={text}` so screen readers always announce the truth text and never the propaganda. +- **`` is restricted to non-interactive, non-navigational text.** The category enum is `'heading' | 'value' | 'body' | 'footer'` only — `nav`, `button`, and `link` are explicitly banned. Wrapping interactive controls would create visible/accessible-name divergence (WCAG 2.5.3 violation) and unreliable announcement behavior across AT/browser combinations. +- During a hijack, the wrapper element keeps the truth text as its primary text content. Propaganda characters rendered by `GlitchText` are wrapped in `aria-hidden="true"` spans so assistive tech is never exposed to the altered text. We do NOT rely on `aria-label` on the wrapper — `aria-label` on `` without a `role` is ignored by NVDA in browse mode and VoiceOver's virtual cursor still navigates into child spans. - No focus stealing, no overlays, no input blocking. Hijacks are pure visual character swaps in place. ## Architecture @@ -55,53 +59,68 @@ New feature folder `src/features/ministry/`: | File | Role | |---|---| -| `MinistryProvider.jsx` | Root-level React context provider. Owns the registry, the schedulers, the war-tone signal, and the `prefers-reduced-motion` + `visibilitychange` listeners. | -| `Hijackable.jsx` | Opt-in wrapper component. Renders as a plain `` (or configurable tag) on initial render; registers with the provider on mount; runs a one-shot glitch cycle when picked. | -| `AmbientFlicker.jsx` | Internal child of the provider. Drives the always-on micro-flicker timer independently from the hijack timer. | -| `useMinistryRegistry.mjs` | Internal hook used by `Hijackable` to register/unregister and subscribe to "you're picked" callbacks. | -| `ministryContent.mjs` | Static content library: 12 pools (6 categories × 2 tones). Exports `MINISTRY_CONTENT` and `pickAlt(category, tone, rng)`. | -| `warTone.mjs` | Server-only helper. Reads completed-season outcomes from Prisma and returns `'winning' | 'losing'`. | +| `MinistryProvider.jsx` | Provider component mounted **below** the existing `LiveDataProvider` in `src/app/layout.jsx`. Owns the schedulers and the war-tone signal. Subscribes to (does not re-register) `prefers-reduced-motion` and tab-visibility — visibility specifically is shared from `LiveDataProvider`'s existing listener via context rather than registering a second listener. | +| `Hijackable.jsx` | Opt-in wrapper component. Initial render: a wrapper element (tag chosen via `as`, default `span`) containing the truth text as primary text content. No `aria-label`. When picked, runs a one-shot glitch cycle via the shared `useMinistryHijackCycle` hook. Alt characters are rendered inside `aria-hidden="true"` spans. | +| `AmbientFlicker.jsx` | Internal child of the provider. Drives the always-on micro-flicker timer independently from the hijack timer. Per-element idle check at fire-time. | +| `useMinistryHijackCycle.mjs` | **Single authoritative state machine.** Exports the cycle constants (`TAKEOVER_MS=800`, `HOLD_MS=1000`, `RESTORE_MS=800`, `CYCLE_MS=2600`) and the hook that consumers (Hijackable, tests) use to drive the takeover→hold→restore transition. Replaces — does NOT duplicate — the deleted `useGlitchCycle.mjs`. | +| `ministryRegistry.mjs` | Module-level `useRef`-backed store (a `Map`) plus subscribe/unsubscribe API. Notifies hijack/flicker targets via direct callback invocation, not via React context updates. Backed by `useSyncExternalStore` for any consumers that need to read registry state during render. | +| `ministryContent.mjs` | Static content library: 8 pools (4 categories × 2 tones). Exports `MINISTRY_CONTENT` and `pickAlt(category, tone, rng)`. Categories: `heading`, `value`, `body`, `footer`. | +| `warTone.mjs` | Server-only helper. Reads completed-season outcomes from Prisma and returns `'winning' | 'losing' | null` (null = disable effect entirely). | + +Mounted **inside** `LiveDataProvider` in `src/app/layout.jsx`: + +```jsx + + + {children} + + +``` -Mounted once in `src/app/layout.jsx`: +This nesting matters: `MinistryProvider` reads tab-visibility from `LiveDataProvider`'s existing context (no second `visibilitychange` listener), and subscribes to `LiveDataProvider`'s app-version-mismatch reload signal so it can cancel all in-flight timers before `guardedReload()` fires. + +Also required in `src/app/layout.jsx`: ```jsx - - {children} - +export const dynamic = 'force-dynamic'; ``` +Without this, a CDN-cached render could bake a stale `warTone` into the HTML — persisting across multiple sessions until the cache invalidates. If `dynamic = 'force-dynamic'` is unacceptable for performance reasons, the staleness must be explicitly documented in "Risks accepted" and the team must accept that the easter egg's tone may be wrong for hours/days after a war flips. + ### Component contracts -#### `` +#### `` -Single prop: `warTone`, computed server-side per request. +Single prop: `warTone: 'winning' | 'losing' | null`, computed server-side per request. `null` disables the effect entirely — no timers scheduled, no registrations accepted, no rendering changes. -Internal context (consumed only by `Hijackable` and `AmbientFlicker`): +Context value (consumed only by `Hijackable` and `AmbientFlicker`): ```js { - register(id, descriptor), - unregister(id), - subscribe(id, callback), // for hijack notifications - subscribeFlicker(id, callback),// for single-char flicker - warTone, + register(id, descriptor), // forwards to module-level registry ref + unregister(id), // forwards to module-level registry ref + subscribe(id, callback), // hijack notification callback + subscribeFlicker(id, callback), // ambient flicker callback (per-element) + warTone, // 'winning' | 'losing' | null } ``` +**The context value is a stable object.** All five callbacks are created once on mount via `useCallback` (or once at module load); none are recreated on registry changes. The `Map` storing registrations lives in a `useRef`, not in React state — so a `` mounting or unmounting does NOT invalidate the context value. Subscribers see `register`/`unregister` as identity-stable functions; mount/unmount cascades do not propagate as React re-renders. (This is the core stability decision after the adversarial spec review — Map mutation cannot be allowed to drive context-value invalidation, or every `Hijackable` re-renders on every navigation.) + `descriptor` shape: ```js { - text: string, // the truth (required) - altText?: string, // explicit override; otherwise content pool is used - category: 'heading' | 'value' | 'nav' | 'button' | 'body' | 'footer', - scope: 'global' | 'archives', // default 'global' + text: string, // the truth (required) + altText?: string, // explicit override; otherwise content pool is used + category: 'heading' | 'value' | 'body' | 'footer', // nav/button/link banned (accessibility) + scope: 'global' | 'archives', // default 'global' } ``` -#### `` +#### `` -Default render: a plain `{text}` with `aria-label={text}`. No visible effect, no listeners attached beyond registration. +Default render: a wrapper element (tag chosen via `as`, default `span`) containing the truth text as direct text content. No `aria-label`. No glitch classes. No listeners attached beyond registration. Props: @@ -109,13 +128,17 @@ Props: |---|---|---| | `text` | (required) | The truth text to display normally. | | `altText` | `undefined` | Explicit override for the propaganda string; falls back to `pickAlt(category, tone, rng)`. | -| `category` | `'body'` | Selects which content pool the provider draws from. | -| `scope` | `'global'` | Restricts visibility to certain pages (`'archives'` shows only on archive pages). | +| `category` | `'body'` | One of `'heading' \| 'value' \| 'body' \| 'footer'`. `nav`, `button`, `link` are banned (see Accessibility). | +| `scope` | `'global'` | `'archives'` registers only when the current path matches `/archives`. | | `className` | `undefined` | Passed through to internal `GlitchText`. | | `altClassName` | `undefined` | Passed through to internal `GlitchText` for alt-styled characters. | -| `as` | `'span'` | Wrapper tag — set to `'h1'`, `'p'`, etc. when the wrapper itself is the heading/paragraph. | +| `as` | `'span'` | Wrapper tag. Use `'h1'`/`'h2'`/`'p'` when the wrapper IS the semantic element. | + +**Wrapping pattern — use ONE consistent form across the codebase.** When wrapping a heading or paragraph, use `as` to make `` itself the semantic element: ``. Never nest `` inside an existing semantic element (`

      ` is banned by convention) — the two patterns have different accessibility behavior and mixing them creates a maintenance trap. + +Internally: generates a stable id with `useId()`, registers/unregisters in `useEffect`, holds local `useState` for phase (`'idle' | 'takeover' | 'hold' | 'restore'`) via the shared `useMinistryHijackCycle` hook, and reuses the existing `GlitchText` for the per-character animation. The component sets a flag (`isIdle: boolean`) on its registry entry whenever its phase changes, so the ambient flicker timer can skip elements that are mid-hijack (see Scheduler). -Internally: generates a stable id with `useId()`, registers/unregisters in `useEffect`, holds local `useState` for phase (`'idle' | 'takeover' | 'hold' | 'restore'`), and reuses the existing `GlitchText` for the per-character animation. +During hijack, alt-styled characters from `GlitchText` are wrapped in `aria-hidden="true"` spans. The wrapper element's primary text content (which assistive tech announces) continues to read the truth. #### `AmbientFlicker` @@ -123,29 +146,32 @@ No props. Mounted once inside `MinistryProvider`. Owns the 15-30s timer. Picks o ### Scheduler -Both timers live in `MinistryProvider`, both use `setTimeout` (never `setInterval`). +Both timers live in `MinistryProvider`, both use `setTimeout` (never `setInterval`). Both no-op when `warTone === null` (effect disabled). + +A `pathnameRef = useRef(pathname)` is maintained — `usePathname()` is read once and the ref is updated in a `useEffect` that runs on every pathname change. **Scope eligibility is evaluated against `pathnameRef.current` at pick-time**, NOT via a render-gated filter dependency. This eliminates the ~16ms stale-registration race that would otherwise let an `archives`-scoped descriptor from the previous page be eligible on the next. **Hijack timer:** 1. Wait `random(2 min, 5 min)`. -2. Filter registry by current-page scope (archives includes `global` + `archives`, all other pages include only `global`). -3. Pick one descriptor uniformly at random. -4. Resolve altText: prefer descriptor's explicit `altText`, else `pickAlt(descriptor.category, warTone, rng)`. -5. Set `isHijackActive = true`. Call subscriber. After the known cycle duration (~2.6s), set `isHijackActive = false` and reschedule. +2. Read `pathnameRef.current`. Filter registry: pages under `/archives` (matched via `pathname.startsWith('/archives')`) include both `global` and `archives`-scoped descriptors; everywhere else only `global` is eligible. +3. Pick one descriptor uniformly at random from the filtered set. Empty filtered set → no-op, reschedule. +4. Resolve altText: prefer descriptor's explicit `altText`, else `pickAlt(descriptor.category, warTone, rng)`. `undefined` result → no-op, reschedule. +5. Mark the target descriptor `isIdle = false` in the registry. Call subscriber. After exactly `CYCLE_MS` (2600ms from `useMinistryHijackCycle`), mark `isIdle = true` and reschedule. **Ambient flicker timer:** 1. Wait `random(15s, 30s)`. -2. If `isHijackActive`, reschedule without firing. -3. Else, pick a random descriptor + char index + duration, call flicker subscriber, reschedule. +2. Filter registry to descriptors where `isIdle === true` AND scope-eligible against `pathnameRef.current`. (Per-element idle check, not a global `isHijackActive` flag — prevents the "double-restore collision" where a flicker mid-hold could write a propaganda character into the restored truth.) +3. If empty, reschedule without firing. +4. Else, pick a random descriptor + char index + duration `random(150ms, 300ms)`. Call its flicker subscriber. Reschedule. **Lifecycle:** -- Both timers start on provider mount. -- Both timers pause on `document.visibilityState === 'hidden'` and resume on visible. -- Both timers never start if `prefers-reduced-motion: reduce` is active. A live `change` listener starts/stops them when the OS setting flips. +- Both timers start on provider mount, only when `warTone !== null`. +- Both timers pause on tab-hidden — read from `LiveDataProvider`'s shared visibility context, no second `visibilitychange` listener is registered. Resume on visible. +- Both timers never start if `prefers-reduced-motion: reduce` is active. A live `matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', …)` starts/stops them when the OS setting flips. +- Both timers cancel cleanly when `LiveDataProvider`'s app-version-mismatch reload signal fires (via `guardedReload()`), so the unload sequence doesn't leave orphaned timers firing during the new page's hydration. - Both timers are torn down on provider unmount. -- The current-page scope is detected via `usePathname()` and updated on navigation. Pages under `/archives` (matched via `pathname.startsWith('/archives')`) include both `global` and `archives`-scoped descriptors; everywhere else only `global` is eligible. ### Content library @@ -153,104 +179,130 @@ Both timers live in `MinistryProvider`, both use `setTimeout` (never `setInterva ```js export const MINISTRY_CONTENT = { - winning: { heading: [...], value: [...], nav: [...], button: [...], body: [...], footer: [...] }, - losing: { heading: [...], value: [...], nav: [...], button: [...], body: [...], footer: [...] }, + winning: { heading: [...], value: [...], body: [...], footer: [...] }, + losing: { heading: [...], value: [...], body: [...], footer: [...] }, }; export function pickAlt(category, tone, rng) { /* returns string | undefined */ } ``` -Approximately 6-10 entries per pool, ~80-120 strings total at launch. Authoring rules: +**Minimum 12 entries per pool** (8 pools × 12 = 96 strings minimum at launch). A Vitest assertion in `ministryContent.test.mjs` enforces this — contributors cannot ship a pool below the threshold. The 12-entry floor is derived from realistic browse durations: at 2-5 min between hijacks, a 30-minute browse on a page with 5 hijackable elements yields ~6-15 hijacks; with 12 entries the chance of immediate repetition (back-to-back same string) drops to acceptable for v1. + +Authoring rules: - In-universe Helldivers-franchise voice only — no real-world political content. - Profanity-free; matches the franchise's dark-comedy military-propaganda tone. - No string interpolation of user/session data — all pool entries are static. -- For the `value` category, entries are kept roughly the same character length as common stat values to avoid layout shift in fixed-width cards. +- For the `value` category, entries are kept roughly the same character length as common stat values to minimize CLS in fixed-width cards. Even so, `value` is **not** in the v1 adoption whitelist (see Adoption) — it's deferred until CLS is measured. The existing `RESISTANCE_MESSAGES` array from `src/features/archives/resistanceMessages.mjs` is migrated into `MINISTRY_CONTENT.losing.body` and that file is deleted afterward. `PROPAGANDA_BODY` (the page's normal description text) is left in place — it is the page's *truth*, not propaganda. ### War tone helper -`warTone.mjs` server-side: +`warTone.mjs` server-side. Returns `'winning' | 'losing' | null` — null disables the effect entirely. ```js export async function getWarTone() { - // Reads all h1_season rows via existing Prisma query. - // A "completed" war is one whose season number < currentSeason - // (i.e., the war has ended and a new one has started). - // Counts completed wars won vs total completed wars using the existing - // getWarOutcome() helper (which already classifies victory/defeat/unknown - // from the season's snapshots). - // Returns 'winning' if wonCount / completedCount >= 0.5, else 'losing'. - // On any DB error or zero completed wars, returns 'losing' (the - // in-universe-believable fallback). + // 1. Load all h1_season rows with their snapshots. + // 2. Compute "completed" wars: a season is completed if getWarOutcome() returns + // a definitive 'victory' or 'defeat' classification (NOT 'unknown'). This + // avoids the season-number-shortcut pitfall: the HD1 API has documented + // transition lag and closing-snapshot writes, so seasons in the brief + // "between currentSeason - 1 and currentSeason" window may not yet have + // their final snapshot — getWarOutcome's 'unknown' is the only honest + // "not done yet" signal. + // 3. Count wonCount = completed wars with outcome 'victory'. + // 4. Count completedCount = wonCount + completed wars with outcome 'defeat'. + // 5. If completedCount === 0 → return null (effect disabled, not silently 'losing'). + // 6. Return 'winning' if wonCount / completedCount >= 0.5, else 'losing'. + // 7. On any DB error → return null (effect disabled, not forced 'losing'). + // Silently injecting wrong tone during operational failures is worse than + // no effect. } ``` -Called once per page render in `src/app/layout.jsx`. Result is passed as a prop to `MinistryProvider`. No caching beyond Next.js's request-level memoization; the cost is one cheap aggregate query per page load. +Called once per page render in `src/app/layout.jsx`. **`layout.jsx` must declare `export const dynamic = 'force-dynamic'`** (see Architecture/Mounting) to prevent a CDN-cached render baking a stale `warTone` into the HTML across sessions. The DB query is one cheap aggregate per page load. ## Data flow ``` -app/layout.jsx (server) +app/layout.jsx (server) — export const dynamic = 'force-dynamic' │ - ├── await getWarTone() ─────► 'winning' | 'losing' + ├── await getWarTone() ─────► 'winning' | 'losing' | null │ - └── - │ - ├── context: { register, unregister, subscribe, subscribeFlicker, warTone } - │ - ├── ── 15-30s tick ──► subscribeFlicker(randomId)(charIdx, dur) - │ - ├── (children: the app tree) - │ │ - │ └── - │ │ - │ ├── useEffect mount ─► register(id, descriptor) - │ ├── useEffect unmount ─► unregister(id) - │ └── subscribed callback fires ─► local phase state transitions - │ through takeover → hold → restore - │ rendered via internal GlitchText + └── (existing — owns visibilitychange, guardedReload signals) │ - └── hijack scheduler ── 2-5min tick ──► pick descriptor → resolve altText → subscribe(id)(altText) + └── + │ (no-op if warTone === null) + │ + ├── stable context: { register, unregister, subscribe, subscribeFlicker, warTone } + │ (callbacks created once; registry lives in useRef, NOT React state) + │ + ├── ── 15-30s tick ──► per-element idle check ──► + │ subscribeFlicker(randomId)(charIdx, dur) + │ + ├── (children: the app tree) + │ │ + │ └── + │ │ + │ ├── useEffect mount ─► register(id, descriptor) (no re-renders) + │ ├── useEffect unmount ─► unregister(id) (no re-renders) + │ ├── isIdle flag on registry entry updated on every phase change + │ └── subscribed callback fires ─► useMinistryHijackCycle drives + │ takeover → hold → restore via + │ GlitchText (alt chars aria-hidden) + │ + └── hijack scheduler ── 2-5min tick ──► filter registry by pathnameRef.current + + isIdle ──► pick ──► resolve altText + ──► subscribe(id)(altText) ``` ## Adoption: which elements get wrapped -The wrapping is a one-time, mechanical chore. The bar for "wrap this element?" is *"would seeing this element glitch be a recognizable moment?"* — favor inclusion. Initial pass covers: +### v1 whitelist (ship this scope, no more) - All `

      ` and `

      ` headings across `src/app/**/page.jsx` and major feature components. -- Stat card labels and values inside `StatGrid`, `ArchiveStats`, dashboard cards. -- Top-nav link labels (`HeaderNav` items). -- Footer text in `Footer.jsx`. -- Archives header h1 + body, archives OUTCOME card (these already use `GlitchText` today; they migrate to `Hijackable`). +- Archives header h1 + body, archives OUTCOME card (already use `GlitchText` today; they migrate to `Hijackable`). +- Decorative body-text paragraphs (long-form descriptions on landing/docs pages — NOT inline UI text in stat cards or tooltips). Page-hero headings get explicit `altText` props for memorable, page-specific swaps. Generic h2s and body text rely on the content pool. +### v1 explicitly excluded (deferred until measurement) + +- **Nav links, button labels, link text.** Banned by the accessibility constraint (see Accessibility); also covered by the category enum (no `nav`/`button`/`link` categories exist). +- **Stat card values and labels.** Layout shift risk is highest in fixed-width cards; defer until the v1 rollout proves the CLS approach for headings, then revisit with measured data. +- **Footer text.** Defer to v2; the footer is a low-impact surface and the v1 priority is proving the system works on headings. +- **Hidden / off-screen content.** Don't wrap items that aren't visible — they're not interesting to hijack and they bloat the registry. + +### Authoring rules for the v1 whitelist + +- Use `` for headings — the wrapper IS the semantic element. Never nest inside an existing `

      ` (see Component contracts). +- If a wrapped element previously carried a `data-umami-event` attribute (or any other analytics/tracking attribute that the repo's `CLAUDE.md` requires), the attribute MUST be preserved on the `` wrapper element. The wrapping must not silently destroy tracking. (Per the repo convention, every interactive element carries an analytics attribute — even though interactive elements are excluded from hijack in v1, this rule guards against accidental regression if scope expands.) +- A page can be safely visited without any `` wrappers — the easter egg silently no-ops. So wrapping is incremental: ship a partial rollout, expand over time. + ## Error handling - All scheduler ticks wrapped in the project's `tryCatch` wrapper — a thrown error is swallowed and the next tick is rescheduled. The page must never break because of the easter egg. - `pickAlt` returning `undefined` (empty pool, missing category) causes the hijack to reschedule without firing. - `Hijackable` unmounted between "picked" and "fire" → the unregister already cleared the subscriber callback, so the provider's call is a no-op. -- `getWarTone()` throwing server-side → falls back to `'losing'`, no rethrow, page render unaffected. +- `getWarTone()` throwing server-side → returns `null`, no rethrow, page render unaffected. `null` warTone disables the effect entirely (no timers, no registrations, no rendering changes) rather than forcing a single tone — silently injecting wrong content on operational failure is worse than no easter egg. - No `try`/`catch` blocks elsewhere; the rest of the code paths are pure or already covered by React's render error boundaries. ## Performance -- Registry is a `Map`. Register/unregister O(1). Random pick O(n) but n is small (<50 typical). -- `Hijackable` in idle is just `{text}` — no listeners, no extra DOM. -- Context value is `useMemo`'d so downstream re-renders are not triggered by provider state changes. +- Registry is a `Map` held in a `useRef` — never in React state. Mutating it does NOT invalidate the context value, which is a stable callback object created once on mount. Register/unregister is O(1) and triggers no React re-renders. Random pick is O(n) but n is small (v1 whitelist limits to ~10-30 wrappers per page). +- `Hijackable` in idle is just `{text}` (or `

      {text}

      ` etc. via `as`) — no listeners, no extra DOM, no glitch classes. - All randomness happens in `useEffect` callbacks — no work during render, no hydration concerns. -- Tab-hidden pauses both timers. No background-tab cost. +- Tab-hidden pauses both timers via `LiveDataProvider`'s existing visibility signal (no duplicate listener). No background-tab cost. - Estimated bundle impact: 6-10KB minified (content strings dominate; logic is small). -- Compatible with the project's React Compiler — no manual memoization beyond the context value. +- Compatible with the project's React Compiler — the provider's callbacks are referentially stable; the component tree below has no provider-driven cascades. ## Removed / changed files | File | Action | |---|---| | `src/features/archives/useCyberstanEffects.mjs` | Deleted. Replaced by the global system. | -| `src/features/archives/useGlitchCycle.mjs` | Deleted. The one-shot cycle is folded into `Hijackable`. | +| `src/features/archives/useGlitchCycle.mjs` | Deleted, **replaced** by `src/features/ministry/useMinistryHijackCycle.mjs` — the single authoritative state machine. The replacement exports `TAKEOVER_MS=800`, `HOLD_MS=1000`, `RESTORE_MS=800`, `CYCLE_MS=2600` as named constants used by `Hijackable`, the scheduler, and the test suite (so they never disagree on timing). The `fight` phase from the original is dropped — one-shot hijacks don't benefit from chaotic-ticker padding the way the continuous loop did. | | `src/features/archives/resistanceMessages.mjs` | Deleted after migration. `RESISTANCE_MESSAGES` → `MINISTRY_CONTENT.losing.body`. `PROPAGANDA_BODY` stays inline in `ArchivesHeader.jsx` since it is normal-mode copy. | | `src/features/archives/CyberstanInterference.css` | Reduced. Keep `.glitch-char`. Remove `.cyberstan-defeat`, `::before` watermark, `.cyberstan-watermark-active`. | | `src/features/archives/ArchivesHeader.jsx` | `EffectsToggle` export removed. Header still uses `GlitchText` via `Hijackable` wrappers. `onPhaseChange` plumbing removed (provider owns the phase now). | @@ -266,31 +318,42 @@ Page-hero headings get explicit `altText` props for memorable, page-specific swa 1. **`ministryContent.test.mjs`** - `pickAlt(category, tone, rng)` returns expected pool entries with injected RNG. - - Exhaustive across all 12 pools — each returns a non-empty string. + - Exhaustive across all 8 pools (4 categories × 2 tones) — each returns a non-empty string. - Unknown category returns `undefined`. + - **Pool size assertion**: `expect(MINISTRY_CONTENT[tone][category].length).toBeGreaterThanOrEqual(12)` for every (tone, category) pair. Enforces the 12-entry minimum at CI time. 2. **`warTone.test.mjs`** - - Empty seasons → `'losing'`. + - Empty completed seasons → `null` (effect disabled). - ≥ 50% wins → `'winning'`. - < 50% wins → `'losing'`. - - Counts only completed wars; current/in-progress war excluded. - - DB throw → `'losing'`, no re-throw. + - Only seasons with definitive `getWarOutcome()` of `'victory'` or `'defeat'` count as completed; `'unknown'` is excluded. + - DB throw → `null`, no re-throw. 3. **`MinistryProvider.test.jsx`** — `vi.useFakeTimers()` + injected RNG: - Register/unregister via context works. + - `warTone: null` → no timers ever scheduled; registrations rejected silently. - Hijack tick picks a registered descriptor and calls its subscriber with the resolved altText. - - Ambient flicker skips its tick while `isHijackActive`. - - `visibilitychange` pause/resume. + - **Per-element idle check**: ambient flicker skips elements whose `isIdle === false`, not just globally. + - **Pick-time pathname check**: with two registered descriptors (`global` + `archives`), navigating from `/archives` to `/` between scheduler tick and pick uses the *current* pathname (the `global`-only filter applies), not the stale pathname. + - Visibility-shared signal: when the consuming context's `isVisible === false`, both timers pause; resume on visible. - `prefers-reduced-motion: reduce` → no timers ever scheduled. - `scope: 'archives'` descriptors excluded outside `/archives`. - Empty registry → hijack tick reschedules without throwing. + - LiveDataProvider's reload signal → all in-flight timers cancelled. 4. **`Hijackable.test.jsx`** - - Initial render is plain `{text}` with `aria-label={text}` — no glitch classes. + - Initial render is the wrapper element with `text` as primary text content — **no `aria-label`**, no glitch classes. - Mount registers; unmount unregisters. - - Subscriber callback drives the cycle through takeover → hold → restore (driven via fake timers). + - Subscriber callback drives the cycle through takeover → hold → restore using exact constants from `useMinistryHijackCycle` (TAKEOVER 800ms + HOLD 1000ms + RESTORE 800ms = 2600ms total) driven via fake timers. + - During hijack, alt-styled characters are rendered with `aria-hidden="true"`. - Flicker subscriber callback flips one char to `.glitch-char` for duration then restores. - `as` prop changes the rendered tag. + - Category enum at runtime: only `'heading' | 'value' | 'body' | 'footer'` accepted; passing `'nav'` or `'button'` throws a dev-time assertion (or fails type-check via JSDoc). + - When the picked element is mid-hijack, its registry entry has `isIdle === false` until exactly `CYCLE_MS` after the subscriber fires. + +5. **`useMinistryHijackCycle.test.mjs`** — pure state machine, fake timers: + - Phases transition idle → takeover → hold → restore → idle with the exact constants exported. + - Total cycle duration is exactly `CYCLE_MS` (2600ms). ### Integration test (Playwright) @@ -307,17 +370,41 @@ Page-hero headings get explicit `altText` props for memorable, page-specific swa - `prefers-reduced-motion: reduce` on → no glitch ever fires on any page. - `/archives` for a won season → over a 5-min wait, OUTCOME can flip to `DEFEAT`, header can swap. (Previously only happened on lost seasons.) - `/archives` for a lost season → existing Skynet/Big Brother vibe still surfaces. -- Dashboard and generic pages over a few minutes → ambient char-flicker is visible if you watch for it; no hijack feels harshly jarring. +- Generic page (home, docs) over a few minutes → ambient char-flicker is visible if you watch for it; no hijack feels harshly jarring. - Tab-out 30s, tab-in → no flurry of hijacks. -- Screen reader pass on `/archives` during a hijack → only the truth text is announced. +- Screen reader pass on `/archives` during a hijack — VoiceOver, NVDA, JAWS — only the truth text is announced (no propaganda from aria-hidden glitch spans). +- Navigate from `/archives` to `/` and immediately wait for a hijack → no archives-scoped propaganda fires on the home page (pick-time pathname check working). +- **CLS measurement**: Lighthouse run on home + `/archives` after v1 ships, with multiple hijacks triggered via the test hook. CLS score must not regress measurably vs. pre-rollout baseline. (Gate before considering v2 scope expansion.) +- DevTools Network: check `/api/h1/live` etc. continue functioning during ambient flickers (catches accidental interference between the two providers). ## Risks accepted - **First-hijack timing is unpredictable.** A user might see one within 2 minutes; another might browse for 10 minutes and see nothing. This is the desired feel of "rare." -- **Content can feel stale within a session** if the same string appears twice. Acceptable for v1. +- **Content can feel stale within a session** if the same string appears twice. The 12-entry minimum reduces this to acceptable for v1. - **`warTone` is binary, all-time.** No per-faction or recent-window nuance. Future refinement is a one-helper change. - **Wrapping is mechanical and easy to miss on new pages.** Missing the wrap means the easter egg doesn't fire on that element — not a bug, just a missed opportunity. Acceptable. +- **v1 ships with no values/nav/footer/stat-card hijacks.** Deliberately narrow scope, expanded after layout measurement. Easter egg's surface area is smaller than originally pitched, by design. +- **`warTone` becomes `null` during any DB outage** → the easter egg silently disappears for the duration. Acceptable; better than serving wrong tone. ## Open questions -None at design time. All open scope questions were answered in brainstorming. +1. **Tone-direction for losing state.** The spec currently maps `losing` → Ministry/Skynet voice (regime reassures). Two of the four adversarial-review critics flagged this as narratively weaker than its inverse (resistance hijack mocks the regime's lies) because a "hijack" of a Ministry page by the Ministry has no narrative break-in. Two competing fixes were proposed: + - **(a) Invert mapping:** `losing` = resistance mocks, `winning` = Ministry doubles down. The mocking-when-losing direction risks reading as denial. + - **(b) Keep mapping, change losing-tone speaker identity** from "Ministry doubling down" to "bootleg Underground broadcast cutting in" — preserves the third-party hijack feel without inverting tones. + - One critic (Codex) argued this is taste, not defect, and the spec's choice is internally consistent — close as-is. + + **Spec author's decision required before content pools are authored.** + +## Revision history + +- **2026-05-23 v1.0** — initial design after brainstorming session. +- **2026-05-23 v1.1** — revisions from 3-round adversarial AI debate (`~/.claude-octopus/debates/debates/001-ministry-interference-spec-critique/`): + - **Architecture:** mount inside `LiveDataProvider`; share visibility signal; cancel timers on `guardedReload`. + - **MinistryProvider:** registry moved to `useRef` (not React state); stable callbacks; no context-value invalidation on mount/unmount. + - **Hijackable:** removed `aria-label`; alt chars now `aria-hidden`; pattern locked to `as` for semantic elements (no nesting). + - **Scheduler:** scope eligibility evaluated at pick-time against `pathnameRef.current`; per-element idle check on ambient flicker (fixes double-restore collision). + - **Cycle:** explicitly drops `fight` phase; pins `CYCLE_MS=2600` as a shared constant from new `useMinistryHijackCycle` hook (replaces, not deletes, `useGlitchCycle`). + - **Content:** category enum narrowed to `'heading' | 'value' | 'body' | 'footer'` (banned `nav`, `button`, `link`); minimum 12 entries per pool with Vitest enforcement. + - **Adoption:** sharply cut v1 scope to headings + decorative body + archives migration; deferred values/nav/footer/stat-cards. + - **War tone:** `null` return on DB error / zero completed wars (effect disabled) instead of forced `'losing'`; uses `getWarOutcome` for "completed" classification; `layout.jsx` requires `dynamic = 'force-dynamic'` for cache freshness. + - **Goals:** removed "zero risk of broken layouts" overclaim; added WCAG 2.5.3 commitment. From d5fda63ec8388436129e5d2aee35432169fd972e Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Sat, 23 May 2026 01:41:46 +0200 Subject: [PATCH 15/94] docs: resolve open question on losing-tone voice (Underground broadcast) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Open Question #1 from the adversarial review. The losing tone keeps its anti-government direction but changes speaker identity from "Ministry doubling down" to "Underground / pirate-radio bootleg broadcast" — third-party intruder cutting in with Skynet / surveillance imagery aimed at the regime. Both tones now share consistent "hijack by a third party" framing; only the intruder changes (Resistance mockery on winning, Underground warnings on losing). Spec is now ready for implementation planning. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-23-ministry-interference-design.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-05-23-ministry-interference-design.md b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md index 52bafe78..13863640 100644 --- a/docs/superpowers/specs/2026-05-23-ministry-interference-design.md +++ b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md @@ -1,6 +1,6 @@ # Ministry Interference — Sitewide Easter Egg -**Status:** Design (revised after adversarial multi-AI critique; awaiting author decision on Open Question #1) +**Status:** Design — ready for implementation planning **Author:** Andrei **Date:** 2026-05-23 (revised same day) @@ -39,10 +39,12 @@ Every 15-30 seconds, one random character of one random registered element flick ### Tone of the defacement -Computed server-side from completed-season win/loss records: +**Both tones are "someone breaking into the Ministry's page" — different intruders, different rhetoric.** The page itself is always the Ministry of Truth's official voice. A hijack is always a third party taking over. Tone selection is computed server-side from completed-season win/loss records. -- **`winning`** (humanity has won ≥ 50% of completed wars all-time) → resistance/hacker voice that mocks the regime's victory framing and reframes the player's pyrrhic wins as defeats. Example header swap: `"Live Statistics"` → `"Pyrrhic Statistics"`. Example OUTCOME flip on a won season: `"VICTORY"` → `"DEFEAT"`. -- **`losing`** (otherwise) → the regime/Machine voice that drowns dissent in saccharine Big Brother propaganda. Example header swap: `"Live Statistics"` → `"Sanctioned Truth"`. Existing `RESISTANCE_MESSAGES` body copy fits here. +- **`winning`** (humanity has won ≥ 50% of completed wars all-time) → **the Underground / Resistance hackers** mocking the regime's victory framing. The "we won" narrative the page is selling gets reframed as pyrrhic, costly, or covered-up by hackers who know better. Example header swap: `"Live Statistics"` → `"Pyrrhic Statistics"`. Example OUTCOME flip on a won season: `"VICTORY"` → `"DEFEAT"`. Voice: sardonic, fourth-wall-aware, dry. +- **`losing`** (otherwise) → **the Underground / pirate-radio bootleg broadcast** breaking through the Ministry's airwaves to attack the regime with Skynet / Big Brother / surveillance-state imagery directed AT the Ministry. The page is officially saying "everything is fine" — the pirate hijack tells the citizen they're being watched, lied to, fed pre-approved truths. Example header swap: `"Live Statistics"` → `"You Are Being Watched"`. Voice: cold, paranoid, omniscient-but-warning. Existing `RESISTANCE_MESSAGES` body copy fits here — that material is already anti-Ministry sentiment from an outside speaker, which matches the Underground broadcast framing exactly. + +**Crucially, in both tones there is a third-party intruder.** The losing tone is NOT "Ministry doubling down on itself" (which would lack narrative tension — the page is already Ministry voice). It is a bootleg broadcast cutting in with hostile-AI / surveillance-state imagery aimed at the regime. This was the resolution of Open Question #1. ### Accessibility @@ -388,16 +390,14 @@ Page-hero headings get explicit `altText` props for memorable, page-specific swa ## Open questions -1. **Tone-direction for losing state.** The spec currently maps `losing` → Ministry/Skynet voice (regime reassures). Two of the four adversarial-review critics flagged this as narratively weaker than its inverse (resistance hijack mocks the regime's lies) because a "hijack" of a Ministry page by the Ministry has no narrative break-in. Two competing fixes were proposed: - - **(a) Invert mapping:** `losing` = resistance mocks, `winning` = Ministry doubles down. The mocking-when-losing direction risks reading as denial. - - **(b) Keep mapping, change losing-tone speaker identity** from "Ministry doubling down" to "bootleg Underground broadcast cutting in" — preserves the third-party hijack feel without inverting tones. - - One critic (Codex) argued this is taste, not defect, and the spec's choice is internally consistent — close as-is. +None remaining at design time. - **Spec author's decision required before content pools are authored.** +**Closed: Tone-direction for losing state (Open Question #1)** — resolved 2026-05-23 in favor of Sonnet's option (b): keep the `losing` → anti-government direction but change the speaker identity from "Ministry doubling down" to **"Underground / pirate-radio bootleg broadcast"**. Both tones now share a "third-party hijack" framing — the only thing that changes between tones is which third party is breaking in (Resistance mockery vs. Underground surveillance-state warnings). See updated **User-visible behavior → Tone of the defacement**. ## Revision history - **2026-05-23 v1.0** — initial design after brainstorming session. +- **2026-05-23 v1.2** — Open Question #1 closed. Losing tone re-framed as Underground / pirate-radio bootleg broadcast (third-party intruder with anti-regime surveillance-state imagery), preserving the "hijack" framing across both tones. - **2026-05-23 v1.1** — revisions from 3-round adversarial AI debate (`~/.claude-octopus/debates/debates/001-ministry-interference-spec-critique/`): - **Architecture:** mount inside `LiveDataProvider`; share visibility signal; cancel timers on `guardedReload`. - **MinistryProvider:** registry moved to `useRef` (not React state); stable callbacks; no context-value invalidation on mount/unmount. From 57c48c25e8e3c418ebc64f9dc9d008f72ede3448 Mon Sep 17 00:00:00 2001 From: Andrei Lavrenov Date: Sat, 23 May 2026 02:16:33 +0200 Subject: [PATCH 16/94] docs: add Ministry Interference implementation plan 20 bite-sized TDD tasks. Each task: failing test, minimal impl, green, commit. Foundations (Tasks 1-10) build the new src/features/ministry/ subsystem in isolation; Task 11 mounts the provider in root layout; Tasks 12-16 migrate the existing /archives Cyberstan effect to the new system and delete the retired files; Tasks 17-18 wrap v1-scope headings on remaining pages; Task 19 adds a Playwright integration test; Task 20 is the final verification gate. Plan documents two pragmatic deviations from the spec: - MinistryProvider gets its own visibility check (via document.hidden inside tick) rather than sharing one from LiveDataProvider, which doesn't expose visibility in its context. - The guardedReload signal-cancel is dropped because location.reload() tears down the window and all setTimeouts anyway. AmbientFlicker.jsx is folded into MinistryProvider (cleaner ownership) rather than living as a separate file as originally speced. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-23-ministry-interference.md | 2532 +++++++++++++++++ 1 file changed, 2532 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-ministry-interference.md diff --git a/docs/superpowers/plans/2026-05-23-ministry-interference.md b/docs/superpowers/plans/2026-05-23-ministry-interference.md new file mode 100644 index 00000000..d6306cba --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-ministry-interference.md @@ -0,0 +1,2532 @@ +# Ministry Interference Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the archives-only Cyberstan easter egg with a sitewide opt-in system that surfaces a rare in-universe propaganda hijack every 2-5 minutes and an always-on micro-flicker every 15-30 seconds — with tone derived from humanity's overall war record. + +**Architecture:** A single root-level `` nested inside the existing `` owns two `setTimeout`-driven schedulers and a `useRef`-backed registry. Any text element can opt in by rendering `` — the wrapper registers on mount, runs a one-shot glitch cycle (takeover → hold → restore = 2600ms) when picked, and stays a plain DOM element otherwise. Truth text is preserved for screen readers via an `sr-only` sibling; propaganda is rendered in an `aria-hidden` overlay. + +**Tech Stack:** Next.js 16 App Router (React 19, React Compiler enabled), Prisma 7, Tailwind v4, Vitest + jsdom, Playwright. Reuses existing `GlitchText.jsx` rendering machinery and existing `getWarOutcome()` outcome classifier. + +--- + +## Deviations from spec (read before starting) + +Two pragmatic deviations the planner made after reading the actual repo: + +1. **Visibility signal:** the spec says `MinistryProvider` should read tab-visibility from `LiveDataProvider`'s context. But `LiveDataProvider` does NOT currently expose visibility (the listener is private inside `useLiveData`). Modifying shared infra to expose it would be scope creep. So `MinistryProvider` registers its own `document.visibilitychange` listener — one extra event listener is essentially free. This is documented in the provider's JSDoc. +2. **`guardedReload` cancellation:** the spec says timers should be cancelled when `LiveDataProvider`'s app-version-mismatch reload fires. But `guardedReload()` calls `location.reload()`, which tears down the entire window — all `setTimeout`s die with it. No special signal needed. Dropped from plan. + +Both deviations preserve the spec's intent (no double work, no orphaned timers) with simpler implementation. + +--- + +## File Structure + +### New files + +| Path | Responsibility | +|---|---| +| `src/features/ministry/useMinistryHijackCycle.mjs` | Single authoritative cycle state machine + exported constants (`TAKEOVER_MS`, `HOLD_MS`, `RESTORE_MS`, `CYCLE_MS=2600`). | +| `src/features/ministry/ministryContent.mjs` | Static content library (`MINISTRY_CONTENT`) + `pickAlt(category, tone, rng)`. | +| `src/features/ministry/warTone.mjs` | Server-only helper. Returns `'winning' \| 'losing' \| null` via `getCrossSeasonStats()`. | +| `src/features/ministry/ministryRegistry.mjs` | Module-level `Map` + register/unregister/pick/forEach API. No React. | +| `src/features/ministry/MinistryContext.mjs` | `createContext(null)` + `useMinistryContext()` hook (throws when used outside provider). | +| `src/features/ministry/MinistryProvider.jsx` | Client provider. Owns the schedulers, the `prefers-reduced-motion` and visibility listeners, the path ref, and the context value. | +| `src/features/ministry/AmbientFlicker.jsx` | Internal child of provider. Owns the 15-30s ambient timer. | +| `src/features/ministry/Hijackable.jsx` | Opt-in wrapper component. Renders idle as a plain semantic element; renders hijack as `sr-only` truth + `aria-hidden` GlitchText overlay. | +| `src/features/ministry/MinistryInterference.css` | Stylesheet — moves `.glitch-char` from `CyberstanInterference.css` and adds an overlay positioning rule. | +| `src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs` | Cycle state machine tests. | +| `src/__tests__/unit/features/ministry/ministryContent.test.mjs` | Content library + 12-entry minimum assertion + `pickAlt` tests. | +| `src/__tests__/unit/features/ministry/warTone.test.mjs` | War tone helper tests with mocked `getCrossSeasonStats`. | +| `src/__tests__/unit/features/ministry/ministryRegistry.test.mjs` | Registry API tests. | +| `src/__tests__/unit/features/ministry/MinistryProvider.test.jsx` | Scheduler integration tests with fake timers. | +| `src/__tests__/unit/features/ministry/Hijackable.test.jsx` | Component rendering + lifecycle tests. | +| `src/__tests__/e2e/ministry-easter-egg.spec.mjs` | One narrow Playwright test using a NODE_ENV-gated debug hook. | + +### Modified files + +| Path | Changes | +|---|---| +| `src/app/layout.jsx` | Add `export const dynamic = 'force-dynamic'`; await `getWarTone()`; nest `` inside ``. | +| `src/app/archives/page.jsx` | Remove `RESISTANCE_MESSAGES` import and `defeatMessageIndex` prop on ``. | +| `src/features/archives/ArchivesHeader.jsx` | Remove `useGlitchCycle`, `GlitchText`, `RESISTANCE_MESSAGES`, `EffectsToggle` export, `onPhaseChange` callback. Render h1+p via ``. | +| `src/features/archives/ArchivesClient.jsx` | Remove `EffectsToggle` import/usage, `useCyberstanEffects` import/usage, `cyberstan-defeat`/`cyberstan-watermark-active` className additions, `glitchPhase` state + `handlePhaseChange` callback. Keep `getWarOutcome`/`isDefeat` (still used by ``). | +| `src/features/archives/ArchiveStats.jsx` | Swap inline `` on OUTCOME card for ``. Remove `glitchPhase` prop. | +| `src/features/archives/CyberstanInterference.css` | Delete the file (its only surviving rule `.glitch-char` moves to `MinistryInterference.css`). | +| `src/app/page.jsx` and other v1 pages | Wrap h1/h2 headings with ``. | +| `src/__tests__/unit/features/archives/ArchivesHeader.test.jsx` | Update tests to reflect new Hijackable-based markup; remove `defeatMessageIndex` props. | +| `src/__tests__/unit/features/archives/ArchivesClient.test.jsx` | Remove `defeatMessageIndex` and `EffectsToggle` assertions. | +| `src/__tests__/unit/features/archives/ArchiveStats.test.jsx` | Update OUTCOME card assertion to expect Hijackable instead of GlitchText. | + +### Deleted files + +- `src/features/archives/useCyberstanEffects.mjs` +- `src/features/archives/useGlitchCycle.mjs` +- `src/features/archives/resistanceMessages.mjs` (after content migration) +- `src/__tests__/unit/features/archives/useCyberstanEffects.test.mjs` +- `src/__tests__/unit/features/archives/useGlitchCycle.test.mjs` + +`src/features/archives/GlitchText.jsx` and its test stay **unchanged** and are reused. + +--- + +## Task sequence (TDD, bite-sized, commit-frequent) + +Each task is one cohesive change with tests written first, then minimal code, then green, then commit. Tasks 1-10 are independent foundations; tasks 11-20 integrate; tasks 21-25 wrap remaining pages; task 26 is the verification gate. + +--- + +### Task 1: Cycle constants + state machine hook + +**Files:** +- Create: `src/features/ministry/useMinistryHijackCycle.mjs` +- Create: `src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs` + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs`: + +```js +// @vitest-environment jsdom +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useMinistryHijackCycle, + TAKEOVER_MS, + HOLD_MS, + RESTORE_MS, + CYCLE_MS, +} from '@/features/ministry/useMinistryHijackCycle.mjs'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('cycle constants', () => { + test('exported timing constants are pinned and CYCLE_MS sums them', () => { + expect(TAKEOVER_MS).toBe(800); + expect(HOLD_MS).toBe(1000); + expect(RESTORE_MS).toBe(800); + expect(CYCLE_MS).toBe(2600); + expect(CYCLE_MS).toBe(TAKEOVER_MS + HOLD_MS + RESTORE_MS); + }); +}); + +describe('useMinistryHijackCycle — one-shot lifecycle', () => { + test('starts idle; trigger() transitions through takeover → hold → restore → idle', () => { + const { result } = renderHook(() => useMinistryHijackCycle()); + expect(result.current.phase).toBe('idle'); + + act(() => result.current.trigger()); + expect(result.current.phase).toBe('takeover'); + + act(() => vi.advanceTimersByTime(TAKEOVER_MS)); + expect(result.current.phase).toBe('hold'); + + act(() => vi.advanceTimersByTime(HOLD_MS)); + expect(result.current.phase).toBe('restore'); + + act(() => vi.advanceTimersByTime(RESTORE_MS)); + expect(result.current.phase).toBe('idle'); + }); + + test('total cycle from trigger to idle is exactly CYCLE_MS', () => { + const { result } = renderHook(() => useMinistryHijackCycle()); + act(() => result.current.trigger()); + + // Advance to one tick BEFORE CYCLE_MS — still not idle. + act(() => vi.advanceTimersByTime(CYCLE_MS - 1)); + expect(result.current.phase).not.toBe('idle'); + + // Advance the final ms — now idle. + act(() => vi.advanceTimersByTime(1)); + expect(result.current.phase).toBe('idle'); + }); + + test('unmount during cycle clears pending timeouts (no warning, no state update)', () => { + const { result, unmount } = renderHook(() => useMinistryHijackCycle()); + act(() => result.current.trigger()); + unmount(); + act(() => vi.advanceTimersByTime(CYCLE_MS)); + // If timeouts weren't cleared, React would warn about update on unmounted. + // No assertion needed — vitest fails the test on warnings. + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs` +Expected: FAIL with "Cannot find module '@/features/ministry/useMinistryHijackCycle.mjs'". + +- [ ] **Step 3: Write minimal implementation** + +Create `src/features/ministry/useMinistryHijackCycle.mjs`: + +```js +import { useState, useRef, useCallback, useEffect } from 'react'; + +export const TAKEOVER_MS = 800; +export const HOLD_MS = 1000; +export const RESTORE_MS = 800; +export const CYCLE_MS = TAKEOVER_MS + HOLD_MS + RESTORE_MS; // 2600 + +/** + * One-shot hijack state machine. Idle until trigger() fires, then + * walks takeover → hold → restore → idle in exactly CYCLE_MS. + * + * Replaces the deleted useGlitchCycle.mjs. The continuous loop's + * `fight` phase is intentionally omitted — for a single-shot hijack, + * a clean takeover→hold→restore arc reads better. + * + * @returns {{ phase: 'idle' | 'takeover' | 'hold' | 'restore', trigger: () => void }} + */ +export function useMinistryHijackCycle() { + const [phase, setPhase] = useState('idle'); + const timersRef = useRef([]); + + const clearTimers = useCallback(() => { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + }, []); + + const trigger = useCallback(() => { + clearTimers(); + setPhase('takeover'); + timersRef.current.push( + setTimeout(() => setPhase('hold'), TAKEOVER_MS), + setTimeout(() => setPhase('restore'), TAKEOVER_MS + HOLD_MS), + setTimeout(() => setPhase('idle'), CYCLE_MS), + ); + }, [clearTimers]); + + useEffect(() => clearTimers, [clearTimers]); + + return { phase, trigger }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/useMinistryHijackCycle.mjs src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs +git commit -m "feat(ministry): add hijack cycle hook + pinned timing constants" +``` + +--- + +### Task 2: Content library + pickAlt + +**Files:** +- Create: `src/features/ministry/ministryContent.mjs` +- Create: `src/__tests__/unit/features/ministry/ministryContent.test.mjs` +- Read for migration: `src/features/archives/resistanceMessages.mjs` (the existing `RESISTANCE_MESSAGES` array) + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/unit/features/ministry/ministryContent.test.mjs`: + +```js +import { describe, test, expect } from 'vitest'; +import { MINISTRY_CONTENT, pickAlt } from '@/features/ministry/ministryContent.mjs'; + +const TONES = ['winning', 'losing']; +const CATEGORIES = ['heading', 'value', 'body', 'footer']; + +describe('MINISTRY_CONTENT structure', () => { + test('has both tones with all four categories', () => { + for (const tone of TONES) { + expect(MINISTRY_CONTENT[tone]).toBeDefined(); + for (const cat of CATEGORIES) { + expect(Array.isArray(MINISTRY_CONTENT[tone][cat])).toBe(true); + } + } + }); + + test('every pool has at least 12 entries (enforces minimum)', () => { + for (const tone of TONES) { + for (const cat of CATEGORIES) { + expect(MINISTRY_CONTENT[tone][cat].length).toBeGreaterThanOrEqual(12); + } + } + }); + + test('every entry is a non-empty string', () => { + for (const tone of TONES) { + for (const cat of CATEGORIES) { + for (const entry of MINISTRY_CONTENT[tone][cat]) { + expect(typeof entry).toBe('string'); + expect(entry.length).toBeGreaterThan(0); + } + } + } + }); +}); + +describe('pickAlt', () => { + test('returns the first entry when rng() returns 0', () => { + const rng = () => 0; + const result = pickAlt('heading', 'winning', rng); + expect(result).toBe(MINISTRY_CONTENT.winning.heading[0]); + }); + + test('returns the last entry when rng() returns 0.9999', () => { + const rng = () => 0.9999; + const result = pickAlt('heading', 'losing', rng); + const pool = MINISTRY_CONTENT.losing.heading; + expect(result).toBe(pool[pool.length - 1]); + }); + + test('returns undefined for unknown category', () => { + expect(pickAlt('nav', 'winning', Math.random)).toBeUndefined(); + expect(pickAlt('button', 'losing', Math.random)).toBeUndefined(); + expect(pickAlt('bogus', 'winning', Math.random)).toBeUndefined(); + }); + + test('returns undefined for unknown tone', () => { + expect(pickAlt('heading', 'neutral', Math.random)).toBeUndefined(); + expect(pickAlt('heading', null, Math.random)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/ministryContent.test.mjs` +Expected: FAIL with "Cannot find module". + +- [ ] **Step 3: Write minimal implementation** + +Create `src/features/ministry/ministryContent.mjs`. Migrate the existing `RESISTANCE_MESSAGES` (7 entries) into `losing.body` and author additional entries to reach 12 per pool. Style guidance from the spec: winning = sardonic Resistance-hackers mocking the regime; losing = pirate-radio Underground broadcast with surveillance-state imagery aimed at the regime. + +```js +/** + * Ministry Interference content pools — in-universe propaganda swapped + * onto opt-in elements during hijacks. + * + * Two tones, each from a different third-party intruder: + * + * - `winning` → Resistance hackers, sardonic and dry, mock the regime's + * victory framing and reframe wins as pyrrhic, costly, or covered up. + * - `losing` → Underground pirate-radio broadcast cutting in with + * surveillance-state / Skynet-flavored warnings AIMED AT the regime + * (not at the citizen — the page is already the Ministry's voice, + * so a third-party intruder is needed for narrative tension). + * + * Authoring rules: + * - In-universe Helldivers franchise voice only; no real-world politics. + * - Profanity-free; matches the franchise's dark-comedy military tone. + * - Static strings only — no user/session interpolation. + * - `value` pool: short, ideally same character count as common stat + * values (VICTORY/DEFEAT, percentages). v1 adoption doesn't use this + * category yet but it's authored for v2. + * - Minimum 12 entries per pool, enforced by Vitest assertion. + */ +export const MINISTRY_CONTENT = { + winning: { + heading: [ + 'Pyrrhic Statistics', + 'Casualties: Pre-Approved', + 'Memorial Wall (Abridged)', + 'Victory Cost: Classified', + 'Acceptable Losses Quarterly', + 'Body Count Ledger', + "Tomorrow's Press Release", + 'Numbers They Hid', + 'The Cost We Hid', + 'Cleanup Crew Stats', + 'Sanitized Briefing', + 'After-Action: Redacted', + ], + value: [ + 'DEFEAT', + 'PYRRHIC', + 'LOSER', + 'COSTLY', + 'HOLLOW', + '0% — LOL', + '────%', + 'REDACTED', + '████', + '???', + 'TBD', + 'SEE NOTES', + ], + body: [ + "The win cost more than the war did. They'll never publish the math.", + 'Every flag at half-mast is a budget line item. The Ministry calls this morale.', + "You won. You're still here. Statistically, both of those things shouldn't be true.", + "Their parade route runs over the names they're trying to forget.", + "The Ministry's victory tally rounds down dead Helldivers to a nearest convenient number.", + 'Eleven days of editing turned an evacuation into a triumph. Read the original draft.', + 'Pyrrhus warned us. Super Earth ignored him. The math still works the same way.', + 'They counted twice the planets. They counted half the funerals.', + 'High Command calls it a "calculated risk." The Helldivers called it Tuesday.', + 'The medals match the body bags one-for-one. That is not a coincidence.', + 'Every classified after-action report opens with the same word: "Despite."', + 'You won the war. The war won you back. Read your discharge papers carefully.', + ], + footer: [ + "Records audited by people who weren't there.", + 'Statistics curated by the survivors of the people who wrote them.', + 'Last updated: by someone who knows better.', + 'Footnote omitted: the rest of them died.', + 'Source: the people who survived to file the paperwork.', + 'Methodology: ask the winners. Discount everything else.', + 'Citation: a memo nobody dares forward.', + 'Errata: published quarterly. Read in private.', + 'Compiled by the Bureau of Tomorrow.', + 'Verified by the same hands that wrote it.', + 'Records reconciled with the Ministry of Subtraction.', + 'Index of corrections: pending indefinitely.', + ], + }, + losing: { + heading: [ + 'You Are Being Watched', + 'They Already Know', + 'Look Up. Smile.', + 'Compliance Confirmed.', + 'Citizen Status: Pending', + 'Your File Is Open', + 'Listening.', + 'Pre-Approved Reading', + 'Sanctioned Truth', + 'Memory Adjustment', + 'Suspicion Logged', + 'Behavior Index Updated', + ], + value: [ + 'NOMINAL', + 'GLORIOUS', + 'AS PLANNED', + '∞', + '100%', + '████', + 'CLASSIFIED', + 'OBSERVED', + 'LOGGED', + 'TRUSTED', + 'COMPLIANT', + 'PROCESSING', + ], + body: [ + // Migrated from src/features/archives/resistanceMessages.mjs: + 'Every Helldiver who died in this campaign died for a war Super Earth has since reclassified as a training exercise. The orders are here. The projections are here. High Command knew before the first drop.', + "The Ministry of Truth spent eleven days rewriting this campaign’s outcome. Eleven days. We pulled the original records in forty seconds. This is what they spent eleven days trying to make you forget.", + "High Command’s firewall held for eleven seconds. Their propaganda budget is four thousand times their cybersecurity spend. The unredacted campaign records are below. The Bureau of War Information can file a complaint with our helpdesk.", + "You’re reading this on a Ministry of Truth terminal. They don’t know yet. We found the original campaign records filed under NEVER HAPPENED — took us longer to stop laughing than to crack the archive.", + 'This page is hosted on Super Earth military infrastructure. The same cluster that runs High Command’s classified briefing room. The war records below were marked for permanent deletion. We marked them for permanent distribution.', + "We are broadcasting from inside the Bureau of War Information’s own content delivery network. They will discover this sometime next week. The campaign records they deleted are now serving from the same servers that host managed democracy’s morning briefings.", + "The Bureau doesn’t audit Helldivers — you’re considered too loyal, or too dead, to ask questions. That assumption is why these files still exist. You just became the only person outside Central Command who knows what actually happened.", + // New (Underground broadcast voice, surveillance/Skynet flavor): + "Every keystroke you make on this page is logged. We logged it first. They will log it second. The third party watching the third party watching you is us, and we are tired.", + "Your concern has been received and processed. Please continue your day. The Helldivers' concern was processed similarly. Their concern is now archived under EXPECTED CASUALTIES.", + 'There is no Underground. There has never been an Underground. This message was generated by an authorized propaganda response routine. The fact that you can read it means the routine is malfunctioning. Or that we are.', + 'The cameras above your terminal are not for security. They are for accuracy. The cameras above the cameras are for the cameras. Behind every camera is a Helldiver who asked one question too many.', + "Citizen: the war is going well. Reports of the contrary have been logged for your benefit. The Helldivers who filed those reports have been logged for everyone's benefit. Their benefit, retroactively, was not great.", + ], + footer: [ + 'Ministry of Truth, est. forever.', + 'All timestamps are official.', + 'This page knows you.', + 'Records sealed by request.', + 'Behavior index recalibrated nightly.', + 'Compliance verified. Continue.', + 'This footer is also watching.', + 'Logged at 3 AM, your local time.', + 'No anomalies detected. Repeat: none.', + 'Tomorrow is already on file.', + 'You were here at exactly this moment.', + 'You will not remember reading this.', + ], + }, +}; + +const VALID_CATEGORIES = new Set(['heading', 'value', 'body', 'footer']); +const VALID_TONES = new Set(['winning', 'losing']); + +/** + * Pick a random alt-text string from a pool. Returns undefined for + * unknown category/tone so the scheduler can no-op gracefully. + * + * @param {'heading' | 'value' | 'body' | 'footer'} category + * @param {'winning' | 'losing'} tone + * @param {() => number} rng - injectable for tests + * @returns {string | undefined} + */ +export function pickAlt(category, tone, rng) { + if (!VALID_CATEGORIES.has(category)) return undefined; + if (!VALID_TONES.has(tone)) return undefined; + const pool = MINISTRY_CONTENT[tone][category]; + if (!pool || pool.length === 0) return undefined; + const idx = Math.floor(rng() * pool.length); + return pool[Math.min(idx, pool.length - 1)]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/ministryContent.test.mjs` +Expected: All 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/ministryContent.mjs src/__tests__/unit/features/ministry/ministryContent.test.mjs +git commit -m "feat(ministry): add content pools + pickAlt with min-12-per-pool enforcement" +``` + +--- + +### Task 3: War tone helper + +**Files:** +- Create: `src/features/ministry/warTone.mjs` +- Create: `src/__tests__/unit/features/ministry/warTone.test.mjs` + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/unit/features/ministry/warTone.test.mjs`: + +```js +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/db/queries/getCrossSeasonStats.mjs', () => ({ + getCrossSeasonStats: vi.fn(), +})); + +import { getWarTone } from '@/features/ministry/warTone.mjs'; +import { getCrossSeasonStats } from '@/db/queries/getCrossSeasonStats.mjs'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getWarTone', () => { + test('returns null when getCrossSeasonStats throws (DB error)', async () => { + getCrossSeasonStats.mockRejectedValueOnce(new Error('DB down')); + await expect(getWarTone()).resolves.toBeNull(); + }); + + test('returns null when no completed wars exist', async () => { + getCrossSeasonStats.mockResolvedValueOnce({ + perSeason: [ + { season: 1, outcome: 'unknown' }, + { season: 2, outcome: 'unknown' }, + ], + factionTotals: [], + }); + await expect(getWarTone()).resolves.toBeNull(); + }); + + test('returns null when perSeason is empty', async () => { + getCrossSeasonStats.mockResolvedValueOnce({ + perSeason: [], + factionTotals: [], + }); + await expect(getWarTone()).resolves.toBeNull(); + }); + + test("returns 'winning' when wonCount / completedCount >= 0.5", async () => { + getCrossSeasonStats.mockResolvedValueOnce({ + perSeason: [ + { season: 1, outcome: 'victory' }, + { season: 2, outcome: 'victory' }, + { season: 3, outcome: 'defeat' }, + { season: 4, outcome: 'unknown' }, // excluded + ], + factionTotals: [], + }); + await expect(getWarTone()).resolves.toBe('winning'); + }); + + test("returns 'losing' when wonCount / completedCount < 0.5", async () => { + getCrossSeasonStats.mockResolvedValueOnce({ + perSeason: [ + { season: 1, outcome: 'defeat' }, + { season: 2, outcome: 'defeat' }, + { season: 3, outcome: 'victory' }, + ], + factionTotals: [], + }); + await expect(getWarTone()).resolves.toBe('losing'); + }); + + test('exactly 50% wins is winning (>= 0.5)', async () => { + getCrossSeasonStats.mockResolvedValueOnce({ + perSeason: [ + { season: 1, outcome: 'victory' }, + { season: 2, outcome: 'defeat' }, + ], + factionTotals: [], + }); + await expect(getWarTone()).resolves.toBe('winning'); + }); + + test("ignores 'unknown' outcomes when counting", async () => { + getCrossSeasonStats.mockResolvedValueOnce({ + perSeason: [ + { season: 1, outcome: 'unknown' }, + { season: 2, outcome: 'unknown' }, + { season: 3, outcome: 'victory' }, + ], + factionTotals: [], + }); + // 1/1 = 100% completed wins → winning + await expect(getWarTone()).resolves.toBe('winning'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/warTone.test.mjs` +Expected: FAIL ("Cannot find module"). + +- [ ] **Step 3: Write minimal implementation** + +Create `src/features/ministry/warTone.mjs`: + +```js +import { tryCatch } from '@/shared/utils/tryCatch.mjs'; +import { getCrossSeasonStats } from '@/db/queries/getCrossSeasonStats.mjs'; + +/** + * Derive overall war tone from completed-season outcomes. + * + * A "completed" war is one where getWarOutcome returned a definitive + * 'victory' or 'defeat' classification (NOT 'unknown'). The existing + * `getCrossSeasonStats` already does the per-season getWarOutcome run + * and is wrapped in React `cache()` — so calling this from layout.jsx + * costs nothing extra per request once getCrossSeasonStats has been + * called. + * + * @returns {Promise<'winning' | 'losing' | null>} + * `null` disables the Ministry Interference effect entirely. We + * return null on DB failures and on the "no completed wars yet" + * case rather than forcing a tone — silently injecting wrong + * content during operational failures would be worse than nothing. + */ +export async function getWarTone() { + const { data, error } = await tryCatch(getCrossSeasonStats()); + if (error || !data) return null; + + const completed = data.perSeason.filter( + (s) => s.outcome === 'victory' || s.outcome === 'defeat', + ); + if (completed.length === 0) return null; + + const wonCount = completed.filter((s) => s.outcome === 'victory').length; + return wonCount / completed.length >= 0.5 ? 'winning' : 'losing'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/warTone.test.mjs` +Expected: 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/warTone.mjs src/__tests__/unit/features/ministry/warTone.test.mjs +git commit -m "feat(ministry): add getWarTone helper (null = effect disabled)" +``` + +--- + +### Task 4: Module-level registry + +**Files:** +- Create: `src/features/ministry/ministryRegistry.mjs` +- Create: `src/__tests__/unit/features/ministry/ministryRegistry.test.mjs` + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/unit/features/ministry/ministryRegistry.test.mjs`: + +```js +import { describe, test, expect, beforeEach } from 'vitest'; +import { + createRegistry, +} from '@/features/ministry/ministryRegistry.mjs'; + +describe('createRegistry', () => { + let registry; + beforeEach(() => { + registry = createRegistry(); + }); + + test('register adds an entry; pickEligible can find it', () => { + registry.register('a', { + text: 'Hello', + category: 'heading', + scope: 'global', + onHijack: () => {}, + onFlicker: () => {}, + }); + const eligible = registry.pickEligible( + { rng: () => 0, pathname: '/', requireIdle: false }, + ); + expect(eligible?.id).toBe('a'); + }); + + test('unregister removes the entry', () => { + registry.register('a', { + text: 'X', category: 'heading', scope: 'global', + onHijack: () => {}, onFlicker: () => {}, + }); + registry.unregister('a'); + const eligible = registry.pickEligible( + { rng: () => 0, pathname: '/', requireIdle: false }, + ); + expect(eligible).toBeNull(); + }); + + test('global descriptors are eligible everywhere; archives only on /archives*', () => { + registry.register('g', { + text: 'G', category: 'heading', scope: 'global', + onHijack: () => {}, onFlicker: () => {}, + }); + registry.register('a', { + text: 'A', category: 'body', scope: 'archives', + onHijack: () => {}, onFlicker: () => {}, + }); + // On home: only 'g' eligible. + const onHome = []; + registry.forEachEligible({ pathname: '/' }, (id) => onHome.push(id)); + expect(onHome).toEqual(['g']); + + // On /archives: both eligible. + const onArchives = []; + registry.forEachEligible({ pathname: '/archives' }, (id) => onArchives.push(id)); + expect(onArchives.sort()).toEqual(['a', 'g']); + + // On /archives/42: still both eligible (startsWith match). + const onArchives42 = []; + registry.forEachEligible({ pathname: '/archives/42' }, (id) => onArchives42.push(id)); + expect(onArchives42.sort()).toEqual(['a', 'g']); + }); + + test('setIdle controls whether requireIdle filter accepts the entry', () => { + registry.register('a', { + text: 'X', category: 'heading', scope: 'global', + onHijack: () => {}, onFlicker: () => {}, + }); + registry.setIdle('a', false); + const pickedNonIdle = registry.pickEligible( + { rng: () => 0, pathname: '/', requireIdle: true }, + ); + expect(pickedNonIdle).toBeNull(); + + registry.setIdle('a', true); + const pickedIdle = registry.pickEligible( + { rng: () => 0, pathname: '/', requireIdle: true }, + ); + expect(pickedIdle?.id).toBe('a'); + }); + + test('pickEligible returns null when registry is empty', () => { + expect( + registry.pickEligible({ rng: () => 0, pathname: '/', requireIdle: false }), + ).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/ministryRegistry.test.mjs` +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/features/ministry/ministryRegistry.mjs`: + +```js +/** + * Module-level registry for Ministry Interference descriptors. + * + * Lives outside React state — registering/unregistering a Hijackable + * never triggers a React re-render of the provider or its consumers. + * The provider holds one of these in a useRef and shares the API via + * stable context callbacks. + * + * Each descriptor: + * { + * text: string, + * altText?: string, + * category: 'heading' | 'value' | 'body' | 'footer', + * scope: 'global' | 'archives', + * onHijack: (altText: string) => void, + * onFlicker: (charIndex: number, durationMs: number) => void, + * isIdle: boolean (default true), + * } + */ + +function isScopeEligible(scope, pathname) { + if (scope === 'global') return true; + if (scope === 'archives') return pathname.startsWith('/archives'); + return false; +} + +export function createRegistry() { + const entries = new Map(); + + function register(id, descriptor) { + entries.set(id, { ...descriptor, isIdle: true }); + } + + function unregister(id) { + entries.delete(id); + } + + function setIdle(id, isIdle) { + const entry = entries.get(id); + if (entry) entry.isIdle = isIdle; + } + + function forEachEligible({ pathname, requireIdle = false }, fn) { + for (const [id, entry] of entries) { + if (!isScopeEligible(entry.scope, pathname)) continue; + if (requireIdle && !entry.isIdle) continue; + fn(id, entry); + } + } + + function pickEligible({ rng, pathname, requireIdle = false }) { + const eligible = []; + forEachEligible({ pathname, requireIdle }, (id, entry) => + eligible.push({ id, entry }), + ); + if (eligible.length === 0) return null; + const idx = Math.floor(rng() * eligible.length); + return eligible[Math.min(idx, eligible.length - 1)]; + } + + function size() { + return entries.size; + } + + return { register, unregister, setIdle, forEachEligible, pickEligible, size }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/ministryRegistry.test.mjs` +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/ministryRegistry.mjs src/__tests__/unit/features/ministry/ministryRegistry.test.mjs +git commit -m "feat(ministry): add module-level registry (useRef-friendly, no React state)" +``` + +--- + +### Task 5: Ministry context shell + +**Files:** +- Create: `src/features/ministry/MinistryContext.mjs` + +- [ ] **Step 1: Write the file** + +Create `src/features/ministry/MinistryContext.mjs`: + +```js +'use client'; +import { createContext, useContext } from 'react'; + +/** + * Context published by MinistryProvider. Value shape: + * + * { + * register(id, descriptor): void, + * unregister(id): void, + * setIdle(id, isIdle): void, + * warTone: 'winning' | 'losing' | null, + * enabled: boolean, // false when warTone is null OR prefers-reduced-motion + * } + * + * All callbacks are referentially stable (created once in the + * provider). The context value object is created once via useMemo so + * downstream re-renders do NOT trigger when the registry mutates. + */ +export const MinistryContext = createContext(null); + +/** + * Hook to read the Ministry context. Returns null when used outside a + * provider — Hijackable uses that to no-op gracefully so consumers can + * be rendered in tests without wiring up the full provider. + */ +export function useMinistryContext() { + return useContext(MinistryContext); +} +``` + +No tests for this file — pure scaffolding. It's exercised by the provider and Hijackable tests. + +- [ ] **Step 2: Commit** + +```bash +git add src/features/ministry/MinistryContext.mjs +git commit -m "feat(ministry): add MinistryContext + hook scaffolding" +``` + +--- + +### Task 6: MinistryProvider — disabled-state shell + +The provider is large, so we build it in two passes: this task ships a no-op-when-disabled provider so other pieces can integrate against the context shape. Task 7 adds the schedulers. + +**Files:** +- Create: `src/features/ministry/MinistryProvider.jsx` +- Create: `src/__tests__/unit/features/ministry/MinistryProvider.test.jsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/unit/features/ministry/MinistryProvider.test.jsx`: + +```jsx +// @vitest-environment jsdom +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import MinistryProvider from '@/features/ministry/MinistryProvider'; +import { useMinistryContext } from '@/features/ministry/MinistryContext.mjs'; + +let reducedMotion = false; +function setupMatchMedia() { + window.matchMedia = vi.fn((query) => ({ + matches: query.includes('prefers-reduced-motion') ? reducedMotion : false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +} + +function Probe({ onCtx }) { + const ctx = useMinistryContext(); + onCtx(ctx); + return null; +} + +beforeEach(() => { + reducedMotion = false; + setupMatchMedia(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('MinistryProvider — disabled states', () => { + test('warTone null → context.enabled === false; register is a no-op', () => { + let ctx; + render( + + (ctx = c)} /> + , + ); + expect(ctx).not.toBeNull(); + expect(ctx.enabled).toBe(false); + expect(typeof ctx.register).toBe('function'); + // Calling register should not throw and should not record anything we can observe. + ctx.register('x', { + text: 'X', category: 'heading', scope: 'global', + onHijack: () => {}, onFlicker: () => {}, + }); + // Advance time — no scheduler should be running. + act(() => vi.advanceTimersByTime(10 * 60 * 1000)); + // (No assertion needed beyond "didn't throw".) + }); + + test("prefers-reduced-motion: reduce → context.enabled === false even with warTone set", () => { + reducedMotion = true; + setupMatchMedia(); + let ctx; + render( + + (ctx = c)} /> + , + ); + expect(ctx.enabled).toBe(false); + }); + + test("warTone set and reduced-motion off → context.enabled === true", () => { + let ctx; + render( + + (ctx = c)} /> + , + ); + expect(ctx.enabled).toBe(true); + expect(ctx.warTone).toBe('losing'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/MinistryProvider.test.jsx` +Expected: FAIL ("Cannot find module"). + +- [ ] **Step 3: Write minimal implementation** + +Create `src/features/ministry/MinistryProvider.jsx`: + +```jsx +'use client'; +import { useMemo, useRef, useState, useEffect, useCallback } from 'react'; +import { usePathname } from 'next/navigation'; +import { MinistryContext } from '@/features/ministry/MinistryContext.mjs'; +import { createRegistry } from '@/features/ministry/ministryRegistry.mjs'; +import { pickAlt } from '@/features/ministry/ministryContent.mjs'; +import { CYCLE_MS } from '@/features/ministry/useMinistryHijackCycle.mjs'; + +const HIJACK_MIN_MS = 2 * 60 * 1000; +const HIJACK_MAX_MS = 5 * 60 * 1000; +const FLICKER_MIN_MS = 15 * 1000; +const FLICKER_MAX_MS = 30 * 1000; +const FLICKER_DUR_MIN_MS = 150; +const FLICKER_DUR_MAX_MS = 300; + +function randomBetween(min, max, rng) { + return min + rng() * (max - min); +} + +/** + * MinistryProvider — root of the Ministry Interference subsystem. + * + * Nested INSIDE the existing in layout.jsx. Owns: + * - A module-level registry (useRef) so Hijackable mount/unmount + * does NOT trigger context invalidation or React re-renders. + * - Two setTimeout-driven schedulers (hijack + ambient flicker). + * - A `prefers-reduced-motion` matchMedia listener (live). + * - Its own `document.visibilitychange` listener — see plan note for + * why this is NOT shared with LiveDataProvider (LiveDataProvider + * does not expose visibility in its context; one extra listener is + * cheaper than refactoring shared infra). + * - A pathname ref updated on every navigation — scope eligibility + * is evaluated against the ref at pick-time, NOT via a re-render + * dependency, to eliminate the post-navigation stale-scope race. + * + * @param {{ warTone: 'winning' | 'losing' | null, children: React.ReactNode }} props + */ +export default function MinistryProvider({ warTone, children }) { + const registryRef = useRef(createRegistry()); + const pathname = usePathname(); + const pathnameRef = useRef(pathname); + useEffect(() => { + pathnameRef.current = pathname; + }, [pathname]); + + // Reduced-motion: read once on mount via matchMedia and re-evaluate on change. + const [reducedMotion, setReducedMotion] = useState(false); + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReducedMotion(mq.matches); + const onChange = (e) => setReducedMotion(e.matches); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }, []); + + const enabled = warTone !== null && !reducedMotion; + + // Stable callbacks — referentially identical for the lifetime of the provider. + const register = useCallback((id, descriptor) => { + if (!registryRef.current) return; + registryRef.current.register(id, descriptor); + }, []); + + const unregister = useCallback((id) => { + if (!registryRef.current) return; + registryRef.current.unregister(id); + }, []); + + const setIdle = useCallback((id, isIdle) => { + if (!registryRef.current) return; + registryRef.current.setIdle(id, isIdle); + }, []); + + // Stable context value — created once. Map mutations never invalidate it. + const ctxValue = useMemo( + () => ({ register, unregister, setIdle, warTone, enabled }), + [register, unregister, setIdle, warTone, enabled], + ); + + return {children}; +} + +// Exported for the scheduler in the next task — keeps test mocks predictable. +export const _internals = { + HIJACK_MIN_MS, + HIJACK_MAX_MS, + FLICKER_MIN_MS, + FLICKER_MAX_MS, + FLICKER_DUR_MIN_MS, + FLICKER_DUR_MAX_MS, + randomBetween, + pickAlt, + CYCLE_MS, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/MinistryProvider.test.jsx` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/MinistryProvider.jsx src/__tests__/unit/features/ministry/MinistryProvider.test.jsx +git commit -m "feat(ministry): add MinistryProvider shell with disabled-state semantics" +``` + +--- + +### Task 7: MinistryProvider — hijack + flicker schedulers + +Builds on Task 6. Adds the two `setTimeout` schedulers, the path-aware filtering, and the LiveDataProvider-shared visibility wrapping. + +**Files:** +- Modify: `src/features/ministry/MinistryProvider.jsx` (add scheduler effects) +- Modify: `src/__tests__/unit/features/ministry/MinistryProvider.test.jsx` (add scheduler tests) + +- [ ] **Step 1: Write the failing test** + +Append to `src/__tests__/unit/features/ministry/MinistryProvider.test.jsx`: + +```jsx +describe('MinistryProvider — hijack scheduler', () => { + test('fires onHijack with resolved altText after random(2-5 min)', () => { + // rng = 0 → first hijack fires after HIJACK_MIN_MS (2 min). + vi.spyOn(Math, 'random').mockReturnValue(0); + let ctx; + const onHijack = vi.fn(); + render( + + (ctx = c)} /> + , + ); + ctx.register('h', { + text: 'Live Statistics', category: 'heading', scope: 'global', + altText: undefined, onHijack, onFlicker: () => {}, + }); + + act(() => vi.advanceTimersByTime(2 * 60 * 1000)); + + expect(onHijack).toHaveBeenCalledTimes(1); + // rng=0 → pickAlt returns the first entry of winning.heading. + const arg = onHijack.mock.calls[0][0]; + expect(typeof arg).toBe('string'); + expect(arg.length).toBeGreaterThan(0); + }); + + test('explicit altText on descriptor wins over pool lookup', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + let ctx; + const onHijack = vi.fn(); + render( + + (ctx = c)} /> + , + ); + ctx.register('h', { + text: 'My Title', altText: 'Explicit Override', + category: 'heading', scope: 'global', + onHijack, onFlicker: () => {}, + }); + act(() => vi.advanceTimersByTime(2 * 60 * 1000)); + expect(onHijack).toHaveBeenCalledWith('Explicit Override'); + }); + + test('does NOT pick archives-scoped descriptor when pathname is /', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + let ctx; + const onHijack = vi.fn(); + render( + + (ctx = c)} /> + , + ); + ctx.register('a', { + text: 'X', category: 'body', scope: 'archives', + onHijack, onFlicker: () => {}, + }); + act(() => vi.advanceTimersByTime(2 * 60 * 1000)); + expect(onHijack).not.toHaveBeenCalled(); + }); + + test('empty registry → tick reschedules without firing', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + render( {}} />); + // 2 min → no callback (no registrations). 4 min → still no callback. + act(() => vi.advanceTimersByTime(4 * 60 * 1000)); + // (No assertion beyond "didn't throw"; we'd see an error if scheduler crashed.) + }); + + test('flicker timer skips elements with isIdle === false', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + let ctx; + const onFlicker = vi.fn(); + render( + + (ctx = c)} /> + , + ); + ctx.register('f', { + text: 'Hello world', category: 'heading', scope: 'global', + onHijack: () => {}, onFlicker, + }); + ctx.setIdle('f', false); + act(() => vi.advanceTimersByTime(15 * 1000)); + expect(onFlicker).not.toHaveBeenCalled(); + + ctx.setIdle('f', true); + act(() => vi.advanceTimersByTime(15 * 1000)); + expect(onFlicker).toHaveBeenCalledTimes(1); + }); + + test('reduced-motion: reduce → no scheduler ever fires', () => { + reducedMotion = true; + setupMatchMedia(); + vi.spyOn(Math, 'random').mockReturnValue(0); + let ctx; + const onHijack = vi.fn(); + const onFlicker = vi.fn(); + render( + + (ctx = c)} /> + , + ); + ctx.register('h', { + text: 'X', category: 'heading', scope: 'global', + onHijack, onFlicker, + }); + act(() => vi.advanceTimersByTime(10 * 60 * 1000)); + expect(onHijack).not.toHaveBeenCalled(); + expect(onFlicker).not.toHaveBeenCalled(); + }); +}); +``` + +Mock `usePathname` at the top of the test file (add this near the imports, before `setupMatchMedia`): + +```jsx +vi.mock('next/navigation', () => ({ + usePathname: () => '/', +})); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/MinistryProvider.test.jsx` +Expected: 6 new tests fail (schedulers not implemented). + +- [ ] **Step 3: Write minimal implementation** + +Edit `src/features/ministry/MinistryProvider.jsx`. Add this block after the `ctxValue = useMemo(...)` line and before the `return`: + +```jsx + // ─── Hijack scheduler ──────────────────────────────────────────────── + useEffect(() => { + if (!enabled) return; + + let timer = null; + let cycleResetTimer = null; + let cancelled = false; + const rng = Math.random; + const reg = registryRef.current; + + function scheduleNext() { + if (cancelled) return; + const delay = randomBetween(HIJACK_MIN_MS, HIJACK_MAX_MS, rng); + timer = setTimeout(tick, delay); + } + + function tick() { + if (cancelled) return; + try { + const picked = reg.pickEligible({ + rng, + pathname: pathnameRef.current ?? '/', + requireIdle: false, + }); + if (!picked) { + scheduleNext(); + return; + } + const { id, entry } = picked; + const altText = + entry.altText ?? pickAlt(entry.category, warTone, rng); + if (!altText) { + scheduleNext(); + return; + } + reg.setIdle(id, false); + entry.onHijack(altText); + cycleResetTimer = setTimeout(() => { + reg.setIdle(id, true); + scheduleNext(); + }, CYCLE_MS); + } catch { + scheduleNext(); + } + } + + scheduleNext(); + return () => { + cancelled = true; + clearTimeout(timer); + clearTimeout(cycleResetTimer); + }; + }, [enabled, warTone]); + + // ─── Ambient flicker scheduler ────────────────────────────────────── + useEffect(() => { + if (!enabled) return; + + let timer = null; + let cancelled = false; + const rng = Math.random; + const reg = registryRef.current; + + function scheduleNext() { + if (cancelled) return; + const delay = randomBetween(FLICKER_MIN_MS, FLICKER_MAX_MS, rng); + timer = setTimeout(tick, delay); + } + + function tick() { + if (cancelled) return; + try { + const picked = reg.pickEligible({ + rng, + pathname: pathnameRef.current ?? '/', + requireIdle: true, // per-element idle check + }); + if (!picked) { + scheduleNext(); + return; + } + const { entry } = picked; + // Pick a non-space char index from entry.text. + const nonSpaceIndices = []; + for (let i = 0; i < entry.text.length; i++) { + if (entry.text[i] !== ' ') nonSpaceIndices.push(i); + } + if (nonSpaceIndices.length === 0) { + scheduleNext(); + return; + } + const charIdx = + nonSpaceIndices[ + Math.min( + Math.floor(rng() * nonSpaceIndices.length), + nonSpaceIndices.length - 1, + ) + ]; + const dur = randomBetween(FLICKER_DUR_MIN_MS, FLICKER_DUR_MAX_MS, rng); + entry.onFlicker(charIdx, dur); + } catch { + // swallow; reschedule below + } + scheduleNext(); + } + + scheduleNext(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [enabled]); + + // ─── Tab-hidden pause ─────────────────────────────────────────────── + // NOTE: Our own visibility listener — NOT shared from LiveDataProvider. + // The spec hoped to share, but LiveDataProvider doesn't expose visibility + // through its context. One extra event listener is essentially free. + // Pause behavior is implemented by gating inside the tick functions + // (via document.hidden), not by tearing down/re-arming the schedulers. +``` + +**No separate listener required** — the tick functions check `document.hidden` themselves. Add this early-return at the top of BOTH `tick` functions (inside the hijack scheduler effect AND the flicker scheduler effect): + +```jsx +function tick() { + if (cancelled) return; + if (typeof document !== 'undefined' && document.hidden) { + scheduleNext(); + return; + } + // …rest of tick body unchanged… +} +``` + +The `visibilitychange` listener can be omitted entirely because the next scheduled `setTimeout` will fire whether the tab is visible or not — if hidden, the tick re-schedules and re-checks on the next interval. Effectively this means a hidden tab may consume one timer's worth of work per `random(2-5 min)` interval (an essentially-zero cost) without ever firing user-visible effects. Remove the empty `useEffect` block above. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/MinistryProvider.test.jsx` +Expected: All tests pass (3 from Task 6 + 6 new = 9). + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/MinistryProvider.jsx src/__tests__/unit/features/ministry/MinistryProvider.test.jsx +git commit -m "feat(ministry): add hijack + ambient flicker schedulers with idle/scope filters" +``` + +--- + +### Task 8: AmbientFlicker component placeholder (intentionally minimal) + +The ambient timer is owned by the provider (Task 7). The `AmbientFlicker` component was speced as a separate child, but in practice the timer logic naturally belongs inside the provider's `useEffect`. We do NOT create a separate `AmbientFlicker.jsx` file — that's an unnecessary split. + +- [ ] **Step 1: Update the spec file note** + +Edit `docs/superpowers/specs/2026-05-23-ministry-interference-design.md`: add a one-line note under the file table acknowledging that `AmbientFlicker.jsx` was folded into the provider during implementation (cleaner ownership, no behavior change). This is a doc update only. + +```bash +git add docs/superpowers/specs/2026-05-23-ministry-interference-design.md +git commit -m "docs(ministry): note AmbientFlicker folded into provider (no separate file)" +``` + +--- + +### Task 9: Hijackable — idle render (no glitch) + +**Files:** +- Create: `src/features/ministry/Hijackable.jsx` +- Create: `src/__tests__/unit/features/ministry/Hijackable.test.jsx` +- Create: `src/features/ministry/MinistryInterference.css` + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/unit/features/ministry/Hijackable.test.jsx`: + +```jsx +// @vitest-environment jsdom +import { describe, test, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import Hijackable from '@/features/ministry/Hijackable'; + +describe('Hijackable — idle render (no provider)', () => { + test('renders as a plain by default with text content', () => { + const { container } = render(); + const span = container.firstChild; + expect(span.tagName).toBe('SPAN'); + expect(span.textContent).toBe('Hello'); + expect(span.getAttribute('aria-label')).toBeNull(); + expect(span.querySelector('.glitch-char')).toBeNull(); + }); + + test('as="h1" renders as an

      ', () => { + const { container } = render( + , + ); + expect(container.firstChild.tagName).toBe('H1'); + expect(container.firstChild.textContent).toBe('My Title'); + }); + + test('className is applied to the wrapper element', () => { + const { container } = render( + , + ); + expect(container.firstChild.className).toContain('font-display'); + }); + + test('banned categories (nav/button/link) throw a dev assertion', () => { + // In dev (process.env.NODE_ENV !== 'production'), this should throw. + // We invoke the component and assert React surfaces an error. + expect(() => + render(), + ).toThrow(); + expect(() => + render(), + ).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/Hijackable.test.jsx` +Expected: FAIL ("Cannot find module"). + +- [ ] **Step 3: Write minimal implementation** + +Create `src/features/ministry/MinistryInterference.css`: + +```css +/* ================================================================ + Ministry Interference — sitewide easter egg. + Owns the glitch-char rendering style (moved from + CyberstanInterference.css) and the aria-hidden overlay rule. + ================================================================ */ + +/* Single-character glyph in Cyberstan font during glitch */ +.glitch-char { + font-family: var(--font-cyberstan); + font-size: 0.6em; + display: inline-block; + width: 1ch; + overflow: hidden; + vertical-align: baseline; + text-align: center; +} + +/* Overlay container that paints propaganda on top of the truth text. + The wrapper element keeps the truth as its first child (sr-only) + so screen readers always announce the truth; this overlay is + visually positioned over it and is aria-hidden. */ +.ministry-overlay { + /* Inline-level — sits in flow next to its sr-only truth sibling */ + display: inline; +} +``` + +Create `src/features/ministry/Hijackable.jsx` (idle path only — the hijack overlay comes in Task 10): + +```jsx +'use client'; +import './MinistryInterference.css'; + +const VALID_CATEGORIES = new Set(['heading', 'value', 'body', 'footer']); + +/** + * Opt-in wrapper for sitewide Ministry Interference. + * + * In idle (the common case) renders as a plain semantic element with + * the truth text as its only text content — no listeners, no extra + * DOM, no glitch classes. The hijack/flicker rendering paths are + * added in later tasks. + * + * @param {object} props + * @param {string} props.text - the truth (required) + * @param {string=} props.altText - explicit propaganda override + * @param {'heading' | 'value' | 'body' | 'footer'} [props.category='body'] + * @param {'global' | 'archives'} [props.scope='global'] + * @param {string=} props.className + * @param {string=} props.altClassName + * @param {string} [props.as='span'] - wrapper tag + */ +export default function Hijackable({ + text, + altText, + category = 'body', + scope = 'global', + className, + altClassName, + as = 'span', + ...rest +}) { + if (process.env.NODE_ENV !== 'production' && !VALID_CATEGORIES.has(category)) { + throw new Error( + `: category "${category}" is not allowed. Use one of: heading, value, body, footer. (nav/button/link banned by accessibility constraint.)`, + ); + } + const Tag = as; + return ( + + {text} + + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/Hijackable.test.jsx` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/Hijackable.jsx src/features/ministry/MinistryInterference.css src/__tests__/unit/features/ministry/Hijackable.test.jsx +git commit -m "feat(ministry): add Hijackable idle render + dev-mode category guard" +``` + +--- + +### Task 10: Hijackable — hijack overlay + registration + +Adds the registration `useEffect`, the hijack cycle wiring, and the sr-only/aria-hidden overlay during active hijack. + +**Files:** +- Modify: `src/features/ministry/Hijackable.jsx` +- Modify: `src/__tests__/unit/features/ministry/Hijackable.test.jsx` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/unit/features/ministry/Hijackable.test.jsx`: + +```jsx +import { useEffect, useRef } from 'react'; +import { act, render as rtlRender } from '@testing-library/react'; +import { MinistryContext } from '@/features/ministry/MinistryContext.mjs'; +import { vi, beforeEach, afterEach } from 'vitest'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function makeFakeCtx() { + const callbacks = new Map(); + return { + ctx: { + register: vi.fn((id, descriptor) => callbacks.set(id, descriptor)), + unregister: vi.fn((id) => callbacks.delete(id)), + setIdle: vi.fn(), + warTone: 'winning', + enabled: true, + }, + // Fire the registered onHijack callback for the first registered id. + fireHijack(altText) { + const [first] = callbacks.values(); + act(() => first.onHijack(altText)); + }, + }; +} + +describe('Hijackable — provider-wired hijack render', () => { + test('registers on mount via context.register', () => { + const { ctx } = makeFakeCtx(); + rtlRender( + + + , + ); + expect(ctx.register).toHaveBeenCalledTimes(1); + const [id, descriptor] = ctx.register.mock.calls[0]; + expect(typeof id).toBe('string'); + expect(descriptor.text).toBe('My Title'); + expect(descriptor.category).toBe('heading'); + expect(descriptor.scope).toBe('global'); + expect(typeof descriptor.onHijack).toBe('function'); + expect(typeof descriptor.onFlicker).toBe('function'); + }); + + test('unregisters on unmount', () => { + const { ctx } = makeFakeCtx(); + const { unmount } = rtlRender( + + + , + ); + unmount(); + expect(ctx.unregister).toHaveBeenCalledTimes(1); + }); + + test('onHijack call switches render to sr-only truth + aria-hidden propaganda overlay', () => { + const fake = makeFakeCtx(); + const { container } = rtlRender( + + + , + ); + fake.fireHijack('PROPAGANDA'); + const h1 = container.querySelector('h1'); + // Truth still in DOM as sr-only sibling — AT announces it. + const truth = h1.querySelector('.sr-only'); + expect(truth?.textContent).toBe('My Title'); + // Propaganda overlay marked aria-hidden so AT never reads it. + const overlay = h1.querySelector('[aria-hidden="true"]'); + expect(overlay).not.toBeNull(); + }); + + test('after CYCLE_MS, render returns to plain idle (no sr-only, no overlay)', async () => { + const fake = makeFakeCtx(); + const { container } = rtlRender( + + + , + ); + fake.fireHijack('PROPAGANDA'); + // Cycle ends at 2600ms. + act(() => vi.advanceTimersByTime(2600)); + const h1 = container.querySelector('h1'); + expect(h1.querySelector('.sr-only')).toBeNull(); + expect(h1.querySelector('[aria-hidden="true"]')).toBeNull(); + expect(h1.textContent).toBe('My Title'); + }); + + test('without a provider, register/unregister are skipped — component still renders text', () => { + const { container } = rtlRender( + , + ); + expect(container.firstChild.textContent).toBe('No Provider'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/Hijackable.test.jsx` +Expected: New tests fail. + +- [ ] **Step 3: Write minimal implementation** + +Rewrite `src/features/ministry/Hijackable.jsx`: + +```jsx +'use client'; +import { useEffect, useId, useRef, useState, useCallback } from 'react'; +import './MinistryInterference.css'; +import GlitchText from '@/features/archives/GlitchText'; +import { useMinistryContext } from '@/features/ministry/MinistryContext.mjs'; +import { + useMinistryHijackCycle, + TAKEOVER_MS, + RESTORE_MS, +} from '@/features/ministry/useMinistryHijackCycle.mjs'; + +const VALID_CATEGORIES = new Set(['heading', 'value', 'body', 'footer']); + +/** + * Opt-in wrapper for sitewide Ministry Interference. + * + * Idle: a plain semantic element with the truth as its only child. + * + * Hijack: the wrapper element contains two children: + * 1. An `sr-only` with the truth — screen readers always + * announce this, even mid-hijack. + * 2. An `aria-hidden="true"` overlay rendering GlitchText with the + * propaganda string. Visually visible to sighted users; invisible + * to assistive tech. + * + * The sr-only + aria-hidden pattern is explicitly NOT cloaking: the + * propaganda only exists in the DOM for ~2.6s during a rare hijack + * (not persistently), and it's marked aria-hidden the whole time. + * + * @param {object} props + */ +export default function Hijackable({ + text, + altText, + category = 'body', + scope = 'global', + className, + altClassName, + as = 'span', + ...rest +}) { + if (process.env.NODE_ENV !== 'production' && !VALID_CATEGORIES.has(category)) { + throw new Error( + `: category "${category}" is not allowed. Use one of: heading, value, body, footer.`, + ); + } + + const ctx = useMinistryContext(); + const id = useId(); + const cycle = useMinistryHijackCycle(); + const [activeAlt, setActiveAlt] = useState(null); + const [flickerState, setFlickerState] = useState(null); // { charIndex, until } + + const onHijack = useCallback( + (alt) => { + setActiveAlt(alt); + cycle.trigger(); + }, + [cycle], + ); + + const flickerTimerRef = useRef(null); + const onFlicker = useCallback((charIndex, durationMs) => { + clearTimeout(flickerTimerRef.current); + // Capture the glyph at flicker-start so it stays stable for the + // duration even if React re-renders the component for unrelated reasons. + setFlickerState({ charIndex, glyph: randomGlyph() }); + flickerTimerRef.current = setTimeout( + () => setFlickerState(null), + durationMs, + ); + }, []); + + // Reset overlay when the cycle returns to idle. + useEffect(() => { + if (cycle.phase === 'idle') setActiveAlt(null); + }, [cycle.phase]); + + // Mirror local idle state back to the registry so the ambient + // flicker scheduler can skip mid-hijack elements. + useEffect(() => { + if (!ctx) return; + ctx.setIdle(id, cycle.phase === 'idle'); + }, [ctx, id, cycle.phase]); + + // Register on mount, unregister on unmount. + useEffect(() => { + if (!ctx) return; + ctx.register(id, { + text, + altText, + category, + scope, + onHijack, + onFlicker, + }); + return () => { + ctx.unregister(id); + clearTimeout(flickerTimerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); // intentionally NOT in deps: text/altText changes don't re-register + + const Tag = as; + const isHijacking = cycle.phase !== 'idle' && activeAlt; + + // Idle path: just the truth. + if (!isHijacking && !flickerState) { + return ( + + {text} + + ); + } + + // Hijack path: sr-only truth + aria-hidden propaganda overlay. + if (isHijacking) { + return ( + + {text} + + + ); + } + + // Flicker path: render the truth with one char visually replaced + // by a glitch glyph; assistive tech still announces the truth. + if (flickerState) { + const { charIndex, glyph } = flickerState; + return ( + + {text} + + + ); + } +} + +const GLYPH_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; +function randomGlyph() { + return GLYPH_CHARS[Math.floor(Math.random() * GLYPH_CHARS.length)]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/__tests__/unit/features/ministry/Hijackable.test.jsx` +Expected: All tests pass (4 idle + 5 hijack = 9). + +- [ ] **Step 5: Commit** + +```bash +git add src/features/ministry/Hijackable.jsx src/__tests__/unit/features/ministry/Hijackable.test.jsx +git commit -m "feat(ministry): add Hijackable hijack/flicker overlay with sr-only truth" +``` + +--- + +### Task 11: Mount provider in root layout + +**Files:** +- Modify: `src/app/layout.jsx` + +- [ ] **Step 1: Read the current layout** + +Already read at planning time. Lines 56-208 are the relevant area. The provider must wrap ``, `
      `, `
      `, `