feat(ui): F8-11 — battles table under table submenu (#54)
Tests · UI / test (push) Successful in 2m53s
Tests · UI / test (pull_request) Successful in 3m0s

Adds a sortable battles list as a new entity under the existing
`view → table` submenu (entity slug `battles`), replacing the
standalone top-level `battle log` shortcut which always opened a
"battle not found" placeholder. The single-battle viewer stays put
and is reached only by clicking a row (or a battle marker on the
map), identical to the existing `section-battles.svelte` flow.

Columns are planet (via the shared `planetLabel` helper) and shots
(the per-battle action count carried by `BattleSummary`), sortable
both ways with shots-desc default. No backend / FBS / map changes:
the wire payload is unchanged. Participants / observers / total
mass require the full BattleReport and were intentionally dropped
to avoid N round trips per menu open.

The top-level `battle log` item is removed from `header/view-menu`
and `sidebar/bottom-tabs` (and their stale comment blocks updated);
the now-orphan `game.view.battle` i18n key is dropped from both
locales.
This commit is contained in:
Ilia Denisov
2026-05-27 22:12:51 +02:00
parent e4fbb6644c
commit 209f8508cd
10 changed files with 529 additions and 30 deletions
@@ -0,0 +1,34 @@
// F8-11 battles table — module-level sort rune.
//
// Held outside the component so the user's sort choice survives the
// component being unmounted (row click → battle viewer → back to the
// table). Held in memory only; an F5 reloads the report and the
// defaults take over.
export type BattlesSortColumn = "planet" | "shots";
export type BattlesSortDirection = "asc" | "desc";
export interface BattlesTableState {
sortColumn: BattlesSortColumn;
sortDirection: BattlesSortDirection;
}
const DEFAULT_STATE: BattlesTableState = {
sortColumn: "shots",
sortDirection: "desc",
};
export const battlesTableState: BattlesTableState = $state({
...DEFAULT_STATE,
});
/**
* resetBattlesTableState restores every field to its default value.
* Production code never calls it; the Vitest harness uses it from
* `beforeEach` to keep cases independent (the rune is a module-level
* singleton that otherwise carries state across test boundaries).
*/
export function resetBattlesTableState(): void {
Object.assign(battlesTableState, DEFAULT_STATE);
}
@@ -0,0 +1,226 @@
<!--
F8-11 battles table. Lists every battle the engine recorded for the
current turn (`Report.battle[]`) with a sortable planet/shots view.
Row click opens the existing Battle Viewer through
`activeView.select("battle", { battleId, turn })`, identical to the
`section-battles.svelte` and battle-marker entry points so a single
viewer wrapper keeps owning the data fetch.
Intentionally minimal: only the two scalars carried by the report's
`BattleSummary` (planet number and shot count). Participants /
observers / fleet mass live in the per-battle `BattleReport` and are
out of scope here — fetching them per row would mean N round trips
on every menu open.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportBattle, ReportPlanet } from "../../api/game-state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { planetLabel } from "./report/format";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatInt } from "$lib/util/number-format";
import {
battlesTableState as persistent,
type BattlesSortColumn as SortColumn,
} from "./table-battles-state.svelte";
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
planet: "game.table.battles.column.planet",
shots: "game.table.battles.column.shots",
};
const COLUMNS: readonly SortColumn[] = ["planet", "shots"];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const battles = $derived<ReportBattle[]>(rendered?.report?.battles ?? []);
const planets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
const turn = $derived<number>(rendered?.report?.turn ?? 0);
const sorted = $derived.by(() => {
const list = [...battles];
const dir = persistent.sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
return list;
});
function compare(
a: ReportBattle,
b: ReportBattle,
column: SortColumn,
): number {
switch (column) {
case "planet":
return a.planet - b.planet;
case "shots":
return a.shots - b.shots;
}
}
function toggleSort(column: SortColumn): void {
if (persistent.sortColumn === column) {
persistent.sortDirection =
persistent.sortDirection === "asc" ? "desc" : "asc";
return;
}
persistent.sortColumn = column;
// shots is most-interesting-largest-first; planet is naturally
// ascending by number.
persistent.sortDirection = column === "shots" ? "desc" : "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (persistent.sortColumn !== column) return "none";
return persistent.sortDirection === "asc" ? "ascending" : "descending";
}
function openBattle(b: ReportBattle): void {
activeView.select("battle", { battleId: b.id, turn });
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="battles"
>
<header>
<h2>{i18n.t("game.table.battles.title")}</h2>
</header>
{#if !reportLoaded}
<ViewState
kind="loading"
testid="battles-loading"
message={i18n.t("game.table.battles.loading")}
/>
{:else if battles.length === 0}
<ViewState
kind="empty"
testid="battles-empty"
message={i18n.t("game.table.battles.empty")}
/>
{:else}
<table class="grid" data-testid="battles-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th
aria-sort={ariaSort(column)}
class:numeric={column === "shots"}
>
<button
type="button"
class="sort"
data-testid="battles-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if persistent.sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{persistent.sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted as b (b.id)}
<tr
data-testid="battles-row"
data-id={b.id}
onclick={() => openBattle(b)}
>
<td data-testid="battles-cell-planet">
{planetLabel(b.planet, planets)}
</td>
<td class="numeric" data-testid="battles-cell-shots">
{formatInt(b.shots)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
</style>
@@ -14,6 +14,7 @@ e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
import TableFleets from "./table-fleets.svelte";
import TableSciences from "./table-sciences.svelte";
import TableRaces from "./table-races.svelte";
import TableBattles from "./table-battles.svelte";
type Props = { entity: string };
let { entity }: Props = $props();
@@ -36,6 +37,8 @@ e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
<TableSciences />
{:else if entity === "races"}
<TableRaces />
{:else if entity === "battles"}
<TableBattles />
{:else}
<section
class="active-view"
+8 -14
View File
@@ -5,12 +5,13 @@ hamburger (☰) at the 1024 px breakpoint via CSS only; the surface
itself is identical. The same component is reused for the mobile
"More" drawer entry of `bottom-tabs.svelte`.
Lists the seven IA destinations: map, tables (sub-list of six
entities), report, battle, mail, ship-class designer, science
designer. Each entry mutates `activeView` (the single-URL app-shell
has no per-view routes) and closes the menu. Closes on Escape, on
outside click, and after a selection. Phase 26 introduces the
history-mode entry; microcopy is refined in a later polish pass.
Lists the IA destinations: map, tables (sub-list of seven entities
including the battles roll-up that replaces the standalone battle-log
shortcut), report, mail, science designer. Each entry mutates
`activeView` (the single-URL app-shell has no per-view routes) and
closes the menu. Closes on Escape, on outside click, and after a
selection. Phase 26 introduces the history-mode entry; microcopy is
refined in a later polish pass.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -31,6 +32,7 @@ history-mode entry; microcopy is refined in a later polish pass.
{ slug: "fleets", key: "game.view.table.fleets" },
{ slug: "sciences", key: "game.view.table.sciences" },
{ slug: "races", key: "game.view.table.races" },
{ slug: "battles", key: "game.view.table.battles" },
];
function toggleOpen(): void {
@@ -120,14 +122,6 @@ history-mode entry; microcopy is refined in a later polish pass.
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-battle"
onclick={() => select("battle")}
>
{i18n.t("game.view.battle")}
</button>
<button
type="button"
role="menuitem"
+6 -1
View File
@@ -183,8 +183,8 @@ const en = {
"game.view.table.fleets": "fleets",
"game.view.table.sciences": "sciences",
"game.view.table.races": "races",
"game.view.table.battles": "battles",
"game.view.report": "turn report",
"game.view.battle": "battle log",
"game.view.mail": "diplomatic mail",
"game.view.mail.badge": "{count}",
"game.events.mail_new.message": "new mail from {from}",
@@ -377,6 +377,11 @@ const en = {
"game.table.fleets.column.speed": "speed",
"game.table.fleets.filter.planet": "planet:",
"game.table.fleets.filter.planet.all": "all planets",
"game.table.battles.title": "battles",
"game.table.battles.loading": "loading battles…",
"game.table.battles.empty": "no battles this turn",
"game.table.battles.column.planet": "planet",
"game.table.battles.column.shots": "shots",
"game.table.ship_classes.title": "ship classes",
"game.table.ship_classes.column.name": "name",
"game.table.ship_classes.column.drive": "drive",
+6 -1
View File
@@ -184,8 +184,8 @@ const ru: Record<keyof typeof en, string> = {
"game.view.table.fleets": "флоты",
"game.view.table.sciences": "науки",
"game.view.table.races": "расы",
"game.view.table.battles": "сражения",
"game.view.report": "отчёт хода",
"game.view.battle": "журнал боёв",
"game.view.mail": "дипломатическая почта",
"game.view.mail.badge": "{count}",
"game.events.mail_new.message": "новое письмо от {from}",
@@ -378,6 +378,11 @@ const ru: Record<keyof typeof en, string> = {
"game.table.fleets.column.speed": "скорость",
"game.table.fleets.filter.planet": "планета:",
"game.table.fleets.filter.planet.all": "все планеты",
"game.table.battles.title": "сражения",
"game.table.battles.loading": "загрузка сражений…",
"game.table.battles.empty": "в этом ходе сражений нет",
"game.table.battles.column.planet": "планета",
"game.table.battles.column.shots": "выстрелы",
"game.table.ship_classes.title": "классы кораблей",
"game.table.ship_classes.column.name": "название",
"game.table.ship_classes.column.drive": "двигатель",
+7 -12
View File
@@ -8,10 +8,12 @@ or the header view-menu naturally drops the overlay.
More opens a drawer with the same destination list as the header
view-menu, each entry mutating `activeView` directly (the single-URL
app-shell has no per-view routes). A later polish pass narrows it to
the IA-spec subset (Mail, Battle log, Tables, History, Settings,
Logout) once History exists; until then the convenience of one source
of truth for destinations beats the duplication.
app-shell has no per-view routes). The Tables sub-list includes the
battles roll-up (F8-11) that replaces the standalone Battle log entry;
the single battle viewer is still reachable by clicking a row. A later
polish pass narrows it to the IA-spec subset (Mail, Tables, History,
Settings, Logout) once History exists; until then the convenience of
one source of truth for destinations beats the duplication.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -41,6 +43,7 @@ of truth for destinations beats the duplication.
{ slug: "fleets", key: "game.view.table.fleets" },
{ slug: "sciences", key: "game.view.table.sciences" },
{ slug: "races", key: "game.view.table.races" },
{ slug: "battles", key: "game.view.table.battles" },
];
function selectTool(tool: MobileTool): void {
@@ -170,14 +173,6 @@ of truth for destinations beats the duplication.
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="bottom-tabs-more-battle"
onclick={() => go("battle")}
>
{i18n.t("game.view.battle")}
</button>
<button
type="button"
role="menuitem"