From 4999478ded573728fcacb45dfd264048cca8afa8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 9 Jun 2026 00:26:35 +0200 Subject: [PATCH 1/2] Stage 17 #5: hide finished games from your own lobby list A player can remove a finished game from their own 'my games' list. The action is per-account, finished-only and irreversible (the game stays for the other players; there is no un-hide). - backend: migration 00012 game_hidden(account_id, game_id); store HideGame + hiddenGameIDs + ListGamesForAccount filtering; service HideGame (seat + finished checks, reusing ErrNotAPlayer / ErrGameActive); POST /api/v1/user/games/:id/hide. - gateway: game.hide edge op (reuses GameActionRequest -> Ack) + backendclient.HideGame. - ui: finished rows reveal a delete via swipe-left (touch) or a kebab tap (desktop), active rows get an inert chevron for icon alignment; optimistic removal + lobby-cache sync; mock + transport + client wiring; lobby.hideGame label (en/ru). - tests: integration (active->ErrGameActive, outsider->ErrNotAPlayer, per-account, idempotent), gateway transcode round-trip, mock e2e (kebab -> delete); hardened a pre-existing chat-screen .back transition flake surfaced by the new test's timing. - docs: ARCHITECTURE persistence list, FUNCTIONAL (+ _ru) lobby story, PLAN tracker. --- PLAN.md | 13 +- backend/internal/game/service.go | 24 +++ backend/internal/game/store.go | 47 ++++++ backend/internal/inttest/hide_test.go | 67 ++++++++ .../postgres/migrations/00012_game_hidden.sql | 18 +++ backend/internal/server/handlers.go | 1 + backend/internal/server/handlers_user.go | 19 +++ docs/ARCHITECTURE.md | 4 + docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 6 +- gateway/internal/backendclient/api.go | 6 + gateway/internal/transcode/transcode.go | 14 ++ gateway/internal/transcode/transcode_test.go | 33 ++++ ui/e2e/game.spec.ts | 3 + ui/e2e/social.spec.ts | 21 +++ ui/src/lib/client.ts | 2 + ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/mock/client.ts | 9 ++ ui/src/lib/transport.ts | 3 + ui/src/screens/Lobby.svelte | 145 ++++++++++++++++-- 21 files changed, 425 insertions(+), 17 deletions(-) create mode 100644 backend/internal/inttest/hide_test.go create mode 100644 backend/internal/postgres/migrations/00012_game_hidden.sql diff --git a/PLAN.md b/PLAN.md index 4523227..5b13332 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1415,9 +1415,18 @@ provided cert) at the contour caddy; prod VPN; rollback. and `--tg-content-top`), with a small padding bump so the native controls aren't flush. - **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`. + - **Hide finished games (#5, shipped):** a player can remove a finished game from their own + *my games* list — **per-account, finished-only and irreversible** (the game stays for the + other players; there is no un-hide). On a finished row a **swipe-left** (touch) or a tap on + its **kebab ⋮** (the desktop affordance) reveals a **❌** that hides it; active rows carry an + inert **›** chevron purely to keep the right-edge icons aligned. New table + `game_hidden(account_id, game_id)` + migration `00012`; `ListGamesForAccount` filters the + hidden set; `POST /api/v1/user/games/:id/hide` behind the `game.hide` edge op (reusing + `GameActionRequest` → an `Ack`); the lobby drops the card optimistically and keeps the cache + in sync. Covered by an integration test (active→`ErrGameActive`, outsider→`ErrNotAPlayer`, + per-account visibility, idempotent), a gateway transcode test, and a mock e2e (kebab → ❌). - **Deferred to the next PR (agreed):** #4 enrich the out-of-app "your turn" / game-end push - with the opponent's name, last word and score; #5 let a player hide finished games from - their lobby (swipe + a desktop affordance). + with the opponent's name, last word and score. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index d57eddf..20ef259 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -727,6 +727,30 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([] return svc.store.ListGamesForAccount(ctx, accountID) } +// HideGame hides a finished game from accountID's own lobby (it stays visible to the other +// players); it is irreversible by design. Only a player of a finished game may hide it +// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op (Stage 17). +func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { + g, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return err + } + seated := false + for _, s := range g.Seats { + if s.AccountID == accountID { + seated = true + break + } + } + if !seated { + return ErrNotAPlayer + } + if g.Status != StatusFinished { + return ErrGameActive + } + return svc.store.HideGame(ctx, accountID, gameID) +} + // GameByID returns a game with its seats for the admin console detail view. func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) { return svc.store.GetGame(ctx, id) diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index d97a2ea..8fce51f 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -186,6 +186,23 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([ if len(grows) == 0 { return nil, nil } + // Drop games the account has hidden from its own lobby (Stage 17). + hidden, err := s.hiddenGameIDs(ctx, accountID) + if err != nil { + return nil, err + } + if len(hidden) > 0 { + kept := grows[:0] + for _, g := range grows { + if !hidden[g.GameID] { + kept = append(kept, g) + } + } + grows = kept + if len(grows) == 0 { + return nil, nil + } + } ids := make([]postgres.Expression, len(grows)) for i, g := range grows { @@ -215,6 +232,36 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([ return out, nil } +// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the +// game is finished and the account is a player (Stage 17). +func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + accountID, gameID) + if err != nil { + return fmt.Errorf("game: hide game: %w", err) + } + return nil +} + +// hiddenGameIDs returns the set of games the account has hidden from its lobby. +func (s *Store) hiddenGameIDs(ctx context.Context, accountID uuid.UUID) (map[uuid.UUID]bool, error) { + rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM backend.game_hidden WHERE account_id = $1`, accountID) + if err != nil { + return nil, fmt.Errorf("game: hidden ids: %w", err) + } + defer rows.Close() + out := map[uuid.UUID]bool{} + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("game: scan hidden id: %w", err) + } + out[id] = true + } + return out, rows.Err() +} + // ListGames returns games for the admin games list, most-recently-updated first, // paginated. status filters by lifecycle ("active"/"finished") when non-empty. // The seats are not loaded — the list shows summaries; the detail view uses diff --git a/backend/internal/inttest/hide_test.go b/backend/internal/inttest/hide_test.go new file mode 100644 index 0000000..aaac244 --- /dev/null +++ b/backend/internal/inttest/hide_test.go @@ -0,0 +1,67 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/game" +) + +// TestHideFinishedGame covers Stage 17 per-account game hiding: an active game cannot be +// hidden, a finished game is removed from the hider's own list while staying visible to the +// other player, an outsider cannot hide it, and the action is idempotent. +func TestHideFinishedGame(t *testing.T) { + ctx := context.Background() + svc, gameID, seats, _ := newDraftGame(t) + + // Hiding while the game is still active is refused. + if err := svc.HideGame(ctx, seats[0], gameID); !errors.Is(err, game.ErrGameActive) { + t.Fatalf("hide active = %v, want ErrGameActive", err) + } + + // Finish the game by seat 0 resigning. + if _, err := svc.Resign(ctx, gameID, seats[0]); err != nil { + t.Fatalf("resign: %v", err) + } + + // A non-player cannot hide it. + if err := svc.HideGame(ctx, provisionAccount(t), gameID); !errors.Is(err, game.ErrNotAPlayer) { + t.Fatalf("hide by outsider = %v, want ErrNotAPlayer", err) + } + + // Seat 0 hides the finished game; hiding again is a no-op success. + if err := svc.HideGame(ctx, seats[0], gameID); err != nil { + t.Fatalf("hide: %v", err) + } + if err := svc.HideGame(ctx, seats[0], gameID); err != nil { + t.Fatalf("hide twice: %v", err) + } + + // It is gone from seat 0's list but still in seat 1's (hiding is per-account). + if containsGame(t, svc, seats[0], gameID) { + t.Error("hidden game still listed for the hider") + } + if !containsGame(t, svc, seats[1], gameID) { + t.Error("hidden game should remain listed for the other player") + } +} + +// containsGame reports whether the account's lobby list includes gameID. +func containsGame(t *testing.T, svc *game.Service, accountID, gameID uuid.UUID) bool { + t.Helper() + games, err := svc.ListForAccount(context.Background(), accountID) + if err != nil { + t.Fatalf("list for account: %v", err) + } + for _, g := range games { + if g.ID == gameID { + return true + } + } + return false +} diff --git a/backend/internal/postgres/migrations/00012_game_hidden.sql b/backend/internal/postgres/migrations/00012_game_hidden.sql new file mode 100644 index 0000000..02452a4 --- /dev/null +++ b/backend/internal/postgres/migrations/00012_game_hidden.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Stage 17: per-account hidden games. A row hides game_id from account_id's own "my games" +-- lobby list, leaving it visible to the other players. Only finished games are hidden, and the +-- action is irreversible by design (there is no un-hide). Queried with raw SQL, so no generated +-- jet code is needed. +SET search_path = backend, pg_catalog; + +CREATE TABLE game_hidden ( + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, game_id) +); + +-- +goose Down +SET search_path = backend, pg_catalog; + +DROP TABLE game_hidden; diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index e1e1965..f760409 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -68,6 +68,7 @@ func (s *Server) registerRoutes() { u.GET("/games/:id/gcg", s.handleExportGCG) u.GET("/games/:id/draft", s.handleGetDraft) u.PUT("/games/:id/draft", s.handleSaveDraft) + u.POST("/games/:id/hide", s.handleHideGame) } if s.matchmaker != nil { u.POST("/lobby/enqueue", s.handleEnqueue) diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 180335a..6fa661e 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -94,6 +94,25 @@ func (s *Server) handleSubmitPlay(c *gin.Context) { } // handleGameState returns the player's view of a game. +// handleHideGame hides a finished game from the caller's own lobby list (Stage 17). +func (s *Server) handleHideGame(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + gameID, ok := gameIDParam(c) + if !ok { + abortBadRequest(c, "invalid game id") + return + } + if err := s.games.HideGame(c.Request.Context(), uid, gameID); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + func (s *Server) handleGameState(c *gin.Context) { uid, ok := userID(c) if !ok { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 96154dc..2858f73 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -419,6 +419,10 @@ English game the Latin pool. (pending confirm-codes) and `game_invitations` / `game_invitation_invitees` (friend-game invitations). Stage 8's migration `00006` widened the `friendships` status to admit `declined` and added `friend_codes` (one-time add-a-friend codes). + Stage 17 added `game_drafts` (a player's in-progress rack order + board composition per + game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one + account's own lobby list, leaving it visible to the other players — finished-only and + irreversible by design, so there is no un-hide). The matchmaking pool is **in-memory** and persists nothing. - **Active games are event-sourced.** A game is a `games` row (pinned `variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c064ce7..48df323 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -62,7 +62,10 @@ Bottom tab menu: **my games**, **profile**. The **my games** list groups games i sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and orders them so the games awaiting your move come first, the longest-waiting on top, while opponent-turn and finished games are most-recent first; it renders as a compact, -line-separated list (Stage 17). The game types offered on **New Game** are +line-separated list (Stage 17). You can **remove a finished game from your own list**: +swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it. +The removal is per-account and permanent — the game disappears only from your list and stays +in the other players' lists, and there is no undo. The game types offered on **New Game** are limited to the languages the player's sign-in service supports (English → Scrabble; Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is unrestricted). Variants are shown by their **display name** — both Scrabble variants read diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 164e4fe..3375dd6 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -63,7 +63,11 @@ Mini App** авторизует по подписанным `initData` плат *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу соперника и завершённые — самые свежие сверху; отображается компактным списком с -линиями-разделителями (Stage 17). Типы партий на экране **Новая игра** +линиями-разделителями (Stage 17). Завершённую партию можно **убрать из своего списка**: +проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть +**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия +исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий +на экране **Новая игра** ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 94ce576..e31bd91 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -354,6 +354,12 @@ func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil) } +// HideGame hides a finished game from the caller's own games list (Stage 17). The action is +// per-account and irreversible; the game stays visible to the other players. +func (c *Client) HideGame(ctx context.Context, userID, gameID string) error { + return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil) +} + // Evaluate previews a tentative play's legality and score. The tiles are addressed by // alphabet index (Stage 13). func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) { diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index d8f23b1..386709c 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -41,6 +41,7 @@ const ( MsgChatNudge = "chat.nudge" MsgDraftGet = "draft.get" MsgDraftSave = "draft.save" + MsgGameHide = "game.hide" ) // Request is one decoded Execute call. @@ -113,6 +114,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true} r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true} r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true} + r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true} registerStage8(r, backend) registerStage11(r, backend, tg, defaultLanguages) return r @@ -451,3 +453,15 @@ func saveDraftHandler(backend *backendclient.Client) Handler { return encodeDraftView(""), nil } } + +// hideGameHandler hides a finished game from the caller's own list (Stage 17). It reuses +// GameActionRequest for the game id and echoes an Ack. +func hideGameHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsGameActionRequest(req.Payload, 0) + if err := backend.HideGame(ctx, req.UserID, string(in.GameId())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index 8e1420f..eada209 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -150,6 +150,39 @@ func gameActionPayload(gameID string) []byte { return b.FinishedBytes() } +// TestHideGameForwardsToBackend checks game.hide reuses GameActionRequest, POSTs to the +// game's /hide endpoint with the caller's id, and echoes an Ack (Stage 17). +func TestHideGameForwardsToBackend(t *testing.T) { + var hit bool + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + hit = true + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/user/games/g-1/hide" { + t.Errorf("unexpected %s %q", r.Method, r.URL.Path) + } + if got := r.Header.Get("X-User-ID"); got != "u-1" { + t.Errorf("X-User-ID = %q, want u-1", got) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgGameHide) + if !ok { + t.Fatal("game.hide not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if !hit { + t.Error("backend not called") + } + if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() { + t.Error("ack not ok") + } +} + func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-User-ID"); got != "u-9" { diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index a903c60..c82f8ac 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -170,6 +170,9 @@ test('chat and word-check open as their own screens and back to the game (Stage await expect(page).toHaveURL(/\/game\/g1\/chat$/); await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle await expect(page.locator('.chat')).toBeVisible(); + // The outgoing game header and the incoming chat header both carry a .back mid-slide; wait + // for the game's to unmount so the click targets a single, settled button. + await expect(page.locator('.back')).toHaveCount(1); await page.locator('.back').click(); // header back chevron returns to the game await expect(page).toHaveURL(/\/game\/g1$/); await expect(page.locator('.pane')).toHaveCount(1); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 228eeda..b89fd68 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -86,6 +86,27 @@ test('finished game draws an inert footer and trims the live-only menu', async ( await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); }); +test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => { + await loginLobby(page); + + // Both seeded finished games are listed; the active row carries the inert chevron, not a kebab. + await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toBeVisible(); + await expect(page.locator('.rowwrap', { hasText: 'Rick' })).toBeVisible(); + const annRow = page.locator('.rowwrap', { hasText: 'Ann' }); + await expect(annRow.locator('.kebab')).toHaveCount(0); + await expect(annRow.locator('.chev')).toBeVisible(); + + // The kebab reveals the delete action in place (no dropdown menu); tapping ❌ hides the game. + const kayaRow = page.locator('.rowwrap', { hasText: 'Kaya' }); + await kayaRow.locator('.kebab').click(); + await expect(kayaRow).toHaveClass(/revealed/); + await kayaRow.locator('.del').click(); + + // The hidden game is gone from the list; the other finished game remains. + await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toHaveCount(0); + await expect(page.locator('.rowwrap', { hasText: 'Rick' })).toBeVisible(); +}); + test('lobby hamburger shows the pending notification count', async ({ page }) => { await loginLobby(page); // One incoming friend request (Rick) + one invitation (Kaya) = 2. diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 878a743..2291477 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -82,6 +82,8 @@ export interface GatewayClient { evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; + /** Hide a finished game from the caller's own lobby list (Stage 17); per-account, irreversible. */ + hideGame(gameId: string): Promise; // --- draft (Stage 17) --- /** The player's server-persisted client-side composition (rack order + board tiles), so a diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index d8a353a..1431634 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -35,6 +35,7 @@ export const en = { 'lobby.about': 'About', 'lobby.yourTurn': 'Your turn', 'lobby.theirTurn': 'Their turn', + 'lobby.hideGame': 'Remove from list', 'lobby.vs': 'vs {opponents}', 'lobby.soon': 'Coming soon', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index aa35294..c039d99 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -36,6 +36,7 @@ export const ru: Record = { 'lobby.about': 'О программе', 'lobby.yourTurn': 'Ваш ход', 'lobby.theirTurn': 'Ход соперника', + 'lobby.hideGame': 'Убрать из списка', 'lobby.vs': 'против {opponents}', 'lobby.soon': 'Скоро', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index e555407..ab4a8bc 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -324,6 +324,15 @@ export class MockGateway implements GatewayClient { } async complaint(): Promise {} + // Hide a finished game from the caller's list (Stage 17): drop it from the in-memory store so a + // subsequent gamesList omits it, mirroring the backend's per-account, finished-only rule. + async hideGame(gameId: string): Promise { + const g = this.game(gameId); + if (g.view.status !== 'finished') throw new GatewayError('game_active'); + this.games.delete(gameId); + this.drafts.delete(gameId); + } + // --- draft (Stage 17): an in-memory composition store, so the reload/off-turn flow is // exercised without a backend. A committed move clears the actor's own draft, as on the server. async draftGet(gameId: string): Promise { diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 324a3f3..02ad414 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -114,6 +114,9 @@ export function createTransport(baseUrl: string): GatewayClient { async complaint(id, word, note) { await exec('game.complaint', codec.encodeComplaint(id, word, note)); }, + async hideGame(id) { + await exec('game.hide', codec.encodeGameAction(id)); + }, async draftGet(id) { return codec.decodeDraftView(await exec('draft.get', codec.encodeGameAction(id))); }, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index e4480a1..236d421 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -61,6 +61,58 @@ return `${me?.score ?? 0} : ${opp.join(', ')}`; } + // Hiding a finished game (Stage 17). The delete action sits behind each finished row and is + // revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is + // per-account and irreversible. Only one row is revealed at a time. + let revealedId = $state(null); + let drag: { id: string; x0: number; y0: number } | null = null; + // A horizontal swipe must not also count as a tap that opens the game; armed on swipe, + // consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten. + let swiped = false; + + function onRowDown(e: PointerEvent, id: string): void { + swiped = false; + if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe + drag = { id, x0: e.clientX, y0: e.clientY }; + } + function onRowUp(e: PointerEvent, finished: boolean): void { + if (!drag) return; + const dx = e.clientX - drag.x0; + const dy = e.clientY - drag.y0; + if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) { + swiped = true; + revealedId = dx < 0 && finished ? drag.id : null; + } + drag = null; + } + function openGame(g: GameView): void { + if (swiped) { + swiped = false; + return; + } + if (revealedId === g.id) { + revealedId = null; + return; + } + navigate(`/game/${g.id}`); + } + function toggleReveal(id: string): void { + revealedId = revealedId === id ? null : id; + } + async function hide(id: string): Promise { + revealedId = null; + const prev = games; + games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out + setLobby({ games, invitations, incoming }); + try { + await gateway.hideGame(id); + } catch (e) { + games = prev; + setLobby({ games, invitations, incoming }); + handleError(e); + } + } + const menuItems = $derived([ ...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]), { label: t('lobby.profile'), onclick: () => navigate('/profile') }, @@ -129,19 +181,36 @@ {/if} - {#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)} + {#each [{ h: 'lobby.yourTurn', list: groups.yourTurn, finished: false }, { h: 'lobby.theirTurn', list: groups.theirTurn, finished: false }, { h: 'lobby.finishedGames', list: groups.finished, finished: true }] as group (group.h)} {#if group.list.length}

