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>
This commit is contained in:
@@ -24,7 +24,7 @@ separate dispatch component.
|
||||
| ------------------------------------------ | ---------------------------------------------- | ----------------------- |
|
||||
| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 |
|
||||
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 |
|
||||
| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 |
|
||||
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 |
|
||||
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
|
||||
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
|
||||
| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) |
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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`).
|
||||
Reference in New Issue
Block a user