From 5e5d92baccd7350a4c89e1800cc32c8da3c4b612 Mon Sep 17 00:00:00 2001 From: VortexUK Date: Tue, 14 Jul 2026 11:12:41 +0100 Subject: [PATCH] feat(raids): dedicated Raid Alts section with claim-driven ownership captions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raid alts move out of the Unassigned bench into their own strip below it (sitout stays strictly for benched raiders). Each alt shows who owns it — "Menludiir's alt" with a hover tooltip — resolved via approved claims: the owner's raider placed in a group is preferred, then any of their raiders, falling back to their Discord display name; unclaimed alts get no caption. Alts still drag into groups as substitutes and return to the strip automatically when unassigned. Co-Authored-By: Claude Fable 5 --- .../pages/guild/raidplanner/RaidPlanner.tsx | 45 ++++++++++++++++--- .../src/pages/guild/raidplanner/logic.test.ts | 31 ++++++++++++- frontend/src/pages/guild/raidplanner/logic.ts | 36 +++++++++++++++ 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx b/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx index 3142f24b..efdac8fd 100644 --- a/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx +++ b/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx @@ -13,6 +13,7 @@ import { Badge, Button, SectionLabel } from '../../../components/ui' import { toErrorMessage } from '../../../lib/errors' import { useClasses } from '../../../useClasses' import { + altOwnerLabel, buildGrid, computeWarnings, moveCharacter, @@ -394,9 +395,7 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: { >
Unassigned - - raiders without a spot{altBench.length > 0 ? ' · raid alts listed after' : ''} - + raiders without a spot {isOfficer && (
+ {/* raid alts: alternate characters of players usually already in the raid */} + {(altBench.length > 0 || roled.some(r => r.role === 'raid_alt')) && ( +
applyMove(selected, { kind: 'bench' }) : undefined} + className={`border border-dashed border-border rounded-md p-2 ${selected ? 'hover:border-gold cursor-pointer' : ''}`} + > +
+ Raid Alts + alternate characters — drag one in to swap a player's toon +
+
+ {altBench.map(r => { + const owner = altOwnerLabel(r.name, data.players, data.roster, placements) + return ( +
+ {chipFor(r.name)} + {owner && ( +

+ {owner}'s alt +

+ )} +
+ ) + })} + {altBench.length === 0 && ( + + every raid alt is placed + + )} +
+
+ )} + {/* officer: manage which guild members are raiders / alts */} {isOfficer && managing && (
diff --git a/frontend/src/pages/guild/raidplanner/logic.test.ts b/frontend/src/pages/guild/raidplanner/logic.test.ts index 8fb4d3fd..7e3374d8 100644 --- a/frontend/src/pages/guild/raidplanner/logic.test.ts +++ b/frontend/src/pages/guild/raidplanner/logic.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { ClassInfo } from '../../../useClasses' -import { buildGrid, computeWarnings, moveCharacter, nextRaidDate, placementsEqual, swapGroups } from './logic' +import { altOwnerLabel, buildGrid, computeWarnings, moveCharacter, nextRaidDate, placementsEqual, swapGroups } from './logic' import type { Placement } from './types' const P = (name: string, group: number | null, slot: number | null, sitout = false): Placement => ({ @@ -153,3 +153,32 @@ describe('placementsEqual', () => { expect(placementsEqual(a, [P('Tanky', 1, 1), P('Healy', null, null, true)])).toBe(false) }) }) + +describe('altOwnerLabel', () => { + const roster = [ + { name: 'Menludiir', role: 'raider' as const }, + { name: 'Menwardiir', role: 'raid_alt' as const }, + { name: 'Menthird', role: 'raider' as const }, + { name: 'Stranger', role: 'raider' as const }, + ] + const players = { menludiir: 'Ben', menwardiir: 'Ben', menthird: 'Ben', stranger: 'Sue' } + + it("names the owner's placed raider first", () => { + const owner = altOwnerLabel('Menwardiir', players, roster, [P('Menthird', 1, 0)]) + expect(owner).toBe('Menthird') + }) + + it("falls back to the owner's unplaced raider", () => { + const owner = altOwnerLabel('Menwardiir', players, roster, []) + expect(owner).toBe('Menludiir') + }) + + it('falls back to the player display name when they have no other character', () => { + const owner = altOwnerLabel('Menwardiir', { menwardiir: 'Ben' }, [{ name: 'Menwardiir', role: 'raid_alt' }], []) + expect(owner).toBe('Ben') + }) + + it('returns null for an unclaimed alt', () => { + expect(altOwnerLabel('Menwardiir', {}, roster, [])).toBeNull() + }) +}) diff --git a/frontend/src/pages/guild/raidplanner/logic.ts b/frontend/src/pages/guild/raidplanner/logic.ts index 84596122..0f9c9bea 100644 --- a/frontend/src/pages/guild/raidplanner/logic.ts +++ b/frontend/src/pages/guild/raidplanner/logic.ts @@ -185,3 +185,39 @@ export function placementsEqual(a: Placement[], b: Placement[]): boolean { const sb = [...b].map(key).sort() return sa.length === sb.length && sa.every((v, i) => v === sb[i]) } + +// ── Raid-alt ownership ─────────────────────────────────────────────────────── + +/** + * Who owns a raid alt, for the "Menwardiir — Menludiir's alt" caption. + * Resolution: the alt's claiming player (via `players`), then that player's + * OTHER rostered character — preferring one currently placed in a group, + * then any raider, then any other roled character. Falls back to the + * player's display name when they have no other character on the roster. + * Returns null when the alt has no claim (owner unknown). + */ +export function altOwnerLabel( + altName: string, + players: Record, + roster: { name: string; role: string | null }[], + placements: Placement[], +): string | null { + const player = players[altName.toLowerCase()] + if (!player) return null + + const placedLower = new Set( + placements.filter(p => !p.sitout && p.group_num !== null).map(p => p.character_name.toLowerCase()), + ) + const siblings = roster.filter( + r => + r.role && + r.name.toLowerCase() !== altName.toLowerCase() && + players[r.name.toLowerCase()] === player, + ) + const ranked = [...siblings].sort((a, b) => { + const score = (r: { name: string; role: string | null }) => + (placedLower.has(r.name.toLowerCase()) ? 0 : 2) + (r.role === 'raider' ? 0 : 1) + return score(a) - score(b) + }) + return ranked[0]?.name ?? player +}