Files
Ilia Denisov c58027c034 ui/phase-23: turn-report view with twenty sections and TOC
Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (galaxy summary, votes,
player status, my/foreign sciences, my/foreign ship classes, battles,
bombings, approaching groups, my/foreign/uninhabited/unknown planets,
ships in production, cargo routes, my fleets, my/foreign/unidentified
ship groups). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:33:56 +02:00

180 lines
8.4 KiB
Markdown

# Report view — Phase 23
The Phase 23 in-game "turn report" view is a single scrollable
layout with twenty sections, one per array on the FBS `Report`
table. The route file is the standard two-line wrapper; the
orchestrator and the per-section components live under
`ui/frontend/src/lib/active-view/report/`.
## Component layout
`lib/active-view/report.svelte` is the orchestrator. It owns the
section list, instantiates `IntersectionObserver` to track which
section is active, and renders the table of contents alongside the
section column.
```
report.svelte
├── report/report-toc.svelte // anchor list + mobile <select>
├── report/section-galaxy-summary.svelte
├── report/section-votes.svelte
├── report/section-player-status.svelte
├── report/section-my-sciences.svelte
├── report/section-foreign-sciences.svelte
├── report/section-my-ship-classes.svelte
├── report/section-foreign-ship-classes.svelte
├── report/section-battles.svelte
├── report/section-bombings.svelte
├── report/section-approaching-groups.svelte
├── report/section-my-planets.svelte
├── report/section-ships-in-production.svelte
├── report/section-cargo-routes.svelte
├── report/section-foreign-planets.svelte
├── report/section-uninhabited-planets.svelte
├── report/section-unknown-planets.svelte
├── report/section-my-fleets.svelte
├── report/section-my-ship-groups.svelte
├── report/section-foreign-ship-groups.svelte
└── report/section-unidentified-groups.svelte
```
Each section component is self-contained:
- reads `RenderedReportSource` from context;
- renders the loading copy when `rendered.report === null`;
- renders the empty-state copy when its array is empty;
- otherwise emits a `<section id="report-<slug>" data-testid="report-section-<slug>">`
containing the relevant grid / list / kv-list.
No shared `<Section>` wrapper exists. The visual scaffolding (dark
grid CSS, header style, status paragraph) is inlined per
component. The CLAUDE.md "wait for the third real caller before
extracting an abstraction" rule applies; with one shape per
section, the per-section inline CSS is the smallest correct
solution.
Shared formatters live in `report/format.ts` (`formatPercent`,
`formatCount`, `formatFloat`, `formatVotes`, `planetLabel`).
## Section order, data sources, empty copy
| # | Slug | Data | Empty copy (en) |
|---|------|------|-----------------|
| 1 | `galaxy-summary` | header turn / size / planet count / race | never empty |
| 2 | `votes` | `myVotes`, `myVoteFor`, `races[].votesReceived` | "no votes cast yet" |
| 3 | `player-status` | `players[]` (full roster, self + extinct) | never empty |
| 4 | `my-sciences` | `localScience[]` | "no sciences defined yet" |
| 5 | `foreign-sciences` | `otherScience[]`, one sub-table per race | "no foreign sciences observed yet" |
| 6 | `my-ship-classes` | `localShipClass[]` | "no ship classes designed yet" |
| 7 | `foreign-ship-classes` | `otherShipClass[]`, one sub-table per race | "no foreign ship classes observed yet" |
| 8 | `battles` | `battleIds[]` (inactive monospace spans) | "no battles last turn" |
| 9 | `bombings` | `bombings[]`, wiped rows visually distinct | "no bombings last turn" |
| 10 | `approaching-groups` | `incomingShipGroups[]` | "no approaching groups" |
| 11 | `my-planets` | `planets.filter(kind==="local")` | "no planets owned yet" |
| 12 | `ships-in-production` | `shipProductions[]` | "no ships in production" |
| 13 | `cargo-routes` | `routes[]` (flattened to one row per entry) | "no cargo routes set" |
| 14 | `foreign-planets` | `planets.filter(kind==="other")` | "no foreign planets observed" |
| 15 | `uninhabited-planets` | `planets.filter(kind==="uninhabited")` | "no uninhabited planets observed" |
| 16 | `unknown-planets` | `planets.filter(kind==="unidentified")` | "no unknown planets" |
| 17 | `my-fleets` | `localFleets[]` | "no fleets created yet" |
| 18 | `my-ship-groups` | `localShipGroups[]` | "no ship groups yet" |
| 19 | `foreign-ship-groups` | `otherShipGroups[]` | "no foreign ship groups observed" |
| 20 | `unidentified-groups` | `unidentifiedShipGroups[]` | "no unidentified groups" |
The orchestrator iterates this list once for the TOC and once for
the body — both surfaces stay in sync by construction.
## Table of contents and active highlight
`report/report-toc.svelte` renders two surfaces driven by the same
entry list:
- **Desktop / tablet sidebar** — sticky `<aside>` with vertical
anchor list. The anchor for the currently-visible section gets
`aria-current="location"` and an `.active` CSS class.
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS
and a sticky `<select>` takes its place at the top of the body.
Picking an option scrolls the matching section into view. The
mobile contract intentionally avoids stacking another overlay on
top of the existing layout-owned bottom-tabs.
Both surfaces also expose a "Back to map" affordance
(`report-back-to-map`) at the top.
The active slug is computed by an `IntersectionObserver` rooted on
the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`.
The skew biases the active band toward the upper third of the
visible area so that scrolling down advances the highlight
naturally. The observer is created on mount and torn down on
unmount.
The in-game shell layout (`routes/games/[id]/+layout.svelte`)
expands `<main class="active-view-host">` to fit content rather
than constraining it, so the document body is the actual scroll
container — not the host. The IntersectionObserver root is `null`
to match.
## Scroll save / restore
`routes/games/[id]/report/+page.svelte` exports a SvelteKit
`Snapshot<{ scrollY: number }>`:
- `capture()` reads `window.scrollY` when SvelteKit's
`beforeNavigate` cycle runs.
- `restore(value)` schedules a short
`requestAnimationFrame` poll that waits for
`document.documentElement.scrollHeight` to grow tall enough to
honour the saved offset, then calls `window.scrollTo(0, value)`.
The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough
body never pins a frame loop.
The capture / restore pair is keyed by route, so:
- Forward navigation from `/report` to `/map` lands `/map` at
scrollY 0 (no snapshot for `/map` to restore from).
- History-back from `/map` to `/report` restores the previously
captured scrollY — the user returns to the same section.
The Snapshot API does not capture the active sidebar slug; the
IntersectionObserver re-derives it from the restored scroll
position on the next animation frame, which keeps the TOC
highlight consistent without a second source of truth.
## i18n namespace
All Phase 23 strings live under `game.report.*`:
- `game.report.loading` — section loading placeholder.
- `game.report.back_to_map`, `game.report.toc.title`,
`game.report.toc.mobile_label` — shell-level strings.
- `game.report.section.<slug>.title` — section heading.
- `game.report.section.<slug>.empty` — empty-state copy (where
applicable).
- `game.report.section.<slug>.column.<column>` — column headings.
- A small number of section-specific keys (`bombings.wiped`,
`player_status.local_marker`, `player_status.extinct_marker`,
`foreign_sciences.race_header`, `foreign_ship_classes.race_header`,
`battles.id_label`, `votes.target_none`).
The namespace is intentionally separate from `game.table.*` even
where the data shape overlaps (e.g. sciences, ship classes); the
two surfaces evolve independently and a shared key set would
couple them silently.
## Test seams
- **Vitest** — four representative specs cover the four section
shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid
with conditional row state (`report-section-bombings.test.ts`),
per-race sub-table (`report-section-foreign-sciences.test.ts`),
TOC (`report-toc.test.ts`). Each spec mounts the component
against a synthetic `RenderedReportSource`, so the orchestrator
/ IntersectionObserver are out of scope.
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
the full integration: every TOC anchor lands its section in
view, the snapshot mechanism preserves `window.scrollY` on
history navigation, the back-to-map button reaches `/map`, the
mobile `<select>` scrolls to the chosen section on a narrow
viewport.
Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row
identifiers (e.g. `report-bombing-row`, `my-planets-row`).