Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s

Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
This commit is contained in:
Ilia Denisov
2026-06-03 19:47:40 +02:00
parent 539e24fba1
commit d733ce3119
114 changed files with 8210 additions and 149 deletions
+32 -1
View File
@@ -16,6 +16,7 @@
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import {
BLANK,
newPlacement,
@@ -369,10 +370,35 @@
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
async function exportGcg() {
try {
await shareOrDownloadGcg(await gateway.exportGcg(id));
} catch (e) {
handleError(e);
}
}
async function addFriend(accountId: string) {
try {
await gateway.friendRequest(accountId);
showToast(t('friends.requestSent'));
} catch (e) {
handleError(e);
}
}
const opponents = $derived(
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
);
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
{ label: t('game.checkWord'), onclick: openCheck },
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) => ({ label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }))
: []),
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
]);
</script>
@@ -400,7 +426,7 @@
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score}</span>
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
</li>
{/each}
{#if moves.length === 0}<li class="hempty"></li>{/if}
@@ -628,6 +654,11 @@
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.ht {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85em;
}
.hempty {
justify-content: center;
color: var(--text-muted);