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
+}