Skip to content

Commit 9065dc0

Browse files
committed
Extract ship list UI view
1 parent a7cd83a commit 9065dc0

File tree

4 files changed

+176
-52
lines changed

4 files changed

+176
-52
lines changed

docs/BACKLOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ authority boundaries rather than rewriting the engine.
4141
- Replace repeated button wiring with a small declarative
4242
registry.
4343

44+
### Reactive experiment note
45+
46+
- `src/client/reactive.ts` should stay experimental until it has
47+
owner-scoped cleanup for nested effects, a disposal strategy for
48+
`computed()`, and clearer propagation semantics.
49+
- Current review findings: nested effects leak subscriptions,
50+
`computed()` stays permanently hot, and shared-dependency updates
51+
can emit glitchy intermediate states.
52+
- Do not make it a core UI pattern yet; reconsider after those
53+
lifecycle and scheduling gaps are closed with tests.
54+
4455
### Phase 3. Shared model boundaries
4556

4657
- Split `src/shared/types.ts` into separate domain, protocol,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// @vitest-environment jsdom
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import type { Ship } from '../../shared/types';
5+
import { ShipListView } from './ship-list-view';
6+
7+
const createShip = (overrides: Partial<Ship> = {}): Ship => ({
8+
id: 'ship-0',
9+
type: 'transport',
10+
owner: 0,
11+
position: { q: 0, r: 0 },
12+
velocity: { dq: 0, dr: 0 },
13+
fuel: 10,
14+
cargoUsed: 0,
15+
nukesLaunchedSinceResupply: 0,
16+
resuppliedThisTurn: false,
17+
landed: false,
18+
destroyed: false,
19+
detected: true,
20+
damage: { disabledTurns: 0 },
21+
...overrides,
22+
});
23+
24+
const installFixture = () => {
25+
document.body.innerHTML = '<div id="shipList"></div>';
26+
};
27+
28+
describe('ShipListView', () => {
29+
beforeEach(() => {
30+
installFixture();
31+
});
32+
33+
it('renders selected details and emits select events for live ships', () => {
34+
const onSelectShip = vi.fn<(shipId: string) => void>();
35+
const view = new ShipListView({ onSelectShip });
36+
37+
view.update(
38+
[
39+
createShip({
40+
id: 'selected',
41+
type: 'packet',
42+
cargoUsed: 15,
43+
landed: true,
44+
}),
45+
createShip({
46+
id: 'burning',
47+
type: 'corvette',
48+
}),
49+
],
50+
'selected',
51+
new Map([['burning', 1]]),
52+
);
53+
54+
const entries = Array.from(
55+
document.querySelectorAll<HTMLElement>('#shipList .ship-entry'),
56+
);
57+
58+
expect(entries).toHaveLength(2);
59+
expect(entries[0]?.classList.contains('active')).toBe(true);
60+
expect(entries[0]?.querySelector('.ship-details')?.textContent).toContain(
61+
'Landed',
62+
);
63+
expect(entries[1]?.querySelector('.burn-dot')).not.toBeNull();
64+
65+
entries[1]?.click();
66+
expect(onSelectShip).toHaveBeenCalledWith('burning');
67+
});
68+
69+
it('marks destroyed ships and does not emit clicks for them', () => {
70+
const onSelectShip = vi.fn<(shipId: string) => void>();
71+
const view = new ShipListView({ onSelectShip });
72+
73+
view.update(
74+
[
75+
createShip({
76+
id: 'destroyed',
77+
destroyed: true,
78+
}),
79+
],
80+
null,
81+
new Map(),
82+
);
83+
84+
const entry = document.querySelector<HTMLElement>('#shipList .ship-entry');
85+
86+
expect(entry?.classList.contains('destroyed')).toBe(true);
87+
entry?.click();
88+
expect(onSelectShip).not.toHaveBeenCalled();
89+
});
90+
});

