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 0565232d..69455574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,167 @@ ## Unreleased +## 0.52.1 + +### Fixes + +- **Fix the `EACCES: permission denied, mkdir '/app/.next/cache'` flood in the production container by chowning the runtime image by numeric uid instead of the unresolvable name `nonroot`.** Root cause: the Chainguard runtime (`cgr.dev/chainguard/node:latest`) has **no `nonroot` entry in `/etc/passwd`** — uid 65532 is named `node` — so the runner-stage `COPY --from=builder --chown=nonroot:nonroot …` lines silently fell back to root (`0:0`). That left `/app/.next` root-owned (mode 755), and since the container runs as uid 65532, the Next.js image optimizer's first remote-avatar optimization (`mkdir('.next/cache/images', { recursive: true })` in `next/dist/server/lib/disk-lru-cache.external.js`, triggered by Discord/GitHub/Google/Gravatar avatars) failed with `EACCES` and rejected on every subsequent cacheable image request. `Dockerfile.app` now uses `--chown=65532:65532` (numeric IDs need no passwd lookup) on all three runner COPY lines, so the runtime user owns the standalone tree as intended and creates `.next/cache` on demand. Verified by reproducing the exact production state (`/app/.next` `uid=0`, `mkdir FAILED:EACCES`) and confirming the numeric-chown image yields `uid=65532` and a successful write. No application code or schema change; the image cache is ephemeral (Postgres holds all persistence). Requires a rebuilt image to deploy — production was on `v0.47.7`, which carries the same defect. + +## 0.52.0 + +### Features + +- **Floating admin-only "Trigger Ministry" widget on every page so the Ministry Interference easter egg can be reproduced on demand instead of waiting on the 2–5 min random scheduler.** Sits bottom-right (above the mobile BottomNav), mounts only when `session.user.role === 'admin'` (server-resolved in `src/app/layout.jsx` via the BetterAuth session lookup; auth-disabled deploys correctly render no widget because `auth` is `null`). Clicking the button calls the new `MinistryContext.forceHijack()` method, which picks the first eligible Hijackable on the current page, fires its `onHijack` cycle with a propaganda string from the existing content pools, and resets idle state after `CYCLE_MS` — same code path the random scheduler uses. Sonner toasts report the outcome: success ("Hijack triggered"), no eligible elements ("No eligible Hijackable on this page"), or disabled state ("Ministry disabled — no war tone resolved"). Admin can stand on any page and reproduce the effect without tab juggling or devtools. + +### Changes + +- **Promote `forceHijack` from the dead `window.__ministry_test__` debug hook to a first-class `MinistryContext` method.** The dev-only `useEffect` that exposed `forceHijack(predicate)` on `window` was orphaned after the Playwright spec it served was replaced with Vitest smoke tests in commit `4ef57c3c`. Repurposed the existing logic into a `useCallback` on the `MinistryContext` value (memoized against `warTone`), updated the context JSDoc to document the new method, and deleted the dead window hook entirely. Public consumer is the new admin-trigger widget; the underlying behavior — pick the first eligible registered descriptor matching the predicate, fire `onHijack(altText)`, reset idle after `CYCLE_MS` — is unchanged. Five new tests in `MinistryProvider.test.jsx` cover the success path, predicate filtering, no-match, `warTone: null`, and scope rejection. + +## 0.51.7 + +### Changes + +- **15 bugs fixed across the `db/queries/` split surface (PRs #411 + #412) from a max-effort code review — one production-broken feature, three concurrency races, one mis-mapped HTTP status, four UI error-handling gaps, plus six smaller correctness fixes.** Patch release. No new behavior; only edge cases get better. Files touched: `src/features/account/actions.mjs`, `src/features/admin/actions.mjs`, `src/features/admin/AdminApiKeys.jsx`, `src/features/account/AccountActions.jsx`, `src/features/account/ApiDashboard.jsx`, `src/shared/utils/api/{authGuards,validateApiKey}.mjs`, `src/app/api/h1/rebroadcast/route.js`, and `src/app/docs/data-flow/page.mdx`, plus matching test updates. + + - **Admin Revoke API key button was completely broken in production.** `
` in `AdminApiKeys.jsx:58` is a bare form action (no `useActionState` wrapper), so Next.js invokes the action with one argument (the FormData). The action's signature was `(_, formData)` — FormData landed in `_` and the named `formData` parameter was `undefined`, throwing `TypeError: Cannot read properties of undefined (reading 'get')` on every click. Fixed by dropping the unused first arg, matching the actual call convention. Tests updated accordingly. + + - **Three TOCTOU races wrapped in `db.$transaction` with `Serializable` isolation.** (1) `updateUserRole` read `db.user.count({ where: { role: ADMIN } })` then `db.user.update` without a transaction — two concurrent admin demotions could both pass the last-admin guard with count=2 and both succeed, leaving zero admins and locking the role out of the admin panel. (2) `toggleUserBan` had the identical count-then-update pattern when banning an admin. (3) `generateApiKey` similarly read `db.ApiKey.count` then called `db.ApiKey.create` non-transactionally, so parallel calls could bypass the 5-key cap. All three now use `db.$transaction(async (tx) => { ... }, { isolationLevel: 'Serializable' })` so the read-then-conditional-write is atomic and concurrent transactions either serialize or one retries. + + - **`validateApiKey` DB outages no longer surface as 401 Unauthorized.** The function previously collapsed Prisma errors and missing-keys into the same `INVALID` code (`if (dbError || !row) return code: INVALID`), so a database outage on `/api/h1/rebroadcast` made operators see a flood of "bad API key" 401s instead of the actual infrastructure failure. Added `API_KEY_ERROR.DB_ERROR` and split the collapse; the route now returns 503 "database unreachable" on DB errors (matching `/api/healthcheck`'s 503 wording) and keeps 401/403 for missing/disabled keys. Pre-existing regression carried verbatim from the old `db/queries/validateApiKey.mjs`. + + - **`deleteUserAccount` Zod-validates input, reorders revoke/delete, fires `revalidatePath`.** (1) The function's JSDoc claimed "Requires email confirmation" / "Must contain userId and confirmEmail fields" but no validation existed and the client never sent `confirmEmail`. Added a Zod schema validating `userId` (matching the sibling `deleteApiKey` pattern) and dropped the false `confirmEmail` claim from the docstring — the existing `window.confirm` dialog remains as the user-facing safeguard (adding an email-confirmation input is a UX decision deferred). (2) Reversed `revokeSessions → delete` order to `delete → revoke` so a transient delete failure leaves the user logged in to retry instead of locked out of a still-existing account. (3) Added the missing `revalidatePath('/profile', 'layout')` that every sibling action already calls, so admin views (UserTable) refresh after a user self-deletes. + + - **Four UI consumers now handle the `result.errors` envelope explicitly.** `ApiDashboard.jsx` rendered "No API keys yet" silently when `getApiKeysByUserId` returned `{ errors: { auth: ... } }` from a mid-render session lapse — now shows a danger-styled "Could not load API keys" message. `AccountActions.jsx::handleExport` produced no toast and no console output when `exportUserData` returned an errors envelope — now fires a Sonner `toast.error`. `AccountActions.jsx::handleDelete` redirected to `/` on _any_ falsy `result?.errors`, including `undefined` (a future regression dropping `return { data: { deleted: true } }` would silently lie about deletion success) — now checks `result?.data?.deleted` explicitly and toasts on every non-success path. Two new test cases pin both `AccountActions.jsx` branches. + + - **`authGuards.mjs` `'use server'` directive removed.** The directive made `requireSession` / `requireUser` / `requireAdmin` callable as RPC server actions even though every importer (`features/admin/actions.mjs`, `features/account/actions.mjs`, `features/archives/reseedSeason.mjs`) is itself a `'use server'` module — they are never reached from `'use client'` code. The directive was carried over verbatim from the old `src/db/queries/_authGuards.mjs` (which had it as an R100 byte-identical rename predecessor). Removing it closes the unintended whoami-probe RPC surface and aligns the file with its `src/shared/utils/api/` siblings (`responses.mjs`, `methodNotAllowed.mjs`, `validateApiKey.mjs`), none of which has the directive. + + - **Six smaller correctness fixes.** (1) `generateApiKey` returned the Prisma model instance after mutating it (`newApiKey['key'] = key`); now returns `{ ...newApiKey, key }` as a fresh DTO to keep the RSC serialization boundary clean. (2) `getSystemStats` did `currentSeason ? : Promise.resolve(0)` — falsy-zero would skip the active-factions count when season 0 (a valid early-war value referenced by `sendTestNotification`) was current; fixed to `currentSeason !== null ?`. (3-5) Three server actions (`updateUserRole`, `adminGetUserApiKeys`, `deleteApiKey`) used raw `formValues.*` for DB queries and ownership checks instead of the validated `check.data.*`; switched to `check.data.*` everywhere to match the `toggleUserBan` / `generateApiKey` convention and pre-empt fragility if a future `.trim()` or `.transform()` is ever added to any of those schemas. (6) `src/app/docs/data-flow/page.mdx` code samples called `isValidStatus(fetchedData)` and `isValidSeason(fetchedData)` as if the validators were callable functions; both are `z.object({...})` instances — switched the doc to `.safeParse(...)` so a reader copy-pasting the example doesn't immediately hit `TypeError: isValidStatus is not a function`. + +## 0.51.6 + +### Changes + +- **Drop the redundant `rootSchema` intermediate from `isValidStatus.mjs` and `isValidSeason.mjs` — all 5 Zod validators now export their schema directly.** Three of the five `src/validators/isValid*.mjs` files (`isValidContentType`, `isValidNumber`, `isValidFormData`) wrote `export const isValidX = z.(...)` directly. The other two bound the root schema to a local `const rootSchema = ...` first and re-exported it on the next line — an unused indirection (the name was never referenced elsewhere in the file). Converged on the direct-export form. The `@typedef {z.infer} StatusPayload` / `@typedef {z.infer} SeasonPayload` annotations continue to work unchanged because they reference the exported name. Pure refactor — no runtime behavior change. Closes the `validator-protocol-unification` cluster and 3 `contract_coherence` findings from /desloppify (issue #406). + +## 0.51.5 + +### Changes + +- **Split `src/db/queries/` by responsibility — pure data-access stays, server actions and boundary helpers move out.** The `src/db/queries/` directory had drifted into a misleading mix of pure DB queries (`get*` / `upsert*`), auth-gated server actions (`admin.mjs`, `api.mjs`, `account.mjs`), and HTTP-boundary helpers (`validateApiKey.mjs`, `_authGuards.mjs`) — all behind a "queries" label. Now reorganized so each layer lives next to its consumer: the seven admin server actions (`getAllUsers`, `updateUserRole`, `toggleUserBan`, `adminGetUserApiKeys`, `adminRevokeApiKey`, `getSystemStats`, `getAllApiKeys`) merge into the existing `src/features/admin/actions.mjs` (next to its UI), the three API-key actions (`getApiKeysByUserId`, `generateApiKey`, `deleteApiKey`) plus the two account lifecycle actions (`exportUserData`, `deleteUserAccount`) consolidate into a new `src/features/account/actions.mjs` (next to `ApiForm.jsx` / `AccountActions.jsx`), the three auth guard helpers (`requireSession` / `requireUser` / `requireAdmin`) move to `src/shared/utils/api/authGuards.mjs` next to `responses.mjs` and `methodNotAllowed.mjs`, and the API-key request validator (`validateApiKey`) moves to `src/shared/utils/api/validateApiKey.mjs` alongside it. After the relocation `src/db/queries/` contains only the pure data-access set (`getCampaign`, `getCascadeLeaderboard`, `getCrossSeasonStats`, `getKillsTrend`, `getPlayersAvg24h`, `rebroadcast`, `upsertEvent*` / `upsertSeason` / `upsertStatistic` / `upsertStatus`). Pure relocation — no behavior changes; imports updated across 9 source files, 7 test files, and 2 docs MDX pages. Closes `db-queries-actions-split` cluster + `db_queri` + `admin_ac` design_coherence findings from /desloppify (issue #406). + +## 0.51.4 + +### Changes + +- **Desloppify mechanical cleanup** — closes three filed issues in one PR. (1) Adds JSDoc `@param` descriptions to `CascadeLog.jsx` (`props`) and `seasonAnalytics.mjs` (`opts`), clearing the two `jsdoc/require-param-description` lint warnings (#400). (2) Renames the cross-file `makeFactionMap` test helpers — `EventCard.test.jsx` uses `makeSectorMap` (it builds a per-faction sectors map for one event card) and `DashboardClient.test.jsx` uses `makeDashboardMap` (it builds the full mapState shape including region 0/11) — each helper is a genuinely different shape (regions 1-11 vs 1-10 vs 0-11 with different field sets), so renaming is more honest than extracting a fake shared abstraction; the third occurrence in `computeFrontier.test.mjs` is already scoped inside a `describe()` block (#401). (3) Annotates the two intentional empty-`catch` swallows in `MinistryProvider.jsx` (flicker scheduler) and `useLiveData.mjs` (`saveCachedState`) — both now capture `err` and `console.debug` it so the swallow is visible during diagnosis, with the existing rationale promoted from inline comment to a fuller multi-line explanation of why each path is non-critical (#399). No runtime behavior change. + +## 0.51.3 + +### Changes + +- **`/api/h1/rebroadcast` reconstruction logic extracted to `src/db/queries/rebroadcast.mjs`; `SEASON_NOT_FOUND` sentinel becomes a named export; rebroadcast snapshots path returns `404` for missing seasons instead of `500`.** The rebroadcast route inlined ~130 LOC of `reconstructCampaignStatus` + `reconstructSnapshots` alongside the POST handler — the same DISTINCT-ON + bucket-merge logic already encapsulated in `db/queries/`, just producing a different wire shape. Moves both functions out into `src/db/queries/rebroadcast.mjs` so the route file becomes pure orchestration (147 LOC → 152 LOC handler kept; ~140 LOC of data-access removed from route layer). The `SEASON_NOT_FOUND` magic string used as `Error.cause` in `src/update/season.mjs` was previously a hand-typed literal duplicated across the throw site and the campaign-route consumer (`fetchError.cause === 'SEASON_NOT_FOUND'`); now exported as a named constant from `src/update/season.mjs` and imported by both campaign and rebroadcast routes — typos fail at `tsc --noEmit` time. **Behavior change:** the rebroadcast `get_snapshots` backfill path previously returned `500` when the requested season didn't exist on the HD1 API; now correctly returns `404`, matching the campaign route's existing behavior. Closes the `rebroadcast-getcampaign-consolidation` cluster + `rebroadcast-churn-hotspot-decoupling` strategic issue from /desloppify (issue #406). + +## 0.51.2 + +### Changes + +- **Type-safety: Zod-inferred validator types + shared enum literal typedefs + tightened JSDoc across the event pipeline.** Adds `@typedef {z.infer} TypeName` exports to `isValidStatus.mjs` (`StatusPayload`), `isValidSeason.mjs` (`SeasonPayload`), and `isValidFormData.mjs` (`FormDataPayload`) so downstream consumers can reference the real wire-format shape instead of typing as `object`. Adds five literal-union typedefs (`EventType`, `EventStatus`, `CampaignStatus`, `MapStatus`) plus a shared `Event` shape and `EventChangeKind` union (`'event_started'|'event_won'|'event_lost'|'catch_up'`) to `src/shared/enums/events.mjs`. Tightens previously widened JSDoc on `detectChanges.mjs` (return shape now refers to the shared `Event` type instead of `event: object`), `EventToast.jsx` (both `toastLabel` and `showEventToast` now consume `Event` + `EventChangeKind`), and consolidates the duplicate `LiveStatus` typedef declaration between `useLiveData.mjs` and `LiveDataContext.mjs` — the hook is now the single source of truth and the context imports via `@typedef {import('...').LiveStatus} LiveStatus`. Pure refactor — no runtime behavior change; protects momentum on the only improving high-weight desloppify dimension (type_safety 55 → 68 → 71). + +## 0.51.1 + +### Changes + +- **Shared auth-guard helpers (`requireSession` / `requireUser` / `requireAdmin`) replace 5+ inlined session+ownership patterns across `src/db/queries/` and `src/features/`.** The auth-guard pattern (`if (!auth) → session lookup → optional ownership/role check → return uniform error envelope`) was duplicated 5 times in `api.mjs` (`getApiKeysByUserId`, `generateApiKey`, `deleteApiKey`), 2 times in `account.mjs` (`exportUserData`, `deleteUserAccount`), and reimplemented inline in `features/archives/reseedSeason.mjs` and `features/admin/actions.mjs` (`sendTestNotification`) — each with a slightly different error string for the same auth-failure condition (`'No session found'`, `'User does not match'`, `"You must be signed in to generate an API key"`, `"You don't have permission to delete this API key"`, `'Unauthorized'`, `'Forbidden'`). Extracted to a new `src/db/queries/_authGuards.mjs` module returning a `{ user, error }` discriminated-union with both keys always present (one nullable) — matching how callers were already destructuring the previous `requireAdmin` and fixing a long-standing JSDoc lie. Error strings standardize to `'Auth not configured'` / `'Not authenticated'` / `'Not authorized'` / `'Forbidden'`, which also distinguishes "no session at all" from "wrong user" in places (e.g. `deleteApiKey`) that previously collapsed both into one generic "permission" message. Closes the auth_consistency 87.5 → 80.0 strict-score regression flagged by `/desloppify`. Affected tests updated to assert the new uniform strings. + +## 0.51.0 + +### Features + +- **Cross-season Cascade Failures section on `/stats` plus a per-season cascade log on `/archives` (#272).** A "cascade" is a sequence of failed defends for one faction with strictly decreasing region numbers and no more than a 1-hour gap between consecutive events — the back-to-back collapses that mark a war's worst moments (e.g. season 155's Illuminate push from region 8 all the way to the homeworld). New `findAllCascades` algorithm in `seasonAnalytics.mjs` returns every qualifying cascade with min length 3, sorted by length DESC then speed (regions/hour) DESC then `endTime` DESC. A new `getCascadeLeaderboard()` cached server query pulls all failed defends in one indexed Prisma read and groups by season. On `/stats` a new `` section renders between **War Outcomes & Streaks** and **All-Time Records**, mirroring the existing `EventLog` layout (`event-log-section` → header + sort toggle → day-grouped grid) but grouped by season instead of by day. A persisted cookie (`cascade-log-sort`) tracks the user's choice between "worst first" (default) and "recent first"; an auto-generated lede sentence summarizes the dataset (`"N cascades across M wars. Worst: season X, where the FACTION pushed all the way home / swept N regions in DURATION."`). Each cascade card shows faction icon + title (`Defend cascade · N regions`), a duration pill, the start/end timestamp line, and the faction-colored region chain (`8 → 7 → 6 → … → 0`); clicking it deep-links to `/archives?season=N#cascade`. On `/archives` the same component renders below the `StatGrid`, filtered to the current season via `findAllCascades(events)` — same visual grammar, no `lede` prop. The cascade chain is the only genuinely new visual element; the existing `EventLog.css` provides the section layout via three additive classes (`.event-log-card-chain`, `.event-log-card--cascade`, `.event-log-lede`). Per-season outcome strings in group headers (`"1 cascade · Defeat"`) are deferred to a follow-up. + +### Changes + +- **`findWorstCascade` and the `WORST_CASCADE` stat card on `/archives` are removed.** The legacy detection helper had no time-gap awareness and accepted length-2 sequences with arbitrary spacing, so it could surface stale streaks weeks apart as "cascades". Replaced by the stricter `findAllCascades` algorithm above, with the dedicated `` panel taking over the storytelling that the lone stat card used to gesture at. The archives extras grid keeps OUTCOME, DEFENSE_RATE, ATTACK_RATE, and AVG_DIFFICULTY; the cascade story moves into its own section directly below. + +## 0.50.0 + +### Features + +- **Sitewide "Ministry Interference" easter egg replaces the archives-only Cyberstan effect.** A single root-level `` (mounted inside the existing `` in `layout.jsx`) drives two `setTimeout`-based schedulers across the whole app: a **rare hijack** every 2-5 minutes picks one opt-in `` element at random and runs a 2.6-second `takeover → hold → restore` glitch cycle on it; a separate **ambient micro-flicker** every 15-30 seconds swaps one random character of one random element to a Cyberstan glyph for 150-300ms. Both schedulers honor `prefers-reduced-motion: reduce` (no manual toggle survives — the old `EffectsToggle` ⚡ button is gone). Tone is computed server-side from humanity's all-time war record via the existing `getCrossSeasonStats()` (React-`cache()`d, no extra DB hit): `'winning'` (≥50% completed wars won) → sardonic Resistance hackers mock the regime's victory framing; `'losing'` → an Underground pirate-radio broadcast cuts in with surveillance-state imagery aimed at the regime. On DB errors or zero completed wars `getWarTone()` returns `null` and the effect disables entirely rather than forcing a tone. Accessibility-first: during a hijack the wrapper element keeps an `sr-only` truth sibling so assistive tech always reads the real text, while the propaganda is rendered in an `aria-hidden` overlay (no visible/accessible-name divergence, no WCAG 2.5.3 risk). Banned categories (`nav`, `button`, `link`) throw in dev mode. **96 propaganda strings** across 8 pools (4 categories × 2 tones), 12-entry minimum enforced via a Vitest assertion. **v1 wrapping scope** is intentionally narrow — h1/h2 headings on the dashboard, archives header (h1 + body), archives OUTCOME card, `/stats`, `/legal`, `/docs/brandkit`, `/sign-in` — with nav/buttons/footer/stat-values deferred until layout-shift is measured. The existing `GlitchText.jsx` rendering machinery is reused unchanged; the old `useGlitchCycle`, `useCyberstanEffects`, `resistanceMessages`, `CyberstanInterference.css`, and `EffectsToggle` are deleted. The full design went through a 3-round adversarial AI debate before implementation; spec and plan archived under `docs/superpowers/specs/` and `docs/superpowers/plans/`. + +## 0.49.0 + +### 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. + +### Changes + +- **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. + +## 0.48.0 + +### 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 + +- **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 + +- **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 + +- **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 highlight changes only stroke and fill — never `filter` — and its lost-sector rule never matches an active sector, so it composes cleanly with the red pulse animation. Purely a `Map.css` restyle; the `sectorLink.mjs` class-toggling logic and its 5 tests are unchanged. + +## 0.47.14 + +### 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 + +- **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. + +### Changes + +- **War-duration card shows a start date.** The `WAR_DURATION` stat card's subtitle now shows the date the span began — war start on the global tab, faction introduction date on a faction tab (`DD MONTH`, UTC) — rather than a repeated humanised duration, so the value and subtitle read as a coherent pair. +- **`HELLDIVERS_LOST` teamkill subtitle relabelled "Martyrs".** The accidental-death subtitle showed the count plus its rate as a percentage of total deaths; it now shows the count with a `Martyrs` label and drops the percentage. + +## 0.47.12 + +### 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 + +- **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 + +- **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/CLAUDE.md b/CLAUDE.md index 4402deb3..0b7a8f0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,29 @@ After any frontend/CSS change, verify via DevTools before declaring done: - For grid/flex: check parent-child sizing chain - For interactive changes: programmatically trigger state changes and verify DOM updates +## Worktree Workflow + +Features use an isolated git worktree off `develop`; small chores commit directly on a branch (no worktree). Both still follow the rules in § Git Workflow. + +**When to use a worktree (features):** new functionality, multi-file refactors, anything large enough to warrant a PR, anything that benefits from isolation while iterating. Default for any task you'd otherwise raise a feature branch for. + +**When to skip the worktree (small chores/bugfixes):** dependency bumps, `npm audit` fixes, doc edits, lint/format passes, copy tweaks, single-call-site bugfixes, CLAUDE.md/CHANGELOG updates. Branch from `develop` in the main checkout, commit, merge with `git merge --no-ff` per § Git Workflow. Use judgment; if unsure, default to a worktree. + +**Feature workflow (worktree):** + +1. Create the worktree off `develop` (run from the main checkout): + `git worktree add .worktrees/ -b feature/ develop` +2. Copy gitignored env files from the main checkout: `cp ../../.env.development .` (and any `.env.local` if present — `*.env*` is gitignored, so the dev server can't boot without them) +3. Install dependencies in the worktree: `npm install && npx prisma generate` (Prisma client outputs to `src/generated/prisma/` which is gitignored, so it must be regenerated per worktree) +4. Do the work in the worktree directory — small, logical commits as you go, not one giant commit at the end +5. Verify in the worktree: `npm run lint`, `npm run typecheck`, `npm run test:unit`, `npm run build` (all four must pass — same chain as § Critical Rules) +6. Merge back from the main checkout: `git checkout develop && git merge --no-ff feature/` — include the version bump + CHANGELOG move into `## X.Y.Z` in the merge commit per § Git Workflow rule #2 +7. Push `develop`, then clean up: `git worktree remove .worktrees/` + `git branch -d feature/` + +**Worktree directory:** `.worktrees/` in project root (already gitignored). Directory names mirror the branch with slashes replaced by hyphens (e.g., `feature/ministry-interference` → `.worktrees/feature-ministry-interference`). + +**Prisma migrations:** If the branch creates a migration under `prisma/migrations/`, remind the user to run `npx prisma migrate deploy` against the local database after merging, before the next dev-server restart. + ## Git Workflow **Branching model:** Simplified Git Flow — no release branches. @@ -54,7 +77,7 @@ After any frontend/CSS change, verify via DevTools before declaring done: **Rules:** 0. **Never squash merge. Never fast-forward merge.** Always use `git merge --no-ff` so every merge creates a merge commit and the branch boundary stays visible in `git log --graph`. Never `--squash`, never `--rebase`, never `--ff-only`. -1. **Create feature/bugfix/chore branches from `develop`.** Features merge back via PR. Bugfix and chore branches merge via `git merge --no-ff` directly into `develop` (branch → commit → `git checkout develop && git merge --no-ff ` → push → delete branch). No PR needed. +1. **Create feature/bugfix/chore branches from `develop`.** Features use a worktree (see § Worktree Workflow) and merge back via PR. Bugfix and chore branches skip the worktree and merge via `git merge --no-ff` directly into `develop` (branch → commit → `git checkout develop && git merge --no-ff ` → push → delete branch). No PR needed. 2. **Version on merge to `develop`:** When merging a branch into `develop`, **in the same commit** move its changelog entries from `## Unreleased` into a new `## X.Y.Z` section and bump `"version"` in `package.json` to match. Do not defer this to a separate commit or ask — it is part of the merge step. Use semver: patch for bugfixes, minor for features, major for breaking changes. Skipping version numbers between releases is fine — not every version on `develop` will be tagged on `main`. 3. **Release process:** Merge `develop` → `main` via PR → **tag `vX.Y.Z` on the merge commit on `main`** (use the latest version from `CHANGELOG.md`) → push tag → **merge `main` back into `develop`** (`git checkout develop && git merge origin/main && git push`). The production Docker build only triggers on version tags, so forgetting to tag means no deployment. The merge-back carries main's PR merge commit into develop so the next release PR doesn't trip the "branch not up to date" check. 4. **Hotfix process:** Cut `hotfix/X.Y.Z` from `main` → fix → update `CHANGELOG.md` with new version section → PR to `main` → tag `vX.Y.Z` → merge back to `develop` @@ -142,7 +165,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/Dockerfile.app b/Dockerfile.app index 46a1569e..67d4542f 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -103,9 +103,18 @@ ARG NEXT_PUBLIC_DEPLOY_ENV ENV NEXT_PUBLIC_DEPLOY_ENV=$NEXT_PUBLIC_DEPLOY_ENV # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./ -COPY --from=builder --chown=nonroot:nonroot /app/.next/static ./.next/static -COPY --from=builder --chown=nonroot:nonroot /app/public ./public +# +# Chown by NUMERIC uid:gid, NOT the name `nonroot`. This Chainguard runtime +# has no `nonroot` entry in /etc/passwd (uid 65532 is named `node`), so +# `--chown=nonroot:nonroot` silently falls back to root (0:0). That left +# /app/.next root-owned and made the Next.js image optimizer's runtime +# `mkdir('.next/cache/images')` fail with EACCES, flooding logs with +# unhandledRejection on every remote-avatar (Discord/GitHub/Google/Gravatar) +# optimization. Numeric IDs need no passwd lookup, so 65532 (the runtime +# user) owns the tree as intended and the cache dir is created on demand. +COPY --from=builder --chown=65532:65532 /app/.next/standalone ./ +COPY --from=builder --chown=65532:65532 /app/.next/static ./.next/static +COPY --from=builder --chown=65532:65532 /app/public ./public EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" diff --git a/docs/superpowers/plans/2026-05-23-cascade-failure-log.md b/docs/superpowers/plans/2026-05-23-cascade-failure-log.md new file mode 100644 index 00000000..245e0855 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-cascade-failure-log.md @@ -0,0 +1,1967 @@ +# Cascade Failure Log 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:** Build a cross-season cascade-failure leaderboard rendered as an `EventLog`-style section on `/stats` (grouped by season), reusing the dashboard's `EventLog` layout idioms. The same component renders on `/archives` filtered to a single season. + +**Architecture:** Replace `findWorstCascade` with a stricter `findAllCascades` (1-hour gap rule, min length 3, returns every qualifying cascade). A new server query (`getCascadeLeaderboard`) aggregates cascades across all seasons. A new `` client component mirrors `` — same `
    ` shape, day-grouped layout, sort toggle. New helpers: `groupCascadesBySeason`, `generateCascadeLede`, `useCascadeLogSort`, `CascadeLogSortToggle`. No new visual primitives; the cascade chain (`8 → 7 → 6 → … → 0`) is the one new rendered element. + +**Tech Stack:** Next.js 16 (App Router), React 19, Vitest + React Testing Library, Prisma 7, Tailwind v4, ESLint v9, JSDoc + `tsc --noEmit` (no TypeScript files). + +**Spec:** `docs/superpowers/specs/2026-05-23-cascade-failure-log-design.md` (committed at `d407eac` on `feature/cascade-failure-log`). + +**Issue:** [#272](https://github.com/elfensky/helldivers.bot/issues/272). + +--- + +## File Structure + +**Added:** + +| File | Responsibility | +|---|---| +| `src/db/queries/getCascadeLeaderboard.mjs` | Cross-season cascade aggregation. One Prisma query, React-cached. | +| `src/features/timeline/groupCascadesBySeason.mjs` | Group + sort cascades by season for `CascadeLog`. | +| `src/features/timeline/CascadeLog.jsx` | Client component, mirror of `EventLog`. | +| `src/features/timeline/CascadeLogCard.jsx` | Single-cascade card. | +| `src/features/timeline/CascadeLogSortToggle.jsx` | Sort toggle, mirror of `EventLogSortToggle`. | +| `src/features/timeline/useCascadeLogSort.mjs` | Persisted sort hook. | +| `src/features/stats/generateCascadeLede.mjs` | Pure lede-string builder. | +| `src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs` | Unit tests (Prisma mocked). | +| `src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs` | Unit tests. | +| `src/__tests__/unit/features/timeline/CascadeLog.test.jsx` | RTL tests. | +| `src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx` | RTL tests. | +| `src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs` | Hook test. | +| `src/__tests__/unit/features/stats/generateCascadeLede.test.mjs` | Unit tests. | + +**Modified:** + +| File | Change | +|---|---| +| `src/shared/utils/game/seasonAnalytics.mjs` | Replace `findWorstCascade` with `findAllCascades`. | +| `src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs` | Replace tests. | +| `src/features/archives/ArchiveStats.jsx` | Remove `WORST_CASCADE` `` and `findWorstCascade` import. | +| `src/__tests__/unit/features/archives/ArchiveStats.test.jsx` | Remove `WORST_CASCADE` assertions. | +| `src/shared/preferences/sortOrder.mjs` | Add `CASCADE_SORT_ORDER_KEY` + `validateCascadeSortOrder`. | +| `src/features/timeline/EventLog.css` | Add `.event-log-card-chain` + `.event-log-lede` classes. | +| `src/app/stats/page.jsx` | Insert `` section. | +| `src/features/archives/ArchivesClient.jsx` | Render `` below the StatGrid. | + +--- + +## Task 1: `findAllCascades` algorithm + +**Files:** +- Modify: `src/shared/utils/game/seasonAnalytics.mjs` +- Modify: `src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs` + +This task adds `findAllCascades` next to the existing `findWorstCascade`. The old function stays for one more task to keep `ArchiveStats.jsx` building. Task 2 will remove both. + +- [ ] **Step 1: Write the failing tests** + +Replace the contents of `src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs` with: + +```js +import { describe, it, expect } from 'vitest'; +import { + findAllCascades, + findWorstCascade, +} from '@/shared/utils/game/seasonAnalytics.mjs'; + +/** + * Helper to build a defend/fail event. `gapAfterPrevEndSec` defaults to + * 1800 (30 minutes), well inside the 1-hour cascade window. + */ +function makeFailedDefend({ enemy, region, prevEndTime = null, durationSec = 7200 }) { + const start_time = prevEndTime != null ? prevEndTime + 1800 : 0; + const end_time = start_time + durationSec; + return { + type: 'defend', + status: 'fail', + enemy, + region, + start_time, + end_time, + event_id: Math.floor(Math.random() * 1_000_000), + }; +} + +describe('findAllCascades', () => { + it('returns [] for empty events', () => { + expect(findAllCascades([])).toEqual([]); + expect(findAllCascades(null)).toEqual([]); + expect(findAllCascades(undefined)).toEqual([]); + }); + + it('returns [] when fewer than 3 failed defends total', () => { + const e1 = makeFailedDefend({ enemy: 2, region: 8 }); + const e2 = makeFailedDefend({ enemy: 2, region: 7, prevEndTime: e1.end_time }); + expect(findAllCascades([e1, e2])).toEqual([]); + }); + + it('returns [] for a length-3 sequence that fails the gap rule', () => { + const e1 = makeFailedDefend({ enemy: 2, region: 8 }); + // 2-hour gap (> 1-hour rule) → cascade breaks + const e2 = { + ...makeFailedDefend({ enemy: 2, region: 7 }), + start_time: e1.end_time + 7200, + end_time: e1.end_time + 7200 + 7200, + }; + const e3 = { + ...makeFailedDefend({ enemy: 2, region: 6 }), + start_time: e2.end_time + 1800, + end_time: e2.end_time + 1800 + 7200, + }; + // Three events but only the last two are back-to-back → length 2 < 3 + expect(findAllCascades([e1, e2, e3])).toEqual([]); + }); + + it('detects a length-3 cascade for one faction', () => { + const e1 = makeFailedDefend({ enemy: 2, region: 8 }); + const e2 = makeFailedDefend({ enemy: 2, region: 7, prevEndTime: e1.end_time }); + const e3 = makeFailedDefend({ enemy: 2, region: 6, prevEndTime: e2.end_time }); + const result = findAllCascades([e1, e2, e3]); + expect(result).toHaveLength(1); + expect(result[0].length).toBe(3); + expect(result[0].faction).toBe('The Illuminate'); + expect(result[0].factionIndex).toBe(2); + expect(result[0].regions).toEqual([8, 7, 6]); + expect(result[0].startTime).toBe(e1.start_time); + expect(result[0].endTime).toBe(e3.end_time); + expect(result[0].durationSec).toBe(e3.end_time - e1.start_time); + expect(result[0].firstEvent.event_id).toBe(e1.event_id); + expect(result[0].lastEvent.event_id).toBe(e3.event_id); + expect(result[0].events).toHaveLength(3); + }); + + it('ignores non-defend and non-fail events', () => { + const events = [ + { type: 'attack', status: 'success', enemy: 0, region: 5, start_time: 0, end_time: 100 }, + { type: 'defend', status: 'success', enemy: 0, region: 4, start_time: 200, end_time: 300 }, + { type: 'defend', status: 'fail', enemy: 0, region: 3, start_time: 400, end_time: 500 }, + ]; + expect(findAllCascades(events)).toEqual([]); + }); + + it('breaks the cascade when region does not strictly decrease', () => { + const e1 = makeFailedDefend({ enemy: 0, region: 5 }); + const e2 = makeFailedDefend({ enemy: 0, region: 5, prevEndTime: e1.end_time }); // plateau + const e3 = makeFailedDefend({ enemy: 0, region: 4, prevEndTime: e2.end_time }); + // Plateau breaks the cascade; running length goes 1 → 1 → 2. None reaches 3. + expect(findAllCascades([e1, e2, e3])).toEqual([]); + }); + + it('keeps cascades from separate factions independent', () => { + // Bugs cascade of 3 + const b1 = makeFailedDefend({ enemy: 0, region: 4 }); + const b2 = makeFailedDefend({ enemy: 0, region: 3, prevEndTime: b1.end_time }); + const b3 = makeFailedDefend({ enemy: 0, region: 2, prevEndTime: b2.end_time }); + // Illuminate cascade of 4 (interleaved by end_time) + const i1 = { ...makeFailedDefend({ enemy: 2, region: 8 }), end_time: b1.end_time + 60 }; + const i2 = { ...makeFailedDefend({ enemy: 2, region: 7 }), start_time: i1.end_time + 600, end_time: i1.end_time + 600 + 7200 }; + const i3 = { ...makeFailedDefend({ enemy: 2, region: 6 }), start_time: i2.end_time + 600, end_time: i2.end_time + 600 + 7200 }; + const i4 = { ...makeFailedDefend({ enemy: 2, region: 5 }), start_time: i3.end_time + 600, end_time: i3.end_time + 600 + 7200 }; + + const result = findAllCascades([b1, i1, b2, i2, b3, i3, i4]); + expect(result).toHaveLength(2); + expect(result[0].length).toBe(4); // Illuminate first (longer) + expect(result[0].factionIndex).toBe(2); + expect(result[1].length).toBe(3); + expect(result[1].factionIndex).toBe(0); + }); + + it('emits multiple cascades from the same faction when separated by a gap', () => { + // Cascade A of length 3 (Bugs) + const a1 = makeFailedDefend({ enemy: 0, region: 5 }); + const a2 = makeFailedDefend({ enemy: 0, region: 4, prevEndTime: a1.end_time }); + const a3 = makeFailedDefend({ enemy: 0, region: 3, prevEndTime: a2.end_time }); + // Big gap (1 day) — should break the chain + const gapEnd = a3.end_time + 86400; + // Cascade B of length 3 (Bugs) + const b1 = { ...makeFailedDefend({ enemy: 0, region: 6 }), start_time: gapEnd, end_time: gapEnd + 7200 }; + const b2 = makeFailedDefend({ enemy: 0, region: 5, prevEndTime: b1.end_time }); + const b3 = makeFailedDefend({ enemy: 0, region: 4, prevEndTime: b2.end_time }); + + const result = findAllCascades([a1, a2, a3, b1, b2, b3]); + expect(result).toHaveLength(2); + expect(result.every((c) => c.length === 3 && c.factionIndex === 0)).toBe(true); + }); + + it('respects custom minLength', () => { + const e1 = makeFailedDefend({ enemy: 1, region: 4 }); + const e2 = makeFailedDefend({ enemy: 1, region: 3, prevEndTime: e1.end_time }); + const e3 = makeFailedDefend({ enemy: 1, region: 2, prevEndTime: e2.end_time }); + // Default min 3 → 1 result + expect(findAllCascades([e1, e2, e3])).toHaveLength(1); + // Min 4 → none + expect(findAllCascades([e1, e2, e3], { minLength: 4 })).toHaveLength(0); + // Min 2 → 1 result still (only one cascade in input) + expect(findAllCascades([e1, e2, e3], { minLength: 2 })).toHaveLength(1); + }); + + it('sorts by length DESC, then speed DESC, then end_time DESC', () => { + // Cascade A: length 3, slow (3h per event = 9h total) + const a1 = { type: 'defend', status: 'fail', enemy: 0, region: 5, start_time: 0, end_time: 10800, event_id: 1 }; + const a2 = { type: 'defend', status: 'fail', enemy: 0, region: 4, start_time: 12000, end_time: 22800, event_id: 2 }; + const a3 = { type: 'defend', status: 'fail', enemy: 0, region: 3, start_time: 24000, end_time: 34800, event_id: 3 }; + // Cascade B: length 3, fast (1h per event = 3h total) — faster, should rank first + const b1 = { type: 'defend', status: 'fail', enemy: 2, region: 5, start_time: 100000, end_time: 103600, event_id: 4 }; + const b2 = { type: 'defend', status: 'fail', enemy: 2, region: 4, start_time: 104000, end_time: 107600, event_id: 5 }; + const b3 = { type: 'defend', status: 'fail', enemy: 2, region: 3, start_time: 108000, end_time: 111600, event_id: 6 }; + + const result = findAllCascades([a1, a2, a3, b1, b2, b3]); + expect(result).toHaveLength(2); + expect(result[0].factionIndex).toBe(2); // faster cascade ranks first + expect(result[1].factionIndex).toBe(0); + }); +}); + +describe('findWorstCascade (legacy — kept for one task)', () => { + it('is still exported until Task 2', () => { + expect(typeof findWorstCascade).toBe('function'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs +``` + +Expected: ALL `findAllCascades` tests FAIL with `findAllCascades is not exported`. The `findWorstCascade` test passes. + +- [ ] **Step 3: Implement `findAllCascades`** + +Replace `src/shared/utils/game/seasonAnalytics.mjs` with: + +```js +import { EVENT_TYPE, EVENT_STATUS } from '@/shared/enums/events.mjs'; +import factions from '@/shared/enums/factions.mjs'; + +const MAX_GAP_SEC = 3600; // 1 hour + +/** + * Find the worst cascade failure in a season — the longest sequence of + * consecutive failed defenses for a single faction with decreasing region + * numbers. Legacy single-result helper, kept until Task 2 of the cascade + * leaderboard implementation removes it. + * + * @deprecated Use {@link findAllCascades} instead. + * @param {Array} events + * @returns {{ length: number, faction: string, regions: number[], firstEvent: object }|null} + */ +export function findWorstCascade(events) { + if (!events?.length) return null; + const failedDefends = events + .filter((e) => e.type === EVENT_TYPE.DEFEND && e.status === EVENT_STATUS.FAIL) + .sort((a, b) => a.end_time - b.end_time); + if (failedDefends.length < 2) return null; + let bestCascade = null; + const currentByFaction = {}; + for (const e of failedDefends) { + const key = e.enemy; + const current = currentByFaction[key]; + if (current && e.region < current.regions[current.regions.length - 1]) { + current.regions.push(e.region); + } else { + currentByFaction[key] = { enemy: key, regions: [e.region], firstEvent: e }; + } + const cascade = currentByFaction[key]; + if (cascade.regions.length >= 2) { + if (!bestCascade || cascade.regions.length > bestCascade.regions.length) { + bestCascade = { ...cascade }; + } + } + } + if (!bestCascade) return null; + return { + length: bestCascade.regions.length, + faction: factions[bestCascade.enemy]?.name ?? 'Unknown', + regions: bestCascade.regions, + firstEvent: bestCascade.firstEvent, + }; +} + +/** + * Return every cascade in `events`, sorted by length DESC, then by speed + * (regions per hour) DESC, then by `end_time` DESC. A cascade is a sequence + * of failed defenses for one faction with strictly decreasing region numbers + * and consecutive events within `MAX_GAP_SEC` (1 hour). + * + * @param {Array} events - h1_event records (any type, any status) + * @param {object} [opts] + * @param {number} [opts.minLength=3] - Inclusive minimum cascade length + * @returns {Array<{ + * length: number, + * faction: string, + * factionIndex: number, + * regions: number[], + * startTime: number, + * endTime: number, + * durationSec: number, + * firstEvent: object, + * lastEvent: object, + * events: object[], + * }>} + */ +export function findAllCascades(events, { minLength = 3 } = {}) { + if (!events?.length) return []; + + const failedDefends = events + .filter((e) => e.type === EVENT_TYPE.DEFEND && e.status === EVENT_STATUS.FAIL) + .sort((a, b) => a.end_time - b.end_time); + + if (failedDefends.length < minLength) return []; + + const cascades = []; + const open = new Map(); // factionIndex → { events: [] } + + for (const e of failedDefends) { + const cur = open.get(e.enemy); + if (cur) { + const last = cur.events[cur.events.length - 1]; + const decreasing = e.region < last.region; + const inWindow = e.start_time - last.end_time <= MAX_GAP_SEC; + if (decreasing && inWindow) { + cur.events.push(e); + continue; + } + if (cur.events.length >= minLength) cascades.push(emit(cur)); + } + open.set(e.enemy, { events: [e] }); + } + for (const cur of open.values()) { + if (cur.events.length >= minLength) cascades.push(emit(cur)); + } + + cascades.sort(compareCascades); + return cascades; +} + +function emit({ events }) { + const first = events[0]; + const last = events[events.length - 1]; + return { + length: events.length, + factionIndex: first.enemy, + faction: factions[first.enemy]?.name ?? 'Unknown', + regions: events.map((e) => e.region), + startTime: first.start_time, + endTime: last.end_time, + durationSec: last.end_time - first.start_time, + firstEvent: first, + lastEvent: last, + events, + }; +} + +function compareCascades(a, b) { + if (b.length !== a.length) return b.length - a.length; + const aSpeed = a.length / (a.durationSec / 3600); + const bSpeed = b.length / (b.durationSec / 3600); + if (bSpeed !== aSpeed) return bSpeed - aSpeed; + return b.endTime - a.endTime; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/utils/game/seasonAnalytics.mjs \ + src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs +git commit -m "feat(analytics): add findAllCascades alongside findWorstCascade + +Stricter cascade detection: 1-hour gap rule, min length 3, returns every +qualifying cascade sorted by length DESC then speed DESC then end_time DESC. + +findWorstCascade kept temporarily to keep ArchiveStats building; removed +in the next commit. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Remove `findWorstCascade` and `WORST_CASCADE` stat card + +**Files:** +- Modify: `src/features/archives/ArchiveStats.jsx` +- Modify: `src/__tests__/unit/features/archives/ArchiveStats.test.jsx` +- Modify: `src/shared/utils/game/seasonAnalytics.mjs` +- Modify: `src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs` + +- [ ] **Step 1: Remove the WORST_CASCADE card from `ArchiveStats.jsx`** + +Open `src/features/archives/ArchiveStats.jsx`. Delete the `findWorstCascade` import line and the `worstCascade` derivation + JSX block. After the edit, the file's global branch should look like this (only the diff is shown): + +```diff +-import GlitchText from '@/features/archives/GlitchText'; + 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'; +``` + +```diff + 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 +``` + +```diff + {rateCards(events)} + {difficultyCard(diff.difficulty, diff.successful)} +- {worstCascade && ( +- +- )} + + ); +``` + +- [ ] **Step 2: Remove `WORST_CASCADE` assertions from `ArchiveStats.test.jsx`** + +Open `src/__tests__/unit/features/archives/ArchiveStats.test.jsx` and remove any `test(...)` block or `expect(...)` that references `WORST_CASCADE` or `worstCascade`. If a test file's purpose was only `WORST_CASCADE`, delete it; otherwise leave the rest intact. + +Search command to find them: + +```bash +grep -n "WORST_CASCADE\|worstCascade" src/__tests__/unit/features/archives/ArchiveStats.test.jsx +``` + +Delete each matching test block (the `test(...)` / `it(...)` call up to its closing `});`). + +- [ ] **Step 3: Remove `findWorstCascade` from `seasonAnalytics.mjs`** + +Open `src/shared/utils/game/seasonAnalytics.mjs`. Delete the `findWorstCascade` function and its JSDoc block (the entire `@deprecated`-marked function from Task 1). Keep `findAllCascades`, `emit`, `compareCascades`, and `MAX_GAP_SEC`. The file should now export only `findAllCascades`. + +- [ ] **Step 4: Remove the `findWorstCascade` test references** + +Open `src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs`. Remove: +- `findWorstCascade` from the import statement (leave `findAllCascades`). +- The `describe('findWorstCascade (legacy — kept for one task)', ...)` block at the bottom. + +- [ ] **Step 5: Run the affected tests to verify they pass** + +```bash +npx vitest run \ + src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs \ + src/__tests__/unit/features/archives/ArchiveStats.test.jsx +``` + +Expected: all tests PASS. No reference to `findWorstCascade` remains. + +- [ ] **Step 6: Run lint + typecheck to catch stale references** + +```bash +npm run lint && npm run typecheck +``` + +Expected: both pass with no errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/shared/utils/game/seasonAnalytics.mjs \ + src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs \ + src/features/archives/ArchiveStats.jsx \ + src/__tests__/unit/features/archives/ArchiveStats.test.jsx +git commit -m "refactor(archives): remove findWorstCascade and WORST_CASCADE stat card + +The cascade story moves to a dedicated CascadeLog section (added in +later tasks). The summary card on /archives becomes redundant once the +log lives directly below the StatGrid. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: `getCascadeLeaderboard` server query + +**Files:** +- Create: `src/db/queries/getCascadeLeaderboard.mjs` +- Create: `src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +Create `src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs`: + +```js +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/db/prisma.mjs', () => ({ + default: { h1_event: { findMany: vi.fn() } }, +})); + +import prisma from '@/db/prisma.mjs'; +import { getCascadeLeaderboard } from '@/db/queries/getCascadeLeaderboard.mjs'; + +function makeEvent(season, enemy, region, endOffset) { + const base = season * 10_000_000; + return { + season, + type: 'defend', + status: 'fail', + enemy, + region, + start_time: base + endOffset - 7200, + end_time: base + endOffset, + event_id: base + endOffset, + }; +} + +describe('getCascadeLeaderboard', () => { + beforeEach(() => { + prisma.h1_event.findMany.mockReset(); + }); + + it('returns [] for no events', async () => { + prisma.h1_event.findMany.mockResolvedValue([]); + const result = await getCascadeLeaderboard(); + expect(result).toEqual([]); + }); + + it('returns [] on Prisma error', async () => { + prisma.h1_event.findMany.mockRejectedValue(new Error('db down')); + const result = await getCascadeLeaderboard(); + expect(result).toEqual([]); + }); + + it('passes the expected filter to Prisma', async () => { + prisma.h1_event.findMany.mockResolvedValue([]); + await getCascadeLeaderboard(); + const call = prisma.h1_event.findMany.mock.calls[0][0]; + expect(call.where).toEqual({ type: 'defend', status: 'fail' }); + }); + + it('attaches season to each cascade and sorts globally', async () => { + const s155 = [ + makeEvent(155, 2, 8, 100_000), + makeEvent(155, 2, 7, 110_000), + makeEvent(155, 2, 6, 120_000), + ]; + const s142 = [ + makeEvent(142, 0, 6, 100_000), + makeEvent(142, 0, 5, 110_000), + makeEvent(142, 0, 4, 120_000), + makeEvent(142, 0, 3, 130_000), + ]; + prisma.h1_event.findMany.mockResolvedValue([...s155, ...s142]); + + const result = await getCascadeLeaderboard(); + expect(result).toHaveLength(2); + // Longer cascade (s142, length 4) ranks first + expect(result[0].season).toBe(142); + expect(result[0].length).toBe(4); + expect(result[1].season).toBe(155); + expect(result[1].length).toBe(3); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs +``` + +Expected: all tests FAIL — module not found. + +- [ ] **Step 3: Implement the query** + +Create `src/db/queries/getCascadeLeaderboard.mjs`: + +```js +import { cache } from 'react'; +import prisma from '@/db/prisma.mjs'; +import { tryCatch } from '@/shared/utils/tryCatch.mjs'; +import { findAllCascades } from '@/shared/utils/game/seasonAnalytics.mjs'; + +/** + * Cross-season cascade leaderboard. One DB read, then per-season cascade + * detection. Sorted globally by length DESC, then speed (regions per hour) + * DESC, then endTime DESC. + * + * Returns `[]` on any DB error so the page can still render without the + * cascade section. + * + * @returns {Promise>} + */ +export const getCascadeLeaderboard = cache(async () => { + const { data: events, error } = await tryCatch( + prisma.h1_event.findMany({ + where: { type: 'defend', status: 'fail' }, + select: { + season: true, + type: true, + status: true, + enemy: true, + region: true, + start_time: true, + end_time: true, + event_id: true, + }, + orderBy: [{ season: 'asc' }, { end_time: 'asc' }], + }), + ); + if (error || !events) return []; + + const bySeason = Map.groupBy(events, (e) => e.season); + const all = []; + for (const [season, seasonEvents] of bySeason) { + for (const cascade of findAllCascades(seasonEvents, { minLength: 3 })) { + all.push({ season, ...cascade }); + } + } + all.sort(compareCascadesForLeaderboard); + return all; +}); + +function compareCascadesForLeaderboard(a, b) { + if (b.length !== a.length) return b.length - a.length; + const aSpeed = a.length / (a.durationSec / 3600); + const bSpeed = b.length / (b.durationSec / 3600); + if (bSpeed !== aSpeed) return bSpeed - aSpeed; + return b.endTime - a.endTime; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/db/queries/getCascadeLeaderboard.mjs \ + src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs +git commit -m "feat(db): add getCascadeLeaderboard cross-season query + +One Prisma query for all failed defends, grouped by season, then +findAllCascades per season, globally sorted by length+speed+endTime. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: `groupCascadesBySeason` helper + +**Files:** +- Create: `src/features/timeline/groupCascadesBySeason.mjs` +- Create: `src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +Create `src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs`: + +```js +import { describe, it, expect } from 'vitest'; +import { groupCascadesBySeason } from '@/features/timeline/groupCascadesBySeason.mjs'; + +const c = (overrides) => ({ + season: 0, + length: 3, + factionIndex: 0, + faction: 'Bugs', + regions: [3, 2, 1], + startTime: 0, + endTime: 1000, + durationSec: 1000, + events: [], + firstEvent: {}, + lastEvent: {}, + ...overrides, +}); + +describe('groupCascadesBySeason', () => { + it('returns [] for empty input', () => { + expect(groupCascadesBySeason([])).toEqual([]); + expect(groupCascadesBySeason(null)).toEqual([]); + }); + + it('worst-first: orders groups by their worst cascade rank', () => { + const input = [ + c({ season: 142, length: 4, endTime: 100 }), + c({ season: 155, length: 9, endTime: 200 }), + c({ season: 198, length: 6, endTime: 300 }), + ]; + const groups = groupCascadesBySeason(input, { sortOrder: 'worst' }); + expect(groups.map((g) => g.season)).toEqual([155, 198, 142]); + }); + + it('recent-first: orders groups by season DESC', () => { + const input = [ + c({ season: 142, length: 4 }), + c({ season: 155, length: 9 }), + c({ season: 198, length: 6 }), + ]; + const groups = groupCascadesBySeason(input, { sortOrder: 'recent' }); + expect(groups.map((g) => g.season)).toEqual([198, 155, 142]); + }); + + it('keeps multi-cascade seasons grouped together', () => { + const input = [ + c({ season: 198, length: 6, endTime: 100 }), + c({ season: 155, length: 9, endTime: 200 }), + c({ season: 198, length: 3, endTime: 300 }), + ]; + const groups = groupCascadesBySeason(input, { sortOrder: 'worst' }); + const s198 = groups.find((g) => g.season === 198); + expect(s198.cascades).toHaveLength(2); + // Within group, longer first + expect(s198.cascades[0].length).toBe(6); + expect(s198.cascades[1].length).toBe(3); + }); + + it('within-group recent ordering uses endTime DESC', () => { + const input = [ + c({ season: 198, length: 3, endTime: 100 }), + c({ season: 198, length: 3, endTime: 300 }), + c({ season: 198, length: 3, endTime: 200 }), + ]; + const groups = groupCascadesBySeason(input, { sortOrder: 'recent' }); + expect(groups[0].cascades.map((c) => c.endTime)).toEqual([300, 200, 100]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs +``` + +Expected: all tests FAIL — module not found. + +- [ ] **Step 3: Implement the helper** + +Create `src/features/timeline/groupCascadesBySeason.mjs`: + +```js +/** + * Group cascades by season, then sort groups + within-group cascades. + * + * - `sortOrder='worst'` (default) — groups ordered by each group's worst + * cascade (length DESC, then speed DESC). Cascades within a group are + * sorted length DESC, then speed DESC, then endTime DESC. + * - `sortOrder='recent'` — groups ordered by season DESC. Cascades within + * a group are sorted by endTime DESC. + * + * @param {Array} cascades - Each cascade includes `season`. + * @param {object} [opts] + * @param {'worst'|'recent'} [opts.sortOrder='worst'] + * @returns {Array<{ season: number, cascades: Array }>} + */ +export function groupCascadesBySeason(cascades, { sortOrder = 'worst' } = {}) { + if (!cascades?.length) return []; + + const groups = new Map(); + for (const c of cascades) { + if (!groups.has(c.season)) groups.set(c.season, []); + groups.get(c.season).push(c); + } + + const within = + sortOrder === 'recent' ? + (a, b) => b.endTime - a.endTime + : compareByWorst; + for (const arr of groups.values()) arr.sort(within); + + const list = Array.from(groups, ([season, cs]) => ({ season, cascades: cs })); + if (sortOrder === 'recent') { + list.sort((a, b) => b.season - a.season); + } else { + list.sort((a, b) => compareByWorst(a.cascades[0], b.cascades[0])); + } + return list; +} + +function compareByWorst(a, b) { + if (b.length !== a.length) return b.length - a.length; + const aSpeed = a.length / (a.durationSec / 3600); + const bSpeed = b.length / (b.durationSec / 3600); + if (bSpeed !== aSpeed) return bSpeed - aSpeed; + return b.endTime - a.endTime; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/timeline/groupCascadesBySeason.mjs \ + src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs +git commit -m "feat(timeline): add groupCascadesBySeason helper + +Mirror of groupEventsByDay. Groups cascades by season with two sort +orders: worst-first (default) and recent-first. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: `generateCascadeLede` helper + +**Files:** +- Create: `src/features/stats/generateCascadeLede.mjs` +- Create: `src/__tests__/unit/features/stats/generateCascadeLede.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +Create `src/__tests__/unit/features/stats/generateCascadeLede.test.mjs`: + +```js +import { describe, it, expect } from 'vitest'; +import { generateCascadeLede } from '@/features/stats/generateCascadeLede.mjs'; + +const cascade = (overrides) => ({ + season: 155, + length: 9, + faction: 'The Illuminate', + regions: [8, 7, 6, 5, 4, 3, 2, 1, 0], + durationSec: 14 * 3600 + 32 * 60, + ...overrides, +}); + +describe('generateCascadeLede', () => { + it('returns null for no cascades', () => { + expect(generateCascadeLede([], 200)).toBeNull(); + expect(generateCascadeLede(null, 200)).toBeNull(); + }); + + it('uses "pushed all the way home" when the last region is 0', () => { + const lede = generateCascadeLede([cascade()], 200); + expect(lede).toContain('pushed all the way home'); + expect(lede).toContain('1 cascade'); + expect(lede).toContain('200 wars'); + expect(lede).toContain('season 155'); + expect(lede).toContain('The Illuminate'); + }); + + it('uses "pushed all the way home" when the last region is 11', () => { + const lede = generateCascadeLede( + [cascade({ regions: [13, 12, 11] })], + 10, + ); + expect(lede).toContain('pushed all the way home'); + }); + + it('falls back to "swept N regions in DURATION" otherwise', () => { + const lede = generateCascadeLede( + [cascade({ length: 5, regions: [6, 5, 4, 3, 2], durationSec: 9 * 3600 })], + 50, + ); + expect(lede).toContain('swept 5 regions in 9h'); + }); + + it('pluralizes "cascades" and "wars" correctly', () => { + const ledeMany = generateCascadeLede( + [cascade(), cascade({ season: 142 })], + 2, + ); + expect(ledeMany).toContain('2 cascades'); + expect(ledeMany).toContain('2 wars'); + + const ledeOne = generateCascadeLede([cascade()], 1); + expect(ledeOne).toContain('1 cascade '); + expect(ledeOne).toContain('1 war.'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/__tests__/unit/features/stats/generateCascadeLede.test.mjs +``` + +Expected: all tests FAIL — module not found. + +- [ ] **Step 3: Implement** + +Create `src/features/stats/generateCascadeLede.mjs`: + +```js +import { formatCompactDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; + +/** + * Build the lede sentence shown above the cascade log on /stats. + * + * @param {Array} cascades - Sorted leaderboard (worst-first). + * @param {number} seasonsCount - Total seasons in the dataset. + * @returns {string | null} + */ +export function generateCascadeLede(cascades, seasonsCount) { + if (!cascades?.length) return null; + const worst = cascades[0]; + const last = worst.regions[worst.regions.length - 1]; + const reachedHome = last === 0 || last === 11; + const verb = + reachedHome ? + 'pushed all the way home' + : `swept ${worst.length} regions in ${formatCompactDuration(worst.durationSec)}`; + return ( + `${cascades.length} cascade${cascades.length === 1 ? '' : 's'} ` + + `across ${seasonsCount} war${seasonsCount === 1 ? '' : 's'}. ` + + `Worst: season ${worst.season}, where the ${worst.faction} ${verb}.` + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/unit/features/stats/generateCascadeLede.test.mjs +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/stats/generateCascadeLede.mjs \ + src/__tests__/unit/features/stats/generateCascadeLede.test.mjs +git commit -m "feat(stats): add generateCascadeLede + +Deterministic lede sentence: \"N cascades across M wars. Worst: season X, +where the FACTION pushed all the way home / swept N regions in DURATION.\" + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: `CASCADE_SORT_ORDER_KEY` + `useCascadeLogSort` hook + +**Files:** +- Modify: `src/shared/preferences/sortOrder.mjs` +- Create: `src/features/timeline/useCascadeLogSort.mjs` +- Create: `src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs` + +- [ ] **Step 1: Add the new key and validator** + +Open `src/shared/preferences/sortOrder.mjs` and append: + +```js +export const CASCADE_SORT_ORDER_KEY = 'cascade-log-sort'; +export const CASCADE_SORT_ORDER_DEFAULT = 'worst'; + +export function validateCascadeSortOrder(value) { + return value === 'worst' || value === 'recent' + ? value + : CASCADE_SORT_ORDER_DEFAULT; +} +``` + +Final file contents should be: + +```js +export const SORT_ORDER_KEY = 'event-log-sort'; +export const SORT_ORDER_DEFAULT = 'desc'; + +export function validateSortOrder(value) { + return value === 'asc' || value === 'desc' ? value : SORT_ORDER_DEFAULT; +} + +export const CASCADE_SORT_ORDER_KEY = 'cascade-log-sort'; +export const CASCADE_SORT_ORDER_DEFAULT = 'worst'; + +export function validateCascadeSortOrder(value) { + return value === 'worst' || value === 'recent' + ? value + : CASCADE_SORT_ORDER_DEFAULT; +} +``` + +- [ ] **Step 2: Write the failing hook test** + +Create `src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs`: + +```js +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('@/shared/utils/cookies.mjs', () => ({ + setPreferenceCookie: vi.fn(), +})); + +import { useCascadeLogSort } from '@/features/timeline/useCascadeLogSort.mjs'; +import { setPreferenceCookie } from '@/shared/utils/cookies.mjs'; + +describe('useCascadeLogSort', () => { + beforeEach(() => { + setPreferenceCookie.mockReset(); + }); + + it('uses the initial value', () => { + const { result } = renderHook(() => useCascadeLogSort('recent')); + expect(result.current[0]).toBe('recent'); + }); + + it('defaults to "worst" when initial is undefined', () => { + const { result } = renderHook(() => useCascadeLogSort()); + expect(result.current[0]).toBe('worst'); + }); + + it('toggles worst → recent → worst', () => { + const { result } = renderHook(() => useCascadeLogSort('worst')); + act(() => result.current[1]()); + expect(result.current[0]).toBe('recent'); + act(() => result.current[1]()); + expect(result.current[0]).toBe('worst'); + }); + + it('writes the new value to the preference cookie on toggle', () => { + const { result } = renderHook(() => useCascadeLogSort('worst')); + act(() => result.current[1]()); + expect(setPreferenceCookie).toHaveBeenCalledWith('cascade-log-sort', 'recent'); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +npx vitest run src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs +``` + +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement the hook** + +Create `src/features/timeline/useCascadeLogSort.mjs`: + +```js +'use client'; +import { useCallback } from 'react'; +import { usePersistedState } from '@/shared/hooks/usePersistedState.mjs'; +import { + CASCADE_SORT_ORDER_KEY, + CASCADE_SORT_ORDER_DEFAULT, +} from '@/shared/preferences/sortOrder.mjs'; + +/** + * Cookie-backed sort preference for the cascade log. Independent of the + * dashboard event log's sort. Returns `[sortOrder, toggleSortOrder]`. + * + * @param {'worst'|'recent'} [initial='worst'] + */ +export function useCascadeLogSort(initial = CASCADE_SORT_ORDER_DEFAULT) { + const [sortOrder, setSortOrder] = usePersistedState(CASCADE_SORT_ORDER_KEY, initial); + const toggleSortOrder = useCallback(() => { + setSortOrder(sortOrder === 'worst' ? 'recent' : 'worst'); + }, [sortOrder, setSortOrder]); + return [sortOrder, toggleSortOrder]; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +npx vitest run src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/shared/preferences/sortOrder.mjs \ + src/features/timeline/useCascadeLogSort.mjs \ + src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs +git commit -m "feat(timeline): add useCascadeLogSort hook + +New cookie-backed preference (worst | recent) for the cascade log, +independent of the event log's sort. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: `CascadeLogSortToggle` component + +**Files:** +- Create: `src/features/timeline/CascadeLogSortToggle.jsx` + +This component is a thin presentational button — no business logic to test. Skip RTL and verify via the `CascadeLog` integration test instead. + +- [ ] **Step 1: Implement the component** + +Create `src/features/timeline/CascadeLogSortToggle.jsx`: + +```jsx +'use client'; + +import Button from '@/shared/components/Button/Button'; + +/** + * Square toggle button that flips the cascade log sort order. + * Mirror of EventLogSortToggle, with 'worst' | 'recent' semantics. + */ +export default function CascadeLogSortToggle({ sortOrder, onToggle }) { + const isWorst = sortOrder === 'worst'; + const label = isWorst ? 'Sort recent first' : 'Sort worst first'; + return ( + + ); +} +``` + +- [ ] **Step 2: Run lint to verify no errors** + +```bash +npm run lint -- src/features/timeline/CascadeLogSortToggle.jsx +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/features/timeline/CascadeLogSortToggle.jsx +git commit -m "feat(timeline): add CascadeLogSortToggle button + +Mirror of EventLogSortToggle. Same Button primitive. 'worst' | 'recent' +state. Tracks 'cascade-log-sort-toggle' for analytics. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 8: `CascadeLogCard` component + chain CSS + +**Files:** +- Create: `src/features/timeline/CascadeLogCard.jsx` +- Modify: `src/features/timeline/EventLog.css` +- Create: `src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx` + +- [ ] **Step 1: Add the chain CSS class** + +Open `src/features/timeline/EventLog.css` and append: + +```css +.event-log-card-chain { + font-family: var(--font-mono, monospace); + font-size: var(--text-small); + color: var(--color-text-muted); + letter-spacing: 0.05em; + margin-top: 0.25rem; + overflow-x: auto; + white-space: nowrap; +} + +.event-log-card-chain[data-faction="0"] { color: var(--color-faction-bugs); } +.event-log-card-chain[data-faction="1"] { color: var(--color-faction-cyborgs); } +.event-log-card-chain[data-faction="2"] { color: var(--color-faction-illuminate); } +``` + +- [ ] **Step 2: Write the failing card test** + +Create `src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx`: + +```jsx +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import CascadeLogCard from '@/features/timeline/CascadeLogCard'; + +const cascade = (overrides) => ({ + season: 155, + length: 9, + factionIndex: 2, + faction: 'The Illuminate', + regions: [8, 7, 6, 5, 4, 3, 2, 1, 0], + startTime: 1709555520, // Mar 4, 2024 (UTC) + endTime: 1709555520 + 14 * 3600 + 32 * 60, + durationSec: 14 * 3600 + 32 * 60, + firstEvent: {}, + lastEvent: {}, + events: [], + ...overrides, +}); + +describe('CascadeLogCard', () => { + it('renders the title with length', () => { + render(); + expect(screen.getByText(/9 regions/i)).toBeInTheDocument(); + }); + + it('renders the chain joined by arrows', () => { + const { container } = render(); + const chain = container.querySelector('.event-log-card-chain'); + expect(chain).toBeInTheDocument(); + expect(chain.textContent).toContain('8 → 7 → 6'); + }); + + it('tags the chain with the faction index', () => { + const { container } = render(); + const chain = container.querySelector('.event-log-card-chain'); + expect(chain.getAttribute('data-faction')).toBe('2'); + }); + + it('wraps the card in an anchor linking to /archives?season=N#cascade', () => { + render(); + const link = screen.getByRole('link'); + expect(link.getAttribute('href')).toBe('/archives?season=155#cascade'); + expect(link.getAttribute('data-umami-event')).toBe('cascade-card-click'); + }); + + it('renders a duration pill with formatted duration', () => { + render(); + // 14h 32m → '14h32m' per formatCompactDuration (largest:2, no spacer) + expect(screen.getByText(/14h32m/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +npx vitest run src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx +``` + +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement the card** + +Create `src/features/timeline/CascadeLogCard.jsx`: + +```jsx +import Link from 'next/link'; +import Image from 'next/image'; +import factions from '@/shared/enums/factions.mjs'; +import { formatCompactDuration } from '@/shared/utils/format/formatCompactDuration.mjs'; + +/** + * One cascade rendered as a card inside a CascadeLog season group. + * Wraps the whole card in a Link to /archives?season=N#cascade. + */ +export default function CascadeLogCard({ cascade }) { + const faction = factions[cascade.factionIndex]; + const start = formatAbsolute(cascade.startTime); + const end = formatAbsolute(cascade.endTime); + const duration = formatCompactDuration(cascade.durationSec); + + return ( + +
    +
    + + {faction && ( + + )} + Defend cascade · {cascade.length} regions + + {duration} +
    + + Started {start} — Ended {end} + + + {cascade.regions.join(' → ')} + +
    + + ); +} + +function formatAbsolute(unixSeconds) { + return new Date(unixSeconds * 1000).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', + }); +} +``` + +- [ ] **Step 5: Add the supporting CSS classes** + +Open `src/features/timeline/EventLog.css` again and append (after the chain class added in Step 1): + +```css +.event-log-card-link { + display: block; + text-decoration: none; + color: inherit; +} + +.event-log-card-link:hover .event-log-card { + border-color: var(--color-primary); +} + +.event-log-card--cascade { + background: var(--color-surface-1); + border: 1px solid var(--color-ghost); + border-left: 4px solid var(--color-danger); + padding: 0.5rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + font-family: var(--font-mono, monospace); +} + +.event-log-card-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.event-log-card-title { + font-family: var(--font-body); + font-size: var(--text-small); + font-weight: 700; + color: var(--color-text); + text-transform: uppercase; + letter-spacing: 0.02em; + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-log-card-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.event-log-card-pill { + padding: 0.1rem 0.4rem; + font-size: 0.7rem; + border: 1px solid var(--color-danger); + color: var(--color-danger); + white-space: nowrap; + flex-shrink: 0; +} + +.event-log-card-time { + font-size: var(--text-small); + color: var(--color-text-muted); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx +``` + +Expected: all tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/features/timeline/CascadeLogCard.jsx \ + src/features/timeline/EventLog.css \ + src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx +git commit -m "feat(timeline): add CascadeLogCard + +Single-cascade card with title, duration pill, start/end time line, and +faction-colored chain. Wrapped in to /archives?season=N#cascade +with Umami tracking. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 9: `CascadeLog` component + lede CSS + +**Files:** +- Create: `src/features/timeline/CascadeLog.jsx` +- Modify: `src/features/timeline/EventLog.css` +- Create: `src/__tests__/unit/features/timeline/CascadeLog.test.jsx` + +- [ ] **Step 1: Add the lede CSS class** + +Open `src/features/timeline/EventLog.css` and append: + +```css +.event-log-lede { + font-size: var(--text-small); + color: var(--color-text-muted); + margin: 0.25rem 0 0.75rem 0; +} +``` + +- [ ] **Step 2: Write the failing component test** + +Create `src/__tests__/unit/features/timeline/CascadeLog.test.jsx`: + +```jsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('@/shared/utils/cookies.mjs', () => ({ + setPreferenceCookie: vi.fn(), +})); + +import CascadeLog from '@/features/timeline/CascadeLog'; + +const c = (overrides) => ({ + season: 155, + length: 9, + factionIndex: 2, + faction: 'The Illuminate', + regions: [8, 7, 6, 5, 4, 3, 2, 1, 0], + startTime: 0, + endTime: 1000, + durationSec: 1000, + firstEvent: {}, + lastEvent: {}, + events: [], + ...overrides, +}); + +describe('CascadeLog', () => { + it('renders nothing for empty cascades', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the heading', () => { + render(); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + /Cascade Failures/i, + ); + }); + + it('renders the optional lede when provided', () => { + render(); + expect(screen.getByText('Test lede sentence.')).toBeInTheDocument(); + }); + + it('omits the lede paragraph when not provided', () => { + const { container } = render(); + expect(container.querySelector('.event-log-lede')).toBeNull(); + }); + + it('renders one group header per distinct season', () => { + render( + , + ); + expect(screen.getByText(/Season 155/)).toBeInTheDocument(); + expect(screen.getByText(/Season 142/)).toBeInTheDocument(); + }); + + it('toggles sort order when the toggle is clicked', () => { + render( + , + ); + // worst-first: 100 (length 9) first, then 200 (length 4) + let headers = screen.getAllByRole('heading', { level: 2 })[0].parentElement + .parentElement.parentElement.querySelectorAll('.event-log-day-label'); + expect(headers[0].textContent).toContain('100'); + // Toggle + fireEvent.click(screen.getByRole('button', { name: /sort/i })); + headers = screen.getAllByRole('heading', { level: 2 })[0].parentElement + .parentElement.parentElement.querySelectorAll('.event-log-day-label'); + // recent-first: 200 first, then 100 + expect(headers[0].textContent).toContain('200'); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +npx vitest run src/__tests__/unit/features/timeline/CascadeLog.test.jsx +``` + +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement the component** + +Create `src/features/timeline/CascadeLog.jsx`: + +```jsx +'use client'; + +import { Fragment } from 'react'; +import './EventLog.css'; +import CascadeLogCard from '@/features/timeline/CascadeLogCard'; +import CascadeLogSortToggle from '@/features/timeline/CascadeLogSortToggle'; +import { useCascadeLogSort } from '@/features/timeline/useCascadeLogSort.mjs'; +import { groupCascadesBySeason } from '@/features/timeline/groupCascadesBySeason.mjs'; + +/** + * Cross-season cascade log. Same section layout as EventLog, grouped by + * season instead of by day. Renders nothing when `cascades` is empty. + * + * @param {object} props + * @param {Array} props.cascades - Each cascade includes a `season` field. + * @param {string} [props.lede] - Optional one-line summary above the groups. + * @param {string} [props.title='Cascade Failures'] + * @param {string} [props.id='cascade'] + * @param {'worst'|'recent'} [props.initialSortOrder] + */ +export default function CascadeLog({ + cascades, + lede, + title = 'Cascade Failures', + id = 'cascade', + initialSortOrder, +}) { + const [sortOrder, toggleSortOrder] = useCascadeLogSort(initialSortOrder); + if (!cascades?.length) return null; + const groups = groupCascadesBySeason(cascades, { sortOrder }); + + return ( +
    +
    +
    +

    {title}

    + +
    + {lede &&

    {lede}

    } +
    + {groups.map((group) => ( + +
    +
    + + Season {group.season} + + + {group.cascades.length} cascade + {group.cascades.length === 1 ? '' : 's'} + +
    +
    + {group.cascades.map((c, i) => ( + + ))} +
    +
    +
    + ))} +
    +
    +
    + ); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +npx vitest run src/__tests__/unit/features/timeline/CascadeLog.test.jsx +``` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/timeline/CascadeLog.jsx \ + src/features/timeline/EventLog.css \ + src/__tests__/unit/features/timeline/CascadeLog.test.jsx +git commit -m "feat(timeline): add CascadeLog component + +Cross-season cascade log, mirror of EventLog. Groups by season, +optional lede prop, persisted sort toggle. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 10: Wire `CascadeLog` into `/stats` page + +**Files:** +- Modify: `src/app/stats/page.jsx` + +- [ ] **Step 1: Add imports + section** + +Open `src/app/stats/page.jsx` and modify as follows: + +```diff + 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'; ++import CascadeLog from '@/features/timeline/CascadeLog'; ++import { getCascadeLeaderboard } from '@/db/queries/getCascadeLeaderboard.mjs'; ++import { generateCascadeLede } from '@/features/stats/generateCascadeLede.mjs'; ++import { cookies } from 'next/headers'; ++import { ++ CASCADE_SORT_ORDER_KEY, ++ validateCascadeSortOrder, ++} from '@/shared/preferences/sortOrder.mjs'; +``` + +Inside `StatsPage`, after `const data = await getCrossSeasonStats();`, add: + +```js +const cascades = await getCascadeLeaderboard(); +const lede = generateCascadeLede(cascades, data.perSeason.length); +const c = await cookies(); +const initialCascadeSort = validateCascadeSortOrder( + c.get(CASCADE_SORT_ORDER_KEY)?.value, +); +``` + +Insert the `CascadeLog` section between **War Outcomes & Streaks** and **All-Time Records** — the new section sits between those two existing sections. The page already wraps each section in `
    `; the cascade log renders its own `
    ` so it should NOT be wrapped in another: + +```diff +
    +

    War Outcomes & Streaks

    + +
    + ++ ++ +
    +

    All-Time Records

    + +
    +``` + +- [ ] **Step 2: Run lint + typecheck** + +```bash +npm run lint && npm run typecheck +``` + +Expected: both pass. + +- [ ] **Step 3: Verify Playwright smoke test still passes** + +```bash +npm run test:e2e +``` + +Expected: smoke tests pass (the stats page loads without runtime errors). + +> If `test:e2e` is slow or skipped during plan execution, at minimum run the dev server and curl `http://localhost:3000/stats` — verify a 200 response and grep for `Cascade Failures` in the HTML. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/stats/page.jsx +git commit -m "feat(stats): wire CascadeLog into /stats page + +New section between War Outcomes and All-Time Records. Reads the +persisted sort preference from the cookie and passes it as +initialSortOrder. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 11: Wire `CascadeLog` into `/archives` (via `ArchivesClient`) + +**Files:** +- Modify: `src/features/archives/ArchivesClient.jsx` +- Modify: `src/app/archives/page.jsx` + +The `/archives` page renders a client component (`ArchivesClient`). The cascade list is per-season — derived from the events already loaded by the page. We add a new prop `initialCascadeSort` to the page → ArchivesClient handoff, and render `` below the StatGrid section. + +- [ ] **Step 1: Pass the cookie value into `ArchivesClient`** + +Open `src/app/archives/page.jsx`. Add the import + read the cookie alongside the existing sort order: + +```diff + import { FACTION_KEY, validateFaction } from '@/shared/preferences/faction.mjs'; +-import { SORT_ORDER_KEY, validateSortOrder } from '@/shared/preferences/sortOrder.mjs'; ++import { ++ SORT_ORDER_KEY, ++ validateSortOrder, ++ CASCADE_SORT_ORDER_KEY, ++ validateCascadeSortOrder, ++} from '@/shared/preferences/sortOrder.mjs'; +``` + +Inside `WarHistoryPage`, after `const initialSortOrder = ...`: + +```diff + const initialSortOrder = validateSortOrder(c.get(SORT_ORDER_KEY)?.value); ++ const initialCascadeSort = validateCascadeSortOrder( ++ c.get(CASCADE_SORT_ORDER_KEY)?.value, ++ ); +``` + +Pass it down to `ArchivesClient`: + +```diff + +``` + +- [ ] **Step 2: Render `` inside `ArchivesClient`** + +Open `src/features/archives/ArchivesClient.jsx`. + +Add to the imports: + +```diff + import StatGrid from '@/features/stats/StatGrid'; + import EventLog from '@/features/timeline/EventLog'; ++import CascadeLog from '@/features/timeline/CascadeLog'; ++import { findAllCascades } from '@/shared/utils/game/seasonAnalytics.mjs'; +``` + +Add the new prop to the function signature (in the `ArchivesClient` destructured params), then derive the per-season cascades just below the existing `const events = data?.events ?? [];`: + +```diff +- const events = data?.events ?? []; ++ const events = data?.events ?? []; ++ const cascades = findAllCascades(events).map((c) => ({ ++ season: data?.season, ++ ...c, ++ })); +``` + +Render `` below the `StatGrid` + `ArchiveStats` section, before any subsequent sections. The exact spot will be slightly file-dependent — locate the JSX block containing `` and `` and add `` after that block's closing element: + +```jsx +{cascades.length > 0 && ( + +)} +``` + +The component renders its own `
    `, so do not wrap it in another one. + +- [ ] **Step 3: Run lint + typecheck** + +```bash +npm run lint && npm run typecheck +``` + +Expected: both pass. + +- [ ] **Step 4: Run the unit test suite to confirm nothing broke** + +```bash +npm run test:unit +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/archives/page.jsx src/features/archives/ArchivesClient.jsx +git commit -m "feat(archives): render CascadeLog below the StatGrid + +Reuses the same component as /stats, filtered to the current season via +findAllCascades(events). The section only renders when the season has +at least one qualifying cascade. No lede prop on /archives. + +Issue: #272 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 12: Final verification — lint + typecheck + tests + build + +**Files:** none. + +Per CLAUDE.md, all four must pass before claiming the feature done. + +- [ ] **Step 1: `npm run lint`** + +```bash +npm run lint +``` + +Expected: clean exit. If failures, fix and re-run. + +- [ ] **Step 2: `npm run typecheck`** + +```bash +npm run typecheck +``` + +Expected: clean exit. + +- [ ] **Step 3: `npm run test:unit`** + +```bash +npm run test:unit +``` + +Expected: all suites pass. + +- [ ] **Step 4: `npm run build`** + +```bash +npm run build +``` + +Expected: clean build. No SSG warnings related to the new code. + +- [ ] **Step 5: Spot-check at `localhost:3000/stats` and `/archives?season=N`** + +Assuming the dev server is running (per CLAUDE.md), visit both pages: + +- `/stats` — confirm the **Cascade Failures** section renders between War Outcomes and All-Time Records. Confirm the sort toggle flips order. Confirm clicking a card navigates to `/archives?season=N`. +- `/archives?season=155` (or any season known to contain a cascade) — confirm the `CascadeLog` renders below the StatGrid. Confirm the `WORST_CASCADE` card is gone from the grid. + +If the dev server is not running, ask the user to start it (`npm run dev`) and confirm. + +- [ ] **Step 6: Verify the bundle is reasonable** + +```bash +npx next build 2>&1 | grep -E "(stats|archives)" | head +``` + +Confirm `/stats` route size hasn't ballooned (should be within ~5 KB of its previous size — the cascade log adds one small component). + +- [ ] **Step 7: Open the PR** + +```bash +git push -u origin feature/cascade-failure-log +gh pr create --base develop --title "feat: cascade failure log (#272)" \ + --body "$(cat <<'EOF' +Adds a cross-season Cascade Failures section to /stats and a per-season +cascade log on /archives, replacing the previous WORST_CASCADE stat +card with a richer EventLog-style layout. + +Design: docs/superpowers/specs/2026-05-23-cascade-failure-log-design.md +Plan: docs/superpowers/plans/2026-05-23-cascade-failure-log.md + +Closes #272 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Per CLAUDE.md, **versioning happens on merge**, not in this branch — the PR reviewer / merger handles the version bump and CHANGELOG update at merge time. + +--- + +## Deferred from spec + +- **Per-season outcome string in the group header.** The design spec describes group header summaries as `"1 cascade · Defeat"`. The spec also flags this as an Open Question with the suggested resolution to "attach `outcome` in `getCascadeLeaderboard`". This plan ships header summaries as `"N cascade(s)"` only — outcome is not threaded through. Follow-up issue should add outcome (probably by reusing `getWarOutcome(season)` once per season inside the leaderboard query). + +## Notes on conventions + +- All async DB / fetch calls go through `tryCatch` from `@/shared/utils/tryCatch.mjs`. No `try`/`catch` blocks. +- File imports use the `@/*` alias mapping to `./src/*`. +- All interactive elements (links, buttons, toggles) include `data-umami-event="category-action"` for analytics. +- Components rely on Tailwind utilities + the existing `EventLog.css` classes. Faction colors come from `--color-faction-{bugs,cyborgs,illuminate}` defined in `src/app/layout.css`. +- The cascade log explicitly **does not** ship a new CSS file — it extends `EventLog.css` with three new classes (`event-log-card-chain`, `event-log-card--cascade`, `event-log-lede`) so style drift stays minimized. 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 ``, `
    `, `
    `, `