From 61c8eeeb8124f254d91bddc547226c3e7e5bf6a8 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 12 Nov 2026 15:57:41 +0200 Subject: [PATCH] feat(games): a player's own seat, and the game venue's city names (#668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the iOS game cards (playerz-ios#62): `OpenGamePlayer.isYou` lets the viewer's seat say «Вие» now that players are named; `OpenGame.venue` carries `cityKey` and `cityNames` like a venue card (#667). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DfeuWUNfBvCav72pHsXdHa --- openapi/playerz-v1.json | 15 +++++++++++++-- src/app-layer/usecases/open-play.ts | 15 ++++++++++++++- src/app/api/v1/_lib/open-play-dto.ts | 16 ++++++++++++++-- tests/integration/open-play.test.ts | 10 ++++++++-- tests/rendered/open-play.test.tsx | 10 +++++++++- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/openapi/playerz-v1.json b/openapi/playerz-v1.json index c3424a36..3af9ed02 100644 --- a/openapi/playerz-v1.json +++ b/openapi/playerz-v1.json @@ -23707,7 +23707,7 @@ }, "venue": { "type": "object", - "required": ["id", "slug", "name", "city", "logoUrl"], + "required": ["id", "slug", "name", "city", "cityKey", "cityNames", "logoUrl"], "properties": { "id": { "type": "string" @@ -23721,6 +23721,13 @@ "city": { "type": "string" }, + "cityKey": { + "type": ["string", "null"], + "description": "The canonical city (#667)." + }, + "cityNames": { + "$ref": "#/components/schemas/CityNames" + }, "logoUrl": { "type": ["string", "null"], "description": "The club's logo (#668)." @@ -27957,7 +27964,7 @@ "OpenGamePlayer": { "type": "object", "description": "One person on a game (#668). Never carries a user id.", - "required": ["firstName", "avatarUrl", "level", "isBooker"], + "required": ["firstName", "avatarUrl", "level", "isBooker", "isYou"], "properties": { "firstName": { "type": ["string", "null"] @@ -27971,6 +27978,10 @@ }, "isBooker": { "type": "boolean" + }, + "isYou": { + "type": "boolean", + "description": "The viewer themselves, so their seat can say «Вие»." } } } diff --git a/src/app-layer/usecases/open-play.ts b/src/app-layer/usecases/open-play.ts index 9044d7b6..ecf58aff 100644 --- a/src/app-layer/usecases/open-play.ts +++ b/src/app-layer/usecases/open-play.ts @@ -162,6 +162,8 @@ export interface OpenGamePlayer { /** Their level in the game's sport, as they let others see it; null when hidden or none. */ level: number | null; isBooker: boolean; + /** The viewer themselves, so their seat can say «Вие». */ + isYou: boolean; } export interface OpenGameSummary { @@ -176,6 +178,8 @@ export interface OpenGameSummary { slug: string; name: string; city: string; + /** The canonical city (#667), or null for one outside the list. */ + cityKey: string | null; /** The club's logo (#668), or null. */ logoUrl: string | null; }; @@ -254,6 +258,7 @@ const GAME_SELECT = { select: { id: true, tenantId: true, + cityKey: true, slug: true, name: true, city: true, @@ -387,6 +392,7 @@ async function summarise( avatarUrl: live ? avatarUrlOf(live.avatarUrl) : null, level: rating ? rating.level : null, isBooker, + isYou: userId === viewerId, }; }; const playersOf = (g: GameRow): OpenGamePlayer[] | null => { @@ -398,7 +404,13 @@ async function summarise( ...b.participants.map((p) => p.userId ? person(p.userId, sport, false) - : { firstName: firstName(p.guestName), avatarUrl: null, level: null, isBooker: false }, + : { + firstName: firstName(p.guestName), + avatarUrl: null, + level: null, + isBooker: false, + isYou: false, + }, ), ]; }; @@ -423,6 +435,7 @@ async function summarise( slug: v.slug, name: v.name, city: v.city, + cityKey: v.cityKey, logoUrl: logos.get(v.tenantId) ?? null, }, courtPrice: { totalCents: b.totalCents, currency: b.currency }, diff --git a/src/app/api/v1/_lib/open-play-dto.ts b/src/app/api/v1/_lib/open-play-dto.ts index db92adda..60c2371d 100644 --- a/src/app/api/v1/_lib/open-play-dto.ts +++ b/src/app/api/v1/_lib/open-play-dto.ts @@ -7,6 +7,9 @@ import type { OpenGameSummary, RequestCard, } from '@/app-layer/usecases/open-play'; +import type { CityKey } from '@/lib/geo/cities'; + +import { cityNames, type CityNames } from './city-names'; /** * Wire shapes for open play (#376). Dates are ISO strings. What anyone sees @@ -23,7 +26,16 @@ export interface OpenGameDto { resourceType: string; courtName: string; isIndoor: boolean; - venue: { id: string; slug: string; name: string; city: string; logoUrl: string | null }; + venue: { + id: string; + slug: string; + name: string; + city: string; + /** The canonical city and its names (#667), as on a venue card. */ + cityKey: string | null; + cityNames: CityNames; + logoUrl: string | null; + }; courtPrice: { totalCents: number; currency: string }; /** Booker first; null unless the viewer books, plays or has asked (#668). */ players: OpenGamePlayer[] | null; @@ -56,7 +68,7 @@ export function toOpenGameDto(g: OpenGameSummary): OpenGameDto { resourceType: g.resourceType, courtName: g.courtName, isIndoor: g.isIndoor, - venue: g.venue, + venue: { ...g.venue, cityNames: cityNames(g.venue.city, g.venue.cityKey as CityKey | null) }, courtPrice: g.courtPrice, players: g.players, startTs: g.startTs.toISOString(), diff --git a/tests/integration/open-play.test.ts b/tests/integration/open-play.test.ts index 44b6b4fd..26d487ac 100644 --- a/tests/integration/open-play.test.ts +++ b/tests/integration/open-play.test.ts @@ -226,7 +226,7 @@ describe('open play (#376)', () => { expect((await card(null)).players).toBeNull(); expect((await card(other)).players).toBeNull(); expect((await card(booker)).players).toEqual([ - { firstName: 'Иван', avatarUrl: null, level: null, isBooker: true }, + { firstName: 'Иван', avatarUrl: null, level: null, isBooker: true, isYou: true }, ]); // Asking shows who is on it; still only the booker. @@ -239,6 +239,9 @@ describe('open play (#376)', () => { await answer(booker, id, requests[0]!.id, 'accept'); const seen = (await card(booker)).players!; expect(seen.map((p) => p.firstName)).toEqual(['Иван', 'Мария']); + // Each sees their own seat as theirs. + const mine = (await card(asker)).players as unknown as Array<{ isYou: boolean }>; + expect(mine.map((p) => p.isYou)).toEqual([false, true]); expect((await card(other)).players).toBeNull(); // Declined: back to counts only. @@ -261,7 +264,10 @@ describe('open play (#376)', () => { expect(await card(null)).toMatchObject({ courtPrice: { totalCents: 2400, currency: 'EUR' }, isIndoor: false, - venue: { logoUrl: 'https://cdn.test/l.webp' }, + venue: { + logoUrl: 'https://cdn.test/l.webp', + cityNames: { bg: expect.any(String), en: expect.any(String) }, + }, }); }); diff --git a/tests/rendered/open-play.test.tsx b/tests/rendered/open-play.test.tsx index c31f1c2d..3a6f1e7e 100644 --- a/tests/rendered/open-play.test.tsx +++ b/tests/rendered/open-play.test.tsx @@ -26,7 +26,15 @@ const GAME: OpenGameDto = { resourceType: 'COURT', courtName: 'Корт 1', isIndoor: false, - venue: { id: 'v1', slug: 'alfa', name: 'Алфа Кортове', city: 'Sofia', logoUrl: null }, + venue: { + id: 'v1', + slug: 'alfa', + name: 'Алфа Кортове', + city: 'Sofia', + cityKey: 'sofia', + cityNames: { bg: 'София', en: 'Sofia' }, + logoUrl: null, + }, courtPrice: { totalCents: 4000, currency: 'EUR' }, players: null, startTs: '2026-10-20T16:00:00.000Z',