From 0319ca565a90af73f8e20b39a72d3aaf79f164e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:20:23 +0000 Subject: [PATCH 01/56] docs: design Together flow improvements Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- ...09-08-together-flow-improvements-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md diff --git a/docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md b/docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md new file mode 100644 index 00000000..1f4f49de --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md @@ -0,0 +1,127 @@ +# Together Flow Improvements — Design + +## Goal + +Make Together produce more relevant choices, work naturally when shared across languages, celebrate matches without ending discovery, explain the benefit of signing in, and support groups of up to four people. + +## Product Decisions + +- The room host selects streaming providers before entering their name. +- The selected providers and region apply to every participant in the room. +- Provider selection is optional through an explicit “Any service” choice so provider API failure never blocks room creation. +- Signed-in hosts start with their saved region and providers; guests retain the existing `BR` region default. +- Shared links contain no locale. The existing proxy detects the recipient browser language and redirects into the localized route. +- A room supports at most four participants. +- Voting can begin with two participants; additional people can join until the room reaches four. +- A title becomes a match when at least two distinct participants choose `LIKE` or `MAYBE`. +- Match percentage continues to use the total participant count, e.g. two interested people in a four-person room is 50%. +- Match celebration is non-blocking and offers “Continue discovering” and “View matches”. +- Guests see a non-blocking sign-in prompt explaining that saved preferences improve recommendations. They can continue without an account. + +## Host Setup + +The current create form becomes a two-step flow: + +1. **Where do you watch?** + - Region selector. + - Movie watch-provider list with logos. + - Multi-select providers or explicit “Any service”. + - Saved user preferences prefill the step when available. +2. **Create your invite** + - Existing display-name field. + - Existing room creation action. + +Room creation sends `watchProviderIds` and `watchRegion`, which the backend already stores. The vote deck reads these values from room state and adds `with_watch_providers` and `watch_region` to TMDB discovery. An empty provider list omits the provider filter. + +## Language-Neutral Sharing + +Invite links use: + +```text +https://plotwist.app/together/{ROOM_CODE} +``` + +They do not include the host locale. On first request, the existing proxy selects a supported locale from `Accept-Language` and redirects to: + +```text +/{detected-locale}/together/{ROOM_CODE} +``` + +The room code and participant token remain independent of locale, so changing locale does not create a new room or participant. + +## Match Notification + +The swipe endpoint already returns a match to the participant whose decision completes it. The vote screen also polls the authenticated room matches endpoint so other participants learn about matches created by someone else. + +The client stores acknowledged match keys for the room in session storage. A match not yet acknowledged opens a celebration overlay containing: + +- poster and title; +- number or percentage of interested participants; +- “Continue discovering”, which closes the overlay and keeps the current deck; +- “View matches”, which navigates to the matches screen. + +Direct swipe results update the same match query/cache used by polling. This avoids two separate notification paths and duplicate celebrations. Previously acknowledged matches are not reopened during the browser session. + +## Guest Sign-In Prompt + +Guests see a compact prompt in the initial Together setup: + +- message: signing in lets Plotwist use saved streaming services and preferences for better recommendations; +- primary action: sign in and return to Together; +- secondary action: continue as guest. + +The prompt does not block provider selection, joining an invite, or voting. The sign-in URL carries a localized redirect back to `/{lang}/together`. + +Authenticated users do not see the prompt. Their saved watch-provider IDs and region prefill host setup but remain editable for this room. + +## Four-Person Rooms + +The backend defines one shared `MAX_TOGETHER_PARTICIPANTS = 4` constant. Joining is rejected once four distinct participants exist; rejoining with an existing valid participant token remains allowed even when full. + +The room response exposes capacity information so the join screen and waiting room can show `current / 4`. The invitation ticket and copy no longer promise admission for exactly two. + +The host can start voting once at least two people are present. The room remains joinable until capacity is reached. + +## Error Handling + +- Provider-list failure shows a retry action and allows “Any service”. +- Room creation retains the selected setup values after an API failure. +- A fifth new participant receives a localized “Room is full” state; valid rejoin tokens continue to work. +- Invalid locale-less invite paths continue through existing not-found behavior after localization. +- Match polling failure is silent while direct voting continues; the existing matches screen remains available. +- Match overlay media uses the existing poster fallback. + +## Accessibility + +- Provider selection uses labelled controls, visible selected state, keyboard operation, and provider names in addition to logos. +- Step changes move focus to the new heading. +- Guest prompt actions are links/buttons with explicit text. +- Match celebration is a labelled dialog, traps focus, supports Escape, and restores focus when dismissed. +- Status is never conveyed by color alone. + +## Testing + +Backend coverage: + +- host-selected providers and region persist; +- first four distinct participants can join; +- fifth distinct participant is rejected; +- valid token can rejoin a full room; +- match threshold remains two interested participants in larger rooms; +- percentage uses the current group size. + +Web coverage: + +- host setup starts with providers and advances to name; +- authenticated preferences prefill but remain editable; +- guest prompt appears only when signed out; +- room creation sends provider IDs and region; +- deck applies room provider filters and omits them for “Any service”; +- invite URL has no locale; +- locale-less invite is redirected from browser language; +- direct and polled matches share one celebration path; +- continuing dismisses without navigating; +- acknowledged matches are not repeated; +- four-person waiting/capacity copy and full-room state are localized. + +All new user-facing copy is added to the seven supported web dictionaries. From d4224d1c2faa8b15e7c0d263e4afcd0bc4478d7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:21:59 +0000 Subject: [PATCH 02/56] docs: plan Together flow improvements Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../2026-09-08-together-flow-improvements.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-together-flow-improvements.md diff --git a/docs/superpowers/plans/2026-09-08-together-flow-improvements.md b/docs/superpowers/plans/2026-09-08-together-flow-improvements.md new file mode 100644 index 00000000..fc383c3d --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-together-flow-improvements.md @@ -0,0 +1,360 @@ +# Together Flow Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve Together with host-selected streaming services, language-neutral invites, match celebrations, guest sign-in guidance, and rooms for up to four participants. + +**Architecture:** Keep room-level recommendation constraints in the existing room entity and apply them when the web client builds its TMDB deck. Enforce group capacity atomically in the backend, while locale detection, provider setup, guest guidance, and match notifications remain focused web components around the existing Together flow. + +**Tech Stack:** Fastify 5, Drizzle ORM, PostgreSQL, Next.js 16, React 19, TanStack Query, TMDB client, Vitest, Testing Library, Biome. + +## Global Constraints + +- Only the host chooses `watchProviderIds` and `watchRegion`; the values apply to the whole room. +- Provider selection is the first host step and includes an explicit unfiltered “Any service” option. +- Signed-in host preferences prefill the room setup but remain editable. +- Shared invite URLs contain no locale and rely on request language detection. +- A room accepts at most four distinct participants. +- A valid participant token may rejoin a full room. +- Voting may start at two participants. +- Two distinct `LIKE` or `MAYBE` decisions create a match, including in larger rooms. +- Both participants who are voting can discover a match; acknowledging it prevents repeated celebration in the same browser session. +- Match celebration offers “Continue discovering” and “View matches”. +- Guest login guidance is non-blocking. +- All new user-facing copy exists in all seven web dictionaries. +- No new dependency is added. + +--- + +### Task 1: Atomic four-person room capacity + +**Files:** +- Create: `apps/backend/src/domain/services/together/constants.ts` +- Modify: `apps/backend/src/infra/db/repositories/together-repository.ts` +- Modify: `apps/backend/src/domain/services/together/join-room.ts` +- Modify: `apps/backend/src/domain/services/together/join-room.spec.ts` +- Modify: `apps/backend/src/domain/services/together/create-swipe.spec.ts` +- Modify: `apps/backend/src/infra/http/controllers/together-controller.ts` +- Modify: `apps/backend/src/infra/http/schemas/together.ts` +- Modify: `apps/web/src/services/together.ts` + +**Interfaces:** +- Produces: `MAX_TOGETHER_PARTICIPANTS = 4` +- Produces: `insertTogetherParticipantWithinCapacity(values, capacity)` returning the participant or `null` +- Adds: `maxParticipants: 4` to serialized room state + +- [ ] **Step 1: Write failing capacity tests** + +Extend `join-room.spec.ts` to create one host and join three distinct participants, then assert a fifth new participant returns `TogetherInvalidInputError`. Reuse one of the four valid tokens after capacity and assert the existing participant is returned: + +```ts +expect(fifth).toBeInstanceOf(TogetherInvalidInputError) +expect(rejoined).toEqual( + expect.objectContaining({ + participant: expect.objectContaining({ id: fourth.participant.id }), + participantToken: fourth.participantToken, + }) +) +``` + +Extend `create-swipe.spec.ts` with four participants. After two distinct interested swipes, assert: + +```ts +expect(result.match).toEqual( + expect.objectContaining({ likeCount: 2, matchPercent: 50 }) +) +``` + +- [ ] **Step 2: Run backend tests and verify RED** + +```bash +pnpm --filter backend test --run \ + src/domain/services/together/join-room.spec.ts \ + src/domain/services/together/create-swipe.spec.ts +``` + +Expected: fifth participant currently succeeds and the four-person percentage case is absent or fails. + +- [ ] **Step 3: Implement atomic capacity** + +Define the shared constant. Add a repository transaction which locks the room row, counts participants, returns `null` at capacity, and inserts otherwise: + +```ts +await tx.execute( + sql`select id from ${togetherRooms} where ${togetherRooms.id} = ${values.roomId} for update` +) +const [{ count }] = await tx + .select({ count: sql`count(*)::int` }) + .from(togetherParticipants) + .where(eq(togetherParticipants.roomId, values.roomId)) +if (count >= capacity) return null +``` + +`joinTogetherRoomService` must check a valid rejoin token before calling the capacity insert. Convert `null` into `TogetherInvalidInputError('Room is full.')`. + +- [ ] **Step 4: Expose capacity** + +Add `maxParticipants` to `serializeRoom`, the Zod room schema, and `TogetherRoom`. Use the shared backend constant in serialization. + +- [ ] **Step 5: Verify and commit** + +Run the two focused backend specs, backend typecheck/build, and Biome on changed backend files. Commit: + +```bash +git add apps/backend apps/web/src/services/together.ts +git commit -m "feat(together): support rooms of up to four" +``` + +### Task 2: Locale-neutral invite URLs + +**Files:** +- Create: `apps/web/src/services/together-invite.ts` +- Create: `apps/web/src/services/together-invite.test.ts` +- Create: `apps/web/src/lib/request-locale.test.ts` +- Modify: `apps/web/src/app/[lang]/together/_components/together-room.tsx` +- Modify: `apps/web/src/proxy.ts` + +**Interfaces:** +- Produces: `buildTogetherInviteUrl(appUrl: string, code: string): string` +- Produces: `detectRequestLocale(acceptLanguage: string | null): Language` + +- [ ] **Step 1: Write failing URL and locale tests** + +```ts +expect(buildTogetherInviteUrl('https://plotwist.app', 'ABC123')).toBe( + 'https://plotwist.app/together/ABC123' +) +expect(buildTogetherInviteUrl('https://plotwist.app/', 'abc123')).toBe( + 'https://plotwist.app/together/ABC123' +) +expect(detectRequestLocale('pt-BR,pt;q=0.9,en;q=0.8')).toBe('pt-BR') +expect(detectRequestLocale('fr;q=0.9,en;q=0.8')).toBe('fr-FR') +expect(detectRequestLocale(null)).toBe('en-US') +``` + +- [ ] **Step 2: Verify RED** + +Run both focused web tests. Expected: helpers do not exist. + +- [ ] **Step 3: Implement helpers and integration** + +Build the invite without a locale and uppercase the room code. Extract the existing proxy language selection into `detectRequestLocale`, preserving traffic guard and locale-prefixed route behavior. `TogetherRoom` uses: + +```ts +const inviteUrl = buildTogetherInviteUrl(APP_URL, roomCode) +``` + +- [ ] **Step 4: Verify and commit** + +Run helper tests, web typecheck, and Biome. Commit: + +```bash +git add apps/web/src/services/together-invite* apps/web/src/lib/request-locale* apps/web/src/proxy.ts apps/web/src/app/[lang]/together/_components/together-room.tsx +git commit -m "feat(together): share locale-neutral invite links" +``` + +### Task 3: Host watch-provider setup and filtered deck + +**Files:** +- Create: `apps/web/src/app/[lang]/together/_components/together-provider-step.tsx` +- Create: `apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/create-invite-form.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/together-vote.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/together-vote.test.tsx` +- Modify: `apps/web/src/services/together.ts` + +**Interfaces:** +- Produces: `TogetherProviderStep({ region, providerIds, onRegionChange, onProviderIdsChange, onContinue })` +- Extends: `createTogetherRoom({ displayName, watchProviderIds, watchRegion })` + +- [ ] **Step 1: Write failing host-flow tests** + +Test that provider setup renders before the name field, selecting providers advances to the name step, and room creation receives: + +```ts +expect(createTogetherRoom).toHaveBeenCalledWith({ + displayName: 'Ana', + watchProviderIds: [8, 337], + watchRegion: 'BR', +}) +``` + +Mock `useUserPreferences` and assert saved provider IDs and region are initially selected for authenticated hosts. + +- [ ] **Step 2: Write failing deck-filter tests** + +With a room containing providers `[8, 337]` and region `BR`, assert TMDB discovery receives: + +```ts +filters: expect.objectContaining({ + with_watch_providers: '8|337', + watch_region: 'BR', +}) +``` + +With an empty provider list, assert `with_watch_providers` and `watch_region` are omitted. + +- [ ] **Step 3: Verify RED** + +Run the three Together component tests. Expected: setup component and extended room body do not exist; deck lacks provider filters. + +- [ ] **Step 4: Implement provider setup** + +Use `tmdb.watchProviders.regions` and `tmdb.watchProviders.list('movie', ...)`. Render provider names and logos as keyboard-operable toggle buttons. Include explicit “Any service”, loading, error/retry, and continue states. Prefill from `useUserPreferences().userPreferences`, falling back to region `BR` and no providers. + +Keep selection state in `CreateInviteForm` so an API error does not reset it. Step one advances to the existing name/create step. + +- [ ] **Step 5: Apply room filters** + +Read `roomQuery.data.room.watchProviderIds` and `watchRegion` in `TogetherVote`. Include the provider values in the deck query key. Only add TMDB availability filters when the provider list is non-empty. + +- [ ] **Step 6: Verify and commit** + +Run focused Together tests, web typecheck, and changed-file Biome. Commit: + +```bash +git add apps/web/src/app/[lang]/together apps/web/src/services/together.ts +git commit -m "feat(together): filter choices by host providers" +``` + +### Task 4: Match celebration and continued discovery + +**Files:** +- Create: `apps/web/src/app/[lang]/together/_components/match-celebration.tsx` +- Create: `apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx` +- Create: `apps/web/src/app/[lang]/together/_components/together-match-notifications.ts` +- Create: `apps/web/src/app/[lang]/together/_components/together-match-notifications.test.ts` +- Modify: `apps/web/src/app/[lang]/together/_components/together-vote.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/together-vote.test.tsx` + +**Interfaces:** +- Produces: `togetherMatchKey(match): string` +- Produces: acknowledged-match session helpers scoped by room code +- Produces: `MatchCelebration({ match, onContinue, onViewMatches, copy })` + +- [ ] **Step 1: Write failing notification tests** + +Cover stable match keys, room-scoped acknowledgement, and no repeated unacknowledged match. Render the celebration and assert both actions: + +```ts +await user.click(screen.getByRole('button', { name: copy.continueDiscovering })) +expect(onContinue).toHaveBeenCalledOnce() +expect(onViewMatches).not.toHaveBeenCalled() +``` + +In the vote test, assert a match returned by `createTogetherSwipe` opens the dialog and a match later returned by polling opens the same dialog. + +- [ ] **Step 2: Verify RED** + +Run focused match/vote tests. Expected: helpers, celebration, and polling path do not exist. + +- [ ] **Step 3: Implement one notification path** + +Add a `getTogetherMatches` query while voting with a 3-second interval. Feed direct swipe matches into that query cache. Select the first unacknowledged match and show the accessible celebration dialog. Acknowledge on either action: + +- Continue closes the dialog and leaves the deck/route unchanged. +- View matches navigates to `/{language}/together/{code}/matches`. + +Use explicit Together colors on portaled dialog content so CSS variables scoped to `.together-shell` are not lost. + +- [ ] **Step 4: Verify and commit** + +Run focused match/vote tests, typecheck, and Biome. Commit: + +```bash +git add apps/web/src/app/[lang]/together/_components +git commit -m "feat(together): celebrate matches while discovering" +``` + +### Task 5: Guest guidance and four-person UI + +**Files:** +- Create: `apps/web/src/app/[lang]/together/_components/together-guest-prompt.tsx` +- Create: `apps/web/src/app/[lang]/together/_components/together-guest-prompt.test.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/welcome-screen.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/together-room.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/join-invite-form.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/invite-screen.tsx` +- Modify: `apps/web/src/app/[lang]/together/_components/waiting-room.tsx` +- Modify: all seven files under `apps/web/public/dictionaries/*.json` + +**Interfaces:** +- Produces: `TogetherGuestPrompt({ language, copy })` +- Consumes: `room.maxParticipants` + +- [ ] **Step 1: Write failing guest/capacity tests** + +Assert signed-out welcome renders the recommendation-benefit prompt, sign-in link includes a localized encoded redirect to `/{lang}/together`, and a continue-as-guest action dismisses it. Assert authenticated users do not see it. + +Assert a room with four participants renders localized full-room state to a visitor, while two or three participants still render the join form. Assert waiting copy contains current and maximum capacity. + +- [ ] **Step 2: Verify RED** + +Run focused guest, room, and waiting tests. Expected: prompt and capacity UI do not exist. + +- [ ] **Step 3: Implement guest prompt** + +Render it before host provider setup. Keep dismissal in component state and never block setup. Use existing Together button styles and localized sign-in URL: + +```ts +`/${language}/sign-in?redirect=${encodeURIComponent(`/${language}/together`)}` +``` + +- [ ] **Step 4: Implement capacity UI and copy** + +Use `participants.length` and `room.maxParticipants` from room state. Replace exact “admit two” copy with “up to four”; show `current / max` in invite and waiting screens. When full and the visitor has no valid membership, show the localized room-full state instead of the join form. + +Add all provider setup, match celebration, guest prompt, and capacity strings to all seven dictionaries. Extend the existing dictionary contract test to require every new key. + +- [ ] **Step 5: Verify and commit** + +Run all focused Together web tests, dictionary contract tests, typecheck, and changed-file Biome. Commit: + +```bash +git add apps/web/src/app/[lang]/together apps/web/public/dictionaries +git commit -m "feat(together): guide guests and show room capacity" +``` + +### Task 6: Final integration verification + +**Files:** +- Modify only files required to fix failures found during verification. + +- [ ] **Step 1: Run focused backend verification** + +```bash +pnpm --filter backend test --run src/domain/services/together +pnpm --filter backend run build +``` + +- [ ] **Step 2: Run focused web verification** + +```bash +pnpm --filter web test --run \ + src/services/together-invite.test.ts \ + src/lib/request-locale.test.ts \ + 'src/app/[lang]/together/**/*.test.ts*' \ + src/utils/dictionaries/get-dictionaries.test.ts +pnpm --filter web run typecheck +``` + +- [ ] **Step 3: Run formatting checks** + +Run Biome on every changed TS, TSX, and dictionary file. Expected: no errors. + +- [ ] **Step 4: Build and manually verify** + +Run root or app builds with available environment services. In the deployed preview, verify: + +1. guest prompt can be dismissed; +2. host provider choices persist into room creation; +3. invite URL is locale-neutral and redirects by browser language; +4. participants two through four join, while a fifth is blocked; +5. a match opens for direct and polling participants; +6. continue discovering stays on the deck; +7. the provider-filtered deck only shows titles available in the selected region/services. + +- [ ] **Step 5: Final review and PR update** + +Push all commits to `cursor/together-flow-improvements-08b6`, run a whole-branch code review, resolve every Critical/Important finding, and update PR #519 with verification evidence and preview instructions. From c686190b0ca6b5a0449c3cfee3fd7cd1794a833d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:23:24 +0000 Subject: [PATCH 03/56] test(together): define four-person room behavior Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../services/together/create-swipe.spec.ts | 37 +++++++++++++++++++ .../services/together/join-room.spec.ts | 37 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/apps/backend/src/domain/services/together/create-swipe.spec.ts b/apps/backend/src/domain/services/together/create-swipe.spec.ts index 1709a55c..e5ec37ab 100644 --- a/apps/backend/src/domain/services/together/create-swipe.spec.ts +++ b/apps/backend/src/domain/services/together/create-swipe.spec.ts @@ -150,4 +150,41 @@ describe('together matching', () => { }) ) }) + + it('should report a 50 percent match when two of four participants are interested', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + const second = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Maria', + }) + if (!('participantToken' in second)) throw new Error('expected join') + + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'João', + }) + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Ana', + }) + + await createTogetherSwipeService({ + code: host.room.code, + participantToken: host.participantToken, + decision: 'LIKE', + ...dune, + }) + const result = await createTogetherSwipeService({ + code: host.room.code, + participantToken: second.participantToken, + decision: 'LIKE', + ...dune, + }) + + expect(result.match).toEqual( + expect.objectContaining({ likeCount: 2, matchPercent: 50 }) + ) + }) }) diff --git a/apps/backend/src/domain/services/together/join-room.spec.ts b/apps/backend/src/domain/services/together/join-room.spec.ts index cff00346..502dc299 100644 --- a/apps/backend/src/domain/services/together/join-room.spec.ts +++ b/apps/backend/src/domain/services/together/join-room.spec.ts @@ -67,4 +67,41 @@ describe('join together room', () => { expect(sut).toBeInstanceOf(TogetherInvalidInputError) }) + + it('should reject a fifth participant while allowing an existing participant to rejoin', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Maria', + }) + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'João', + }) + const fourth = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Ana', + }) + if (!('participantToken' in fourth)) throw new Error('expected join') + + const fifth = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Lucas', + }) + const rejoined = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Ignored', + participantToken: fourth.participantToken, + }) + + expect(fifth).toBeInstanceOf(TogetherInvalidInputError) + expect(rejoined).toEqual( + expect.objectContaining({ + participant: expect.objectContaining({ id: fourth.participant.id }), + participantToken: fourth.participantToken, + }) + ) + }) }) From ccadad3174ee94c7e8f2b08fd77dc2119458f993 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:25:54 +0000 Subject: [PATCH 04/56] test(together): cover concurrent room joins Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../services/together/join-room.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/backend/src/domain/services/together/join-room.spec.ts b/apps/backend/src/domain/services/together/join-room.spec.ts index 502dc299..6a15e338 100644 --- a/apps/backend/src/domain/services/together/join-room.spec.ts +++ b/apps/backend/src/domain/services/together/join-room.spec.ts @@ -104,4 +104,36 @@ describe('join together room', () => { }) ) }) + + it('should allow only one of two concurrent joins when one place remains', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Maria', + }) + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'João', + }) + + const results = await Promise.all([ + joinTogetherRoomService({ + code: host.room.code, + displayName: 'Ana', + }), + joinTogetherRoomService({ + code: host.room.code, + displayName: 'Lucas', + }), + ]) + + expect( + results.filter(result => result instanceof TogetherInvalidInputError) + ).toHaveLength(1) + expect( + results.filter(result => 'participantToken' in result) + ).toHaveLength(1) + }) }) From b1c8dfd2334ac9b8b2b82d48ac91f7b9e25f6a0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:26:55 +0000 Subject: [PATCH 05/56] feat(together): support rooms of up to four Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../src/domain/services/together/constants.ts | 1 + .../src/domain/services/together/join-room.ts | 21 ++++++++----- .../db/repositories/together-repository.ts | 31 +++++++++++++++++++ .../http/controllers/together-controller.ts | 2 ++ .../src/infra/http/schemas/together.ts | 2 ++ apps/web/src/services/together.ts | 1 + 6 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/domain/services/together/constants.ts diff --git a/apps/backend/src/domain/services/together/constants.ts b/apps/backend/src/domain/services/together/constants.ts new file mode 100644 index 00000000..fb2eb079 --- /dev/null +++ b/apps/backend/src/domain/services/together/constants.ts @@ -0,0 +1 @@ +export const MAX_TOGETHER_PARTICIPANTS = 4 diff --git a/apps/backend/src/domain/services/together/join-room.ts b/apps/backend/src/domain/services/together/join-room.ts index 77c0ba4c..aa8e390f 100644 --- a/apps/backend/src/domain/services/together/join-room.ts +++ b/apps/backend/src/domain/services/together/join-room.ts @@ -1,10 +1,11 @@ import { TogetherInvalidInputError } from '@/domain/errors/together-invalid-input-error' import { TogetherRoomNotFoundError } from '@/domain/errors/together-room-not-found-error' import { - insertTogetherParticipant, + insertTogetherParticipantWithinCapacity, selectTogetherParticipantByTokenHash, selectTogetherRoomByCode, } from '@/infra/db/repositories/together-repository' +import { MAX_TOGETHER_PARTICIPANTS } from './constants' import { createTogetherToken, hashTogetherToken } from './together-token' export type JoinTogetherRoomInput = { @@ -39,12 +40,18 @@ export async function joinTogetherRoomService(input: JoinTogetherRoomInput) { } const participantToken = createTogetherToken() - const participant = await insertTogetherParticipant({ - roomId: room.id, - displayName, - tokenHash: hashTogetherToken(participantToken), - userId: input.userId ?? null, - }) + const participant = await insertTogetherParticipantWithinCapacity( + { + roomId: room.id, + displayName, + tokenHash: hashTogetherToken(participantToken), + userId: input.userId ?? null, + }, + MAX_TOGETHER_PARTICIPANTS + ) + if (!participant) { + return new TogetherInvalidInputError('Room is full.') + } return { room, participant, participantToken } } diff --git a/apps/backend/src/infra/db/repositories/together-repository.ts b/apps/backend/src/infra/db/repositories/together-repository.ts index 9587ced0..a312c495 100644 --- a/apps/backend/src/infra/db/repositories/together-repository.ts +++ b/apps/backend/src/infra/db/repositories/together-repository.ts @@ -40,6 +40,37 @@ export async function insertTogetherParticipant(values: { return participant } +export async function insertTogetherParticipantWithinCapacity( + values: { + roomId: string + displayName: string + tokenHash: string + userId?: string | null + }, + capacity: number +) { + return db.transaction(async tx => { + await tx.execute( + sql`select id from ${togetherRooms} where ${togetherRooms.id} = ${values.roomId} for update` + ) + + const [row] = await tx + .select({ count: sql`count(*)::int` }) + .from(togetherParticipants) + .where(eq(togetherParticipants.roomId, values.roomId)) + + if ((row?.count ?? 0) >= capacity) { + return null + } + + const [participant] = await tx + .insert(togetherParticipants) + .values(values) + .returning() + return participant + }) +} + export async function selectTogetherParticipantByTokenHash(tokenHash: string) { const [participant] = await db .select() diff --git a/apps/backend/src/infra/http/controllers/together-controller.ts b/apps/backend/src/infra/http/controllers/together-controller.ts index 3fec20f8..fff07c3c 100644 --- a/apps/backend/src/infra/http/controllers/together-controller.ts +++ b/apps/backend/src/infra/http/controllers/together-controller.ts @@ -9,6 +9,7 @@ import { createTogetherSwipeService } from '@/domain/services/together/create-sw import { getTogetherMatchesService } from '@/domain/services/together/get-matches' import { getTogetherRoomService } from '@/domain/services/together/get-room' import { joinTogetherRoomService } from '@/domain/services/together/join-room' +import { MAX_TOGETHER_PARTICIPANTS } from '@/domain/services/together/constants' import { createTogetherRoomBodySchema, createTogetherSwipeBodySchema, @@ -35,6 +36,7 @@ function serializeRoom(room: TogetherRoom) { watchRegion: room.watchRegion, maxRuntime: room.maxRuntime, mood: room.mood, + maxParticipants: MAX_TOGETHER_PARTICIPANTS, createdAt: asIso(room.createdAt), } } diff --git a/apps/backend/src/infra/http/schemas/together.ts b/apps/backend/src/infra/http/schemas/together.ts index fe94bffb..af72c9f7 100644 --- a/apps/backend/src/infra/http/schemas/together.ts +++ b/apps/backend/src/infra/http/schemas/together.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { MAX_TOGETHER_PARTICIPANTS } from '@/domain/services/together/constants' export const togetherMoodSchema = z.enum(['FUN', 'SUSPENSE', 'COMFORT', 'ANY']) export const togetherMediaTypeSchema = z.enum(['MOVIE', 'TV_SHOW']) @@ -38,6 +39,7 @@ const togetherRoomSchema = z.object({ watchRegion: z.string(), maxRuntime: z.number().nullable(), mood: togetherMoodSchema, + maxParticipants: z.literal(MAX_TOGETHER_PARTICIPANTS), createdAt: z.string(), }) diff --git a/apps/web/src/services/together.ts b/apps/web/src/services/together.ts index e1318cb6..e1ebfd73 100644 --- a/apps/web/src/services/together.ts +++ b/apps/web/src/services/together.ts @@ -10,6 +10,7 @@ export type TogetherRoom = { watchRegion: string maxRuntime: number | null mood: string + maxParticipants: number createdAt: string } From 072b9c4fb7ae41de214f43c30c1e202e37b5a1cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:27:44 +0000 Subject: [PATCH 06/56] chore(together): satisfy checks for capacity changes Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../src/domain/services/together/create-swipe.spec.ts | 1 + apps/backend/src/domain/services/together/join-room.spec.ts | 6 +++--- .../src/infra/http/controllers/together-controller.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/domain/services/together/create-swipe.spec.ts b/apps/backend/src/domain/services/together/create-swipe.spec.ts index e5ec37ab..8f1f0e79 100644 --- a/apps/backend/src/domain/services/together/create-swipe.spec.ts +++ b/apps/backend/src/domain/services/together/create-swipe.spec.ts @@ -182,6 +182,7 @@ describe('together matching', () => { decision: 'LIKE', ...dune, }) + if (!('match' in result)) throw new Error('expected swipe') expect(result.match).toEqual( expect.objectContaining({ likeCount: 2, matchPercent: 50 }) diff --git a/apps/backend/src/domain/services/together/join-room.spec.ts b/apps/backend/src/domain/services/together/join-room.spec.ts index 6a15e338..70f54217 100644 --- a/apps/backend/src/domain/services/together/join-room.spec.ts +++ b/apps/backend/src/domain/services/together/join-room.spec.ts @@ -132,8 +132,8 @@ describe('join together room', () => { expect( results.filter(result => result instanceof TogetherInvalidInputError) ).toHaveLength(1) - expect( - results.filter(result => 'participantToken' in result) - ).toHaveLength(1) + expect(results.filter(result => 'participantToken' in result)).toHaveLength( + 1 + ) }) }) diff --git a/apps/backend/src/infra/http/controllers/together-controller.ts b/apps/backend/src/infra/http/controllers/together-controller.ts index fff07c3c..8eab7f96 100644 --- a/apps/backend/src/infra/http/controllers/together-controller.ts +++ b/apps/backend/src/infra/http/controllers/together-controller.ts @@ -4,12 +4,12 @@ import type { TogetherRoom, } from '@/domain/entities/together' import { DomainError } from '@/domain/errors/domain-error' +import { MAX_TOGETHER_PARTICIPANTS } from '@/domain/services/together/constants' import { createTogetherRoomService } from '@/domain/services/together/create-room' import { createTogetherSwipeService } from '@/domain/services/together/create-swipe' import { getTogetherMatchesService } from '@/domain/services/together/get-matches' import { getTogetherRoomService } from '@/domain/services/together/get-room' import { joinTogetherRoomService } from '@/domain/services/together/join-room' -import { MAX_TOGETHER_PARTICIPANTS } from '@/domain/services/together/constants' import { createTogetherRoomBodySchema, createTogetherSwipeBodySchema, From cde78367697912580141a4757bb9ea31856cc823 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:32:31 +0000 Subject: [PATCH 07/56] test(together): require atomic room host creation Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../services/together/create-room.spec.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/domain/services/together/create-room.spec.ts b/apps/backend/src/domain/services/together/create-room.spec.ts index 6038a6b9..1aa29e6c 100644 --- a/apps/backend/src/domain/services/together/create-room.spec.ts +++ b/apps/backend/src/domain/services/together/create-room.spec.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { TogetherInvalidInputError } from '@/domain/errors/together-invalid-input-error' +import { selectTogetherRoomByCode } from '@/infra/db/repositories/together-repository' import { createTogetherRoomService } from './create-room' +import * as togetherToken from './together-token' + +afterEach(() => { + vi.restoreAllMocks() +}) describe('create together room', () => { it('should create a room with a host participant and token', async () => { @@ -36,4 +42,40 @@ describe('create together room', () => { expect(sut).toBeInstanceOf(TogetherInvalidInputError) }) + + it('should roll back the room when creating the host participant fails', async () => { + vi.spyOn(togetherToken, 'generateRoomCode') + .mockReturnValueOnce('ATOM01') + .mockReturnValueOnce('ATOM02') + vi.spyOn(togetherToken, 'createTogetherToken').mockReturnValue( + 'a'.repeat(64) + ) + + const first = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in first)) throw new Error('expected room') + expect(first.room.code).toBe('ATOM01') + + await expect( + createTogetherRoomService({ displayName: 'Maria' }) + ).rejects.toThrow() + + expect(await selectTogetherRoomByCode('ATOM02')).toBeNull() + }) + + it('should retry room creation when a generated code already exists', async () => { + vi.spyOn(togetherToken, 'generateRoomCode') + .mockReturnValueOnce('RETRY1') + .mockReturnValueOnce('RETRY1') + .mockReturnValueOnce('RETRY2') + + const first = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in first)) throw new Error('expected room') + + const second = await createTogetherRoomService({ displayName: 'Maria' }) + if (!('room' in second)) throw new Error('expected room') + + expect(second.room.code).toBe('RETRY2') + expect(second.participantToken).toHaveLength(64) + expect(second.participantToken).not.toBe(first.participantToken) + }) }) From a01f222d55a6f9bd46d89f8e2c46e650e5fd151c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:33:18 +0000 Subject: [PATCH 08/56] fix(together): create rooms with hosts atomically Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../domain/services/together/create-room.ts | 52 +++++++----------- .../db/repositories/together-repository.ts | 55 +++++++++++-------- 2 files changed, 53 insertions(+), 54 deletions(-) diff --git a/apps/backend/src/domain/services/together/create-room.ts b/apps/backend/src/domain/services/together/create-room.ts index b38252e8..321f045f 100644 --- a/apps/backend/src/domain/services/together/create-room.ts +++ b/apps/backend/src/domain/services/together/create-room.ts @@ -1,9 +1,5 @@ import { TogetherInvalidInputError } from '@/domain/errors/together-invalid-input-error' -import { - insertTogetherParticipant, - insertTogetherRoom, - selectTogetherRoomByCode, -} from '@/infra/db/repositories/together-repository' +import { insertTogetherRoomWithHost } from '@/infra/db/repositories/together-repository' import { createTogetherToken, generateRoomCode, @@ -27,34 +23,28 @@ export async function createTogetherRoomService( return new TogetherInvalidInputError('Display name is required.') } - let room = null + const participantToken = createTogetherToken() for (let attempt = 0; attempt < 8; attempt++) { const code = generateRoomCode() - const existing = await selectTogetherRoomByCode(code) - if (existing) continue - - room = await insertTogetherRoom({ - code, - hostUserId: input.hostUserId ?? null, - watchProviderIds: input.watchProviderIds ?? [], - watchRegion: input.watchRegion ?? 'BR', - maxRuntime: input.maxRuntime ?? null, - mood: input.mood ?? 'ANY', - }) - break + const created = await insertTogetherRoomWithHost( + { + code, + hostUserId: input.hostUserId ?? null, + watchProviderIds: input.watchProviderIds ?? [], + watchRegion: input.watchRegion ?? 'BR', + maxRuntime: input.maxRuntime ?? null, + mood: input.mood ?? 'ANY', + }, + { + displayName, + tokenHash: hashTogetherToken(participantToken), + userId: input.hostUserId ?? null, + } + ) + if (created) { + return { ...created, participantToken } + } } - if (!room) { - return new TogetherInvalidInputError('Could not create a unique room code.') - } - - const participantToken = createTogetherToken() - const participant = await insertTogetherParticipant({ - roomId: room.id, - displayName, - tokenHash: hashTogetherToken(participantToken), - userId: input.hostUserId ?? null, - }) - - return { room, participant, participantToken } + return new TogetherInvalidInputError('Could not create a unique room code.') } diff --git a/apps/backend/src/infra/db/repositories/together-repository.ts b/apps/backend/src/infra/db/repositories/together-repository.ts index a312c495..83b9b5c1 100644 --- a/apps/backend/src/infra/db/repositories/together-repository.ts +++ b/apps/backend/src/infra/db/repositories/together-repository.ts @@ -6,16 +6,38 @@ import { togetherSwipes, } from '../schema/together' -export async function insertTogetherRoom(values: { - code: string - hostUserId?: string | null - watchProviderIds?: number[] - watchRegion?: string - maxRuntime?: number | null - mood?: 'FUN' | 'SUSPENSE' | 'COMFORT' | 'ANY' -}) { - const [room] = await db.insert(togetherRooms).values(values).returning() - return room +export async function insertTogetherRoomWithHost( + roomValues: { + code: string + hostUserId?: string | null + watchProviderIds?: number[] + watchRegion?: string + maxRuntime?: number | null + mood?: 'FUN' | 'SUSPENSE' | 'COMFORT' | 'ANY' + }, + hostValues: { + displayName: string + tokenHash: string + userId?: string | null + } +) { + return db.transaction(async tx => { + const [room] = await tx + .insert(togetherRooms) + .values(roomValues) + .onConflictDoNothing({ target: togetherRooms.code }) + .returning() + if (!room) { + return null + } + + const [participant] = await tx + .insert(togetherParticipants) + .values({ ...hostValues, roomId: room.id }) + .returning() + + return { room, participant } + }) } export async function selectTogetherRoomByCode(code: string) { @@ -27,19 +49,6 @@ export async function selectTogetherRoomByCode(code: string) { return room ?? null } -export async function insertTogetherParticipant(values: { - roomId: string - displayName: string - tokenHash: string - userId?: string | null -}) { - const [participant] = await db - .insert(togetherParticipants) - .values(values) - .returning() - return participant -} - export async function insertTogetherParticipantWithinCapacity( values: { roomId: string From ab96dcb22ca54b41ed19c53d486ce0a282955f9e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:37:19 +0000 Subject: [PATCH 09/56] feat(together): share locale-neutral invite links Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../together/_components/together-room.tsx | 3 +- apps/web/src/lib/request-locale.test.ts | 24 ++++++++++++ apps/web/src/lib/request-locale.ts | 38 +++++++++++++++++++ apps/web/src/proxy.ts | 17 +-------- apps/web/src/services/together-invite.test.ts | 16 ++++++++ apps/web/src/services/together-invite.ts | 6 +++ 6 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/lib/request-locale.test.ts create mode 100644 apps/web/src/lib/request-locale.ts create mode 100644 apps/web/src/services/together-invite.test.ts create mode 100644 apps/web/src/services/together-invite.ts diff --git a/apps/web/src/app/[lang]/together/_components/together-room.tsx b/apps/web/src/app/[lang]/together/_components/together-room.tsx index 6e2a6ea0..d208f66e 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-room.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { useLanguage } from '@/context/language' import { getTogetherRoom, getTogetherToken } from '@/services/together' +import { buildTogetherInviteUrl } from '@/services/together-invite' import { APP_URL } from '../../../../../constants' import { InviteScreen } from './invite-screen' import { JoinInviteForm } from './join-invite-form' @@ -28,7 +29,7 @@ export function TogetherRoom({ code }: { code: string }) { setContinued(sessionStorage.getItem(HOST_CONTINUED_KEY(roomCode)) === '1') }, [roomCode]) - const inviteUrl = `${APP_URL}/${language}/together/${roomCode}` + const inviteUrl = buildTogetherInviteUrl(APP_URL, roomCode) const roomQuery = useQuery({ queryKey: ['together-room', roomCode, token], diff --git a/apps/web/src/lib/request-locale.test.ts b/apps/web/src/lib/request-locale.test.ts new file mode 100644 index 00000000..f889b29c --- /dev/null +++ b/apps/web/src/lib/request-locale.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { detectRequestLocale } from './request-locale' + +describe('detectRequestLocale', () => { + it('selects an exact supported locale', () => { + expect(detectRequestLocale('pt-BR,pt;q=0.9,en;q=0.8')).toBe('pt-BR') + }) + + it('maps a base language to its supported locale', () => { + expect(detectRequestLocale('fr;q=0.9,en;q=0.8')).toBe('fr-FR') + }) + + it('honors language quality preferences', () => { + expect(detectRequestLocale('es;q=0.4,de;q=0.9')).toBe('de-DE') + }) + + it('ignores malformed and wildcard language ranges', () => { + expect(detectRequestLocale('not_a_locale,*;q=0.9,ja;q=0.8')).toBe('ja-JP') + }) + + it('defaults to English when the header is absent', () => { + expect(detectRequestLocale(null)).toBe('en-US') + }) +}) diff --git a/apps/web/src/lib/request-locale.ts b/apps/web/src/lib/request-locale.ts new file mode 100644 index 00000000..de36111b --- /dev/null +++ b/apps/web/src/lib/request-locale.ts @@ -0,0 +1,38 @@ +import { match } from '@formatjs/intl-localematcher' +import Negotiator from 'negotiator' +import { languages } from '../../languages' +import type { Language } from '@/types/languages' +import { isLanguage } from '@/types/languages' + +const DEFAULT_LOCALE: Language = 'en-US' + +function isValidLanguageRange(language: string): boolean { + if (language === '*') { + return false + } + + try { + Intl.getCanonicalLocales(language) + return true + } catch { + return false + } +} + +export function detectRequestLocale( + acceptLanguage: string | null +): Language { + if (!acceptLanguage) { + return DEFAULT_LOCALE + } + + const requestedLanguages = new Negotiator({ + headers: { 'accept-language': acceptLanguage }, + }) + .languages() + .filter(isValidLanguageRange) + + const locale = match(requestedLanguages, languages, DEFAULT_LOCALE) + + return isLanguage(locale) ? locale : DEFAULT_LOCALE +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 3a417824..dbffdc73 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,16 +1,8 @@ -import { match } from '@formatjs/intl-localematcher' -import Negotiator from 'negotiator' import { type NextRequest, NextResponse } from 'next/server' +import { detectRequestLocale } from '@/lib/request-locale' import { shouldBlockTraffic } from '@/lib/traffic-guard' import { languages as appLanguages } from '../languages' -const headers = { 'accept-language': 'en-US' } -const languages = new Negotiator({ headers }).languages() - -const DEFAULT_LOCALE = 'en-US' - -match(languages, appLanguages, DEFAULT_LOCALE) - export function proxy(req: NextRequest) { const { pathname } = req.nextUrl @@ -36,12 +28,7 @@ export function proxy(req: NextRequest) { const reqHeaders = new Headers(req.headers) reqHeaders.set('x-current-path', pathname) - const browserLanguage = - req.headers.get('accept-language')?.split(',')[0] ?? 'en' - - const language = - appLanguages.find(language => language.startsWith(browserLanguage)) ?? - DEFAULT_LOCALE + const language = detectRequestLocale(req.headers.get('accept-language')) const pathnameHasLocale = appLanguages.some( locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` diff --git a/apps/web/src/services/together-invite.test.ts b/apps/web/src/services/together-invite.test.ts new file mode 100644 index 00000000..6ef7331b --- /dev/null +++ b/apps/web/src/services/together-invite.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { buildTogetherInviteUrl } from './together-invite' + +describe('buildTogetherInviteUrl', () => { + it('builds a locale-neutral invite URL', () => { + expect(buildTogetherInviteUrl('https://plotwist.app', 'ABC123')).toBe( + 'https://plotwist.app/together/ABC123' + ) + }) + + it('normalizes a trailing slash and lowercase room code', () => { + expect(buildTogetherInviteUrl('https://plotwist.app/', 'abc123')).toBe( + 'https://plotwist.app/together/ABC123' + ) + }) +}) diff --git a/apps/web/src/services/together-invite.ts b/apps/web/src/services/together-invite.ts new file mode 100644 index 00000000..cd92be8e --- /dev/null +++ b/apps/web/src/services/together-invite.ts @@ -0,0 +1,6 @@ +export function buildTogetherInviteUrl( + appUrl: string, + code: string +): string { + return `${appUrl.replace(/\/+$/, '')}/together/${code.toUpperCase()}` +} From bcd35de53a66f46eff99a0dcbf15fc56502033a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:37:47 +0000 Subject: [PATCH 10/56] style(together): format invite helpers Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/src/lib/request-locale.ts | 6 ++---- apps/web/src/services/together-invite.ts | 5 +---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/request-locale.ts b/apps/web/src/lib/request-locale.ts index de36111b..7b164ee2 100644 --- a/apps/web/src/lib/request-locale.ts +++ b/apps/web/src/lib/request-locale.ts @@ -1,8 +1,8 @@ import { match } from '@formatjs/intl-localematcher' import Negotiator from 'negotiator' -import { languages } from '../../languages' import type { Language } from '@/types/languages' import { isLanguage } from '@/types/languages' +import { languages } from '../../languages' const DEFAULT_LOCALE: Language = 'en-US' @@ -19,9 +19,7 @@ function isValidLanguageRange(language: string): boolean { } } -export function detectRequestLocale( - acceptLanguage: string | null -): Language { +export function detectRequestLocale(acceptLanguage: string | null): Language { if (!acceptLanguage) { return DEFAULT_LOCALE } diff --git a/apps/web/src/services/together-invite.ts b/apps/web/src/services/together-invite.ts index cd92be8e..c9b6a586 100644 --- a/apps/web/src/services/together-invite.ts +++ b/apps/web/src/services/together-invite.ts @@ -1,6 +1,3 @@ -export function buildTogetherInviteUrl( - appUrl: string, - code: string -): string { +export function buildTogetherInviteUrl(appUrl: string, code: string): string { return `${appUrl.replace(/\/+$/, '')}/together/${code.toUpperCase()}` } From b3024056fec4ec9bb5a088b25fbe14ef2747e1b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:41:02 +0000 Subject: [PATCH 11/56] fix(together): reject invalid locale quality values Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/src/lib/request-locale.test.ts | 7 +++++++ apps/web/src/lib/request-locale.ts | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/request-locale.test.ts b/apps/web/src/lib/request-locale.test.ts index f889b29c..99535211 100644 --- a/apps/web/src/lib/request-locale.test.ts +++ b/apps/web/src/lib/request-locale.test.ts @@ -14,6 +14,13 @@ describe('detectRequestLocale', () => { expect(detectRequestLocale('es;q=0.4,de;q=0.9')).toBe('de-DE') }) + it.each(['fr;q=2,de;q=1', 'fr;q=0.9junk,de;q=0.8'])( + 'ignores an entry with an invalid quality value in %s', + acceptLanguage => { + expect(detectRequestLocale(acceptLanguage)).toBe('de-DE') + } + ) + it('ignores malformed and wildcard language ranges', () => { expect(detectRequestLocale('not_a_locale,*;q=0.9,ja;q=0.8')).toBe('ja-JP') }) diff --git a/apps/web/src/lib/request-locale.ts b/apps/web/src/lib/request-locale.ts index 7b164ee2..151586a6 100644 --- a/apps/web/src/lib/request-locale.ts +++ b/apps/web/src/lib/request-locale.ts @@ -5,6 +5,16 @@ import { isLanguage } from '@/types/languages' import { languages } from '../../languages' const DEFAULT_LOCALE: Language = 'en-US' +const QUALITY_PARAMETER = + /^\s*q=(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\s*$/i + +function hasValidQualityParameter(languageEntry: string): boolean { + const [, ...parameters] = languageEntry.split(';') + + return parameters.length === 0 + ? true + : parameters.length === 1 && QUALITY_PARAMETER.test(parameters[0]) +} function isValidLanguageRange(language: string): boolean { if (language === '*') { @@ -24,8 +34,13 @@ export function detectRequestLocale(acceptLanguage: string | null): Language { return DEFAULT_LOCALE } + const sanitizedAcceptLanguage = acceptLanguage + .split(',') + .filter(hasValidQualityParameter) + .join(',') + const requestedLanguages = new Negotiator({ - headers: { 'accept-language': acceptLanguage }, + headers: { 'accept-language': sanitizedAcceptLanguage }, }) .languages() .filter(isValidLanguageRange) From e9a9205f2cbb1244ebe402cc6332201619b8a3f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:41:32 +0000 Subject: [PATCH 12/56] style(together): format locale regression Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/src/lib/request-locale.test.ts | 12 ++++++------ apps/web/src/lib/request-locale.ts | 3 +-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/request-locale.test.ts b/apps/web/src/lib/request-locale.test.ts index 99535211..a7ca59a8 100644 --- a/apps/web/src/lib/request-locale.test.ts +++ b/apps/web/src/lib/request-locale.test.ts @@ -14,12 +14,12 @@ describe('detectRequestLocale', () => { expect(detectRequestLocale('es;q=0.4,de;q=0.9')).toBe('de-DE') }) - it.each(['fr;q=2,de;q=1', 'fr;q=0.9junk,de;q=0.8'])( - 'ignores an entry with an invalid quality value in %s', - acceptLanguage => { - expect(detectRequestLocale(acceptLanguage)).toBe('de-DE') - } - ) + it.each([ + 'fr;q=2,de;q=1', + 'fr;q=0.9junk,de;q=0.8', + ])('ignores an entry with an invalid quality value in %s', acceptLanguage => { + expect(detectRequestLocale(acceptLanguage)).toBe('de-DE') + }) it('ignores malformed and wildcard language ranges', () => { expect(detectRequestLocale('not_a_locale,*;q=0.9,ja;q=0.8')).toBe('ja-JP') diff --git a/apps/web/src/lib/request-locale.ts b/apps/web/src/lib/request-locale.ts index 151586a6..25f14937 100644 --- a/apps/web/src/lib/request-locale.ts +++ b/apps/web/src/lib/request-locale.ts @@ -5,8 +5,7 @@ import { isLanguage } from '@/types/languages' import { languages } from '../../languages' const DEFAULT_LOCALE: Language = 'en-US' -const QUALITY_PARAMETER = - /^\s*q=(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\s*$/i +const QUALITY_PARAMETER = /^\s*q=(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\s*$/i function hasValidQualityParameter(languageEntry: string): boolean { const [, ...parameters] = languageEntry.split(';') From 2cfdb9a62c9e8f7b538ce8e3c110eab3a3356f44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:43:22 +0000 Subject: [PATCH 13/56] docs: assign provider copy to setup task Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../plans/2026-09-08-together-flow-improvements.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-08-together-flow-improvements.md b/docs/superpowers/plans/2026-09-08-together-flow-improvements.md index fc383c3d..e6aee1da 100644 --- a/docs/superpowers/plans/2026-09-08-together-flow-improvements.md +++ b/docs/superpowers/plans/2026-09-08-together-flow-improvements.md @@ -163,6 +163,7 @@ git commit -m "feat(together): share locale-neutral invite links" - Modify: `apps/web/src/app/[lang]/together/_components/together-vote.tsx` - Modify: `apps/web/src/app/[lang]/together/_components/together-vote.test.tsx` - Modify: `apps/web/src/services/together.ts` +- Modify: all seven files under `apps/web/public/dictionaries/*.json` **Interfaces:** - Produces: `TogetherProviderStep({ region, providerIds, onRegionChange, onProviderIdsChange, onContinue })` @@ -205,6 +206,8 @@ Use `tmdb.watchProviders.regions` and `tmdb.watchProviders.list('movie', ...)`. Keep selection state in `CreateInviteForm` so an API error does not reset it. Step one advances to the existing name/create step. +Add the provider-step heading, explanation, “Any service”, retry, back, and continue copy to all seven dictionaries in this task. + - [ ] **Step 5: Apply room filters** Read `roomQuery.data.room.watchProviderIds` and `watchRegion` in `TogetherVote`. Include the provider values in the deck query key. Only add TMDB availability filters when the provider list is non-empty. @@ -305,7 +308,7 @@ Render it before host provider setup. Keep dismissal in component state and neve Use `participants.length` and `room.maxParticipants` from room state. Replace exact “admit two” copy with “up to four”; show `current / max` in invite and waiting screens. When full and the visitor has no valid membership, show the localized room-full state instead of the join form. -Add all provider setup, match celebration, guest prompt, and capacity strings to all seven dictionaries. Extend the existing dictionary contract test to require every new key. +Add all remaining match celebration, guest prompt, and capacity strings to all seven dictionaries. Extend the existing dictionary contract test to require every new key, including the provider-step keys from Task 3. - [ ] **Step 5: Verify and commit** From 24925c46a24af15eff07ccc44e0cf0cbe283833a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:45:19 +0000 Subject: [PATCH 14/56] test(together): cover provider setup and deck filters Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../_components/create-invite-form.test.tsx | 173 ++++++++++++++++ .../together-provider-step.test.tsx | 196 ++++++++++++++++++ .../_components/together-vote.test.tsx | 130 ++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx create mode 100644 apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx create mode 100644 apps/web/src/app/[lang]/together/_components/together-vote.test.tsx diff --git a/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx b/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx new file mode 100644 index 00000000..b4f9a723 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx @@ -0,0 +1,173 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CreateInviteForm } from './create-invite-form' + +const mocks = vi.hoisted(() => ({ + createTogetherRoom: vi.fn(), + push: vi.fn(), + setTogetherToken: vi.fn(), + user: undefined as { displayName?: string; username?: string } | undefined, + userPreferences: null as { + watchProvidersIds: number[] | null + watchRegion: string | null + } | null, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() }, +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'pt-BR', + dictionary: { + together: { + your_name: 'Your name', + your_name_placeholder: 'Name', + create_invite: 'Start the night', + creating: 'Preparing...', + create_error: 'Could not create.', + back: 'Back', + }, + }, + }), +})) + +vi.mock('@/context/session', () => ({ + useSession: () => ({ user: mocks.user }), +})) + +vi.mock('@/context/user-preferences', () => ({ + useUserPreferences: () => ({ + userPreferences: mocks.userPreferences, + formatWatchProvidersIds: (ids: number[]) => ids.join('|'), + }), +})) + +vi.mock('@/services/together', () => ({ + createTogetherRoom: mocks.createTogetherRoom, + setTogetherToken: mocks.setTogetherToken, +})) + +vi.mock('./together-provider-step', () => ({ + TogetherProviderStep: ({ + region, + providerIds, + onRegionChange, + onProviderIdsChange, + onContinue, + }: { + region: string + providerIds: number[] + onRegionChange: (region: string) => void + onProviderIdsChange: (providerIds: number[]) => void + onContinue: () => void + }) => ( +
+ {region} + {providerIds.join('|')} + + +
+ ), +})) + +describe('CreateInviteForm provider flow', () => { + beforeEach(() => { + mocks.user = undefined + mocks.userPreferences = null + mocks.createTogetherRoom.mockResolvedValue({ + room: { code: 'ABC123' }, + participantToken: 'host-token', + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders provider setup before the name step and creates with its values', async () => { + render() + + expect(screen.getByRole('button', { name: 'Continue' })).toBeTruthy() + expect(screen.queryByLabelText('Your name')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Select services' })) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.change(screen.getByLabelText('Your name'), { + target: { value: ' Ana ' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Start the night' })) + + await waitFor(() => + expect(mocks.createTogetherRoom).toHaveBeenCalledWith({ + displayName: 'Ana', + watchProviderIds: [8, 337], + watchRegion: 'BR', + }) + ) + expect(mocks.setTogetherToken).toHaveBeenCalledWith('ABC123', 'host-token') + expect(mocks.push).toHaveBeenCalledWith('/pt-BR/together/ABC123') + }) + + it('prefills saved provider IDs and region for an authenticated host', () => { + mocks.user = { displayName: 'Ana' } + mocks.userPreferences = { + watchProvidersIds: [8, 337], + watchRegion: 'US', + } + + render() + + expect(screen.getByLabelText('Selected region').textContent).toBe('US') + expect(screen.getByLabelText('Selected providers').textContent).toBe( + '8|337' + ) + }) + + it('falls back to BR and Any service for a guest', () => { + mocks.userPreferences = { + watchProvidersIds: [8], + watchRegion: 'US', + } + + render() + + expect(screen.getByLabelText('Selected region').textContent).toBe('BR') + expect(screen.getByLabelText('Selected providers').textContent).toBe('') + }) + + it('keeps provider form state after room creation fails', async () => { + mocks.createTogetherRoom.mockRejectedValueOnce(new Error('offline')) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Select services' })) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.change(screen.getByLabelText('Your name'), { + target: { value: 'Ana' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Start the night' })) + await waitFor(() => expect(mocks.createTogetherRoom).toHaveBeenCalledOnce()) + + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + expect(screen.getByLabelText('Selected region').textContent).toBe('BR') + expect(screen.getByLabelText('Selected providers').textContent).toBe( + '8|337' + ) + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx b/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx new file mode 100644 index 00000000..06aa632a --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx @@ -0,0 +1,196 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TogetherProviderStep } from './together-provider-step' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + regions: vi.fn(), +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'en-US', + dictionary: { + together: { + provider_heading: 'Where do you watch?', + provider_explanation: 'Choose every service you can use.', + provider_region: 'Region', + provider_any: 'Any service', + provider_loading: 'Finding services...', + provider_error: 'Could not load services.', + provider_retry: 'Try again', + provider_continue: 'Continue', + }, + }, + }), +})) + +vi.mock('@/services/tmdb', () => ({ + tmdb: { + watchProviders: { + list: mocks.list, + regions: mocks.regions, + }, + }, +})) + +vi.mock('next/image', () => ({ + default: ({ alt }: { alt: string }) => , +})) + +function wrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + return function QueryWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + } +} + +const providers = [ + { + provider_id: 8, + provider_name: 'Netflix', + logo_path: '/netflix.jpg', + display_priority: 1, + display_priorities: {}, + }, + { + provider_id: 337, + provider_name: 'Disney Plus', + logo_path: '/disney.jpg', + display_priority: 2, + display_priorities: {}, + }, +] + +describe('TogetherProviderStep', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('shows loading, then renders region and keyboard-operable provider toggles', async () => { + mocks.regions.mockResolvedValue([ + { + iso_3166_1: 'BR', + english_name: 'Brazil', + native_name: 'Brasil', + }, + ]) + mocks.list.mockResolvedValue(providers) + const onRegionChange = vi.fn() + const onProviderIdsChange = vi.fn() + + render( + , + { wrapper: wrapper() } + ) + + expect(screen.getByText('Finding services...')).toBeTruthy() + + const netflix = await screen.findByRole('button', { name: 'Netflix' }) + expect(netflix.tagName).toBe('BUTTON') + expect(netflix.tabIndex).toBe(0) + expect(netflix.getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(netflix) + expect(onProviderIdsChange).toHaveBeenCalledWith([8]) + expect(mocks.list).toHaveBeenCalledWith('movie', { + language: 'en-US', + watch_region: 'BR', + }) + }) + + it('marks saved providers selected and exposes Any service to clear them', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list.mockResolvedValue(providers) + const onProviderIdsChange = vi.fn() + + render( + , + { wrapper: wrapper() } + ) + + expect( + (await screen.findByRole('button', { name: 'Netflix' })).getAttribute( + 'aria-pressed' + ) + ).toBe('true') + const anyService = screen.getByRole('button', { name: 'Any service' }) + expect(anyService.getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(anyService) + expect(onProviderIdsChange).toHaveBeenCalledWith([]) + }) + + it('shows an error and retries provider loading', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(providers) + + render( + , + { wrapper: wrapper() } + ) + + expect(await screen.findByText('Could not load services.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2)) + expect(await screen.findByRole('button', { name: 'Netflix' })).toBeTruthy() + }) + + it('continues with no providers when Any service is selected', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list.mockResolvedValue(providers) + const onContinue = vi.fn() + + render( + , + { wrapper: wrapper() } + ) + + await screen.findByRole('button', { name: 'Netflix' }) + expect( + screen + .getByRole('button', { name: 'Any service' }) + .getAttribute('aria-pressed') + ).toBe('true') + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onContinue).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx new file mode 100644 index 00000000..495cdad4 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx @@ -0,0 +1,130 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TogetherVote } from './together-vote' + +const mocks = vi.hoisted(() => ({ + discover: vi.fn(), + getRoom: vi.fn(), + replace: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: mocks.replace, + }), +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'pt-BR', + dictionary: { + together: { + loading: 'Loading...', + tonight_with: 'with {name}', + see_matches: 'The match', + empty_deck: 'That is all.', + vote_nope: 'No', + vote_maybe: 'Maybe', + vote_yes: 'Yes', + swipe_error: 'Could not save.', + }, + }, + }), +})) + +vi.mock('@/services/tmdb', () => ({ + tmdb: { + movies: { + discover: mocks.discover, + details: vi.fn(), + }, + }, +})) + +vi.mock('@/services/together', () => ({ + clearTogetherToken: vi.fn(), + createTogetherSwipe: vi.fn(), + getTogetherRoom: mocks.getRoom, + getTogetherToken: () => 'participant-token', +})) + +function wrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + return function QueryWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + } +} + +function room(watchProviderIds: number[] | null, watchRegion = 'BR') { + return { + room: { + id: 'room-id', + code: 'ABC123', + watchProviderIds, + watchRegion, + maxRuntime: null, + mood: 'ANY', + maxParticipants: 4, + createdAt: '2026-09-08T00:00:00.000Z', + }, + participants: [{ id: 'host', displayName: 'Ana' }], + me: { id: 'host', displayName: 'Ana' }, + swipedIds: [], + } +} + +describe('TogetherVote provider deck filters', () => { + beforeEach(() => { + mocks.discover.mockResolvedValue({ + results: [], + page: 1, + total_pages: 1, + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('discovers movies from the room providers and region', async () => { + mocks.getRoom.mockResolvedValue(room([8, 337], 'BR')) + + render(, { wrapper: wrapper() }) + + await waitFor(() => + expect(mocks.discover).toHaveBeenCalledWith({ + language: 'pt-BR', + page: 1, + filters: expect.objectContaining({ + with_watch_providers: '8|337', + watch_region: 'BR', + }), + }) + ) + }) + + it.each([[], null])( + 'omits availability filters when room providers are %s', + async watchProviderIds => { + mocks.getRoom.mockResolvedValue(room(watchProviderIds)) + + render(, { wrapper: wrapper() }) + + await waitFor(() => expect(mocks.discover).toHaveBeenCalledOnce()) + const filters = mocks.discover.mock.calls[0]?.[0].filters + expect(filters).not.toHaveProperty('with_watch_providers') + expect(filters).not.toHaveProperty('watch_region') + } + ) +}) From a5cc10d3cd150a055c455d02df2f9d685c2b18a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:46:48 +0000 Subject: [PATCH 15/56] feat(together): filter choices by host providers Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/public/dictionaries/de-DE.json | 8 + apps/web/public/dictionaries/en-US.json | 8 + apps/web/public/dictionaries/es-ES.json | 8 + apps/web/public/dictionaries/fr-FR.json | 8 + apps/web/public/dictionaries/it-IT.json | 8 + apps/web/public/dictionaries/ja-JP.json | 8 + apps/web/public/dictionaries/pt-BR.json | 8 + .../_components/create-invite-form.tsx | 33 ++++ .../_components/together-provider-step.tsx | 162 ++++++++++++++++++ .../together/_components/together-vote.tsx | 14 +- apps/web/src/services/together.ts | 6 +- 11 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/[lang]/together/_components/together-provider-step.tsx diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index d3796841..234bcacd 100644 --- a/apps/web/public/dictionaries/de-DE.json +++ b/apps/web/public/dictionaries/de-DE.json @@ -935,6 +935,14 @@ "night_for_two": "ein Abend für zwei", "title": "Der Film des Abends. Ohne Diskussion.", "subtitle": "Schick die Einladung. Die andere Person kommt ohne Konto dazu. Ihr beide stimmt ab. Der Treffer erscheint.", + "provider_heading": "Wo kannst du Filme schauen?", + "provider_explanation": "Wähle alle Streamingdienste aus, die du nutzt. Wir zeigen nur Filme, die dort verfügbar sind.", + "provider_region": "Region", + "provider_any": "Beliebiger Dienst", + "provider_loading": "Deine Dienste werden gesucht...", + "provider_error": "Die Streamingdienste konnten nicht geladen werden.", + "provider_retry": "Erneut versuchen", + "provider_continue": "Weiter", "your_name": "Dein Name", "your_name_placeholder": "Henrique", "create_invite": "Den Abend starten", diff --git a/apps/web/public/dictionaries/en-US.json b/apps/web/public/dictionaries/en-US.json index 398f4c2b..cd1937ce 100644 --- a/apps/web/public/dictionaries/en-US.json +++ b/apps/web/public/dictionaries/en-US.json @@ -937,6 +937,14 @@ "night_for_two": "a night for two", "title": "Tonight’s movie, no debate.", "subtitle": "Send the invite. They join without an account. You both vote. The match shows up.", + "provider_heading": "Where can you watch?", + "provider_explanation": "Choose every streaming service you can use. We’ll only show movies available there.", + "provider_region": "Region", + "provider_any": "Any service", + "provider_loading": "Finding your services...", + "provider_error": "Could not load streaming services.", + "provider_retry": "Try again", + "provider_continue": "Continue", "your_name": "Your name", "your_name_placeholder": "Henrique", "create_invite": "Start the night", diff --git a/apps/web/public/dictionaries/es-ES.json b/apps/web/public/dictionaries/es-ES.json index 70dae2c9..a9da84a2 100644 --- a/apps/web/public/dictionaries/es-ES.json +++ b/apps/web/public/dictionaries/es-ES.json @@ -939,6 +939,14 @@ "night_for_two": "una noche para dos", "title": "La película de la noche, sin discusión.", "subtitle": "Manda la invitación. La otra persona entra sin cuenta. Los dos votan. Aparece el match.", + "provider_heading": "¿Dónde puedes ver películas?", + "provider_explanation": "Elige todos los servicios de streaming que usas. Solo mostraremos películas disponibles en ellos.", + "provider_region": "Región", + "provider_any": "Cualquier servicio", + "provider_loading": "Buscando tus servicios...", + "provider_error": "No se pudieron cargar los servicios de streaming.", + "provider_retry": "Intentar de nuevo", + "provider_continue": "Continuar", "your_name": "Tu nombre", "your_name_placeholder": "Henrique", "create_invite": "Empezar la noche", diff --git a/apps/web/public/dictionaries/fr-FR.json b/apps/web/public/dictionaries/fr-FR.json index 46b5eb73..436b1c03 100644 --- a/apps/web/public/dictionaries/fr-FR.json +++ b/apps/web/public/dictionaries/fr-FR.json @@ -941,6 +941,14 @@ "night_for_two": "une soirée pour deux", "title": "Le film de la soirée, sans débat.", "subtitle": "Envoie l’invitation. L’autre rejoint sans compte. Vous votez tous les deux. Le match apparaît.", + "provider_heading": "Où regardez-vous vos films ?", + "provider_explanation": "Choisissez tous les services de streaming que vous utilisez. Nous ne montrerons que les films qui y sont disponibles.", + "provider_region": "Région", + "provider_any": "N’importe quel service", + "provider_loading": "Recherche de vos services...", + "provider_error": "Impossible de charger les services de streaming.", + "provider_retry": "Réessayer", + "provider_continue": "Continuer", "your_name": "Votre nom", "your_name_placeholder": "Henrique", "create_invite": "Commencer la soirée", diff --git a/apps/web/public/dictionaries/it-IT.json b/apps/web/public/dictionaries/it-IT.json index 1c1f8b98..f8981e58 100644 --- a/apps/web/public/dictionaries/it-IT.json +++ b/apps/web/public/dictionaries/it-IT.json @@ -938,6 +938,14 @@ "night_for_two": "una serata per due", "title": "Il film della sera, senza discussione.", "subtitle": "Manda l’invito. L’altra persona entra senza account. Votate entrambi. Compare il match.", + "provider_heading": "Dove guardi i film?", + "provider_explanation": "Scegli tutti i servizi di streaming che usi. Mostreremo solo i film disponibili su quei servizi.", + "provider_region": "Regione", + "provider_any": "Qualsiasi servizio", + "provider_loading": "Ricerca dei tuoi servizi...", + "provider_error": "Impossibile caricare i servizi di streaming.", + "provider_retry": "Riprova", + "provider_continue": "Continua", "your_name": "Il tuo nome", "your_name_placeholder": "Henrique", "create_invite": "Inizia la serata", diff --git a/apps/web/public/dictionaries/ja-JP.json b/apps/web/public/dictionaries/ja-JP.json index 16a53a70..7bbb97ac 100644 --- a/apps/web/public/dictionaries/ja-JP.json +++ b/apps/web/public/dictionaries/ja-JP.json @@ -941,6 +941,14 @@ "night_for_two": "ふたりの夜", "title": "今夜の映画を、議論なしで。", "subtitle": "招待を送る。相手はアカウントなしで入る。ふたりで投票。マッチが出る。", + "provider_heading": "どこで映画を観ますか?", + "provider_explanation": "利用している動画配信サービスをすべて選んでください。そこで視聴できる映画だけを表示します。", + "provider_region": "地域", + "provider_any": "どのサービスでも", + "provider_loading": "サービスを検索中...", + "provider_error": "動画配信サービスを読み込めませんでした。", + "provider_retry": "もう一度試す", + "provider_continue": "続ける", "your_name": "名前", "your_name_placeholder": "Henrique", "create_invite": "夜をはじめる", diff --git a/apps/web/public/dictionaries/pt-BR.json b/apps/web/public/dictionaries/pt-BR.json index 9c3cd684..900628a1 100644 --- a/apps/web/public/dictionaries/pt-BR.json +++ b/apps/web/public/dictionaries/pt-BR.json @@ -940,6 +940,14 @@ "night_for_two": "uma noite para dois", "title": "O filme da noite, sem discussão.", "subtitle": "Manda o convite. A outra pessoa entra sem conta. Vocês dois votam. O match aparece.", + "provider_heading": "Onde você assiste?", + "provider_explanation": "Escolha todos os serviços de streaming que você usa. Só mostraremos filmes disponíveis neles.", + "provider_region": "Região", + "provider_any": "Qualquer serviço", + "provider_loading": "Buscando seus serviços...", + "provider_error": "Não foi possível carregar os serviços de streaming.", + "provider_retry": "Tentar novamente", + "provider_continue": "Continuar", "your_name": "Seu nome", "your_name_placeholder": "Henrique", "create_invite": "Começar a noite", diff --git a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx b/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx index 7ecdbaa1..24c0af54 100644 --- a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx +++ b/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx @@ -6,14 +6,26 @@ import { type FormEvent, useState } from 'react' import { toast } from 'sonner' import { useLanguage } from '@/context/language' import { useSession } from '@/context/session' +import { useUserPreferences } from '@/context/user-preferences' import { createTogetherRoom, setTogetherToken } from '@/services/together' import { PrimaryButton } from './primary-button' +import { TogetherProviderStep } from './together-provider-step' export function CreateInviteForm() { const { dictionary, language } = useLanguage() const { user } = useSession() + const { userPreferences } = useUserPreferences() const router = useRouter() const copy = dictionary.together + const [step, setStep] = useState<'providers' | 'name'>('providers') + const [watchRegion, setWatchRegion] = useState( + (user && userPreferences?.watchRegion) || 'BR' + ) + const [watchProviderIds, setWatchProviderIds] = useState( + user && userPreferences?.watchProvidersIds + ? [...userPreferences.watchProvidersIds] + : [] + ) const [displayName, setDisplayName] = useState( user?.displayName || user?.username || '' ) @@ -26,6 +38,8 @@ export function CreateInviteForm() { try { const session = await createTogetherRoom({ displayName: displayName.trim(), + watchProviderIds, + watchRegion, }) setTogetherToken(session.room.code, session.participantToken) router.push(`/${language}/together/${session.room.code}`) @@ -36,6 +50,18 @@ export function CreateInviteForm() { } } + if (step === 'providers') { + return ( + setStep('name')} + /> + ) + } + return (
changeRegion(event.target.value)} - className="together-surface together-body h-[3.15rem] w-full rounded-[0.9rem] border border-[var(--tg-border)] px-3 text-[var(--tg-text)] outline-none focus-visible:border-[var(--tg-accent)] focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--tg-accent)_28%,transparent)]" + {isError ? ( +
- {regionsQuery.data?.map(item => ( - - ))} - - +

+ {copy.provider_error} +

+ +
+ ) : ( + + )}
- {providersQuery.data?.map(provider => { + {!isError && + providersQuery.data?.map(provider => { const selected = providerIds.includes(provider.provider_id) return ( @@ -153,9 +174,18 @@ export function TogetherProviderStep({ className="size-8 shrink-0 rounded-lg" /> {provider.provider_name} + {selected && ( + <> +
diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx index 502dea7d..7da8d4ad 100644 --- a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx @@ -36,7 +36,6 @@ vi.mock('@/context/language', () => ({ dictionary: { together: { loading: 'Loading...', - tonight_with: 'with {name}', choosing_with: 'Choosing with {count} people', see_matches: 'The match', empty_deck: 'That is all.', diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.tsx b/apps/web/src/app/[lang]/together/_components/together-vote.tsx index 6308f4bc..92e9ad86 100644 --- a/apps/web/src/app/[lang]/together/_components/together-vote.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-vote.tsx @@ -267,18 +267,19 @@ export function TogetherVote({ code }: { code: string }) { ?.slice(0, 2) .map(item => item.name) .join(', ') - const partnerName = roomQuery.data?.participants.find( - participant => participant.id !== roomQuery.data.me?.id - )?.displayName + const participantCount = roomQuery.data?.participants.length ?? 0 return (
- {partnerName ? ( + {participantCount > 1 ? (

- {copy.tonight_with.replace('{name}', partnerName)} + {copy.choosing_with.replace( + '{count}', + String(participantCount) + )}

) : null}
@@ -362,6 +363,7 @@ export function TogetherVote({ code }: { code: string }) { interestSummary: copy.match_interest_summary, continueDiscovering: copy.continue_discovering, viewMatches: copy.view_matches, + close: copy.match_close, }} onContinue={acknowledgeCelebration} onViewMatches={viewMatches} diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx b/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx index 6cf4e411..4a6f2b3b 100644 --- a/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx @@ -4,7 +4,7 @@ import { InviteScreen } from './invite-screen' import { WaitingRoom } from './waiting-room' const copy = { - night_for_two: 'A night together', + group_kicker: 'A night together', host_invite_title: '{name}, this is the invite.', invite_help: 'Send this invite.', continue_as_host: 'Wait here', diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx index 13cc42ee..e53221a9 100644 --- a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx +++ b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx @@ -9,9 +9,10 @@ type WaitingRoomProps = { ready: boolean maxParticipants: number copy: { - night_for_two: string + group_kicker: string waiting_title: string waiting_body: string + ready_title: string start_choosing: string you: string empty_seat: string @@ -31,10 +32,7 @@ export function WaitingRoom({ participantIds, onStart, }: WaitingRoomProps) { - const first = names[0] - const second = names[1] - const pairTitle = - ready && first && second ? `${first} & ${second}` : copy.waiting_title + const heading = ready ? copy.ready_title : copy.waiting_title const capacity = copy.room_capacity .replace('{current}', String(names.length)) .replace('{max}', String(maxParticipants)) @@ -47,9 +45,9 @@ export function WaitingRoom({

- {copy.night_for_two} + {copy.group_kicker}

-

{pairTitle}

+

{heading}

{capacity}

{!ready && (

diff --git a/apps/web/src/app/[lang]/together/_components/welcome-screen.tsx b/apps/web/src/app/[lang]/together/_components/welcome-screen.tsx index 20b6bc4b..4e0ddf51 100644 --- a/apps/web/src/app/[lang]/together/_components/welcome-screen.tsx +++ b/apps/web/src/app/[lang]/together/_components/welcome-screen.tsx @@ -32,7 +32,7 @@ export function WelcomeScreen() { <>

- {copy.night_for_two} + {copy.group_kicker}

{copy.title}

From 558687d4062747aba4de5f2700498a31eff9ac3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:38:43 +0000 Subject: [PATCH 45/56] feat(together): show localized match posters Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../_components/match-celebration.test.tsx | 4 ++-- .../_components/match-celebration.tsx | 24 ++++++++++++++++++- packages/ui/src/components/ui/dialog.tsx | 12 +++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx b/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx index 6c964b49..a8d27762 100644 --- a/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx @@ -11,7 +11,7 @@ import { MatchCelebration } from './match-celebration' vi.mock('next/image', () => ({ default: ({ alt, src }: { alt: string; src: string }) => ( - {alt} + ), })) @@ -61,7 +61,7 @@ describe('MatchCelebration', () => { screen.getByText('2 people are interested · 100% match') ).toBeTruthy() expect( - screen.getByRole('img', { name: match.title }).getAttribute('src') + screen.getByRole('img', { name: match.title }).getAttribute('data-src') ).toContain('/matrix.jpg') expect(screen.getByRole('button', { name: copy.close })).toBeTruthy() expect(dialog.className).toContain('[&>button]:text-[#f7f3ea]') diff --git a/apps/web/src/app/[lang]/together/_components/match-celebration.tsx b/apps/web/src/app/[lang]/together/_components/match-celebration.tsx index fff8c8b2..6caa412e 100644 --- a/apps/web/src/app/[lang]/together/_components/match-celebration.tsx +++ b/apps/web/src/app/[lang]/together/_components/match-celebration.tsx @@ -6,13 +6,17 @@ import { DialogDescription, DialogTitle, } from '@plotwist/ui/components/ui/dialog' +import Image from 'next/image' +import { PosterFallback } from '@/components/poster-fallback' import type { TogetherMatch } from '@/services/together' +import { tmdbImage } from '@/utils/tmdb/image' type MatchCelebrationCopy = { heading: string interestSummary: string continueDiscovering: string viewMatches: string + close: string } type MatchCelebrationProps = { @@ -35,12 +39,30 @@ export function MatchCelebration({ return (

!open && onContinue()}> - +
{copy.heading}
+
+ {match.posterPath ? ( + {match.title} + ) : ( + + )} +

{match.title} diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index 5452c0c4..e837349a 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef< )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +type DialogContentProps = React.ComponentPropsWithoutRef< + typeof DialogPrimitive.Content +> & { + closeLabel?: string +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, children, closeLabel = 'Close', ...props }, ref) => ( - Close + {closeLabel} From 53452f60a41485c62b63ebca517e301f62ae9e19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:39:08 +0000 Subject: [PATCH 46/56] fix(together): label selected providers clearly Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../_components/together-provider-step.test.tsx | 6 +++--- .../together/_components/together-provider-step.tsx | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx b/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx index 13824934..9a0b6592 100644 --- a/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx @@ -142,7 +142,7 @@ describe('TogetherProviderStep', () => { ) const netflix = await screen.findByRole('button', { - name: 'Netflix Selected', + name: 'Netflix, Selected', }) expect(netflix.getAttribute('aria-pressed')).toBe('true') expect(netflix.querySelector('svg')).toBeTruthy() @@ -181,7 +181,7 @@ describe('TogetherProviderStep', () => { { wrapper: wrapper() } ) - await screen.findByRole('button', { name: 'Netflix' }) + await screen.findByRole('button', { name: 'Netflix, Selected' }) expect(onProviderIdsChange).not.toHaveBeenCalled() fireEvent.change(screen.getByRole('combobox', { name: 'Region' }), { @@ -238,7 +238,7 @@ describe('TogetherProviderStep', () => { await screen.findByRole('button', { name: 'Netflix' }) expect( screen - .getByRole('button', { name: 'Any service Selected' }) + .getByRole('button', { name: 'Any service, Selected' }) .getAttribute('aria-pressed') ).toBe('true') diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx b/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx index 45a1a674..ef8ebcd7 100644 --- a/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx @@ -132,6 +132,11 @@ export function TogetherProviderStep({ - ) + const selected = providerIds.includes(provider.provider_id) + + return ( + + ) })}

diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.tsx b/apps/web/src/app/[lang]/together/_components/together-vote.tsx index 92e9ad86..739cabd2 100644 --- a/apps/web/src/app/[lang]/together/_components/together-vote.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-vote.tsx @@ -276,10 +276,7 @@ export function TogetherVote({ code }: { code: string }) { {participantCount > 1 ? (

- {copy.choosing_with.replace( - '{count}', - String(participantCount) - )} + {copy.choosing_with.replace('{count}', String(participantCount))}

) : null}
From 1eaeb158391f0364970bbfa300817769e47b2a3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:40:09 +0000 Subject: [PATCH 48/56] test(together): clarify localized match metadata Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../domain/services/together/create-swipe.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/domain/services/together/create-swipe.spec.ts b/apps/backend/src/domain/services/together/create-swipe.spec.ts index 2dd74d32..d8c09995 100644 --- a/apps/backend/src/domain/services/together/create-swipe.spec.ts +++ b/apps/backend/src/domain/services/together/create-swipe.spec.ts @@ -105,8 +105,8 @@ describe('together matching', () => { participantToken: host.participantToken, decision: 'LIKE', ...dune, - title: 'Alpha title', - overview: 'Alpha overview', + title: 'Duna', + overview: 'Deserto, política e especiaria.', }) const direct = await createTogetherSwipeService({ @@ -114,8 +114,8 @@ describe('together matching', () => { participantToken: guest.participantToken, decision: 'MAYBE', ...dune, - title: 'Zulu title', - overview: 'Zulu overview', + title: 'Dune', + overview: 'Sand, politics, and spice.', }) if (!('match' in direct) || !direct.match) { throw new Error('expected direct match') @@ -134,8 +134,8 @@ describe('together matching', () => { }) expect(direct.match).toEqual( expect.objectContaining({ - title: 'Alpha title', - overview: 'Alpha overview', + title: 'Duna', + overview: 'Deserto, política e especiaria.', likeCount: 1, maybeCount: 1, interestCount: 2, From 033d70840d69011ba2e878b7f075c5f7344f9a64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:40:59 +0000 Subject: [PATCH 49/56] docs: record Together final review fixes Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .superpowers/sdd/final-fix-report.md | 101 +++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .superpowers/sdd/final-fix-report.md diff --git a/.superpowers/sdd/final-fix-report.md b/.superpowers/sdd/final-fix-report.md new file mode 100644 index 00000000..63f63950 --- /dev/null +++ b/.superpowers/sdd/final-fix-report.md @@ -0,0 +1,101 @@ +# Together PR #519 final-fix report + +## Status + +All seven final review findings are fixed on +`cursor/together-flow-improvements-08b6` and pushed. + +## Changes + +1. Match aggregation now groups only by `tmdbId` and `mediaType`. PostgreSQL + `min` aggregates select every metadata field deterministically, while + like, maybe, and distinct-interest counts retain their existing ordering. + Direct swipe matches use the same aggregate query as polled matches and + expose the same metadata/count contract. +2. Provider load failures retain Retry, explicit Any service, and Continue. +3. Together copy and rendered UI are group-neutral for 2–4 participants in + all seven locales. Waiting and voting headings use neutral group copy and + participant counts rather than the first two names. +4. Match celebrations render the TMDB poster with `next/image`, or the + existing `PosterFallback` when no poster exists. +5. Provider/name step transitions focus their focusable headings. Selected + providers and Any service show a visible Check indicator, localized + screen-reader text, and retain `aria-pressed`. +6. Proxy integration tests exercise locale-less path/query redirect + preservation and already-localized pass-through. +7. Shared `DialogContent` accepts an optional `closeLabel`, defaults to + `Close`, and MatchCelebration supplies localized Together copy. + +No dependency was added. + +## TDD evidence + +The regression commit was run before implementation: + +- Backend mixed-locale regression failed because polling returned zero + matches and direct counts did not distinguish LIKE from MAYBE. +- Web regressions failed for absent poster/fallback and localized close + behavior, missing heading focus, provider failure controls/selection + indicator, pair-specific headings, participant-count voting copy, and + missing dictionary keys. +- The new proxy integration tests passed immediately because current proxy + behavior already preserves the path/query and passes localized requests; + they replace the previous parser-only coverage gap with integration + protection. + +After implementation, every focused regression passed. A follow-up test run +found concatenated accessible text (`NetflixSelected`); explicit localized +`aria-label` values corrected it to `Netflix, Selected`. + +## Final verification + +```text +Backend Together services: + pnpm --filter plotwist-api test --run src/domain/services/together + 4 files passed, 20 tests passed + +Web Together/auth/locale/dictionary: + pnpm --filter web test --run \ + 'src/app/[lang]/together/_components' \ + src/services/together-invite.test.ts \ + src/actions/auth \ + src/utils/auth-redirect.test.ts \ + 'src/app/[lang]/sign-in' \ + src/lib/request-locale.test.ts \ + src/proxy.test.ts \ + src/utils/dictionaries/get-dictionaries.test.ts + 19 files passed, 95 tests passed + +Backend build: + pnpm --filter plotwist-api run build + ESM, CJS, and DTS builds passed + +Web typecheck: + pnpm --filter web run typecheck + passed + +Changed-file Biome: + 58 files checked, no fixes required +``` + +`apps/web/tsconfig.tsbuildinfo` was restored after typechecking. + +## Commits + +- `28335232` — `test(together): cover final review regressions` +- `413cb7fd` — `fix(together): aggregate matches by media identity` +- `e8c9b1ff` — `fix(together): expose consistent match metadata` +- `58323c0c` — `fix(together): make group setup neutral and accessible` +- `558687d4` — `feat(together): show localized match posters` +- `53452f60` — `fix(together): label selected providers clearly` +- `90a41661` — `style(together): format final review fixes` +- `1eaeb158` — `test(together): clarify localized match metadata` + +## Concerns + +- The repository declares Node 23+, while this environment runs Node + 22.14.0. Every requested command passed despite the existing engine + warning. +- All seven locale dictionaries satisfy the structural contract and no + pair-specific Together terms remain in the audited copy. Native-speaker + review is still advisable for editorial nuance. From 949ca969654637d52334349050c626479564133d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:45:57 +0000 Subject: [PATCH 50/56] test(together): cover final hardening cases Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../services/together/create-swipe.spec.ts | 61 +++++++++- .../_components/create-invite-form.test.tsx | 15 ++- .../together-guest-prompt.test.tsx | 8 +- .../together-provider-step.test.tsx | 115 ++++++++++++++---- 4 files changed, 171 insertions(+), 28 deletions(-) diff --git a/apps/backend/src/domain/services/together/create-swipe.spec.ts b/apps/backend/src/domain/services/together/create-swipe.spec.ts index d8c09995..aef5bf1a 100644 --- a/apps/backend/src/domain/services/together/create-swipe.spec.ts +++ b/apps/backend/src/domain/services/together/create-swipe.spec.ts @@ -106,7 +106,10 @@ describe('together matching', () => { decision: 'LIKE', ...dune, title: 'Duna', - overview: 'Deserto, política e especiaria.', + posterPath: '/zulu.jpg', + voteAverage: 9.1, + releaseDate: '2030-01-01', + overview: 'Visão geral em português.', }) const direct = await createTogetherSwipeService({ @@ -115,7 +118,10 @@ describe('together matching', () => { decision: 'MAYBE', ...dune, title: 'Dune', - overview: 'Sand, politics, and spice.', + posterPath: '/alpha.jpg', + voteAverage: 1.2, + releaseDate: '2000-01-01', + overview: 'English overview.', }) if (!('match' in direct) || !direct.match) { throw new Error('expected direct match') @@ -135,7 +141,10 @@ describe('together matching', () => { expect(direct.match).toEqual( expect.objectContaining({ title: 'Duna', - overview: 'Deserto, política e especiaria.', + posterPath: '/zulu.jpg', + voteAverage: 9.1, + releaseDate: '2030-01-01', + overview: 'Visão geral em português.', likeCount: 1, maybeCount: 1, interestCount: 2, @@ -143,6 +152,52 @@ describe('together matching', () => { ) }) + it('orders equal-ranked matches by media type and TMDB ID', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + const guest = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Maria', + }) + if (!('participantToken' in guest)) throw new Error('expected join') + + const titles = [ + { tmdbId: 1, mediaType: 'TV_SHOW' as const, title: 'TV title' }, + { tmdbId: 20, mediaType: 'MOVIE' as const, title: 'Movie twenty' }, + { tmdbId: 10, mediaType: 'MOVIE' as const, title: 'Movie ten' }, + ] + + for (const title of titles) { + await createTogetherSwipeService({ + code: host.room.code, + participantToken: host.participantToken, + decision: 'LIKE', + ...title, + }) + await createTogetherSwipeService({ + code: host.room.code, + participantToken: guest.participantToken, + decision: 'LIKE', + ...title, + }) + } + + const polled = await getTogetherMatchesService({ + code: host.room.code, + participantToken: host.participantToken, + }) + if (!('matches' in polled)) throw new Error('expected polled matches') + + expect( + polled.matches.map(match => [match.mediaType, match.tmdbId]) + ).toEqual([ + ['MOVIE', 10], + ['MOVIE', 20], + ['TV_SHOW', 1], + ]) + }) + it('should not match on a pass', async () => { const host = await createTogetherRoomService({ displayName: 'Henrique' }) if (!('room' in host)) throw new Error('expected room') diff --git a/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx b/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx index 7a26fb6b..126cfb0f 100644 --- a/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx @@ -67,16 +67,21 @@ vi.mock('./together-provider-step', () => ({ onRegionChange, onProviderIdsChange, onContinue, + focusHeading, }: { region: string providerIds: number[] onRegionChange: (region: string) => void onProviderIdsChange: (providerIds: number[]) => void onContinue: () => void + focusHeading?: boolean }) => (
{region} {providerIds.join('|')} + + {String(Boolean(focusHeading))} + + + , + { wrapper: wrapper() } + ) + + expect(document.activeElement).toBe(document.body) + expect(container.querySelector('button')).toBe( + screen.getByRole('button', { name: 'Continue as guest' }) + ) + }) + + it('focuses its heading when returning from the name step', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list.mockResolvedValue(providers) + const Wrapper = wrapper() + const props = { + region: 'BR', + providerIds: [] as number[], + onRegionChange: vi.fn(), + onProviderIdsChange: vi.fn(), + onContinue: vi.fn(), + } + const { rerender } = render( + , + { wrapper: Wrapper } + ) + + const continueButton = await screen.findByRole('button', { + name: 'Continue', + }) + continueButton.focus() + rerender() + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { name: 'Where do you watch?' }) + ) + ) + }) + it('marks saved providers selected and exposes Any service to clear them', async () => { mocks.regions.mockResolvedValue([]) mocks.list.mockResolvedValue(providers) @@ -192,31 +258,34 @@ describe('TogetherProviderStep', () => { expect(onProviderIdsChange).toHaveBeenCalledWith([]) }) - it('keeps retry, Any service, and Continue available after loading fails', async () => { + it('recovers an outage, then continues with controlled Any service state', async () => { mocks.regions.mockResolvedValue([]) - mocks.list.mockRejectedValue(new Error('offline')) - const onProviderIdsChange = vi.fn() + mocks.list + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(providers) const onContinue = vi.fn() - render( - , - { wrapper: wrapper() } - ) + render(, { + wrapper: wrapper(), + }) expect(await screen.findByText('Could not load services.')).toBeTruthy() - expect(screen.getByRole('button', { name: 'Try again' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect( + await screen.findByRole('button', { name: 'Netflix, Selected' }) + ).toBeTruthy() + expect(mocks.list).toHaveBeenCalledTimes(2) fireEvent.click(screen.getByRole('button', { name: 'Any service' })) - expect(onProviderIdsChange).toHaveBeenCalledWith([]) + expect( + screen + .getByRole('button', { name: 'Any service, Selected' }) + .getAttribute('aria-pressed') + ).toBe('true') fireEvent.click(screen.getByRole('button', { name: 'Continue' })) - expect(onContinue).toHaveBeenCalledOnce() + expect(onContinue).toHaveBeenCalledWith([]) }) it('continues with no providers when Any service is selected', async () => { From 6880d95b03145aeb66890c8a909463b755a54490 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:46:27 +0000 Subject: [PATCH 51/56] fix(together): select metadata from one swipe Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../db/repositories/together-repository.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/infra/db/repositories/together-repository.ts b/apps/backend/src/infra/db/repositories/together-repository.ts index 1b0dc662..4152f126 100644 --- a/apps/backend/src/infra/db/repositories/together-repository.ts +++ b/apps/backend/src/infra/db/repositories/together-repository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, type SQL, sql } from 'drizzle-orm' import { db } from '..' import { togetherParticipants, @@ -153,11 +153,19 @@ function selectTogetherMatchesWhere(where: SQL | undefined) { .select({ tmdbId: togetherSwipes.tmdbId, mediaType: togetherSwipes.mediaType, - title: sql`min(${togetherSwipes.title})`, - posterPath: sql`min(${togetherSwipes.posterPath})`, - voteAverage: sql`min(${togetherSwipes.voteAverage})`, - releaseDate: sql`min(${togetherSwipes.releaseDate})`, - overview: sql`min(${togetherSwipes.overview})`, + title: sql`(array_agg(${togetherSwipes.title} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + posterPath: sql< + string | null + >`(array_agg(${togetherSwipes.posterPath} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + voteAverage: sql< + number | null + >`(array_agg(${togetherSwipes.voteAverage} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + releaseDate: sql< + string | null + >`(array_agg(${togetherSwipes.releaseDate} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + overview: sql< + string | null + >`(array_agg(${togetherSwipes.overview} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, likeCount: sql`count(*) filter (where ${togetherSwipes.decision} = 'LIKE')::int`, maybeCount: sql`count(*) filter (where ${togetherSwipes.decision} = 'MAYBE')::int`, interestCount: sql`count(distinct ${togetherSwipes.participantId})::int`, @@ -168,7 +176,9 @@ function selectTogetherMatchesWhere(where: SQL | undefined) { .having(sql`count(distinct ${togetherSwipes.participantId}) >= 2`) .orderBy( desc(sql`count(*) filter (where ${togetherSwipes.decision} = 'LIKE')`), - desc(sql`count(*) filter (where ${togetherSwipes.decision} = 'MAYBE')`) + desc(sql`count(*) filter (where ${togetherSwipes.decision} = 'MAYBE')`), + asc(togetherSwipes.mediaType), + asc(togetherSwipes.tmdbId) ) } From 4b53f16962e4fecf574947fbdc2db8fa450fee97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:46:31 +0000 Subject: [PATCH 52/56] fix(together): focus provider heading only on return Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[lang]/together/_components/create-invite-form.tsx | 7 ++++++- .../together/_components/together-provider-step.tsx | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx b/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx index 95c8293e..d6f8feb1 100644 --- a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx +++ b/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx @@ -18,6 +18,7 @@ export function CreateInviteForm() { const router = useRouter() const copy = dictionary.together const [step, setStep] = useState<'providers' | 'name'>('providers') + const [focusProviderHeading, setFocusProviderHeading] = useState(false) const [watchRegion, setWatchRegion] = useState( (user && userPreferences?.watchRegion) || 'BR' ) @@ -62,9 +63,13 @@ export function CreateInviteForm() { setStep('name')} + onContinue={() => { + setFocusProviderHeading(true) + setStep('name') + }} /> ) } diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx b/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx index 5f3efa78..fc4dbe5a 100644 --- a/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx @@ -13,6 +13,7 @@ import { PrimaryButton } from './primary-button' type TogetherProviderStepProps = { region: string providerIds: number[] + focusHeading?: boolean onRegionChange: (region: string) => void onProviderIdsChange: (providerIds: number[]) => void onContinue: () => void @@ -21,6 +22,7 @@ type TogetherProviderStepProps = { export function TogetherProviderStep({ region, providerIds, + focusHeading = false, onRegionChange, onProviderIdsChange, onContinue, @@ -30,8 +32,10 @@ export function TogetherProviderStep({ const headingRef = useRef(null) useEffect(() => { - headingRef.current?.focus() - }, []) + if (focusHeading) { + headingRef.current?.focus() + } + }, [focusHeading]) const regionsQuery = useQuery({ queryKey: ['together-watch-provider-regions', language], From d327846ca5b03139ce0bc203917d166976e46f4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:52:37 +0000 Subject: [PATCH 53/56] docs: record Together hardening verification Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .superpowers/sdd/final-fix-report.md | 88 ++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/.superpowers/sdd/final-fix-report.md b/.superpowers/sdd/final-fix-report.md index 63f63950..c9aa62a7 100644 --- a/.superpowers/sdd/final-fix-report.md +++ b/.superpowers/sdd/final-fix-report.md @@ -99,3 +99,91 @@ Changed-file Biome: - All seven locale dictionaries satisfy the structural contract and no pair-specific Together terms remain in the audited copy. Native-speaker review is still advisable for editorial nuance. + +--- + +## Hardening follow-up (three desired fixes) + +Inspected `HEAD` `4b53f169` on `cursor/together-flow-improvements-08b6`. +The three requested hardening items were already implemented and pushed +by earlier commits on this branch (`949ca969`, `6880d95b`, `4b53f169`). +This pass re-verified those commits against the review text, ran the +requested suites, and recorded evidence. No additional production change +was required. + +### 1) Match metadata from one representative swipe + +`selectTogetherMatchesWhere` no longer uses independent `min()` on each +display field. Every metadata column is taken from +`(array_agg(column ORDER BY title, id))[1]`, so title, poster, vote, +release date, and overview come from the same swipe row. Match list +order is LIKE count, then MAYBE count, then `mediaType`, then `tmdbId`. + +Real Postgres fixtures in `create-swipe.spec.ts`: + +- Mixed-locale Dune/Duna swipes assert every metadata field stays on + the title-ordered representative row (`Duna` / `/zulu.jpg` / `9.1` / + `2030-01-01` / Portuguese overview), not a mix of `min()` values. +- Three equal LIKE matches assert stable order + `MOVIE/10`, `MOVIE/20`, `TV_SHOW/1`. + +### 2) Provider heading focus + +`TogetherProviderStep` focuses its heading only when `focusHeading` is +true. `CreateInviteForm` keeps that flag false on first paint so the +guest prompt stays first in tab order, focuses the name heading on the +name step, and sets the flag only when leaving providers so Back +returns focus to the provider heading. + +Regression tests: + +- Initial render: `document.activeElement` stays `document.body`. +- Name step focuses `Create your invite`, not the name input. +- Back sets `focusHeading` true; provider heading receives focus. + +### 3) Provider outage harness + +`ControlledProviderHarness` owns `providerIds` in React state. The +outage test rejects the first provider list, clicks Retry, asserts the +query refetches (`list` called twice) and Netflix is selected again, +then clicks Any service and Continue and asserts `onContinue([])`. + +### Hardening verification + +```text +Backend Together services: + pnpm --filter plotwist-api test --run src/domain/services/together + 4 files passed, 21 tests passed + +Web Together components: + pnpm --filter web test --run 'src/app/[lang]/together/_components' + 10 files passed, 47 tests passed + +Backend build: + pnpm --filter plotwist-api run build + ESM, CJS, and DTS builds passed + +Web typecheck: + pnpm --filter web run typecheck + passed + +Changed-file Biome: + 7 files checked, no fixes applied +``` + +`apps/web/tsconfig.tsbuildinfo` was restored after typechecking. + +### Hardening commits + +- `949ca969` — `test(together): cover final hardening cases` +- `6880d95b` — `fix(together): select metadata from one swipe` +- `4b53f169` — `fix(together): focus provider heading only on return` + +### Hardening concerns + +- Representative metadata still uses five `array_agg(...)[1]` expressions + that share one `ORDER BY title, id`. That is a consistent ordered + aggregate and the mixed-field fixture fails under independent `min()`, + but a single subquery/`json` row pick would be harder to drift. +- The repository still declares Node 23+; this environment is Node + 22.14.0. Requested commands passed with the existing engine warning. From fb4679c9acc6be5c067c046c61184201eb3e2fae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 21:07:00 +0000 Subject: [PATCH 54/56] fix(together): hide expanded room capacity Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../src/domain/services/together/constants.ts | 2 +- .../services/together/join-room.spec.ts | 60 ++++++++++--------- apps/web/public/dictionaries/de-DE.json | 3 +- apps/web/public/dictionaries/en-US.json | 3 +- apps/web/public/dictionaries/es-ES.json | 3 +- apps/web/public/dictionaries/fr-FR.json | 3 +- apps/web/public/dictionaries/it-IT.json | 3 +- apps/web/public/dictionaries/ja-JP.json | 3 +- apps/web/public/dictionaries/pt-BR.json | 3 +- .../together/_components/invite-screen.tsx | 15 ++--- .../_components/join-invite-form.test.tsx | 17 +++++- .../together/_components/join-invite-form.tsx | 11 ++-- .../together-room-hydration.test.tsx | 10 ++-- .../_components/together-room.test.tsx | 41 ++++++------- .../together/_components/together-room.tsx | 4 +- .../_components/together-vote.test.tsx | 2 +- .../_components/waiting-room.test.tsx | 36 +++++++---- .../together/_components/waiting-room.tsx | 20 +++---- .../dictionaries/get-dictionaries.test.ts | 16 +++-- .../2026-09-08-together-flow-improvements.md | 48 ++++++++------- ...09-08-together-flow-improvements-design.md | 28 +++++---- 21 files changed, 181 insertions(+), 150 deletions(-) diff --git a/apps/backend/src/domain/services/together/constants.ts b/apps/backend/src/domain/services/together/constants.ts index fb2eb079..87676455 100644 --- a/apps/backend/src/domain/services/together/constants.ts +++ b/apps/backend/src/domain/services/together/constants.ts @@ -1 +1 @@ -export const MAX_TOGETHER_PARTICIPANTS = 4 +export const MAX_TOGETHER_PARTICIPANTS = 20 diff --git a/apps/backend/src/domain/services/together/join-room.spec.ts b/apps/backend/src/domain/services/together/join-room.spec.ts index 70f54217..aa391e51 100644 --- a/apps/backend/src/domain/services/together/join-room.spec.ts +++ b/apps/backend/src/domain/services/together/join-room.spec.ts @@ -68,39 +68,43 @@ describe('join together room', () => { expect(sut).toBeInstanceOf(TogetherInvalidInputError) }) - it('should reject a fifth participant while allowing an existing participant to rejoin', async () => { + it('should allow 20 distinct participants, reject participant 21, and allow rejoining when full', async () => { const host = await createTogetherRoomService({ displayName: 'Henrique' }) if (!('room' in host)) throw new Error('expected room') - await joinTogetherRoomService({ + const joins = [] + for (let index = 2; index <= 20; index += 1) { + joins.push( + await joinTogetherRoomService({ + code: host.room.code, + displayName: `Participant ${index}`, + }) + ) + } + const twentieth = joins.at(-1) + if (!twentieth || !('participantToken' in twentieth)) { + throw new Error('expected twentieth participant to join') + } + + const twentyFirst = await joinTogetherRoomService({ code: host.room.code, - displayName: 'Maria', - }) - await joinTogetherRoomService({ - code: host.room.code, - displayName: 'João', - }) - const fourth = await joinTogetherRoomService({ - code: host.room.code, - displayName: 'Ana', - }) - if (!('participantToken' in fourth)) throw new Error('expected join') - - const fifth = await joinTogetherRoomService({ - code: host.room.code, - displayName: 'Lucas', + displayName: 'Participant 21', }) const rejoined = await joinTogetherRoomService({ code: host.room.code, displayName: 'Ignored', - participantToken: fourth.participantToken, + participantToken: twentieth.participantToken, }) - expect(fifth).toBeInstanceOf(TogetherInvalidInputError) + expect(joins).toHaveLength(19) + expect( + joins.every(result => 'participantToken' in result) + ).toBe(true) + expect(twentyFirst).toBeInstanceOf(TogetherInvalidInputError) expect(rejoined).toEqual( expect.objectContaining({ - participant: expect.objectContaining({ id: fourth.participant.id }), - participantToken: fourth.participantToken, + participant: expect.objectContaining({ id: twentieth.participant.id }), + participantToken: twentieth.participantToken, }) ) }) @@ -109,14 +113,12 @@ describe('join together room', () => { const host = await createTogetherRoomService({ displayName: 'Henrique' }) if (!('room' in host)) throw new Error('expected room') - await joinTogetherRoomService({ - code: host.room.code, - displayName: 'Maria', - }) - await joinTogetherRoomService({ - code: host.room.code, - displayName: 'João', - }) + for (let index = 2; index <= 19; index += 1) { + await joinTogetherRoomService({ + code: host.room.code, + displayName: `Participant ${index}`, + }) + } const results = await Promise.all([ joinTogetherRoomService({ diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index 506778ae..57cb6bac 100644 --- a/apps/web/public/dictionaries/de-DE.json +++ b/apps/web/public/dictionaries/de-DE.json @@ -973,7 +973,7 @@ "room_full_body": "Bitte den Gastgeber, einen neuen Raum zu erstellen.", "waiting_title": "Warten auf deine Gruppe.", "waiting_body": "Sobald jemand dazukommt, könnt ihr gemeinsam auswählen.", - "room_capacity": "{current} / {max} Personen", + "participant_count": "{current} Personen in der Gruppe", "ready_title": "Deine Gruppe ist bereit.", "start_choosing": "Jetzt", "vote_nope": "Nein", @@ -987,7 +987,6 @@ "recommendation": "Dieser hier.", "its_this": "Dieser hier.", "pick_this": "Den schauen wir", - "up_to_four": "Bis zu vier Personen", "send_whatsapp": "Per WhatsApp senden", "share_text": "{name} hat dich eingeladen, den Film des Abends zu wählen.", "empty_seat": "Leer", diff --git a/apps/web/public/dictionaries/en-US.json b/apps/web/public/dictionaries/en-US.json index 1700ec49..4b9cf255 100644 --- a/apps/web/public/dictionaries/en-US.json +++ b/apps/web/public/dictionaries/en-US.json @@ -975,7 +975,7 @@ "room_full_body": "Ask the host to start a new room.", "waiting_title": "Waiting for your group.", "waiting_body": "When someone joins, you can start choosing together.", - "room_capacity": "{current} / {max} people", + "participant_count": "In the group: {current}", "ready_title": "Your group is ready.", "start_choosing": "Let’s go", "vote_nope": "No", @@ -989,7 +989,6 @@ "recommendation": "This one.", "its_this": "This one.", "pick_this": "Let’s watch it", - "up_to_four": "Up to four people", "send_whatsapp": "Send on WhatsApp", "share_text": "{name} invited you to pick tonight’s movie.", "empty_seat": "Empty", diff --git a/apps/web/public/dictionaries/es-ES.json b/apps/web/public/dictionaries/es-ES.json index 92a84015..9b7c09fa 100644 --- a/apps/web/public/dictionaries/es-ES.json +++ b/apps/web/public/dictionaries/es-ES.json @@ -977,7 +977,7 @@ "room_full_body": "Pide al anfitrión que cree una sala nueva.", "waiting_title": "Esperando a tu grupo.", "waiting_body": "Cuando entre alguien más, pueden empezar a elegir.", - "room_capacity": "{current} / {max} personas", + "participant_count": "{current} personas en el grupo", "ready_title": "Tu grupo está listo.", "start_choosing": "Es ahora", "vote_nope": "No", @@ -991,7 +991,6 @@ "recommendation": "Es esta.", "its_this": "Es esta.", "pick_this": "Vamos a verla", - "up_to_four": "Hasta cuatro personas", "send_whatsapp": "Mandar por WhatsApp", "share_text": "{name} te llamó para elegir la película de la noche.", "empty_seat": "Vacía", diff --git a/apps/web/public/dictionaries/fr-FR.json b/apps/web/public/dictionaries/fr-FR.json index c9d18e83..0c69f16b 100644 --- a/apps/web/public/dictionaries/fr-FR.json +++ b/apps/web/public/dictionaries/fr-FR.json @@ -979,7 +979,7 @@ "room_full_body": "Demande à l’hôte de créer une nouvelle salle.", "waiting_title": "En attente de votre groupe.", "waiting_body": "Quand quelqu’un vous rejoint, vous pouvez commencer à choisir.", - "room_capacity": "{current} / {max} personnes", + "participant_count": "{current} personnes dans le groupe", "ready_title": "Votre groupe est prêt.", "start_choosing": "C’est maintenant", "vote_nope": "Non", @@ -993,7 +993,6 @@ "recommendation": "C’est celui-là.", "its_this": "C’est celui-là.", "pick_this": "On le regarde", - "up_to_four": "Jusqu’à quatre personnes", "send_whatsapp": "Envoyer sur WhatsApp", "share_text": "{name} t’a invité à choisir le film de la soirée.", "empty_seat": "Vide", diff --git a/apps/web/public/dictionaries/it-IT.json b/apps/web/public/dictionaries/it-IT.json index 7d3c30d5..aa066d11 100644 --- a/apps/web/public/dictionaries/it-IT.json +++ b/apps/web/public/dictionaries/it-IT.json @@ -976,7 +976,7 @@ "room_full_body": "Chiedi all’host di creare una nuova stanza.", "waiting_title": "In attesa del tuo gruppo.", "waiting_body": "Quando arriva qualcuno, potete iniziare a scegliere insieme.", - "room_capacity": "{current} / {max} persone", + "participant_count": "{current} persone nel gruppo", "ready_title": "Il tuo gruppo è pronto.", "start_choosing": "È adesso", "vote_nope": "No", @@ -990,7 +990,6 @@ "recommendation": "È questo.", "its_this": "È questo.", "pick_this": "Guardiamo questo", - "up_to_four": "Fino a quattro persone", "send_whatsapp": "Invia su WhatsApp", "share_text": "{name} ti ha chiamato per scegliere il film della sera.", "empty_seat": "Vuoto", diff --git a/apps/web/public/dictionaries/ja-JP.json b/apps/web/public/dictionaries/ja-JP.json index bd59055f..d2225a22 100644 --- a/apps/web/public/dictionaries/ja-JP.json +++ b/apps/web/public/dictionaries/ja-JP.json @@ -979,7 +979,7 @@ "room_full_body": "ホストに新しいルームの作成を依頼してください。", "waiting_title": "グループを待っています。", "waiting_body": "誰かが参加したら、一緒に選び始められます。", - "room_capacity": "{current} / {max}人", + "participant_count": "{current}人が参加中", "ready_title": "グループの準備ができました。", "start_choosing": "いまから", "vote_nope": "いいえ", @@ -993,7 +993,6 @@ "recommendation": "これだ。", "its_this": "これだ。", "pick_this": "これを観る", - "up_to_four": "最大4人", "send_whatsapp": "WhatsAppで送る", "share_text": "{name}が今夜の映画選びに招待しました。", "empty_seat": "空き", diff --git a/apps/web/public/dictionaries/pt-BR.json b/apps/web/public/dictionaries/pt-BR.json index e8491130..b2f18b9f 100644 --- a/apps/web/public/dictionaries/pt-BR.json +++ b/apps/web/public/dictionaries/pt-BR.json @@ -978,7 +978,7 @@ "room_full_body": "Peça para o anfitrião criar uma nova sala.", "waiting_title": "Aguardando seu grupo.", "waiting_body": "Quando mais alguém entrar, vocês podem começar a escolher.", - "room_capacity": "{current} / {max} pessoas", + "participant_count": "{current} pessoas no grupo", "ready_title": "Seu grupo está pronto.", "start_choosing": "É agora", "vote_nope": "Não", @@ -992,7 +992,6 @@ "recommendation": "É este.", "its_this": "É este.", "pick_this": "Vamos assistir", - "up_to_four": "Até quatro pessoas", "send_whatsapp": "Mandar no WhatsApp", "share_text": "{name} te chamou pra escolher o filme da noite.", "empty_seat": "Vazia", diff --git a/apps/web/src/app/[lang]/together/_components/invite-screen.tsx b/apps/web/src/app/[lang]/together/_components/invite-screen.tsx index aa2300e2..1b4d5e32 100644 --- a/apps/web/src/app/[lang]/together/_components/invite-screen.tsx +++ b/apps/web/src/app/[lang]/together/_components/invite-screen.tsx @@ -10,15 +10,13 @@ type InviteScreenProps = { inviteCode: string inviteUrl: string participantCount: number - maxParticipants: number copy: { group_kicker: string host_invite_title: string invite_help: string continue_as_host: string invite_code_label: string - up_to_four: string - room_capacity: string + participant_count: string send_whatsapp: string share_text: string } @@ -30,7 +28,6 @@ export function InviteScreen({ inviteCode, inviteUrl, participantCount, - maxParticipants, copy, onContinue, }: InviteScreenProps) { @@ -55,11 +52,11 @@ export function InviteScreen({

{copy.invite_help}

-

{copy.up_to_four}

-

- {copy.room_capacity - .replace('{current}', String(participantCount)) - .replace('{max}', String(maxParticipants))} +

+ {copy.participant_count.replace( + '{current}', + String(participantCount) + )}

{inviteCode}

diff --git a/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx b/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx index 3867e3f8..e0b7751b 100644 --- a/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx @@ -33,7 +33,7 @@ vi.mock('@/context/language', () => ({ join_title: '{name} invited you.', have_invite_title: 'Join the night', join_subtitle: 'Enter your name.', - room_capacity: '{current} / {max} people', + participant_count: 'In the group: {current}', invite_code_label: 'Invite code', invite_code_placeholder: 'ABC123', your_name: 'Your name', @@ -86,7 +86,6 @@ describe('JoinInviteForm capacity errors', () => { code="ABC123" hostName="Host" participantCount={3} - maxParticipants={4} onRoomFull={onRoomFull} /> ) @@ -113,7 +112,6 @@ describe('JoinInviteForm capacity errors', () => { code="ABC123" hostName="Host" participantCount={3} - maxParticipants={4} /> ) submitJoin() @@ -138,6 +136,19 @@ describe('JoinInviteForm capacity errors', () => { expect(screen.queryByText('This room is full.')).toBeNull() }) + it('shows the current participant count without advertising a maximum', () => { + render( + + ) + + expect(screen.getByText('In the group: 19')).toBeTruthy() + expect(screen.queryByText(/\/ 20|up to|maximum/i)).toBeNull() + }) + it('requires the stable capacity status as well as its payload', async () => { mocks.joinRoom.mockRejectedValue( new ApiError( diff --git a/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx b/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx index b8bef655..26010fcc 100644 --- a/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx +++ b/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx @@ -17,7 +17,6 @@ type JoinInviteFormProps = { code?: string hostName?: string participantCount?: number - maxParticipants?: number onJoined?: () => void onRoomFull?: () => void } @@ -26,7 +25,6 @@ export function JoinInviteForm({ code, hostName, participantCount, - maxParticipants, onJoined, onRoomFull, }: JoinInviteFormProps) { @@ -92,11 +90,12 @@ export function JoinInviteForm({

{copy.join_subtitle}

- {participantCount !== undefined && maxParticipants !== undefined && ( + {participantCount !== undefined && (

- {copy.room_capacity - .replace('{current}', String(participantCount)) - .replace('{max}', String(maxParticipants))} + {copy.participant_count.replace( + '{current}', + String(participantCount) + )}

)}
diff --git a/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx b/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx index 63e96f57..9ef87015 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx @@ -108,8 +108,8 @@ describe('TogetherRoom token hydration', () => { if (token === 'member-token') return memberRoom.promise return Promise.resolve({ - room: { maxParticipants: 4 }, - participants: Array.from({ length: 4 }, (_, index) => ({ + room: { maxParticipants: 20 }, + participants: Array.from({ length: 20 }, (_, index) => ({ id: `participant-${index + 1}`, displayName: `Person ${index + 1}`, })), @@ -126,7 +126,7 @@ describe('TogetherRoom token hydration', () => { expect(screen.getByText('Loading...')).toBeTruthy() expect(screen.queryByText('This room is full.')).toBeNull() - const participants = Array.from({ length: 4 }, (_, index) => ({ + const participants = Array.from({ length: 20 }, (_, index) => ({ id: `participant-${index + 1}`, displayName: `Person ${index + 1}`, })) @@ -138,11 +138,11 @@ describe('TogetherRoom token hydration', () => { watchRegion: 'BR', maxRuntime: null, mood: 'ANY', - maxParticipants: 4, + maxParticipants: 20, createdAt: '2026-09-08T00:00:00.000Z', }, participants, - me: participants[3], + me: participants[19], swipedIds: [], }) diff --git a/apps/web/src/app/[lang]/together/_components/together-room.test.tsx b/apps/web/src/app/[lang]/together/_components/together-room.test.tsx index b620f88b..3ae7313d 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-room.test.tsx @@ -57,25 +57,21 @@ vi.mock('../../../../../constants', () => ({ vi.mock('./invite-screen', () => ({ InviteScreen: ({ participantCount, - maxParticipants, }: { participantCount: number - maxParticipants: number - }) =>
{`Invite ${participantCount}/${maxParticipants}`}
, + }) =>
{`Invite ${participantCount}`}
, })) vi.mock('./join-invite-form', () => ({ JoinInviteForm: ({ participantCount, - maxParticipants, onRoomFull, }: { participantCount: number - maxParticipants: number onRoomFull?: () => void }) => (
- {`Join form ${participantCount}/${maxParticipants}`} + {`Join form ${participantCount}`} @@ -86,11 +82,11 @@ vi.mock('./join-invite-form', () => ({ vi.mock('./waiting-room', () => ({ WaitingRoom: ({ names, - maxParticipants, + isFull, }: { names: string[] - maxParticipants: number - }) =>
{`Waiting ${names.length}/${maxParticipants}`}
, + isFull: boolean + }) =>
{`Waiting ${names.length} ${isFull ? 'full' : 'open'}`}
, })) vi.mock('./primary-button', () => ({ @@ -122,25 +118,26 @@ describe('TogetherRoom capacity', () => { vi.clearAllMocks() }) - it.each([ - 2, 3, - ])('keeps the join form available to a visitor when %i of 4 seats are filled', count => { + it.each([2, 19])( + 'keeps the join form available to a visitor when %i participants have joined', + count => { mocks.roomState = { - room: { maxParticipants: 4 }, + room: { maxParticipants: 20 }, participants: participants(count), me: null, } render() - expect(screen.getByText(`Join form ${count}/4`)).toBeTruthy() + expect(screen.getByText(`Join form ${count}`)).toBeTruthy() expect(screen.queryByText('This room is full.')).toBeNull() - }) + } + ) it('shows a localized full-room state instead of the join form to a visitor', () => { mocks.roomState = { - room: { maxParticipants: 4 }, - participants: participants(4), + room: { maxParticipants: 20 }, + participants: participants(20), me: null, } @@ -153,8 +150,8 @@ describe('TogetherRoom capacity', () => { it('refetches room state when a concurrent join reports full capacity', () => { mocks.roomState = { - room: { maxParticipants: 4 }, - participants: participants(3), + room: { maxParticipants: 20 }, + participants: participants(19), me: null, } @@ -165,16 +162,16 @@ describe('TogetherRoom capacity', () => { }) it('lets a valid member continue when the room is full', () => { - const roomParticipants = participants(4) + const roomParticipants = participants(20) mocks.roomState = { - room: { maxParticipants: 4 }, + room: { maxParticipants: 20 }, participants: roomParticipants, me: roomParticipants[3], } render() - expect(screen.getByText('Waiting 4/4')).toBeTruthy() + expect(screen.getByText('Waiting 20 full')).toBeTruthy() expect(screen.queryByText('This room is full.')).toBeNull() }) }) diff --git a/apps/web/src/app/[lang]/together/_components/together-room.tsx b/apps/web/src/app/[lang]/together/_components/together-room.tsx index 1efc0fa5..7c69c4bf 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-room.tsx @@ -103,7 +103,6 @@ export function TogetherRoom({ code }: { code: string }) { code={roomCode} hostName={hostName} participantCount={room.participants.length} - maxParticipants={room.room.maxParticipants} onJoined={() => { setToken(getTogetherToken(roomCode)) void roomQuery.refetch() @@ -123,7 +122,6 @@ export function TogetherRoom({ code }: { code: string }) { inviteCode={roomCode} inviteUrl={inviteUrl} participantCount={room.participants.length} - maxParticipants={room.room.maxParticipants} copy={copy} onContinue={continueAsHost} /> @@ -136,7 +134,7 @@ export function TogetherRoom({ code }: { code: string }) { participantIds={room.participants.map(participant => participant.id)} meId={room.me?.id} ready={ready} - maxParticipants={room.room.maxParticipants} + isFull={isFull} copy={copy} onStart={() => router.push(`/${language}/together/${roomCode}/vote`)} /> diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx index 7da8d4ad..3b9bc9c1 100644 --- a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx @@ -94,7 +94,7 @@ function room(watchProviderIds: number[] | null, watchRegion = 'BR') { watchRegion, maxRuntime: null, mood: 'ANY', - maxParticipants: 4, + maxParticipants: 20, createdAt: '2026-09-08T00:00:00.000Z', }, participants: [{ id: 'host', displayName: 'Ana' }], diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx b/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx index 4a6f2b3b..a2170c68 100644 --- a/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx @@ -9,8 +9,7 @@ const copy = { invite_help: 'Send this invite.', continue_as_host: 'Wait here', invite_code_label: 'Invite code', - up_to_four: 'Up to four people', - room_capacity: '{current} / {max} people', + participant_count: 'In the group: {current}', send_whatsapp: 'Send on WhatsApp', share_text: '{name} invited you.', waiting_title: 'Waiting for your group.', @@ -35,48 +34,65 @@ vi.mock('./together-shell', () => ({ ), })) -describe('Together capacity UI', () => { +describe('Together participant UI', () => { afterEach(cleanup) - it('shows current and maximum capacity on the invite ticket', () => { + it('shows only the current participant count on the invite ticket', () => { render( ) - expect(screen.getByText('Up to four people')).toBeTruthy() - expect(screen.getByText('1 / 4 people')).toBeTruthy() + expect(screen.getByText('In the group: 1')).toBeTruthy() + expect(screen.queryByText(/four|\/ 4/i)).toBeNull() }) - it('renders every participant and open seat with current and maximum capacity', () => { + it('renders participants and one waiting card without exposing capacity', () => { render( ) - expect(screen.getByText('3 / 4 people')).toBeTruthy() + expect(screen.getByText('In the group: 3')).toBeTruthy() expect(screen.getByText('Ana')).toBeTruthy() expect(screen.getByText('Ben')).toBeTruthy() expect(screen.getByText('Cleo')).toBeTruthy() expect(screen.getByText('Empty')).toBeTruthy() + expect(screen.getAllByText('Empty')).toHaveLength(1) + expect(screen.queryByText(/\/ 20/)).toBeNull() expect(screen.getByText('You')).toBeTruthy() expect( screen.getByRole('heading', { name: 'Your group is ready.' }) ).toBeTruthy() expect(screen.queryByText('Ana & Ben')).toBeNull() }) + + it('does not render a waiting card when the room is full', () => { + render( + + ) + + expect(screen.queryByText('Empty')).toBeNull() + expect(screen.getByText('In the group: 2')).toBeTruthy() + }) }) diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx index e53221a9..b08d6301 100644 --- a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx +++ b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx @@ -7,7 +7,7 @@ import { TogetherShell } from './together-shell' type WaitingRoomProps = { names: string[] ready: boolean - maxParticipants: number + isFull: boolean copy: { group_kicker: string waiting_title: string @@ -16,7 +16,7 @@ type WaitingRoomProps = { start_choosing: string you: string empty_seat: string - room_capacity: string + participant_count: string } meId?: string participantIds: string[] @@ -26,20 +26,18 @@ type WaitingRoomProps = { export function WaitingRoom({ names, ready, - maxParticipants, + isFull, copy, meId, participantIds, onStart, }: WaitingRoomProps) { const heading = ready ? copy.ready_title : copy.waiting_title - const capacity = copy.room_capacity - .replace('{current}', String(names.length)) - .replace('{max}', String(maxParticipants)) - const seats = Array.from( - { length: maxParticipants }, - (_, index) => names[index] + const participantCount = copy.participant_count.replace( + '{current}', + String(names.length) ) + const seats = isFull ? names : [...names, undefined] return ( @@ -48,7 +46,9 @@ export function WaitingRoom({ {copy.group_kicker}

{heading}

-

{capacity}

+

+ {participantCount} +

{!ready && (

{copy.waiting_body} diff --git a/apps/web/src/utils/dictionaries/get-dictionaries.test.ts b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts index 6acdceea..a475eba2 100644 --- a/apps/web/src/utils/dictionaries/get-dictionaries.test.ts +++ b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts @@ -31,8 +31,7 @@ const REQUIRED_TOGETHER_KEYS = [ 'guest_prompt_body', 'guest_prompt_sign_in', 'continue_as_guest', - 'up_to_four', - 'room_capacity', + 'participant_count', 'room_full_title', 'room_full_body', 'subtitle', @@ -42,7 +41,12 @@ const REQUIRED_TOGETHER_KEYS = [ 'matches_title', ] as const -const DEPRECATED_PAIR_KEYS = ['night_for_two', 'tonight_with'] as const +const DEPRECATED_TOGETHER_KEYS = [ + 'night_for_two', + 'tonight_with', + 'up_to_four', + 'room_capacity', +] as const describe('appearance dictionary contract', () => { it('covers all seven supported locales', () => { @@ -71,7 +75,7 @@ describe('Together dictionary contract', () => { expect(value.trim(), `${language}.together.${key}`).not.toBe('') } - for (const key of DEPRECATED_PAIR_KEYS) { + for (const key of DEPRECATED_TOGETHER_KEYS) { expect(together[key], `${language}.together.${key}`).toBeUndefined() } @@ -79,5 +83,9 @@ describe('Together dictionary contract', () => { together.choosing_with, `${language}.together.choosing_with` ).toContain('{count}') + expect( + together.participant_count, + `${language}.together.participant_count` + ).toContain('{current}') }) }) diff --git a/docs/superpowers/plans/2026-09-08-together-flow-improvements.md b/docs/superpowers/plans/2026-09-08-together-flow-improvements.md index cacfb4a5..1a90135a 100644 --- a/docs/superpowers/plans/2026-09-08-together-flow-improvements.md +++ b/docs/superpowers/plans/2026-09-08-together-flow-improvements.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Improve Together with host-selected streaming services, language-neutral invites, match celebrations, guest sign-in guidance, and rooms for up to four participants. +**Goal:** Improve Together with host-selected streaming services, language-neutral invites, match celebrations, guest sign-in guidance, and an invisible technical room limit of 20 participants. **Architecture:** Keep room-level recommendation constraints in the existing room entity and apply them when the web client builds its TMDB deck. Enforce group capacity atomically in the backend, while locale detection, provider setup, guest guidance, and match notifications remain focused web components around the existing Together flow. @@ -14,7 +14,7 @@ - Provider selection is the first host step and includes an explicit unfiltered “Any service” option. - Signed-in host preferences prefill the room setup but remain editable. - Shared invite URLs contain no locale and rely on request language detection. -- A room accepts at most four distinct participants. +- A room accepts at most 20 distinct participants, but this technical limit is not advertised in the UI. - A valid participant token may rejoin a full room. - Voting may start at two participants. - Two distinct `LIKE` or `MAYBE` decisions create a match, including in larger rooms. @@ -26,7 +26,7 @@ --- -### Task 1: Atomic four-person room capacity +### Task 1: Atomic 20-person technical room capacity **Files:** - Create: `apps/backend/src/domain/services/together/constants.ts` @@ -39,25 +39,27 @@ - Modify: `apps/web/src/services/together.ts` **Interfaces:** -- Produces: `MAX_TOGETHER_PARTICIPANTS = 4` +- Produces: `MAX_TOGETHER_PARTICIPANTS = 20` - Produces: `insertTogetherParticipantWithinCapacity(values, capacity)` returning the participant or `null` -- Adds: `maxParticipants: 4` to serialized room state +- Adds: `maxParticipants: 20` to serialized room state for internal full-room detection - [ ] **Step 1: Write failing capacity tests** -Extend `join-room.spec.ts` to create one host and join three distinct participants, then assert a fifth new participant returns `TogetherInvalidInputError`. Reuse one of the four valid tokens after capacity and assert the existing participant is returned: +Extend `join-room.spec.ts` to create one host and join 19 distinct participants, then assert participant 21 returns `TogetherInvalidInputError`. Reuse one of the 20 valid tokens after capacity and assert the existing participant is returned: ```ts -expect(fifth).toBeInstanceOf(TogetherInvalidInputError) +expect(twentyFirst).toBeInstanceOf(TogetherInvalidInputError) expect(rejoined).toEqual( expect.objectContaining({ - participant: expect.objectContaining({ id: fourth.participant.id }), - participantToken: fourth.participantToken, + participant: expect.objectContaining({ id: twentieth.participant.id }), + participantToken: twentieth.participantToken, }) ) ``` -Extend `create-swipe.spec.ts` with four participants. After two distinct interested swipes, assert: +Prefill 19 participants, launch two joins concurrently for the final place, and assert exactly one succeeds while exactly one receives the full-room error. + +Preserve the larger-room match-percentage coverage. After two distinct interested swipes among four participants, assert: ```ts expect(result.match).toEqual( @@ -73,7 +75,7 @@ pnpm --filter backend test --run \ src/domain/services/together/create-swipe.spec.ts ``` -Expected: fifth participant currently succeeds and the four-person percentage case is absent or fails. +Expected: participants 5 through 20 are rejected by the old limit, and the concurrent twentieth-seat case fails. - [ ] **Step 3: Implement atomic capacity** @@ -94,7 +96,7 @@ if (count >= capacity) return null - [ ] **Step 4: Expose capacity** -Add `maxParticipants` to `serializeRoom`, the Zod room schema, and `TogetherRoom`. Use the shared backend constant in serialization. +Add `maxParticipants` to `serializeRoom`, the Zod room schema, and `TogetherRoom`. Use the shared backend constant in serialization. Treat this field as internal state for full-room detection; never render or advertise it. - [ ] **Step 5: Verify and commit** @@ -102,7 +104,7 @@ Run the two focused backend specs, backend typecheck/build, and Biome on changed ```bash git add apps/backend apps/web/src/services/together.ts -git commit -m "feat(together): support rooms of up to four" +git commit -m "fix(together): keep room capacity private" ``` ### Task 2: Locale-neutral invite URLs @@ -273,7 +275,7 @@ git add apps/web/src/app/[lang]/together/_components git commit -m "feat(together): celebrate matches while discovering" ``` -### Task 5: Guest guidance and four-person UI +### Task 5: Guest guidance and private-capacity UI **Files:** - Create: `apps/web/src/app/[lang]/together/_components/together-guest-prompt.tsx` @@ -293,7 +295,7 @@ git commit -m "feat(together): celebrate matches while discovering" Assert signed-out welcome renders the recommendation-benefit prompt, sign-in link includes a localized encoded redirect to `/{lang}/together`, and a continue-as-guest action dismisses it. Assert authenticated users do not see it. -Assert a room with four participants renders localized full-room state to a visitor, while two or three participants still render the join form. Assert waiting copy contains current and maximum capacity. +Assert a room with 20 participants renders localized full-room state to a visitor, while fewer participants still render the join form. Assert invite, join, and waiting copy contains only the current participant count. Assert the waiting room renders current participant cards plus at most one generic waiting card while open, and no waiting card while full. - [ ] **Step 2: Verify RED** @@ -309,9 +311,9 @@ Render it before host provider setup. Keep dismissal in component state and neve - [ ] **Step 4: Implement capacity UI and copy** -Use `participants.length` and `room.maxParticipants` from room state. Replace exact “admit two” copy with “up to four”; show `current / max` in invite and waiting screens. When full and the visitor has no valid membership, show the localized room-full state instead of the join form. +Use `participants.length` for localized, group-neutral current-count copy. Use `room.maxParticipants` only to derive whether the room is full. Do not pass the maximum to presentational invite or join components, and pass only `isFull` to the waiting room. Never render “up to four”, `current / max`, 20, or one empty card per available technical slot. When full and the visitor has no valid membership, show the localized room-full state without naming the limit. -Add all remaining guest prompt and capacity strings to all seven dictionaries. Extend the existing dictionary contract test to require every new key, including the provider-step and match keys from Tasks 3 and 4. +Replace `up_to_four` and `room_capacity` with `participant_count` in all seven dictionaries. Extend the dictionary contract test to require the replacement and reject the removed keys, including the provider-step and match keys from Tasks 3 and 4. - [ ] **Step 5: Verify and commit** @@ -319,7 +321,7 @@ Run all focused Together web tests, dictionary contract tests, typecheck, and ch ```bash git add apps/web/src/app/[lang]/together apps/web/public/dictionaries -git commit -m "feat(together): guide guests and show room capacity" +git commit -m "fix(together): hide technical room capacity" ``` ### Task 6: Final integration verification @@ -356,10 +358,12 @@ Run root or app builds with available environment services. In the deployed prev 1. guest prompt can be dismissed; 2. host provider choices persist into room creation; 3. invite URL is locale-neutral and redirects by browser language; -4. participants two through four join, while a fifth is blocked; -5. a match opens for direct and polling participants; -6. continue discovering stays on the deck; -7. the provider-filtered deck only shows titles available in the selected region/services. +4. participants two through 20 join, participant 21 is blocked, and a valid participant can rejoin when full; +5. invite, join, and waiting UI show only current participant count and never expose 20 or `current / max`; +6. an open waiting room shows at most one generic waiting card, while a full room shows none; +7. a match opens for direct and polling participants; +8. continue discovering stays on the deck; +9. the provider-filtered deck only shows titles available in the selected region/services. - [ ] **Step 5: Final review and PR update** diff --git a/docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md b/docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md index 1f4f49de..4cecd96e 100644 --- a/docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md +++ b/docs/superpowers/specs/2026-09-08-together-flow-improvements-design.md @@ -2,7 +2,7 @@ ## Goal -Make Together produce more relevant choices, work naturally when shared across languages, celebrate matches without ending discovery, explain the benefit of signing in, and support groups of up to four people. +Make Together produce more relevant choices, work naturally when shared across languages, celebrate matches without ending discovery, explain the benefit of signing in, and safely support larger groups without advertising a room-size limit. ## Product Decisions @@ -11,10 +11,10 @@ Make Together produce more relevant choices, work naturally when shared across l - Provider selection is optional through an explicit “Any service” choice so provider API failure never blocks room creation. - Signed-in hosts start with their saved region and providers; guests retain the existing `BR` region default. - Shared links contain no locale. The existing proxy detects the recipient browser language and redirects into the localized route. -- A room supports at most four participants. -- Voting can begin with two participants; additional people can join until the room reaches four. +- A room has an invisible technical limit of 20 participants. +- Voting can begin with two participants; additional people can join until the technical limit is reached. - A title becomes a match when at least two distinct participants choose `LIKE` or `MAYBE`. -- Match percentage continues to use the total participant count, e.g. two interested people in a four-person room is 50%. +- Match percentage continues to use the current participant count. - Match celebration is non-blocking and offers “Continue discovering” and “View matches”. - Guests see a non-blocking sign-in prompt explaining that saved preferences improve recommendations. They can continue without an account. @@ -74,11 +74,13 @@ The prompt does not block provider selection, joining an invite, or voting. The Authenticated users do not see the prompt. Their saved watch-provider IDs and region prefill host setup but remain editable for this room. -## Four-Person Rooms +## Invisible Room Capacity -The backend defines one shared `MAX_TOGETHER_PARTICIPANTS = 4` constant. Joining is rejected once four distinct participants exist; rejoining with an existing valid participant token remains allowed even when full. +The backend defines one shared `MAX_TOGETHER_PARTICIPANTS = 20` constant. Joining is rejected once 20 distinct participants exist; rejoining with an existing valid participant token remains allowed even when full. The atomic room lock remains responsible for preventing concurrent joins from exceeding this limit. -The room response exposes capacity information so the join screen and waiting room can show `current / 4`. The invitation ticket and copy no longer promise admission for exactly two. +The room response may retain `maxParticipants` as an internal signal so the client can detect a full room, but no UI copy or component output advertises that value. Invite, join, and waiting screens may show only the current participant count through localized, group-neutral copy. + +The waiting room renders one card per current participant and at most one generic empty/waiting card while the room is not full. It never renders one placeholder per remaining technical slot. The host can start voting once at least two people are present. The room remains joinable until capacity is reached. @@ -86,7 +88,7 @@ The host can start voting once at least two people are present. The room remains - Provider-list failure shows a retry action and allows “Any service”. - Room creation retains the selected setup values after an API failure. -- A fifth new participant receives a localized “Room is full” state; valid rejoin tokens continue to work. +- A twenty-first new participant receives a localized “Room is full” state that does not name the limit; valid rejoin tokens continue to work. - Invalid locale-less invite paths continue through existing not-found behavior after localization. - Match polling failure is silent while direct voting continues; the existing matches screen remains available. - Match overlay media uses the existing poster fallback. @@ -104,9 +106,10 @@ The host can start voting once at least two people are present. The room remains Backend coverage: - host-selected providers and region persist; -- first four distinct participants can join; -- fifth distinct participant is rejected; +- first 20 distinct participants, including the host, can join; +- participant 21 is rejected; - valid token can rejoin a full room; +- two concurrent joins competing for the twentieth place produce one success and one full-room error; - match threshold remains two interested participants in larger rooms; - percentage uses the current group size. @@ -122,6 +125,9 @@ Web coverage: - direct and polled matches share one celebration path; - continuing dismisses without navigating; - acknowledged matches are not repeated; -- four-person waiting/capacity copy and full-room state are localized. +- invite, join, and waiting screens show current-only participant copy in all seven locales; +- no Together UI renders “up to four”, `current / max`, or the technical limit; +- a non-full waiting room renders at most one waiting card, while a full room renders none; +- full-room state remains localized and does not name the technical limit. All new user-facing copy is added to the seven supported web dictionaries. From af0470b4d3f542d4d60718b0f0feccacb2dd5d23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 21:07:35 +0000 Subject: [PATCH 55/56] style(together): format capacity tests Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../services/together/join-room.spec.ts | 4 +-- .../_components/join-invite-form.test.tsx | 12 ++------- .../_components/together-room.test.tsx | 27 +++++++------------ .../together/_components/waiting-room.tsx | 4 +-- 4 files changed, 14 insertions(+), 33 deletions(-) diff --git a/apps/backend/src/domain/services/together/join-room.spec.ts b/apps/backend/src/domain/services/together/join-room.spec.ts index aa391e51..d9ca9329 100644 --- a/apps/backend/src/domain/services/together/join-room.spec.ts +++ b/apps/backend/src/domain/services/together/join-room.spec.ts @@ -97,9 +97,7 @@ describe('join together room', () => { }) expect(joins).toHaveLength(19) - expect( - joins.every(result => 'participantToken' in result) - ).toBe(true) + expect(joins.every(result => 'participantToken' in result)).toBe(true) expect(twentyFirst).toBeInstanceOf(TogetherInvalidInputError) expect(rejoined).toEqual( expect.objectContaining({ diff --git a/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx b/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx index e0b7751b..dfe1897f 100644 --- a/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx @@ -108,11 +108,7 @@ describe('JoinInviteForm capacity errors', () => { ) render( - + ) submitJoin() @@ -138,11 +134,7 @@ describe('JoinInviteForm capacity errors', () => { it('shows the current participant count without advertising a maximum', () => { render( - + ) expect(screen.getByText('In the group: 19')).toBeTruthy() diff --git a/apps/web/src/app/[lang]/together/_components/together-room.test.tsx b/apps/web/src/app/[lang]/together/_components/together-room.test.tsx index 3ae7313d..8cf662cf 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room.test.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-room.test.tsx @@ -55,11 +55,9 @@ vi.mock('../../../../../constants', () => ({ })) vi.mock('./invite-screen', () => ({ - InviteScreen: ({ - participantCount, - }: { - participantCount: number - }) =>

{`Invite ${participantCount}`}
, + InviteScreen: ({ participantCount }: { participantCount: number }) => ( +
{`Invite ${participantCount}`}
+ ), })) vi.mock('./join-invite-form', () => ({ @@ -80,13 +78,9 @@ vi.mock('./join-invite-form', () => ({ })) vi.mock('./waiting-room', () => ({ - WaitingRoom: ({ - names, - isFull, - }: { - names: string[] - isFull: boolean - }) =>
{`Waiting ${names.length} ${isFull ? 'full' : 'open'}`}
, + WaitingRoom: ({ names, isFull }: { names: string[]; isFull: boolean }) => ( +
{`Waiting ${names.length} ${isFull ? 'full' : 'open'}`}
+ ), })) vi.mock('./primary-button', () => ({ @@ -118,9 +112,9 @@ describe('TogetherRoom capacity', () => { vi.clearAllMocks() }) - it.each([2, 19])( - 'keeps the join form available to a visitor when %i participants have joined', - count => { + it.each([ + 2, 19, + ])('keeps the join form available to a visitor when %i participants have joined', count => { mocks.roomState = { room: { maxParticipants: 20 }, participants: participants(count), @@ -131,8 +125,7 @@ describe('TogetherRoom capacity', () => { expect(screen.getByText(`Join form ${count}`)).toBeTruthy() expect(screen.queryByText('This room is full.')).toBeNull() - } - ) + }) it('shows a localized full-room state instead of the join form to a visitor', () => { mocks.roomState = { diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx index b08d6301..71033b76 100644 --- a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx +++ b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx @@ -46,9 +46,7 @@ export function WaitingRoom({ {copy.group_kicker}

{heading}

-

- {participantCount} -

+

{participantCount}

{!ready && (

{copy.waiting_body} From fb6d55659baeaddc44cd0c8c5535da2f74f2b7e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 21:11:38 +0000 Subject: [PATCH 56/56] fix(together): use count-neutral room labels Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/public/dictionaries/de-DE.json | 2 +- apps/web/public/dictionaries/es-ES.json | 2 +- apps/web/public/dictionaries/fr-FR.json | 2 +- apps/web/public/dictionaries/it-IT.json | 2 +- apps/web/public/dictionaries/ja-JP.json | 2 +- apps/web/public/dictionaries/pt-BR.json | 2 +- .../dictionaries/get-dictionaries.test.ts | 25 +++++++++++++++++++ 7 files changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index 57cb6bac..d0224cb4 100644 --- a/apps/web/public/dictionaries/de-DE.json +++ b/apps/web/public/dictionaries/de-DE.json @@ -973,7 +973,7 @@ "room_full_body": "Bitte den Gastgeber, einen neuen Raum zu erstellen.", "waiting_title": "Warten auf deine Gruppe.", "waiting_body": "Sobald jemand dazukommt, könnt ihr gemeinsam auswählen.", - "participant_count": "{current} Personen in der Gruppe", + "participant_count": "In der Gruppe: {current}", "ready_title": "Deine Gruppe ist bereit.", "start_choosing": "Jetzt", "vote_nope": "Nein", diff --git a/apps/web/public/dictionaries/es-ES.json b/apps/web/public/dictionaries/es-ES.json index 9b7c09fa..8ea1e240 100644 --- a/apps/web/public/dictionaries/es-ES.json +++ b/apps/web/public/dictionaries/es-ES.json @@ -977,7 +977,7 @@ "room_full_body": "Pide al anfitrión que cree una sala nueva.", "waiting_title": "Esperando a tu grupo.", "waiting_body": "Cuando entre alguien más, pueden empezar a elegir.", - "participant_count": "{current} personas en el grupo", + "participant_count": "En el grupo: {current}", "ready_title": "Tu grupo está listo.", "start_choosing": "Es ahora", "vote_nope": "No", diff --git a/apps/web/public/dictionaries/fr-FR.json b/apps/web/public/dictionaries/fr-FR.json index 0c69f16b..3f305fe3 100644 --- a/apps/web/public/dictionaries/fr-FR.json +++ b/apps/web/public/dictionaries/fr-FR.json @@ -979,7 +979,7 @@ "room_full_body": "Demande à l’hôte de créer une nouvelle salle.", "waiting_title": "En attente de votre groupe.", "waiting_body": "Quand quelqu’un vous rejoint, vous pouvez commencer à choisir.", - "participant_count": "{current} personnes dans le groupe", + "participant_count": "Dans le groupe : {current}", "ready_title": "Votre groupe est prêt.", "start_choosing": "C’est maintenant", "vote_nope": "Non", diff --git a/apps/web/public/dictionaries/it-IT.json b/apps/web/public/dictionaries/it-IT.json index aa066d11..b17e3c16 100644 --- a/apps/web/public/dictionaries/it-IT.json +++ b/apps/web/public/dictionaries/it-IT.json @@ -976,7 +976,7 @@ "room_full_body": "Chiedi all’host di creare una nuova stanza.", "waiting_title": "In attesa del tuo gruppo.", "waiting_body": "Quando arriva qualcuno, potete iniziare a scegliere insieme.", - "participant_count": "{current} persone nel gruppo", + "participant_count": "Nel gruppo: {current}", "ready_title": "Il tuo gruppo è pronto.", "start_choosing": "È adesso", "vote_nope": "No", diff --git a/apps/web/public/dictionaries/ja-JP.json b/apps/web/public/dictionaries/ja-JP.json index d2225a22..0373418e 100644 --- a/apps/web/public/dictionaries/ja-JP.json +++ b/apps/web/public/dictionaries/ja-JP.json @@ -979,7 +979,7 @@ "room_full_body": "ホストに新しいルームの作成を依頼してください。", "waiting_title": "グループを待っています。", "waiting_body": "誰かが参加したら、一緒に選び始められます。", - "participant_count": "{current}人が参加中", + "participant_count": "グループ内:{current}", "ready_title": "グループの準備ができました。", "start_choosing": "いまから", "vote_nope": "いいえ", diff --git a/apps/web/public/dictionaries/pt-BR.json b/apps/web/public/dictionaries/pt-BR.json index b2f18b9f..96946c6f 100644 --- a/apps/web/public/dictionaries/pt-BR.json +++ b/apps/web/public/dictionaries/pt-BR.json @@ -978,7 +978,7 @@ "room_full_body": "Peça para o anfitrião criar uma nova sala.", "waiting_title": "Aguardando seu grupo.", "waiting_body": "Quando mais alguém entrar, vocês podem começar a escolher.", - "participant_count": "{current} pessoas no grupo", + "participant_count": "No grupo: {current}", "ready_title": "Seu grupo está pronto.", "start_choosing": "É agora", "vote_nope": "Não", diff --git a/apps/web/src/utils/dictionaries/get-dictionaries.test.ts b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts index a475eba2..db6c2450 100644 --- a/apps/web/src/utils/dictionaries/get-dictionaries.test.ts +++ b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts @@ -48,6 +48,19 @@ const DEPRECATED_TOGETHER_KEYS = [ 'room_capacity', ] as const +const PRIVATE_CAPACITY_COPY_KEYS = [ + 'participant_count', + 'room_full_title', + 'room_full_body', +] as const + +const PUBLIC_CAPACITY_PATTERNS = [ + /\{max\}/i, + /(^|\D)20(\D|$)/, + /\{current\}\s*\/|\/\s*\{(?:current|max)\}/i, + /\b(?:four|quatre|cuatro|quattro|vier|quatro)\b|4\s*(?:人|people|persons|personen|personas|pessoas|persone)?/i, +] as const + describe('appearance dictionary contract', () => { it('covers all seven supported locales', () => { expect(languages).toHaveLength(7) @@ -87,5 +100,17 @@ describe('Together dictionary contract', () => { together.participant_count, `${language}.together.participant_count` ).toContain('{current}') + expect( + together.participant_count, + `${language}.together.participant_count` + ).toMatch(/[::]\s*\{current\}$/) + + for (const key of PRIVATE_CAPACITY_COPY_KEYS) { + for (const pattern of PUBLIC_CAPACITY_PATTERNS) { + expect(together[key], `${language}.together.${key}`).not.toMatch( + pattern + ) + } + } }) })