Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions frontend/src/pages/guild/raidplanner/RaidPlanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -394,9 +395,7 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: {
>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-heading text-text text-[0.85rem]">Unassigned</span>
<span className="text-[0.68rem] text-text-muted">
raiders without a spot{altBench.length > 0 ? ' Β· raid alts listed after' : ''}
</span>
<span className="text-[0.68rem] text-text-muted">raiders without a spot</span>
{isOfficer && (
<button
type="button"
Expand All @@ -411,15 +410,47 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: {
{grid.bench.map(n => (
<div key={n}>{chipFor(n)}</div>
))}
{altBench.map(r => (
<div key={r.name}>{chipFor(r.name)}</div>
))}
{grid.bench.length === 0 && altBench.length === 0 && (
{grid.bench.length === 0 && (
<span className="text-[0.68rem] text-text-muted/50 px-1 py-2 select-none">everyone is placed</span>
)}
</div>
</div>

{/* raid alts: alternate characters of players usually already in the raid */}
{(altBench.length > 0 || roled.some(r => r.role === 'raid_alt')) && (
<div
onDragOver={allowDrop}
onDrop={dropHandler({ kind: 'bench' })}
onClick={selected ? () => applyMove(selected, { kind: 'bench' }) : undefined}
className={`border border-dashed border-border rounded-md p-2 ${selected ? 'hover:border-gold cursor-pointer' : ''}`}
>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-heading text-text text-[0.85rem]">Raid Alts</span>
<span className="text-[0.68rem] text-text-muted">alternate characters β€” drag one in to swap a player's toon</span>
</div>
<div className="grid gap-1 grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 min-h-[38px]">
{altBench.map(r => {
const owner = altOwnerLabel(r.name, data.players, data.roster, placements)
return (
<div key={r.name} title={owner ? `${r.name} is ${owner}'s raid alt` : undefined}>
{chipFor(r.name)}
{owner && (
<p className="text-[0.62rem] text-text-muted leading-tight pl-2.5 -mt-0.5 truncate">
{owner}'s alt
</p>
)}
</div>
)
})}
{altBench.length === 0 && (
<span className="text-[0.68rem] text-text-muted/50 px-1 py-2 select-none">
every raid alt is placed
</span>
)}
</div>
</div>
)}

{/* officer: manage which guild members are raiders / alts */}
{isOfficer && managing && (
<div className="border border-border rounded-md p-2 flex flex-col gap-2">
Expand Down
31 changes: 30 additions & 1 deletion frontend/src/pages/guild/raidplanner/logic.test.ts
Original file line number Diff line number Diff line change
@@ -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 => ({
Expand Down Expand Up @@ -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()
})
})
36 changes: 36 additions & 0 deletions frontend/src/pages/guild/raidplanner/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
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
}
Loading