src/client/ui/ship-list-view.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Ship } from '../../shared/types';
2+
import { byId } from '../dom';
3+
import { buildShipListView } from './ship-list';
4+
5+
export interface ShipListViewDeps {
6+
onSelectShip: (shipId: string) => void;
7+
}
8+
9+
export class ShipListView {
10+
private readonly shipListEl = byId('shipList');
11+
12+
constructor(private readonly deps: ShipListViewDeps) {}
13+
14+
update(
15+
ships: Ship[],
16+
selectedId: string | null,
17+
burns: Map<string, number | null>,
18+
): void {
19+
this.shipListEl.innerHTML = '';
20+
21+
const shipListView = buildShipListView(ships, selectedId, burns);
22+
23+
for (const [index, ship] of ships.entries()) {
24+
const entryView = shipListView[index];
25+
const entry = document.createElement('div');
26+
entry.className = 'ship-entry';
27+
28+
if (entryView.isSelected) {
29+
entry.classList.add('active');
30+
}
31+
if (entryView.isDestroyed) {
32+
entry.classList.add('destroyed');
33+
}
34+
35+
entry.innerHTML = `
36+
<span class="ship-name">${entryView.displayName}</span>
37+
<span class="ship-status">
38+
${entryView.statusText}
39+
${entryView.hasBurn ? '<span class="burn-dot"></span>' : ''}
40+
</span>
41+
<span class="ship-fuel">${entryView.fuelText}</span>
42+
`;
43+
44+
if (entryView.detailRows.length > 0) {
45+
const details = document.createElement('div');
46+
details.className = 'ship-details';
47+
48+
const rows = entryView.detailRows.map((row) => {
49+
const style = row.tone ? ` style="color:var(--${row.tone})"` : '';
50+
51+
return `<div class="ship-detail-row"><span class="ship-detail-label">${row.label}</span><span class="ship-detail-value"${style}>${row.value}</span></div>`;
52+
});
53+
54+
details.innerHTML = rows.join('');
55+
entry.appendChild(details);
56+
}
57+
58+
if (!ship.destroyed) {
59+
entry.addEventListener('click', () => {
60+
this.deps.onSelectShip(ship.id);
61+
});
62+
}
63+
64+
this.shipListEl.appendChild(entry);
65+
}
66+
}
67+
}

src/client/ui/ui.ts

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
buildWaitingScreenCopy,
2727
type UIScreenMode,
2828
} from './screens';
29-
import { buildShipListView } from './ship-list';
29+
import { ShipListView } from './ship-list-view';
3030

3131
export class UIManager {
3232
private menuEl: HTMLElement;
@@ -45,6 +45,7 @@ export class UIManager {
4545
private readonly actionButtonIds = ACTION_BUTTON_IDS;
4646
private readonly fleetBuildingView: FleetBuildingView;
4747
private readonly gameLogView: GameLogView;
48+
private readonly shipListView: ShipListView;
4849

4950
onEvent: ((event: UIEvent) => void) | null = null;
5051

@@ -77,6 +78,11 @@ export class UIManager {
7778
this.emit({ type: 'chat', text });
7879
},
7980
});
81+
this.shipListView = new ShipListView({
82+
onSelectShip: (shipId) => {
83+
this.emit({ type: 'selectShip', shipId });
84+
},
85+
});
8086

8187
const mobileQuery = window.matchMedia('(max-width: 760px)');
8288
this.isMobile = mobileQuery.matches;
@@ -448,57 +454,7 @@ export class UIManager {
448454
selectedId: string | null,
449455
burns: Map<string, number | null>,
450456
) {
451-
this.shipListEl.innerHTML = '';
452-
453-
const shipListView = buildShipListView(ships, selectedId, burns);
454-
455-
for (const [index, ship] of ships.entries()) {
456-
const entryView = shipListView[index];
457-
458-
const entry = document.createElement('div');
459-
entry.className = 'ship-entry';
460-
if (entryView.isSelected) {
461-
entry.classList.add('active');
462-
}
463-
if (entryView.isDestroyed) {
464-
entry.classList.add('destroyed');
465-
}
466-
467-
entry.innerHTML = `
468-
<span class="ship-name">${entryView.displayName}</span>
469-
<span class="ship-status">
470-
${entryView.statusText}
471-
${entryView.hasBurn ? '<span class="burn-dot"></span>' : ''}
472-
</span>
473-
<span class="ship-fuel">${entryView.fuelText}</span>
474-
`;
475-
476-
// Show expanded details for selected ship
477-
if (entryView.detailRows.length > 0) {
478-
const details = document.createElement('div');
479-
details.className = 'ship-details';
480-
481-
const rows = entryView.detailRows.map((row) => {
482-
const style = row.tone ? ` style="color:var(--${row.tone})"` : '';
483-
484-
return `<div class="ship-detail-row"><span class="ship-detail-label">${row.label}</span><span class="ship-detail-value"${style}>${row.value}</span></div>`;
485-
});
486-
487-
details.innerHTML = rows.join('');
488-
entry.appendChild(details);
489-
}
490-
491-
if (!ship.destroyed) {
492-
entry.addEventListener('click', () =>
493-
this.onEvent?.({
494-
type: 'selectShip',
495-
shipId: ship.id,
496-
}),
497-
);
498-
}
499-
500-
this.shipListEl.appendChild(entry);
501-
}
457+
this.shipListView.update(ships, selectedId, burns);
502458
}
503459

504460
showAttackButton(isVisible: boolean) {

0 commit comments

Comments
 (0)