{t(group.h as 'lobby.yourTurn')}

{#each group.list as g (g.id)} - +
+ {#if group.finished} + + {/if} +
+ + {#if group.finished} + + {:else} + + {/if} +
+
{/each}
@@ -208,25 +277,75 @@ display: flex; flex-direction: column; } + /* Each finished row can slide left to reveal a delete action sitting behind it; the row's + own opaque background hides that action until revealed (Stage 17). */ + .rowwrap { + position: relative; + overflow: hidden; + } + .rowwrap + .rowwrap { + border-top: 1px solid var(--border); + } + .del { + position: absolute; + inset: 0 0 0 auto; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--danger); + color: #fff; + font-size: 1.1rem; + } .row { + position: relative; + display: flex; + align-items: center; + gap: 2px; + background: var(--bg); + transform: translateX(0); + transition: transform 0.18s ease; + } + .rowwrap.revealed .row { + transform: translateX(-64px); + } + .open { + flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; - width: 100%; + min-width: 0; text-align: left; padding: 10px 6px; border: none; background: none; color: var(--text); user-select: none; + touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */ } - .row + .row { - border-top: 1px solid var(--border); - } - .row:active { + .open:active { background: var(--surface-2); } + .kebab { + flex: 0 0 auto; + width: 30px; + padding: 10px 0; + border: none; + background: none; + color: var(--text-muted); + font-size: 1.4rem; + line-height: 1; + } + .chev { + flex: 0 0 auto; + width: 30px; + text-align: center; + color: var(--text-muted); + font-size: 1.4rem; + line-height: 1; + } .info { display: flex; flex-direction: column; From 13361c098cc752b7865deeef6647082709966087 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 9 Jun 2026 00:39:54 +0200 Subject: [PATCH 2/2] Stage 17 #5: make the active-row chevron open the game (not a no-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner review: the '>' on an active game row should be a real tap target that opens the game, like the rest of the row — not inert. The chevron now navigates (kept out of the tab order / a11y tree since the row's main button already does the same), and active-row swipes no longer suppress the tap. Adds an e2e for the chevron navigation. --- ui/e2e/social.spec.ts | 7 +++++++ ui/src/screens/Lobby.svelte | 13 ++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index b89fd68..f9756f2 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -107,6 +107,13 @@ test('lobby: hiding a finished game removes it (kebab → ❌), keeping the othe await expect(page.locator('.rowwrap', { hasText: 'Rick' })).toBeVisible(); }); +test('lobby: the active-row chevron opens the game (not a no-op)', async ({ page }) => { + await loginLobby(page); + // The inert-looking '>' on an active row is a tap target that opens the game, like the row. + await page.locator('.rowwrap', { hasText: 'Ann' }).locator('.chev').click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); +}); + test('lobby hamburger shows the pending notification count', async ({ page }) => { await loginLobby(page); // One incoming friend request (Rick) + one invitation (Kaya) = 2. diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 236d421..11248e4 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -79,9 +79,11 @@ if (!drag) return; const dx = e.clientX - drag.x0; const dy = e.clientY - drag.y0; - if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) { + // Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it + // does not also open the game. Active rows ignore swipes and stay plain tap-to-open. + if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) { swiped = true; - revealedId = dx < 0 && finished ? drag.id : null; + revealedId = dx < 0 ? drag.id : null; } drag = null; } @@ -207,7 +209,9 @@ {#if group.finished} {:else} - + + {/if} @@ -341,6 +345,9 @@ .chev { flex: 0 0 auto; width: 30px; + padding: 10px 0; + border: none; + background: none; text-align: center; color: var(--text-muted); font-size: 1.4rem;