From b053721f2d82042cc350457cf359af08b1920acd Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:45:26 +0200 Subject: [PATCH 01/25] spec --- TOURNAMENT_LFG_PLAN.md | 329 +++++++++++++++++++++++++++++++++++++++++ TOURNAMENT_LFG_SPEC.md | 166 +++++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 TOURNAMENT_LFG_PLAN.md create mode 100644 TOURNAMENT_LFG_SPEC.md diff --git a/TOURNAMENT_LFG_PLAN.md b/TOURNAMENT_LFG_PLAN.md new file mode 100644 index 000000000..f1dce2d37 --- /dev/null +++ b/TOURNAMENT_LFG_PLAN.md @@ -0,0 +1,329 @@ +# Tournament LFG Implementation Plan + +## Overview + +Add a new `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before the tournament starts. + +## Phase 1: Database Migration + +**File**: `migrations/118-tournament-lfg.js` + +Create three new tables: + +### TournamentLFGGroup +| Column | Type | Notes | +|--------|------|-------| +| id | integer primary key | Auto-increment | +| tournamentId | integer not null | FK to Tournament | +| tournamentTeamId | integer | FK to TournamentTeam (null for unregistered groups) | +| visibility | text | JSON (AssociationVisibility) | +| chatCode | text not null | Unique room code for group chat | +| createdAt | integer | Default now | + +### TournamentLFGGroupMember +| Column | Type | Notes | +|--------|------|-------| +| groupId | integer not null | FK to TournamentLFGGroup | +| userId | integer not null | FK to User | +| role | text not null | OWNER / MANAGER / REGULAR | +| note | text | Public note | +| isStayAsSub | integer | Boolean (0/1), default 0 | +| createdAt | integer | Default now | + +### TournamentLFGLike +| Column | Type | Notes | +|--------|------|-------| +| likerGroupId | integer not null | FK to TournamentLFGGroup | +| targetGroupId | integer not null | FK to TournamentLFGGroup | +| createdAt | integer | Default now | + +All tables use `STRICT` mode, `ON DELETE CASCADE` for FKs, and have indexes on FK columns. + +--- + +## Phase 2: TypeScript Types + +**File**: `app/db/tables.ts` + +Add interfaces for the three new tables following existing patterns (`GeneratedAlways`, `Generated`, `JSONColumnTypeNullable`). + +Add to the `DB` interface: +- `TournamentLFGGroup` +- `TournamentLFGGroupMember` +- `TournamentLFGLike` + +--- + +## Phase 3: Feature File Structure + +``` +app/features/tournament-lfg/ +├── TournamentLFGRepository.server.ts # Database operations +├── tournament-lfg-types.ts # TypeScript types (LFGGroup, LFGGroupMember, etc.) +├── tournament-lfg-schemas.server.ts # Zod validation schemas +├── tournament-lfg-constants.ts # Constants (note max length, etc.) +├── tournament-lfg-utils.ts # Utility functions +├── routes/ +│ └── to.$id.looking.tsx # Main route (loader + action + component) +├── loaders/ +│ └── to.$id.looking.server.ts # Data loader +├── actions/ +│ └── to.$id.looking.server.ts # Action handler +└── components/ + └── LFGGroupCard.tsx # Group card (mirrors SendouQ GroupCard pattern) +``` + +--- + +## Phase 4: Repository Implementation + +**File**: `app/features/tournament-lfg/TournamentLFGRepository.server.ts` + +Mirror `SQGroupRepository.server.ts` pattern. Key functions: + +**Group Management:** +- `findGroupsByTournamentId(tournamentId)` - Get all active groups +- `addMember(groupId, { userId, role, stayAsSub? })` - Add member to group +- `morphGroups({ survivingGroupId, otherGroupId })` - Merge two groups + +**Likes:** +- `addLike({ likerGroupId, targetGroupId })` - Add like +- `deleteLike({ likerGroupId, targetGroupId })` - Remove like +- `allLikesByGroupId(groupId)` - Get { given: [], received: [] } + +**Member Management:** +- `updateMemberNote({ groupId, userId, value })` - Update public note +- `updateMemberRole({ userId, groupId, role })` - Change role +- `updateStayAsSub({ groupId, userId, value })` - Toggle sub preference +- `kickMember({ groupId, userId })` - Owner kicks member + +**Tournament Integration:** +- `cleanupForTournamentStart(tournamentId)` - Delete groups, preserve stayAsSub members +- `getSubsForTournament(tournamentId)` - Get users who opted to stay as sub + +--- + +## Phase 5: Route Implementation + +### 5.1 Route Registration + +**File**: `app/routes.ts` + +Add inside `/to/:id` children: +```typescript +route("looking", "features/tournament-lfg/routes/to.$id.looking.tsx"), +``` + +### 5.2 Loader + +**File**: `app/features/tournament-lfg/loaders/to.$id.looking.server.ts` + +Returns: +- `groups` - All visible groups (filtered by visibility) +- `ownGroup` - User's current group (if any) +- `likes` - { given: [], received: [] } for own group +- `privateNotes` - User's private notes on other players (reuse from SendouQ) +- `lastUpdated` - Timestamp for auto-refresh + +### 5.3 Action + +**File**: `app/features/tournament-lfg/actions/to.$id.looking.server.ts` + +Actions: +- `JOIN_QUEUE` - Create new group +- `LIKE` / `UNLIKE` - Like/unlike another group +- `ACCEPT` - Accept mutual like (triggers team creation/merge) +- `LEAVE_GROUP` - Leave current group +- `KICK_FROM_GROUP` - Owner kicks member +- `GIVE_MANAGER` / `REMOVE_MANAGER` - Role management +- `UPDATE_NOTE` - Update public note +- `UPDATE_STAY_AS_SUB` - Toggle sub preference +- `REFRESH_GROUP` - Refresh activity timestamp + +**ACCEPT Action Flow:** +1. Verify mutual like exists +2. Check if either group has `tournamentTeamId` +3. If neither: Create new `TournamentTeam` (use auto-generated name) +4. Merge groups (use `morphGroups`) +5. Add all members to `TournamentTeamMember` +6. Send `TO_LFG_TEAM_FORMED` notification +7. If team reaches `maxMembersPerTeam`: delete the LFG group + +### 5.4 Component + +**File**: `app/features/tournament-lfg/routes/to.$id.looking.tsx` + +Structure (mirror `/q/looking`): +- Three-column desktop layout: My Group | Groups | Invitations +- Tab structure on mobile +- Reuse `GroupCard` display pattern (weapons, VC, tier) +- Reuse `MemberAdder` for quick-add trusted players +- Reuse `GroupLeaver` component +- Chat integration for groups with 2+ members +- "Stay as sub" checkbox in join form + +--- + +## Phase 6: Tournament Integration + +### 6.1 Add "Looking" Tab + +**File**: `app/features/tournament/routes/to.$id.tsx` + +Add new `SubNavLink` (show only before tournament starts, not for invitationals): +```tsx +{!tournament.hasStarted && !tournament.isInvitational && ( + {t("tournament:tabs.looking")} +)} +``` + +### 6.2 Tournament.ts Getter + +**File**: `app/features/tournament-bracket/core/Tournament.ts` + +Add: +```typescript +get lfgEnabled() { + return !this.isInvitational && !this.hasStarted && this.subsFeatureEnabled; +} +``` + +### 6.3 Auto-cleanup on Tournament Start + +When tournament bracket starts, call `TournamentLFGRepository.cleanupForTournamentStart(tournamentId)`: +- Delete all `TournamentLFGGroup` records +- Preserve `TournamentLFGGroupMember` records where `stayAsSub = 1` for subs list + +--- + +## Phase 7: Notifications + +**File**: `app/features/notifications/notifications-types.ts` + +Add three new notification types: + +```typescript +| NotificationItem< + "TO_LFG_LIKED", + { + tournamentId: number; + tournamentName: string; + likerUsername: string; + } + > +| NotificationItem< + "TO_LFG_TEAM_FORMED", + { + tournamentId: number; + tournamentName: string; + teamName: string; + tournamentTeamId: number; + } + > +| NotificationItem< + "TO_LFG_CHAT_MESSAGE", + { + tournamentId: number; + tournamentName: string; + teamName: string; + tournamentTeamId: number; + } + > +``` + +**File**: `app/features/notifications/notifications-utils.ts` + +Add notification link handlers and icon mappings. + +--- + +## Phase 8: Translations + +**File**: `public/locales/en/tournament.json` + +Add keys: +- `tabs.looking` +- `lfg.join.header`, `lfg.join.stayAsSub`, `lfg.join.visibility` +- `lfg.myGroup.header`, `lfg.myGroup.empty` +- `lfg.groups.header`, `lfg.groups.empty` +- `lfg.invitations.header`, `lfg.invitations.empty`, `lfg.invitations.accept` +- `lfg.actions.like`, `lfg.actions.unlike`, `lfg.actions.leave` + +Run `npm run i18n:sync` after adding. + +--- + +## Phase 9: Group Merging Logic + +When two groups merge via ACCEPT: + +| Group A has team | Group B has team | Result | +|------------------|------------------|--------| +| No | No | Create new TournamentTeam, both join it | +| Yes | No | B's members join A's team | +| No | Yes | A's members join B's team | +| Yes | Yes | Accepting team absorbs liker team (accepting team name persists) | + +Auto-generated team name format: `"'s Team"` (e.g., "Sendou's Team" - can be changed later on registration page) + +After merge: +- Combined group stays in LFG queue +- When `maxMembersPerTeam` reached, group is auto-removed from queue + +--- + +## Key Files to Reference + +| Purpose | File | +|---------|------| +| Repository pattern | `app/features/sendouq/SQGroupRepository.server.ts` | +| Route pattern | `app/features/sendouq/routes/q.looking.tsx` | +| Action pattern | `app/features/sendouq/actions/q.looking.server.ts` | +| GroupCard UI | `app/features/sendouq/components/GroupCard.tsx` | +| Tournament tabs | `app/features/tournament/routes/to.$id.tsx` | +| Team creation | `app/features/tournament/TournamentTeamRepository.server.ts` | +| Notification types | `app/features/notifications/notifications-types.ts` | +| Visibility type | `app/features/associations/associations-types.ts` | + +--- + +## Implementation Order + +1. Migration (100-tournament-lfg.js) +2. Types (tables.ts + tournament-lfg-types.ts) +3. Repository (TournamentLFGRepository.server.ts) +4. Schemas (tournament-lfg-schemas.server.ts) +5. Loader (to.$id.looking.server.ts) +6. Action (to.$id.looking.server.ts) +7. Route component (to.$id.looking.tsx) +8. Tournament integration (tab, cleanup hook) +9. Notifications (types + utils) +10. Translations +11. Testing + +--- + +## Verification + +1. **Manual Testing:** + - Join LFG as solo player + - Create group with 2 players + - Like another group, verify notification sent + - Accept mutual like, verify team created + - Verify team appears on registration page + - Test "stay as sub" checkbox + - Test visibility filtering + +2. **Unit Tests:** + - Repository functions (create, merge, delete, likes) + - Visibility filtering logic + +3. **E2E Tests:** + - Full flow: join -> like -> accept -> team formed + - Leave group + - Kick from group + +4. **Run checks:** + ```bash + npm run checks + ``` diff --git a/TOURNAMENT_LFG_SPEC.md b/TOURNAMENT_LFG_SPEC.md new file mode 100644 index 000000000..102431ccd --- /dev/null +++ b/TOURNAMENT_LFG_SPEC.md @@ -0,0 +1,166 @@ +# Tournament LFG Feature Spec + +## Overview + +New `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before tournament starts. + +## Route + +`/to/:id/looking` (new route, mirrors `/q/looking`) + +## Data Model + +Separate tables from SendouQ (cleaner separation): + +### TournamentLFGGroup + +| Column | Type | Description | +|--------|------|-------------| +| id | number | Primary key | +| tournamentTeamId | number | null | FK to TournamentTeam | +| visibility | string | JSON visibility info (/scrims style) | +| chatCode | string | Unique room code for group chat | +| createdAt | number | Timestamp | + +### TournamentLFGGroupMember + +| Column | Type | Description | +|--------|------|-------------| +| groupId | number | FK to TournamentLFGGroup | +| userId | number | FK to User | +| role | string | OWNER / MANAGER / REGULAR | +| note | string | Public note visible to group members | +| stayAsSub | boolean | Convert to sub if team not formed by start | +| createdAt | number | Timestamp | + +### TournamentLFGLike + +| Column | Type | Description | +|--------|------|-------------| +| likerGroupId | number | FK to TournamentLFGGroup | +| targetGroupId | number | FK to TournamentLFGGroup | +| createdAt | number | Timestamp | + +### TournamentSub + +Redundant, removed. + +## Who Can Join + +- **Solo players** - Looking for a team +- **Partial groups (2-3 players)** - Looking for more members +- **Already-registered tournament teams** - Recruiting up to `maxMembersPerTeam` + +## Features + +### Joining the Queue + +- Players reuse weapon/VC data from `/q/settings` (`User.qWeaponPool`, `User.vc`, `User.languages`) +- Checkbox on join: "Add me as sub if I don't find a team" +- Support for notes (like SendouQ public member notes) +- Uses schema based SendouForm (forms.md for details) + +### Visibility System (Scrims-style) + +- **Base visibility**: team/org/public +- **Not-found visibility**: Time-delayed expansion if no match found +- Uses existing `AssociationVisibility` system from scrims + +### Likes & Matching + +1. Players/groups can like each other +2. Target group receives `TO_LFG_LIKED` notification +3. On mutual like, accepting party sees accept button +4. Accept click triggers: + - If neither party is registered team → create a team (some default name is used) + - If one party is registered team → Other party joins that team + - If both parties are teams, the accepting team absorbs the liker team (accepting team's name is used for the new merged team) +5. After merge, combined group stays in queue to recruit more members + +### Team Formation + +- **Immediate registration**: When first two players merge, default name is used +- Newly formed team stays in LFG queue +- Teams can grow up to `maxMembersPerTeam` (typically 6 for 4v4) +- When `maxMembersPerTeam` is reached, team is automatically removed from the queue +- Solo players liking registered teams get absorbed as new members + +### Tournament Start Auto-Cleanup + +When tournament starts: +1. All unregistered LFG groups are deleted +2. Players who checked "stay as sub" are shown in a simple list "`TournamentLFGGroupMember` reused here even if they technically are no longer members of anything) +3. Their sub data uses existing `/q/settings` weapon/VC preferences + +## Notifications + +| Type | When | Meta | +|------|------|------| +| `TO_LFG_LIKED` | Someone likes your group | `{ tournamentId, tournamentName, likerUsername }` | +| `TO_LFG_TEAM_FORMED` | You join/form a team via LFG | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` | +| `TO_LFG_CHAT_MESSAGE` | Chat message sent | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` | + +## UI + +### Reuse from `/q/looking` + +- `GroupCard` component (weapons, VC, tier display) +- Tab structure (My Group, Groups, Invitations) +- `MemberAdder` component (invite link, quick add), note for these same invite link and quick add endpoint is used as on /to/:id/register page +- `GroupLeaver` component +- Private user notes system + +### Tab Structure + +1. **My Group** - Current group members, invite link, leave button, chat (if 2+ members) +2. **Groups** - Other groups looking, with like/unlike buttons +3. **Invitations** - Groups that have liked your group, with accept/decline + +### Accept Flow + +Simple button click (SendouQ-style) + +## Files to Create/Modify + +### New Files + +``` +app/features/tournament-lfg/ +├── core/ +│ └── TournamentLFG.server.ts # Main class (like SendouQ.server.ts) +├── routes/ +│ ├── to.$id.looking.tsx # Main LFG page +│ └── to.$id.looking.new.tsx # Join LFG form (if needed) +├── loaders/ +│ └── to.$id.looking.server.ts # Data loader +├── actions/ +│ └── to.$id.looking.server.ts # Action handler +├── components/ +│ └── (reuse from sendouq where possible) +├── TournamentLFGRepository.server.ts # Database queries +├── tournament-lfg-types.ts # TypeScript types +├── tournament-lfg-schemas.server.ts # Zod validation +└── tournament-lfg-constants.ts # Constants +``` + +### Migrations + +``` +migrations/XXX-tournament-lfg.js # Create new tables +``` + +### Modified Files + +- `app/routes.ts` - Add new route +- `app/features/notifications/notifications-types.ts` - Add new notification types +- `app/features/tournament-bracket/core/Tournament.ts` - Add LFG-related getters +- `app/features/tournament/components/TournamentTabs.tsx` - Add "Looking" tab + +## Differences to SendouQ + +- no invite code (tournament teams have their own invite code by default) +- no expiredAt + +## Open Questions + +- How we should define the autogenerated tournament team name? From 1159c0607cec83fa8c344fbd5d7f9f19e4019fa6 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:53:53 +0200 Subject: [PATCH 02/25] Phase 1 - migrations --- migrations/118-tournament-lfg.js | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 migrations/118-tournament-lfg.js diff --git a/migrations/118-tournament-lfg.js b/migrations/118-tournament-lfg.js new file mode 100644 index 000000000..42f5d31aa --- /dev/null +++ b/migrations/118-tournament-lfg.js @@ -0,0 +1,69 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /*sql*/ ` + create table "TournamentLFGGroup" ( + "id" integer primary key, + "tournamentId" integer not null, + "tournamentTeamId" integer, + "visibility" text, + "chatCode" text not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("tournamentId") references "Tournament"("id") on delete cascade, + foreign key ("tournamentTeamId") references "TournamentTeam"("id") on delete cascade + ) strict + `, + ).run(); + + db.prepare( + /*sql*/ `create index tournament_lfg_group_tournament_id on "TournamentLFGGroup"("tournamentId")`, + ).run(); + db.prepare( + /*sql*/ `create index tournament_lfg_group_tournament_team_id on "TournamentLFGGroup"("tournamentTeamId")`, + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentLFGGroupMember" ( + "groupId" integer not null, + "tournamentId" integer not null, + "userId" integer not null, + "role" text not null, + "note" text, + "isStayAsSub" integer default 0 not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("groupId") references "TournamentLFGGroup"("id") on delete cascade, + foreign key ("userId") references "User"("id") on delete cascade, + unique("tournamentId", "userId") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /*sql*/ `create index tournament_lfg_group_member_group_id on "TournamentLFGGroupMember"("groupId")`, + ).run(); + db.prepare( + /*sql*/ `create index tournament_lfg_group_member_user_id on "TournamentLFGGroupMember"("userId")`, + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentLFGLike" ( + "likerGroupId" integer not null, + "targetGroupId" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("likerGroupId") references "TournamentLFGGroup"("id") on delete cascade, + foreign key ("targetGroupId") references "TournamentLFGGroup"("id") on delete cascade, + unique("likerGroupId", "targetGroupId") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /*sql*/ `create index tournament_lfg_like_liker_group_id on "TournamentLFGLike"("likerGroupId")`, + ).run(); + db.prepare( + /*sql*/ `create index tournament_lfg_like_target_group_id on "TournamentLFGLike"("targetGroupId")`, + ).run(); + })(); +} From 4180acbea79ea8f57e9a4d5ce5a96fce0108a0f2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:58:26 +0200 Subject: [PATCH 03/25] Phase 2 - tables.ts --- app/db/tables.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/db/tables.ts b/app/db/tables.ts index 88d42ca67..72be84631 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -692,6 +692,31 @@ export interface TournamentSub { visibility: "+1" | "+2" | "+3" | "ALL"; } +export interface TournamentLFGGroup { + id: GeneratedAlways; + tournamentId: number; + tournamentTeamId: number | null; + visibility: JSONColumnTypeNullable; + chatCode: string; + createdAt: Generated; +} + +export interface TournamentLFGGroupMember { + groupId: number; + tournamentId: number; + userId: number; + role: "OWNER" | "MANAGER" | "REGULAR"; + note: string | null; + isStayAsSub: Generated; + createdAt: Generated; +} + +export interface TournamentLFGLike { + likerGroupId: number; + targetGroupId: number; + createdAt: Generated; +} + export interface TournamentStaff { tournamentId: number; userId: number; @@ -1198,6 +1223,9 @@ export interface DB { Tournament: Tournament; TournamentStaff: TournamentStaff; TournamentGroup: TournamentGroup; + TournamentLFGGroup: TournamentLFGGroup; + TournamentLFGGroupMember: TournamentLFGGroupMember; + TournamentLFGLike: TournamentLFGLike; TournamentMatch: TournamentMatch; TournamentMatchPickBanEvent: TournamentMatchPickBanEvent; TournamentMatchGameResult: TournamentMatchGameResult; From c0562bcc878a355075d73ee02bd0eefa8a324d24 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:21:25 +0200 Subject: [PATCH 04/25] Phase 3 - basic folder structure --- .../TournamentLFGRepository.server.ts | 2 + .../actions/to.$id.looking.server.ts | 50 +++++++++++++++++++ .../components/LFGGroupCard.tsx | 11 ++++ .../loaders/to.$id.looking.server.ts | 15 ++++++ .../tournament-lfg/routes/to.$id.looking.tsx | 9 ++++ .../tournament-lfg-constants.ts | 3 ++ .../tournament-lfg-schemas.server.ts | 46 +++++++++++++++++ .../tournament-lfg/tournament-lfg-utils.ts | 1 + 8 files changed, 137 insertions(+) create mode 100644 app/features/tournament-lfg/TournamentLFGRepository.server.ts create mode 100644 app/features/tournament-lfg/actions/to.$id.looking.server.ts create mode 100644 app/features/tournament-lfg/components/LFGGroupCard.tsx create mode 100644 app/features/tournament-lfg/loaders/to.$id.looking.server.ts create mode 100644 app/features/tournament-lfg/routes/to.$id.looking.tsx create mode 100644 app/features/tournament-lfg/tournament-lfg-constants.ts create mode 100644 app/features/tournament-lfg/tournament-lfg-schemas.server.ts create mode 100644 app/features/tournament-lfg/tournament-lfg-utils.ts diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts new file mode 100644 index 000000000..66431f27b --- /dev/null +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -0,0 +1,2 @@ +// Phase 4: Implement repository functions here +// See SQGroupRepository.server.ts for pattern reference diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts new file mode 100644 index 000000000..610fd83d6 --- /dev/null +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -0,0 +1,50 @@ +import type { ActionFunctionArgs } from "react-router"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { lookingSchema } from "../tournament-lfg-schemas.server"; + +// Phase 5: Implement action handler here +// See app/features/sendouq/actions/q.looking.server.ts for pattern reference + +export const action = async ({ request }: ActionFunctionArgs) => { + const data = await parseRequestPayload({ + request, + schema: lookingSchema, + }); + + switch (data._action) { + case "JOIN_QUEUE": { + // TODO: Implement + break; + } + case "LIKE": { + // TODO: Implement + break; + } + case "UNLIKE": { + // TODO: Implement + break; + } + case "ACCEPT": { + // TODO: Implement + break; + } + case "GIVE_MANAGER": { + // TODO: Implement + break; + } + case "REMOVE_MANAGER": { + // TODO: Implement + break; + } + case "UPDATE_NOTE": { + // TODO: Implement + break; + } + case "UPDATE_STAY_AS_SUB": { + // TODO: Implement + break; + } + } + + return null; +}; diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx new file mode 100644 index 000000000..6c461568f --- /dev/null +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -0,0 +1,11 @@ +// Phase 5: Implement group card component here, or delete this and reuse GroupCard? +// See app/features/sendouq/components/GroupCard.tsx for pattern reference + +export function LFGGroupCard({ group }: { group: any }) { + return ( +
+ Group {group.id} + {group.members.length} members +
+ ); +} diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts new file mode 100644 index 000000000..8d2c0b7e9 --- /dev/null +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -0,0 +1,15 @@ +import type { LoaderFunctionArgs } from "react-router"; + +// Phase 5: Implement loader here +// See app/features/sendouq/loaders/q.looking.server.ts for pattern reference + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const tournamentId = Number(params.id); + + return { + tournamentId, + groups: [], + ownGroup: null, + likes: { given: [], received: [] }, + }; +}; diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx new file mode 100644 index 000000000..07689b156 --- /dev/null +++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx @@ -0,0 +1,9 @@ +// Phase 5: Implement route component here +// See app/features/sendouq/routes/q.looking.tsx for pattern reference + +export { action } from "../actions/to.$id.looking.server"; +export { loader } from "../loaders/to.$id.looking.server"; + +export default function TournamentLFGPage() { + return
TODO: Tournament LFG
; +} diff --git a/app/features/tournament-lfg/tournament-lfg-constants.ts b/app/features/tournament-lfg/tournament-lfg-constants.ts new file mode 100644 index 000000000..5415241e9 --- /dev/null +++ b/app/features/tournament-lfg/tournament-lfg-constants.ts @@ -0,0 +1,3 @@ +export const TOURNAMENT_LFG = { + PUBLIC_NOTE_MAX_LENGTH: 160, +} as const; diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.server.ts b/app/features/tournament-lfg/tournament-lfg-schemas.server.ts new file mode 100644 index 000000000..664e68748 --- /dev/null +++ b/app/features/tournament-lfg/tournament-lfg-schemas.server.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { _action, checkboxValueToBoolean, falsyToNull, id } from "~/utils/zod"; +import { TOURNAMENT_LFG } from "./tournament-lfg-constants"; + +export const lookingSchema = z.union([ + z.object({ + _action: _action("JOIN_QUEUE"), + // xxx: use SendouForm style + stayAsSub: z.preprocess(checkboxValueToBoolean, z.boolean().optional()), + }), + z.object({ + _action: _action("LIKE"), + targetGroupId: id, + }), + z.object({ + _action: _action("UNLIKE"), + targetGroupId: id, + }), + z.object({ + _action: _action("ACCEPT"), + targetGroupId: id, + }), + z.object({ + _action: _action("GIVE_MANAGER"), + userId: id, + }), + z.object({ + _action: _action("REMOVE_MANAGER"), + userId: id, + }), + z.object({ + _action: _action("UPDATE_NOTE"), + value: z.preprocess( + falsyToNull, + // xxx: use SendouForm style? (but would need SQ to be migrated too) + z + .string() + .max(TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH) + .nullable(), + ), + }), + z.object({ + _action: _action("UPDATE_STAY_AS_SUB"), + value: z.preprocess(checkboxValueToBoolean, z.boolean()), + }), +]); diff --git a/app/features/tournament-lfg/tournament-lfg-utils.ts b/app/features/tournament-lfg/tournament-lfg-utils.ts new file mode 100644 index 000000000..b4f607bd3 --- /dev/null +++ b/app/features/tournament-lfg/tournament-lfg-utils.ts @@ -0,0 +1 @@ +// utils here From a0b55c2359b0f188dcb3f80920a89305e3b76fa8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:25:24 +0200 Subject: [PATCH 05/25] Fix migration order --- migrations/{118-tournament-lfg.js => 119-tournament-lfg.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{118-tournament-lfg.js => 119-tournament-lfg.js} (100%) diff --git a/migrations/118-tournament-lfg.js b/migrations/119-tournament-lfg.js similarity index 100% rename from migrations/118-tournament-lfg.js rename to migrations/119-tournament-lfg.js From d52fe7c45a9f69c75f6d0f89cc0ef5cee37310d3 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:26:41 +0200 Subject: [PATCH 06/25] Repository initial --- .../TournamentLFGRepository.server.test.ts | 631 ++++++++++++++++++ .../TournamentLFGRepository.server.ts | 363 +++++++++- .../tournament-lfg-schemas.server.ts | 5 +- app/routes.ts | 2 + db-test.sqlite3 | Bin 1130496 -> 1179648 bytes 5 files changed, 995 insertions(+), 6 deletions(-) create mode 100644 app/features/tournament-lfg/TournamentLFGRepository.server.test.ts diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts new file mode 100644 index 000000000..97205e502 --- /dev/null +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -0,0 +1,631 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as TournamentLFGRepository from "./TournamentLFGRepository.server"; + +const createTournament = () => + db + .insertInto("Tournament") + .values({ + mapPickingStyle: "TO", + settings: JSON.stringify({ bracketProgression: [] }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createGroup = (tournamentId: number, userId: number) => + TournamentLFGRepository.createGroup({ tournamentId, userId }); + +describe("createGroup", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("creates a group with owner member", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe(group.id); + }); + + test("owner has OWNER role", async () => { + const tournament = await createTournament(); + await createGroup(tournament.id, 1); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + + expect(members[0].role).toBe("OWNER"); + }); +}); + +describe("findGroupsByTournamentId", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns groups for given tournament", async () => { + const tournament = await createTournament(); + await createGroup(tournament.id, 1); + await createGroup(tournament.id, 2); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(2); + }); + + test("returns empty array when no groups exist", async () => { + const tournament = await createTournament(); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(0); + }); + + test("returns groups with member data", async () => { + const tournament = await createTournament(); + await createGroup(tournament.id, 1); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + + expect(members[0].id).toBe(1); + expect(members[0].username).toBeDefined(); + expect(members[0].role).toBe("OWNER"); + }); + + test("does not return groups from other tournaments", async () => { + const tournament1 = await createTournament(); + const tournament2 = await createTournament(); + await createGroup(tournament1.id, 1); + await createGroup(tournament2.id, 2); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament1.id, + ); + + expect(groups).toHaveLength(1); + }); +}); + +describe("addLike / deleteLike", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("adds a like between two groups", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + + await TournamentLFGRepository.addLike({ + likerGroupId: group1.id, + targetGroupId: group2.id, + }); + + const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id); + + expect(likes.given).toHaveLength(1); + expect(likes.given[0].groupId).toBe(group2.id); + }); + + test("duplicate like does not throw", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + + await TournamentLFGRepository.addLike({ + likerGroupId: group1.id, + targetGroupId: group2.id, + }); + await TournamentLFGRepository.addLike({ + likerGroupId: group1.id, + targetGroupId: group2.id, + }); + + const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id); + + expect(likes.given).toHaveLength(1); + }); + + test("deleteLike removes the like", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + + await TournamentLFGRepository.addLike({ + likerGroupId: group1.id, + targetGroupId: group2.id, + }); + await TournamentLFGRepository.deleteLike({ + likerGroupId: group1.id, + targetGroupId: group2.id, + }); + + const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id); + + expect(likes.given).toHaveLength(0); + }); +}); + +describe("allLikesByGroupId", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("separates likes into given and received", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + const group3 = await createGroup(tournament.id, 3); + + await TournamentLFGRepository.addLike({ + likerGroupId: group1.id, + targetGroupId: group2.id, + }); + await TournamentLFGRepository.addLike({ + likerGroupId: group3.id, + targetGroupId: group1.id, + }); + + const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id); + + expect(likes.given).toHaveLength(1); + expect(likes.given[0].groupId).toBe(group2.id); + expect(likes.received).toHaveLength(1); + expect(likes.received[0].groupId).toBe(group3.id); + }); + + test("returns empty given/received when no likes", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + const likes = await TournamentLFGRepository.allLikesByGroupId(group.id); + + expect(likes.given).toHaveLength(0); + expect(likes.received).toHaveLength(0); + }); +}); + +describe("morphGroups", () => { + beforeEach(async () => { + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("merges two groups, other group is deleted", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + + await TournamentLFGRepository.morphGroups({ + survivingGroupId: group1.id, + otherGroupId: group2.id, + maxGroupSize: 4, + }); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe(group1.id); + const members = groups[0].members; + expect(members).toHaveLength(2); + }); + + test("demotes other group's OWNER to MANAGER", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + + await TournamentLFGRepository.morphGroups({ + survivingGroupId: group1.id, + otherGroupId: group2.id, + maxGroupSize: 4, + }); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + const user2Member = members.find((m) => m.id === 2); + + expect(user2Member?.role).toBe("MANAGER"); + }); + + test("throws when merged size exceeds maxGroupSize", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + + await expect( + TournamentLFGRepository.morphGroups({ + survivingGroupId: group1.id, + otherGroupId: group2.id, + maxGroupSize: 1, + }), + ).rejects.toThrow("Group has too many members after merge"); + }); + + test("clears likes on surviving group after merge", async () => { + const tournament = await createTournament(); + const group1 = await createGroup(tournament.id, 1); + const group2 = await createGroup(tournament.id, 2); + const group3 = await createGroup(tournament.id, 3); + + await TournamentLFGRepository.addLike({ + likerGroupId: group1.id, + targetGroupId: group3.id, + }); + await TournamentLFGRepository.addLike({ + likerGroupId: group3.id, + targetGroupId: group1.id, + }); + + await TournamentLFGRepository.morphGroups({ + survivingGroupId: group1.id, + otherGroupId: group2.id, + maxGroupSize: 4, + }); + + const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id); + + expect(likes.given).toHaveLength(0); + expect(likes.received).toHaveLength(0); + }); +}); + +describe("updateMemberNote", () => { + beforeEach(async () => { + await dbInsertUsers(1); + }); + + afterEach(() => { + dbReset(); + }); + + test("sets and clears a member note", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + await TournamentLFGRepository.updateMemberNote({ + groupId: group.id, + userId: 1, + value: "Looking for support", + }); + + let groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + let members = groups[0].members; + expect(members[0].note).toBe("Looking for support"); + + await TournamentLFGRepository.updateMemberNote({ + groupId: group.id, + userId: 1, + value: null, + }); + + groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + members = groups[0].members; + expect(members[0].note).toBeNull(); + }); +}); + +describe("updateMemberRole", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("changes role from REGULAR to MANAGER", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + await db + .insertInto("TournamentLFGGroupMember") + .values({ + groupId: group.id, + tournamentId: tournament.id, + userId: 2, + role: "REGULAR", + }) + .execute(); + + await TournamentLFGRepository.updateMemberRole({ + userId: 2, + groupId: group.id, + role: "MANAGER", + }); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + const user2Member = members.find((m) => m.id === 2); + + expect(user2Member?.role).toBe("MANAGER"); + }); + + test("throws when attempting to set OWNER role", () => { + expect(() => + TournamentLFGRepository.updateMemberRole({ + userId: 1, + groupId: 1, + role: "OWNER", + }), + ).toThrow("Can't set role to OWNER with this function"); + }); +}); + +describe("updateStayAsSub", () => { + beforeEach(async () => { + await dbInsertUsers(1); + }); + + afterEach(() => { + dbReset(); + }); + + test("toggles isStayAsSub on/off", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + await TournamentLFGRepository.updateStayAsSub({ + groupId: group.id, + userId: 1, + value: true, + }); + + let subs = await TournamentLFGRepository.getSubsForTournament( + tournament.id, + ); + expect(subs).toContain(1); + + await TournamentLFGRepository.updateStayAsSub({ + groupId: group.id, + userId: 1, + value: false, + }); + + subs = await TournamentLFGRepository.getSubsForTournament(tournament.id); + expect(subs).not.toContain(1); + }); +}); + +describe("leaveGroup", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("removes member from group", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + await db + .insertInto("TournamentLFGGroupMember") + .values({ + groupId: group.id, + tournamentId: tournament.id, + userId: 2, + role: "REGULAR", + }) + .execute(); + + await TournamentLFGRepository.leaveGroup({ + userId: 2, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + + expect(members).toHaveLength(1); + expect(members[0].id).toBe(1); + }); + + test("deletes group when last member leaves", async () => { + const tournament = await createTournament(); + await createGroup(tournament.id, 1); + + await TournamentLFGRepository.leaveGroup({ + userId: 1, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(0); + }); + + test("promotes MANAGER to OWNER when owner leaves", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + await db + .insertInto("TournamentLFGGroupMember") + .values({ + groupId: group.id, + tournamentId: tournament.id, + userId: 2, + role: "MANAGER", + }) + .execute(); + + await db + .insertInto("TournamentLFGGroupMember") + .values({ + groupId: group.id, + tournamentId: tournament.id, + userId: 3, + role: "REGULAR", + }) + .execute(); + + await TournamentLFGRepository.leaveGroup({ + userId: 1, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + const newOwner = members.find((m) => m.id === 2); + + expect(newOwner?.role).toBe("OWNER"); + }); + + test("promotes first member to OWNER when owner leaves and no manager exists", async () => { + const tournament = await createTournament(); + const group = await createGroup(tournament.id, 1); + + await db + .insertInto("TournamentLFGGroupMember") + .values({ + groupId: group.id, + tournamentId: tournament.id, + userId: 2, + role: "REGULAR", + }) + .execute(); + + await TournamentLFGRepository.leaveGroup({ + userId: 1, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + + expect(members[0].role).toBe("OWNER"); + }); +}); + +describe("getSubsForTournament", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns userIds with isStayAsSub", async () => { + const tournament = await createTournament(); + await TournamentLFGRepository.createGroup({ + tournamentId: tournament.id, + userId: 1, + isStayAsSub: true, + }); + await createGroup(tournament.id, 2); + + const subs = await TournamentLFGRepository.getSubsForTournament( + tournament.id, + ); + + expect(subs).toEqual([1]); + }); + + test("returns empty when nobody opted in", async () => { + const tournament = await createTournament(); + await createGroup(tournament.id, 1); + + const subs = await TournamentLFGRepository.getSubsForTournament( + tournament.id, + ); + + expect(subs).toHaveLength(0); + }); +}); + +describe("cleanupForTournamentStart", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("deletes all groups for a tournament", async () => { + const tournament = await createTournament(); + await createGroup(tournament.id, 1); + await createGroup(tournament.id, 2); + + await TournamentLFGRepository.cleanupForTournamentStart(tournament.id); + + const groups = await TournamentLFGRepository.findGroupsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(0); + }); + + test("does not affect groups from other tournaments", async () => { + const tournament1 = await createTournament(); + const tournament2 = await createTournament(); + await createGroup(tournament1.id, 1); + await createGroup(tournament2.id, 2); + + await TournamentLFGRepository.cleanupForTournamentStart(tournament1.id); + + const groups1 = await TournamentLFGRepository.findGroupsByTournamentId( + tournament1.id, + ); + const groups2 = await TournamentLFGRepository.findGroupsByTournamentId( + tournament2.id, + ); + + expect(groups1).toHaveLength(0); + expect(groups2).toHaveLength(1); + }); +}); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index 66431f27b..59d85ba65 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -1,2 +1,361 @@ -// Phase 4: Implement repository functions here -// See SQGroupRepository.server.ts for pattern reference +import type { Transaction } from "kysely"; +import { jsonBuildObject } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import type { DB, Tables } from "~/db/tables"; +import { shortNanoid } from "~/utils/id"; +import invariant from "~/utils/invariant"; +import { userChatNameColorForJson } from "~/utils/kysely.server"; +import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; + +type CreateGroupArgs = { + tournamentId: number; + userId: number; + isStayAsSub?: boolean; +}; +export function createGroup(args: CreateGroupArgs) { + return db.transaction().execute(async (trx) => { + const createdGroup = await trx + .insertInto("TournamentLFGGroup") + .values({ + tournamentId: args.tournamentId, + chatCode: shortNanoid(), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .insertInto("TournamentLFGGroupMember") + .values({ + groupId: createdGroup.id, + tournamentId: args.tournamentId, + userId: args.userId, + role: "OWNER", + isStayAsSub: args.isStayAsSub ? 1 : 0, + }) + .execute(); + + return createdGroup; + }); +} + +type TournamentLFGMemberObject = { + id: Tables["User"]["id"]; + username: Tables["User"]["username"]; + discordId: Tables["User"]["discordId"]; + discordAvatar: Tables["User"]["discordAvatar"]; + customUrl: Tables["User"]["customUrl"]; + languages: Tables["User"]["languages"]; + vc: Tables["User"]["vc"]; + role: Tables["TournamentLFGGroupMember"]["role"]; + note: Tables["TournamentLFGGroupMember"]["note"]; + isStayAsSub: Tables["TournamentLFGGroupMember"]["isStayAsSub"]; + weapons: Tables["User"]["qWeaponPool"]; + chatNameColor: string | null; + plusTier: Tables["PlusTier"]["tier"] | null; +}; + +export async function findGroupsByTournamentId(tournamentId: number) { + return db + .selectFrom("TournamentLFGGroup") + .innerJoin( + "TournamentLFGGroupMember", + "TournamentLFGGroupMember.groupId", + "TournamentLFGGroup.id", + ) + .innerJoin("User", "User.id", "TournamentLFGGroupMember.userId") + .leftJoin("PlusTier", "PlusTier.userId", "User.id") + .select(({ fn, eb }) => [ + "TournamentLFGGroup.id", + "TournamentLFGGroup.chatCode", + "TournamentLFGGroup.tournamentTeamId", + "TournamentLFGGroup.visibility", + fn + .agg("json_group_array", [ + jsonBuildObject({ + id: eb.ref("User.id"), + username: eb.ref("User.username"), + discordId: eb.ref("User.discordId"), + discordAvatar: eb.ref("User.discordAvatar"), + customUrl: eb.ref("User.customUrl"), + languages: eb.ref("User.languages"), + vc: eb.ref("User.vc"), + role: eb.ref("TournamentLFGGroupMember.role"), + note: eb.ref("TournamentLFGGroupMember.note"), + isStayAsSub: eb.ref("TournamentLFGGroupMember.isStayAsSub"), + weapons: eb.ref("User.qWeaponPool"), + chatNameColor: userChatNameColorForJson, + plusTier: eb.ref("PlusTier.tier"), + }), + ]) + .$castTo() + .as("members"), + ]) + .where("TournamentLFGGroup.tournamentId", "=", tournamentId) + .groupBy("TournamentLFGGroup.id") + .execute(); +} + +export function morphGroups({ + survivingGroupId, + otherGroupId, + maxGroupSize, +}: { + survivingGroupId: number; + otherGroupId: number; + maxGroupSize: number; +}) { + return db.transaction().execute(async (trx) => { + await trx + .updateTable("TournamentLFGGroup") + .set({ chatCode: shortNanoid() }) + .where("TournamentLFGGroup.id", "=", survivingGroupId) + .execute(); + + const otherGroupMembers = await trx + .selectFrom("TournamentLFGGroupMember") + .select([ + "TournamentLFGGroupMember.userId", + "TournamentLFGGroupMember.role", + ]) + .where("TournamentLFGGroupMember.groupId", "=", otherGroupId) + .execute(); + + for (const member of otherGroupMembers) { + await trx + .updateTable("TournamentLFGGroupMember") + .set({ + role: member.role === "OWNER" ? "MANAGER" : member.role, + groupId: survivingGroupId, + }) + .where("TournamentLFGGroupMember.groupId", "=", otherGroupId) + .where("TournamentLFGGroupMember.userId", "=", member.userId) + .execute(); + } + + await deleteLikesByGroupId(survivingGroupId, trx); + + await trx + .deleteFrom("TournamentLFGGroup") + .where("TournamentLFGGroup.id", "=", otherGroupId) + .execute(); + + invariant( + await isGroupCorrect(survivingGroupId, trx, maxGroupSize), + "Group has too many members after merge", + ); + }); +} + +export async function addLike({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + try { + await db + .insertInto("TournamentLFGLike") + .values({ likerGroupId, targetGroupId }) + .onConflict((oc) => + oc.columns(["likerGroupId", "targetGroupId"]).doNothing(), + ) + .execute(); + } catch (error) { + if (errorIsSqliteForeignKeyConstraintFailure(error)) { + return; + } + throw error; + } +} + +export function deleteLike({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + return db + .deleteFrom("TournamentLFGLike") + .where("likerGroupId", "=", likerGroupId) + .where("targetGroupId", "=", targetGroupId) + .execute(); +} + +export async function allLikesByGroupId(groupId: number) { + const rows = await db + .selectFrom("TournamentLFGLike") + .select([ + "TournamentLFGLike.likerGroupId", + "TournamentLFGLike.targetGroupId", + ]) + .where((eb) => + eb.or([ + eb("TournamentLFGLike.likerGroupId", "=", groupId), + eb("TournamentLFGLike.targetGroupId", "=", groupId), + ]), + ) + .execute(); + + return { + given: rows + .filter((row) => row.likerGroupId === groupId) + .map((row) => ({ groupId: row.targetGroupId })), + received: rows + .filter((row) => row.targetGroupId === groupId) + .map((row) => ({ groupId: row.likerGroupId })), + }; +} + +export function updateMemberNote({ + groupId, + userId, + value, +}: { + groupId: number; + userId: number; + value: string | null; +}) { + return db + .updateTable("TournamentLFGGroupMember") + .set({ note: value }) + .where("groupId", "=", groupId) + .where("userId", "=", userId) + .execute(); +} + +export function updateMemberRole({ + userId, + groupId, + role, +}: { + userId: number; + groupId: number; + role: Tables["TournamentLFGGroupMember"]["role"]; +}) { + if (role === "OWNER") { + throw new Error("Can't set role to OWNER with this function"); + } + + return db + .updateTable("TournamentLFGGroupMember") + .set({ role }) + .where("userId", "=", userId) + .where("groupId", "=", groupId) + .execute(); +} + +export function updateStayAsSub({ + groupId, + userId, + value, +}: { + groupId: number; + userId: number; + value: boolean; +}) { + return db + .updateTable("TournamentLFGGroupMember") + .set({ isStayAsSub: value ? 1 : 0 }) + .where("groupId", "=", groupId) + .where("userId", "=", userId) + .execute(); +} + +export function leaveGroup({ + userId, + tournamentId, +}: { + userId: number; + tournamentId: number; +}) { + return db.transaction().execute(async (trx) => { + const userGroup = await trx + .selectFrom("TournamentLFGGroupMember") + .select([ + "TournamentLFGGroupMember.groupId", + "TournamentLFGGroupMember.role", + ]) + .where("TournamentLFGGroupMember.userId", "=", userId) + .where("TournamentLFGGroupMember.tournamentId", "=", tournamentId) + .executeTakeFirstOrThrow(); + + await trx + .deleteFrom("TournamentLFGGroupMember") + .where("userId", "=", userId) + .where("groupId", "=", userGroup.groupId) + .execute(); + + const remainingMembers = await trx + .selectFrom("TournamentLFGGroupMember") + .select(["userId", "role"]) + .where("groupId", "=", userGroup.groupId) + .execute(); + + if (remainingMembers.length === 0) { + await trx + .deleteFrom("TournamentLFGGroup") + .where("id", "=", userGroup.groupId) + .execute(); + return; + } + + if (userGroup.role === "OWNER") { + const newOwner = + remainingMembers.find((m) => m.role === "MANAGER") ?? + remainingMembers[0]; + + await trx + .updateTable("TournamentLFGGroupMember") + .set({ role: "OWNER" }) + .where("userId", "=", newOwner.userId) + .where("groupId", "=", userGroup.groupId) + .execute(); + } + }); +} + +export function cleanupForTournamentStart(tournamentId: number) { + return db + .deleteFrom("TournamentLFGGroup") + .where("TournamentLFGGroup.tournamentId", "=", tournamentId) + .execute(); +} + +export async function getSubsForTournament(tournamentId: number) { + const rows = await db + .selectFrom("TournamentLFGGroupMember") + .select("TournamentLFGGroupMember.userId") + .where("TournamentLFGGroupMember.tournamentId", "=", tournamentId) + .where("TournamentLFGGroupMember.isStayAsSub", "=", 1) + .execute(); + + return rows.map((row) => row.userId); +} + +function deleteLikesByGroupId(groupId: number, trx: Transaction) { + return trx + .deleteFrom("TournamentLFGLike") + .where((eb) => + eb.or([ + eb("TournamentLFGLike.likerGroupId", "=", groupId), + eb("TournamentLFGLike.targetGroupId", "=", groupId), + ]), + ) + .execute(); +} + +async function isGroupCorrect( + groupId: number, + trx: Transaction, + maxGroupSize: number, +): Promise { + const members = await trx + .selectFrom("TournamentLFGGroupMember") + .select("TournamentLFGGroupMember.userId") + .where("TournamentLFGGroupMember.groupId", "=", groupId) + .execute(); + + return members.length <= maxGroupSize; +} diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.server.ts b/app/features/tournament-lfg/tournament-lfg-schemas.server.ts index 664e68748..56ab4cb2c 100644 --- a/app/features/tournament-lfg/tournament-lfg-schemas.server.ts +++ b/app/features/tournament-lfg/tournament-lfg-schemas.server.ts @@ -33,10 +33,7 @@ export const lookingSchema = z.union([ value: z.preprocess( falsyToNull, // xxx: use SendouForm style? (but would need SQ to be migrated too) - z - .string() - .max(TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH) - .nullable(), + z.string().max(TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH).nullable(), ), }), z.object({ diff --git a/app/routes.ts b/app/routes.ts index 88468af24..be93c3bbc 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -108,6 +108,8 @@ export default [ route("results", "features/tournament/routes/to.$id.results.tsx"), route("streams", "features/tournament/routes/to.$id.streams.tsx"), + route("looking", "features/tournament-lfg/routes/to.$id.looking.tsx"), + route("subs", "features/tournament-subs/routes/to.$id.subs.tsx"), route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"), diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 7861ee43343bf086976acd388850e3eee8e9bf9f..87474164b4c63d53094d7e9eed90d3a07b370725 100644 GIT binary patch delta 2619 zcmds3|8Emz81H+%>-Fy1-i@(d*D-c2-!@oTyDh*5b?aa-k`0Ojw?SgvSi!PyrR{+E z3+=gp#9%-}9ulK`5i~*hA_cm{L{JlmQN9@wV^BBUPyS#6Mva7k@7?+pwm(2GdEQ*! ze%|MK?)f~==WR6F7>za^D9Zaxqd}4;{h*@<%d7C_!u<~^yqU)z#VeY5FenlH(fDbt z1H~_+F{1@jPPQb%ftL!Xs=1HZ3+18--1WjmD4Ad^8PcfuE%#E}?0fPu|M&zMklD3x|k=H=n<-OlxO~fcEkpl3C3Z#q+#3 z^&REANX_CZm#fAR85j)pclP=FBaYrJTNiE%S2`;fIh>V_N|)qv);gz?v_h#gWc!K__mgfbEL}`-{yhS2YKE{65$EbUBh+sCam%?50Jxn|N(^q_eBn zp95JH8W;?=`1`v2p{e$f1#bUJTFlsC7TPYAvJ!sa*j;Ki9Eo!jygaTefUn2NJQ%&e zl5qAMYlkCu4KtcR|I0$&~%9YM6qnjFA^d8n6l_|Avb@dczp^2+wU37 zvkK*6H&(-~d0IXEe8(Utdn=)CgquF#muqOT%0|SHS_Nh9d=qo$Q{MyK z8}L?}I*Eu~{(PfneWSE`&5Fi$X%eh7&@b6D2XILgkW!3ld5KP3WrJfyyrm<9@VPSY zh>_>Zi)qnjBZiCBie-MwydixuiS?d_=Ej_8QYqXzAy^^0i4ounFJotL`%ZiecxG!w zn3!i(BB>n5ox$Vv&g1njy;)CMSlX)LH=9NbZtgd( zfqh4RSl>#I&{iyxk99L+R(S<2wpI`^_av2K|rzNUWlhWN;kPbTss_IUm&MQBsOKEa%F6=1=vx61>U+<_{|BcJWnqmr58A)G<3ZD~ySN;X3U3a_y delta 687 zcmZo@@Mvgooggi^mw|y%1cXK$4MrVu8Zu)y(>g zoA)z5Gy!fS47C*?^cGh&h0m z6NtHhm>YV{$V`ArP<_nmv zVIVMd`voHbFUIZK<^oqaSQr?X`nSuO2pndtk7?3m6W5nzs?5wwNv%jM$c!(^&rZ#Y zFD*_jiqA}OEXWK2@fo?CgIpa$TopY1TwEg*Fw`mJ=P4+`6f0>cfwX(3C}}o%HL0${g0tQ#CBmpep@C+uFZ@J|K%4M z2td%p0Cq;E?Hm&Xe)4OxzG2{J-p{}v&9{x~4yP!4Jv%pBKkHYPT-L2DO3eFtV|X=r zHZZq Date: Sat, 21 Feb 2026 14:30:07 +0200 Subject: [PATCH 07/25] Add seed --- app/db/seed/index.ts | 135 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index a43cc2e32..d1b35b40d 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -42,6 +42,7 @@ import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepos import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import * as VodRepository from "~/features/vods/VodRepository.server"; @@ -223,6 +224,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ calendarEventWithToToolsLUTI, calendarEventWithToToolsTeamsLUTI, tournamentSubs, + variation === "NO_TOURNAMENT_TEAMS" ? undefined : tournamentLfgGroups, adminBuilds, manySplattershotBuilds, detailedTeam(variation), @@ -280,6 +282,9 @@ function wipeDB() { "MapPoolMap", "TournamentMatchGameResult", "TournamentTeamCheckIn", + "TournamentLFGLike", + "TournamentLFGGroupMember", + "TournamentLFGGroup", "TournamentTeam", "TournamentStage", "TournamentResult", @@ -1633,6 +1638,136 @@ function tournamentSubs() { return null; } +async function tournamentLfgGroups() { + const availableUsers = userIdsInAscendingOrderById().slice(300); + + const tournaments = [ + { tournamentId: 1, teamId: 1 }, + { tournamentId: 2, teamId: 116 }, + { tournamentId: 3, teamId: 201 }, + ]; + + const insertMember = sql.prepare( + /* sql */ `INSERT INTO "TournamentLFGGroupMember" ("groupId", "tournamentId", "userId", "role") VALUES (@groupId, @tournamentId, @userId, @role)`, + ); + const updateNote = sql.prepare( + /* sql */ `UPDATE "TournamentLFGGroupMember" SET "note" = @note WHERE "groupId" = @groupId AND "userId" = @userId`, + ); + const insertLike = sql.prepare( + /* sql */ `INSERT INTO "TournamentLFGLike" ("likerGroupId", "targetGroupId") VALUES (@likerGroupId, @targetGroupId)`, + ); + + let userIndex = 0; + for (const { tournamentId, teamId } of tournaments) { + const teamOwner = ( + sql + .prepare( + /* sql */ `SELECT "userId" FROM "TournamentTeamMember" WHERE "tournamentTeamId" = @teamId AND "isOwner" = 1`, + ) + .get({ teamId }) as { userId: number } + ).userId; + + const users = availableUsers.slice(userIndex, userIndex + 12); + userIndex += 12; + + // Group 1: solo, has note, isStayAsSub=1 + const group1 = await TournamentLFGRepository.createGroup({ + tournamentId, + userId: users[0], + isStayAsSub: true, + }); + updateNote.run({ + note: "Looking for a team, can play any role", + groupId: group1.id, + userId: users[0], + }); + + // Group 2: duo, one member has note + const group2 = await TournamentLFGRepository.createGroup({ + tournamentId, + userId: users[1], + }); + insertMember.run({ + groupId: group2.id, + tournamentId, + userId: users[2], + role: "REGULAR", + }); + updateNote.run({ + note: "Prefer backline weapons", + groupId: group2.id, + userId: users[2], + }); + + // Group 3: trio + const group3 = await TournamentLFGRepository.createGroup({ + tournamentId, + userId: users[3], + }); + for (const userId of [users[4], users[5]]) { + insertMember.run({ + groupId: group3.id, + tournamentId, + userId, + role: "REGULAR", + }); + } + + // Group 4: full (4 members) + const group4 = await TournamentLFGRepository.createGroup({ + tournamentId, + userId: users[6], + }); + for (const userId of [users[7], users[8], users[9]]) { + insertMember.run({ + groupId: group4.id, + tournamentId, + userId, + role: "REGULAR", + }); + } + + // Group 5: solo + const group5 = await TournamentLFGRepository.createGroup({ + tournamentId, + userId: users[10], + }); + + // Group 6: duo linked to existing team + const group6 = await TournamentLFGRepository.createGroup({ + tournamentId, + userId: teamOwner, + }); + sql + .prepare( + /* sql */ `UPDATE "TournamentLFGGroup" SET "tournamentTeamId" = @teamId WHERE "id" = @groupId`, + ) + .run({ teamId, groupId: group6.id }); + insertMember.run({ + groupId: group6.id, + tournamentId, + userId: users[11], + role: "REGULAR", + }); + + // Group 1 -> Group 2 (one-way like) + insertLike.run({ + likerGroupId: group1.id, + targetGroupId: group2.id, + }); + // Group 2 -> Group 1 (mutual — tests invitation UI) + insertLike.run({ + likerGroupId: group2.id, + targetGroupId: group1.id, + }); + // Group 3 -> Group 5 (one-way like) + insertLike.run({ + likerGroupId: group3.id, + targetGroupId: group5.id, + }); + } +} + const randomAbility = (legalTypes: AbilityType[]) => { const randomOrderAbilities = faker.helpers.shuffle([...abilities]); From ae9941e781c3fa5181563cb384a47c463980e19b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:12:26 +0200 Subject: [PATCH 08/25] LFGGroupCard initial --- .../TournamentLFGRepository.server.ts | 2 + .../components/LFGGroupCard.module.css | 91 ++++ .../components/LFGGroupCard.tsx | 387 +++++++++++++++++- locales/da/q.json | 1 + locales/de/q.json | 1 + locales/en/q.json | 1 + locales/es-ES/q.json | 1 + locales/es-US/q.json | 1 + locales/fr-CA/q.json | 1 + locales/fr-EU/q.json | 1 + locales/he/q.json | 1 + locales/it/q.json | 1 + locales/ja/q.json | 1 + locales/ko/q.json | 1 + locales/nl/q.json | 1 + locales/pl/q.json | 1 + locales/pt-BR/q.json | 1 + locales/ru/q.json | 1 + locales/zh/q.json | 1 + 19 files changed, 490 insertions(+), 6 deletions(-) create mode 100644 app/features/tournament-lfg/components/LFGGroupCard.module.css diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index 59d85ba65..6fa7689bd 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -46,6 +46,7 @@ type TournamentLFGMemberObject = { customUrl: Tables["User"]["customUrl"]; languages: Tables["User"]["languages"]; vc: Tables["User"]["vc"]; + pronouns: Tables["User"]["pronouns"]; role: Tables["TournamentLFGGroupMember"]["role"]; note: Tables["TournamentLFGGroupMember"]["note"]; isStayAsSub: Tables["TournamentLFGGroupMember"]["isStayAsSub"]; @@ -79,6 +80,7 @@ export async function findGroupsByTournamentId(tournamentId: number) { customUrl: eb.ref("User.customUrl"), languages: eb.ref("User.languages"), vc: eb.ref("User.vc"), + pronouns: eb.ref("User.pronouns"), role: eb.ref("TournamentLFGGroupMember.role"), note: eb.ref("TournamentLFGGroupMember.note"), isStayAsSub: eb.ref("TournamentLFGGroupMember.isStayAsSub"), diff --git a/app/features/tournament-lfg/components/LFGGroupCard.module.css b/app/features/tournament-lfg/components/LFGGroupCard.module.css new file mode 100644 index 000000000..7623dda0b --- /dev/null +++ b/app/features/tournament-lfg/components/LFGGroupCard.module.css @@ -0,0 +1,91 @@ +.group { + background-color: var(--bg-lighter-solid); + width: 100%; + border-radius: var(--rounded); + padding: var(--s-2-5); + display: flex; + flex-direction: column; + gap: var(--s-4); + position: relative; + color: var(--text); +} + +.member { + display: flex; + gap: var(--s-2); + align-items: center; + background-color: var(--bg-darker); + border-radius: var(--rounded); + font-size: var(--fonts-xsm); + font-weight: var(--semi-bold); + padding-inline-end: var(--s-2-5); +} + +.name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 7.5rem; + font-size: var(--fonts-xs); + color: var(--text); +} + +.avatar { + min-width: 36px; +} + +.extraInfo { + font-size: var(--fonts-xs); + background-color: var(--bg-darker); + border-radius: var(--rounded); + padding: var(--s-0-5) var(--s-1-5); + width: max-content; + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--semi-bold); + min-height: 24px; +} + +.extraInfoButton { + font-size: var(--fonts-xs); + background-color: var(--bg-darker); + border-radius: var(--rounded); + padding: var(--s-0-5) var(--s-1-5); + width: max-content; + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--semi-bold); + color: var(--text); + border: none; + min-height: 24px; +} + +.noteTextarea { + height: 4rem !important; +} + +.vcIcon { + height: 15px; + stroke-width: 2; +} + +.star { + min-width: 18px; + max-width: 18px; + color: var(--theme-secondary); + stroke-width: 2; +} + +.starInactive { + color: var(--text-lighter); +} + +.stayAsSub { + font-size: var(--fonts-xxs); + background-color: var(--bg-darker); + border-radius: var(--rounded); + padding: var(--s-0-5) var(--s-1-5); + font-weight: var(--semi-bold); +} diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index 6c461568f..30dee0ca0 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -1,11 +1,386 @@ -// Phase 5: Implement group card component here, or delete this and reuse GroupCard? -// See app/features/sendouq/components/GroupCard.tsx for pattern reference +import clsx from "clsx"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useFetcher } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { Image, WeaponImage } from "~/components/Image"; +import { MicrophoneIcon } from "~/components/icons/Microphone"; +import { SpeakerIcon } from "~/components/icons/Speaker"; +import { SpeakerXIcon } from "~/components/icons/SpeakerX"; +import { StarIcon } from "~/components/icons/Star"; +import { StarFilledIcon } from "~/components/icons/StarFilled"; +import { SubmitButton } from "~/components/SubmitButton"; +import type { Pronouns } from "~/db/tables"; +import { useUser } from "~/features/auth/core/user"; +import { languagesUnified } from "~/modules/i18n/config"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { navIconUrl, userPage } from "~/utils/urls"; +import { TOURNAMENT_LFG } from "../tournament-lfg-constants"; +import styles from "./LFGGroupCard.module.css"; + +export type LFGGroupMember = { + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + customUrl: string | null; + languages: string[]; + vc: "YES" | "NO" | "LISTEN_ONLY" | null; + pronouns: Pronouns | null; + role: "OWNER" | "MANAGER" | "REGULAR"; + note: string | null; + isStayAsSub: boolean; + weapons: Array<{ weaponSplId: MainWeaponId; isFavorite: boolean }> | null; + chatNameColor: string | null; + plusTier: number | null; +}; + +export type LFGGroup = { + id: number; + tournamentTeamId: number | null; + members: LFGGroupMember[]; + usersRole: "OWNER" | "MANAGER" | "REGULAR" | null; +}; + +export function LFGGroupCard({ + group, + action, + ownGroup, +}: { + group: LFGGroup; + action?: "LIKE" | "UNLIKE" | "ACCEPT"; + ownGroup?: LFGGroup; +}) { + const { t } = useTranslation(["common", "q"]); + const fetcher = useFetcher(); + + const isOwnGroup = group.id === ownGroup?.id; + const showActions = isOwnGroup && group.usersRole === "OWNER"; -export function LFGGroupCard({ group }: { group: any }) { return ( -
- Group {group.id} - {group.members.length} members +
+
+ {group.members.map((member) => ( + + ))} +
+ {action && + (ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? ( + + + + {action === "LIKE" + ? t("q:looking.groups.actions.invite") + : action === "ACCEPT" + ? t("common:actions.accept") + : t("q:looking.groups.actions.undo")} + + + ) : null} +
+ ); +} + +function LFGGroupMemberRow({ + member, + showActions, + isOwnGroup, +}: { + member: LFGGroupMember; + showActions: boolean; + isOwnGroup: boolean; +}) { + const { t } = useTranslation(["q"]); + const user = useUser(); + + return ( +
+
+
+ + + {member.username} + + {member.pronouns ? ( + + {member.pronouns.subject}/{member.pronouns.object} + + ) : null} +
+
+ {showActions || (!showActions && member.role === "OWNER") ? ( + + ) : null} +
+
+
+
+ {member.vc ? ( +
+ +
+ ) : null} + {member.plusTier ? ( +
+ + {member.plusTier} +
+ ) : null} + {member.isStayAsSub ? ( +
+ {t("q:looking.groups.stayAsSub")} +
+ ) : null} +
+ {member.weapons && member.weapons.length > 0 ? ( +
+ {member.weapons.map((weapon) => ( + + ))} +
+ ) : null} +
+ {isOwnGroup ? ( + + ) : null}
); } + +function LFGMemberNote({ + note, + editable, +}: { + note: string | null; + editable: boolean; +}) { + const { t } = useTranslation(["common", "q"]); + const [editing, setEditing] = React.useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies(note): when note updates exit editing mode + React.useEffect(() => { + setEditing(false); + }, [note]); + + if (editing) { + return setEditing(false)} />; + } + + if (note) { + return ( +
+ {note}{" "} + {editable ? ( + setEditing(true)} + className="mt-2 ml-auto" + > + {t("q:looking.groups.editNote")} + + ) : null} +
+ ); + } + + if (!editable) return null; + + return ( + setEditing(true)} + > + {t("q:looking.groups.addNote")} + + ); +} + +function LFGAddNoteForm({ + note, + stopEditing, +}: { + note: string | null; + stopEditing: () => void; +}) { + const fetcher = useFetcher(); + const textareaRef = React.useRef(null); + const { t } = useTranslation(["common"]); + const [value, setValue] = React.useState(note ?? ""); + + const newValueLegal = value.length <= TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH; + + React.useEffect(() => { + if (!textareaRef.current) return; + textareaRef.current.focus(); + textareaRef.current.selectionStart = textareaRef.current.selectionEnd = + textareaRef.current.value.length; + }, []); + + return ( + +