feat(ui): F8-11 — battles table under table submenu (#54)
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user