Add event management features to API, including event cancellation, deletion, registration closure, and workflow balance generation. Introduce new roster management endpoints and enhance participant handling with avoided player preferences. Update database schema and service logic to support these changes.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 02:10:02 +03:00
parent 59c0bdb345
commit 4b889fb8a0
30 changed files with 2766 additions and 149 deletions

View File

@@ -13,6 +13,8 @@ describe('Mixmaker frontend', () => {
render(<App />)
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live')
expect(screen.getByRole('link', { name: 'Open Cancelled Community Mix' })).toHaveClass('status-cancelled')
})
it('renders the Discord login screen', async () => {
@@ -20,4 +22,20 @@ describe('Mixmaker frontend', () => {
render(<App />)
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
})
it('locks RSVP and exposes live navigation after the scrim starts', async () => {
window.history.replaceState({}, '', '/events/event-3')
render(<App />)
expect(await screen.findByRole('link', { name: /open live series/i })).toHaveAttribute('href', '/series/series-1')
expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled()
expect(screen.getByText('Registration has been closed by the organizer.')).toBeInTheDocument()
})
it('explains map selection and captain rules in the FAQ', async () => {
window.history.replaceState({}, '', '/faq')
render(<App />)
expect(await screen.findByRole('heading', { name: 'Frequently asked questions' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Who picks later maps?' })).toBeInTheDocument()
expect(screen.getByText(/team that lost that map immediately chooses/i)).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import {
CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
@@ -11,7 +11,7 @@ import {
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
type PlayerRole, type Registration, type RsvpStatus, type Series, type Session, type Team,
type Player, type PlayerRole, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
} from './api/client'
import {
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
@@ -31,6 +31,7 @@ const protectedRoute = createRoute({
})
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
const liveRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/series/$seriesId', component: LiveSeriesPage })
@@ -41,13 +42,14 @@ const adminRoute = createRoute({
beforeLoad: ({ context }) => { if (context.session?.account.role !== 'admin') throw redirect({ to: '/events' }) },
})
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
indexRoute, profileRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
])])
const router = createRouter({ routeTree, context: { session: null } })
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
const mainNavItems = [
{ to: '/events', label: 'Events', icon: CalendarDays },
{ to: '/faq', label: 'FAQ', icon: CircleHelp },
...(demoMode ? [
{ to: '/series/$seriesId' as const, params: { seriesId: 'series-1' }, label: 'Live', icon: Radio },
{ to: '/bracket/$eventId' as const, params: { eventId: 'bracket-1' }, label: 'Bracket', icon: Trophy },
@@ -60,7 +62,31 @@ function Brand() {
function AppShell() {
const [open, setOpen] = useState(false)
const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
const client = useQueryClient()
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
useEffect(() => subscribeToEvents('*', (message) => {
const [scope, id] = message.topic.split(':', 2)
if (scope === 'event' && id) {
void client.invalidateQueries({ queryKey: ['event', id] })
void client.invalidateQueries({ queryKey: ['registrations', id] })
void client.invalidateQueries({ queryKey: ['roster', id] })
void client.invalidateQueries({ queryKey: ['teams', id] })
void client.invalidateQueries({ queryKey: ['tournament', id] })
void client.invalidateQueries({ queryKey: ['events'] })
} else if (scope === 'series' && id) {
void client.invalidateQueries({ queryKey: ['series', id] })
} else if (scope === 'tournament') {
void client.invalidateQueries({ queryKey: ['tournament'] })
} else if (scope === 'team') {
void client.invalidateQueries({ queryKey: ['teams'] })
} else if (message.topic === 'events') {
void client.invalidateQueries({ queryKey: ['events'] })
} else if (message.topic === 'players') {
void client.invalidateQueries({ queryKey: ['players'] })
void client.invalidateQueries({ queryKey: ['profile'] })
}
}, setRealtimeStatus), [client])
const logout = useMutation({
mutationFn: api.logout,
onSuccess: () => {
@@ -76,7 +102,7 @@ function AppShell() {
<header className="topbar">
<Brand />
<nav className="desktop-nav" aria-label="Primary navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon />{item.label}</Link>)}</nav>
<div className="account"><LanguageSelector /><span className="live-dot" title="Realtime connected" /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
<div className="account"><LanguageSelector /><span className={`live-dot ${realtimeStatus}`} title={realtimeStatus === 'connected' ? 'Realtime connected' : 'Realtime reconnecting'} /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
</header>
{open && <nav className="mobile-nav" aria-label="Mobile navigation">{[...navItems, { to: '/profile' as const, label: 'Profile', icon: UserRound }].map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} onClick={() => setOpen(false)}><Icon />{item.label}</Link>)}</nav>}
<main><Outlet /></main>
@@ -95,7 +121,7 @@ function LoginPage() {
function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
return <header className="page-header"><div><span className="eyebrow">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>{actions && <div className="page-actions">{actions}</div>}</header>
}
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
function ErrorState({ retry }: { retry?: () => void }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>Your data is safe. Check your connection and try again.</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
@@ -110,13 +136,17 @@ function EventsPage() {
<PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} />
{demoMode && <section className="featured-event"><div><Badge tone="live"><Radio />Live now</Badge><h2>{demoEvents[2].title}</h2><p>Semifinal 1 · Map 2 draft in progress</p></div><div className="featured-score"><span>EMBER</span><strong>1<i>:</i>0</strong><span>AZURE</span></div><Link className="button primary" to="/watch/$seriesId" params={{ seriesId: 'series-1' }}>Watch live <ChevronRight /></Link></section>}
<div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div>
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.filter((e) => e.status !== 'live').map((event) => <EventCard key={event.id} event={event} />)}</div>}
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.map((event) => <EventCard key={event.id} event={event} />)}</div>}
</div>
}
function EventCard({ event }: { event: MixEvent }) {
const date = new Date(event.startsAt)
return <article className="card event-card"><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta"><Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge><span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><Link to="/events/$eventId" params={{ eventId: event.id }}>View event <ChevronRight /></Link></div></div></article>
const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge>
: event.status === 'completed' ? <Badge tone="success"><Check />Completed</Badge>
: event.status === 'live' ? <Badge tone="live"><Radio />LIVE</Badge>
: <Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge>
return <Link className={`card event-card status-${event.status}`} to="/events/$eventId" params={{ eventId: event.id }} aria-label={`Open ${event.title}`}><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta">{statusBadge}<span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><span className="event-open">View event <ChevronRight /></span></div></div></Link>
}
function EventPage() {
@@ -134,24 +164,34 @@ function EventPage() {
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
},
})
useEffect(() => subscribeToEvents(`event:${eventId}`, () => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
}), [client, eventId])
const labels: Record<RsvpStatus, string> = { going: 'Going', maybe: 'Maybe', not_going: 'Cant go' }
if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return <LoadingState label="Loading event…" />
if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return <ErrorState retry={() => { void eventQuery.refetch(); void registrations.refetch(); void session.refetch() }} />
const event = eventQuery.data
const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp
const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts
const attendanceTotal = Object.values(counts).reduce((total, count) => total + count, 0)
const statusOrder: Record<RsvpStatus, number> = { going: 0, maybe: 1, not_going: 2 }
const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName))
return <div className="page"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</Badge>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>Your latest response is sent to the organizer.</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.activeSeriesId && <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.activeSeriesId }}>Open live series</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
}
function FAQPage() {
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.</p></div></section>
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Cant go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.</p></div></section>
<section className="card faq-card"><span>04</span><div><h2>How is the first map selected?</h2><p>The initial coin toss decides which team bans first. Teams alternate map bans until one map remains. That map becomes the first map of the series.</p></div></section>
<section className="card faq-card"><span>05</span><div><h2>Who picks later maps?</h2><p>After a captain or admin records a map result, the team that lost that map immediately chooses the next map from the next ruleset pool. Already played maps are unavailable. After a draw, the initial coin-toss loser receives the pick.</p></div></section>
<section className="card faq-card"><span>06</span><div><h2>How do hero bans work?</h2><p>Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A teams two bans must use different roles, and a team cannot repeat its own hero ban during the same series. It may ban a hero previously banned by the opponent.</p></div></section>
<section className="card faq-card"><span>07</span><div><h2>How does Bo3 scoring work?</h2><p>The first team to win two maps wins the series. A captain selects the result after each map; admins can correct mistakes. Tournament winners advance automatically.</p></div></section>
<section className="card faq-card"><span>08</span><div><h2>What can captains do?</h2><p>Captains can perform actions for their team: map bans, later-map picks, and hero bans. Admins can perform or correct any authoritative action. Every command is versioned and recorded in the match history.</p></div></section>
</div></div>
}
function ProfilePage() {
const client = useQueryClient()
const language = useLanguage()
@@ -161,15 +201,24 @@ function ProfilePage() {
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
useEffect(() => {
if (profile.data) {
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
setPreferredRoles(profile.data.preferredRoles)
setPreferredPlayerIds(profile.data.preferredPlayerIds)
setAvoidedPlayerIds(profile.data.avoidedPlayerIds)
}
}, [profile.data])
useEffect(() => {
if (players.data) {
const eligible = new Set(players.data.filter((player) => Boolean(player.accountId)).map((player) => player.id))
setPreferredPlayerIds((current) => current.filter((id) => eligible.has(id)))
setAvoidedPlayerIds((current) => current.filter((id) => eligible.has(id)))
}
}, [players.data])
const update = useMutation({
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds),
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
onSuccess: (player) => {
client.setQueryData(['profile'], player)
void client.invalidateQueries({ queryKey: ['session'] })
@@ -180,7 +229,8 @@ function ProfilePage() {
if (!profile.data || !players.data) return <LoadingState label="Loading profile…" />
const roles: Array<{ key: PlayerRole; label: string; hint: string }> = [{ key: 'tank', label: 'Tank', hint: 'Space, pressure, and frontline' }, { key: 'damage', label: 'Damage', hint: 'Eliminations and map control' }, { key: 'support', label: 'Support', hint: 'Sustain, utility, and tempo' }]
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{players.data.filter((player) => player.id !== profile.data.id).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
}
type AdminTab = 'events' | 'participants' | 'balance'
@@ -193,7 +243,7 @@ function AdminPage() {
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
if (events.isLoading) return <LoadingState label="Loading admin control room…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
}
function toLocalInput(value: string) {
@@ -214,8 +264,9 @@ function eventInput(event?: MixEvent): EventInput {
}
}
function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event: MixEvent) => void }) {
function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreated: (event: MixEvent) => void; onDeleted: () => void }) {
const client = useQueryClient()
const language = useLanguage()
const initialForm = useMemo(() => eventInput(event), [event])
const [form, setForm] = useState<EventInput>(initialForm)
useEffect(() => setForm(initialForm), [initialForm])
@@ -229,8 +280,40 @@ function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event:
onCreated(saved)
},
})
const cancel = useMutation({
mutationFn: () => {
if (!event) return Promise.reject(new Error('Event is required'))
if (demoMode) return Promise.resolve({ ...event, status: 'cancelled' as const, workflowState: 'Cancelled' as const, version: event.version + 1 })
return api.cancelEvent(event.id, event.version)
},
onSuccess: (cancelled) => {
client.setQueryData<MixEvent[]>(['events'], (current) => current?.map((item) => item.id === cancelled.id ? cancelled : item))
void client.invalidateQueries({ queryKey: ['events'] })
void client.invalidateQueries({ queryKey: ['event', cancelled.id] })
onCreated(cancelled)
},
})
const remove = useMutation({
mutationFn: async () => {
if (!event) return
if (!demoMode) await api.deleteEvent(event.id)
},
onSuccess: () => {
client.setQueryData<MixEvent[]>(['events'], (current) => current?.filter((item) => item.id !== event?.id))
void client.invalidateQueries({ queryKey: ['events'] })
onDeleted()
},
})
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
return <div className="admin-grid"><form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}><div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <Badge tone="success">Published</Badge>}</div><div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => set('startsAt', e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div><label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label><div className="form-footer"><p>{save.isError ? save.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div></form><aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside></div>
return <div className="admin-grid">
<form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}>
<div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</div>
<div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => set('startsAt', e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div>
<label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label>
<div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div>
</form>
<aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside>
</div>
}
function Participants({ eventId }: { eventId: string }) {
@@ -254,6 +337,7 @@ function Participants({ eventId }: { eventId: string }) {
ratings: { ...ratings, updatedAt: now },
preferredRoles: [],
preferredPlayerIds: [],
avoidedPlayerIds: [],
},
status,
updatedAt: now,
@@ -275,13 +359,122 @@ function Participants({ eventId }: { eventId: string }) {
void client.invalidateQueries({ queryKey: ['events'] })
},
})
const remove = useMutation({
mutationFn: (playerId: string) => demoMode ? Promise.resolve() : api.removeParticipant(eventId, playerId),
onSuccess: (_, playerId) => {
client.setQueryData<Registration[]>(['registrations', eventId], (current) => current?.filter((registration) => registration.player.id !== playerId) ?? [])
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
},
})
if (rows.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section>
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending || remove.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
}
function BalanceView({ eventId }: { eventId: string }) {
if (demoMode) return <LegacyBalanceView eventId={eventId} />
return <WorkflowControl eventId={eventId} />
}
function WorkflowControl({ eventId }: { eventId: string }) {
const client = useQueryClient()
const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId) })
const players = useQuery({ queryKey: ['players'], queryFn: api.players })
const roster = useQuery({
queryKey: ['roster', eventId],
queryFn: () => api.roster(eventId),
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(event.data.workflowState)),
retry: false,
})
const [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
const [selectedCandidate, setSelectedCandidate] = useState(0)
const refresh = useCallback(() => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
void client.invalidateQueries({ queryKey: ['roster', eventId] })
void client.invalidateQueries({ queryKey: ['teams', eventId] })
}, [client, eventId])
const close = useMutation({
mutationFn: () => api.closeRegistration(eventId, event.data!.rulesetId, event.data!.version),
onSuccess: refresh,
onError: refresh,
})
const generate = useMutation({
mutationFn: () => api.generateWorkflowBalance(eventId, event.data!.version),
onSuccess: (result) => {
setCandidates(result.candidates)
setSelectedCandidate(0)
refresh()
},
onError: refresh,
})
const select = useMutation({
mutationFn: () => api.selectWorkflowBalance(eventId, candidates[selectedCandidate], event.data!.version),
onSuccess: refresh,
onError: refresh,
})
const confirm = useMutation({
mutationFn: () => api.confirmRosters(eventId, event.data!.version, roster.data!.version),
onSuccess: refresh,
onError: refresh,
})
const start = useMutation({
mutationFn: () => api.startScrim(eventId, event.data!.version),
onSuccess: (result) => {
refresh()
if (result.series) void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } })
else if (result.tournament) void router.navigate({ to: '/bracket/$eventId', params: { eventId } })
},
onError: refresh,
})
if (event.isLoading || players.isLoading) return <LoadingState label="Loading scrim workflow…" />
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
const state = event.data.workflowState
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error
return <div className="workflow-control">
<section className="card workflow-progress">
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].indexOf(state)
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
})}
</section>
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
{roster.data && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div></section>}
{error && <p className="error-note">{error.message}</p>}
</div>
}
function WorkflowAction({ title, description, button, pending, onClick }: { title: string; description: string; button: string; pending: boolean; onClick: () => void }) {
return <section className="card workflow-action"><div><h2>{title}</h2><p>{description}</p></div><button className="button primary" disabled={pending} onClick={onClick}>{pending ? 'Working…' : button}</button></section>
}
function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) {
const language = useLanguage()
const names = new Map(players.map((player) => [player.id, player.displayName]))
const mutate = useMutation({
mutationFn: (action: { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string } | { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string } | { kind: 'captain'; teamId: string; playerId: string }) => {
if (action.kind === 'swap') return api.swapRoster(eventId, { ...action, expectedVersion: roster.version })
if (action.kind === 'substitute') return api.substituteRoster(eventId, { ...action, expectedVersion: roster.version })
return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version)
},
onSuccess: onChanged,
onError: onChanged,
})
return <section className="card roster-editor"><div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · swaps are restricted to the same role.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot) => {
const swapOptions = roster.teams.flatMap((other) => other.id === team.id ? [] : other.slots.filter((candidate) => candidate.role === slot.role).map((candidate) => ({ ...candidate, teamId: other.id })))
return <div className="roster-slot" key={slot.playerId}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span><strong>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <><select aria-label={`Swap ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => { const [teamBId, playerBId] = event.target.value.split(':'); if (playerBId) mutate.mutate({ kind: 'swap', teamAId: team.id, playerAId: slot.playerId, teamBId, playerBId }) }}><option value="">Swap</option>{swapOptions.map((candidate) => <option key={candidate.playerId} value={`${candidate.teamId}:${candidate.playerId}`}>{names.get(candidate.playerId)}</option>)}</select>{roster.reserve.length > 0 && <select aria-label={`Substitute ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => event.target.value && mutate.mutate({ kind: 'substitute', teamId: team.id, outgoingPlayerId: slot.playerId, reservePlayerId: event.target.value })}><option value="">Reserve</option>{roster.reserve.map((id) => <option value={id} key={id}>{names.get(id) ?? id}</option>)}</select>}</>}</div>
})}</article>)}</div>{roster.reserve.length > 0 && <div className="reserve-bar"><strong>Reserve</strong>{roster.reserve.map((id) => <Badge key={id}>{names.get(id) ?? id}</Badge>)}</div>}{mutate.isError && <p className="error-note">{mutate.error.message}</p>}</section>
}
function LegacyBalanceView({ eventId }: { eventId: string }) {
const client = useQueryClient()
const existingTeams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
const generate = useMutation({ mutationFn: () => api.generateBalance(eventId) })
@@ -314,24 +507,45 @@ function LiveSeriesPage() {
const { seriesId } = liveRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const [selected, setSelected] = useState<string>()
const [mapName, setMapName] = useState('')
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
const result = useMutation({
mutationFn: () => api.recordResult(seriesId, mapName, outcome, series.data!.version),
mutationFn: () => {
const data = series.data!
if (demoMode) return api.recordResult(seriesId, `Map ${data.maps.length + 1}`, outcome, data.version)
const actingTeamId = session.data?.account.role === 'admin'
? data.teamAlpha.id
: data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id
: data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : ''
return api.recordSeriesMap(seriesId, outcome, actingTeamId, data.version)
},
onSuccess: (updated) => {
client.setQueryData(['series', seriesId], updated)
setMapName('')
},
onError: () => void series.refetch(),
})
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => {
void client.invalidateQueries({ queryKey: ['series', seriesId] })
}), [client, seriesId])
if (series.isLoading) return <LoadingState />
if (series.isError) return <ErrorState retry={() => void series.refetch()} />
if (!series.data) return <LoadingState />
const action = useMutation({
mutationFn: () => {
const data = series.data!
const teamId = data.currentStep.activeTeam === 'beta' ? data.teamBeta.id : data.teamAlpha.id
if (data.currentStep.kind === 'coin_toss') return api.tossSeriesCoin(seriesId, `${Date.now()}-${crypto.randomUUID()}`, teamId, data.version)
if (data.currentStep.kind === 'map_ban') return api.banSeriesMap(seriesId, teamId, selected!, data.version)
if (data.currentStep.kind === 'map_pick') return api.pickSeriesMap(seriesId, teamId, selected!, data.version)
if (data.currentStep.kind === 'hero_ban') return api.banSeriesHero(seriesId, teamId, selected!, data.version)
return Promise.resolve(data)
},
onSuccess: (updated) => {
client.setQueryData(['series', seriesId], updated)
setSelected(undefined)
},
onError: () => void series.refetch(),
})
if (series.isLoading || session.isLoading) return <LoadingState />
if (series.isError || session.isError) return <ErrorState retry={() => { void series.refetch(); void session.refetch() }} />
if (!series.data || !session.data) return <LoadingState />
const data = series.data
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Map name<input required value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="e.g. Lijiang Tower" /></label><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={!selected}>Confirm action <ChevronRight /></button></div></>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={(data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
}
function Scoreboard({ series }: { series: Series }) {
@@ -344,13 +558,11 @@ function AuditLog({ series }: { series: Series }) { return <section className="c
function SpectatorPage() {
const { seriesId } = spectatorRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => void client.invalidateQueries({ queryKey: ['series', seriesId] })), [client, seriesId])
if (series.isLoading) return <LoadingState />
if (series.isError || !series.data) return <ErrorState retry={() => void series.refetch()} />
const data = series.data
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{demoMode && data.currentStep.kind === 'hero_ban' && <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div>}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{data.maps.at(-1)?.heroBans.length ? <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div> : null}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
}
function BracketPage() {

View File

@@ -8,6 +8,7 @@ const player: RawPlayer = {
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
avoidedPlayerIds: ['player-3'],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-18T00:00:00Z',
}
@@ -21,6 +22,7 @@ describe('backend DTO adapters', () => {
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
avoidedPlayerIds: ['player-3'],
})
})
@@ -35,22 +37,39 @@ describe('backend DTO adapters', () => {
createdBy: 'account-1',
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
state: 'RegistrationOpen',
version: 0,
rulesetId: 'rules-1',
activeSeriesId: '',
tournamentId: '',
}
const adapted = adaptEvent(event, [
{ eventId: event.id, playerId: player.id, actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: event.updatedAt },
], player.id)
expect(adapted.myRsvp).toBe('going')
expect(adapted.counts.going).toBe(1)
expect(adaptEvent({ ...event, state: 'Cancelled' }).status).toBe('cancelled')
expect(adaptEvent({ ...event, state: 'Completed' }).status).toBe('completed')
expect(adaptEvent({ ...event, state: 'Live' }).status).toBe('live')
})
it('keeps corrected results out of the authoritative score', () => {
const series: RawSeries = {
id: 'series-1',
eventId: 'event-1',
tournamentId: 't-1',
teamAId: 'a',
teamBId: 'b',
winnerTeamId: '',
bestOf: 3,
rulesetId: 'rules-1',
phase: 'Playing',
currentMap: 'Lijiang',
playedMaps: [],
nextMapPickerId: '',
availableMaps: [],
maps: [],
audit: [],
version: 2,
results: [
{ mapName: 'Lijiang', outcome: 'TeamAWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:00:00Z' },
@@ -67,9 +86,10 @@ describe('actual backend routes', () => {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'])
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]')
})
it('sends backend RSVP enum values to the player-specific route', async () => {

View File

@@ -16,6 +16,7 @@ export interface Player {
ratings: Ratings
preferredRoles: PlayerRole[]
preferredPlayerIds: string[]
avoidedPlayerIds: string[]
}
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
@@ -35,11 +36,17 @@ export interface MixEvent {
startsAt: string
endsAt: string
registrationDeadline: string
status: 'registration' | 'balancing' | 'live' | 'completed'
status: 'registration' | 'balancing' | 'live' | 'completed' | 'cancelled'
rulesetName: string
counts: Record<RsvpStatus, number>
myRsvp?: RsvpStatus
workflowState: EventWorkflowState
version: number
rulesetId: string
activeSeriesId?: string
tournamentId?: string
}
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'Live' | 'Completed' | 'Cancelled'
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
export interface Team {
id: string
@@ -86,7 +93,7 @@ export interface Series {
title: string
instruction: string
activeTeam?: TeamSide
kind: 'coin_toss' | 'map_ban' | 'hero_ban' | 'result' | 'complete'
kind: 'coin_toss' | 'map_ban' | 'map_pick' | 'hero_ban' | 'result' | 'complete'
version: number
}
options: DraftOption[]
@@ -124,12 +131,14 @@ export interface RawPlayer {
id: string; accountId: string; displayName: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
avoidedPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
state: EventWorkflowState; version: number; rulesetId: string; activeSeriesId: string; tournamentId: string
}
export interface RawRSVP {
eventId: string; playerId: string; actorAccountId: string
@@ -140,14 +149,26 @@ export interface RawTeam {
id: string; eventId: string; name: string; captainPlayerId: string; slots: RawSlot[]
}
export interface RawBalanceCandidate { teams: RawTeam[]; reserve: string[]; score: number; explanation?: string[] }
export interface RawRosterDraft {
eventId: string; teams: RawTeam[]; reserve: string[]; version: number; confirmed: boolean
}
export interface RawMapResult {
mapName: string; actorAccountId: string; outcome: MapOutcome
recordedAt: string; correctionOf?: number
}
export interface RawSeries {
id: string; tournamentId: string; teamAId: string; teamBId: string
winnerTeamId: string; bestOf: number; results: RawMapResult[]; version: number
id: string; eventId: string; tournamentId: string; teamAId: string; teamBId: string
winnerTeamId: string; bestOf: number; rulesetId: string
phase: 'CoinTossPending' | 'MapBan' | 'MapPick' | 'HeroBan' | 'Playing' | 'Completed'
coinToss?: { seed: string; winnerTeamId: string; performedAt: string }
mapDraft?: { pool: string[]; banned: string[]; firstTeamId: string; teamIds: [string, string]; actions: RawDraftAction[] }
heroDraft?: { heroes: Array<{ name: string; role: 'Tank' | 'Damage' | 'Support' }>; teamIds: [string, string]; firstTeamId: string; bansPerTeam: number; currentBans: RawDraftAction[] }
currentMap: string; playedMaps: string[]; nextMapPickerId: string; availableMaps: string[]
maps: Array<{ number: number; name: string; heroBans: RawDraftAction[]; result?: RawMapResult }>
audit: Array<{ kind: DraftAction['kind']; summary: string; teamId?: string; actorAccountId: string; at: string }>
results: RawMapResult[]; version: number
}
export interface RawDraftAction { teamId: string; value: string; actorAccountId: string; at: string }
export interface RawTournament {
id: string; eventId: string; name: string; winnerTeamId: string
teamIds: string[]; rounds: RawSeries[][]
@@ -178,6 +199,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
avoidedPlayerIds: raw.avoidedPlayerIds ?? [],
}
}
export function adaptSession(raw: RawSession): Session {
@@ -194,7 +216,6 @@ export function adaptSession(raw: RawSession): Session {
}
export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent {
const now = Date.now()
const starts = new Date(raw.startsAt).getTime()
const ends = new Date(raw.endsAt).getTime()
const deadline = new Date(raw.registrationDeadline).getTime()
const counts = { going: 0, maybe: 0, not_going: 0 }
@@ -207,10 +228,18 @@ export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId
startsAt: raw.startsAt,
endsAt: raw.endsAt,
registrationDeadline: raw.registrationDeadline,
status: now > ends ? 'completed' : now >= starts ? 'live' : now > deadline ? 'balancing' : 'registration',
status: raw.state === 'Cancelled' ? 'cancelled'
: raw.state === 'Completed' || now > ends ? 'completed'
: raw.state === 'Live' ? 'live'
: raw.state === 'RegistrationOpen' && now <= deadline ? 'registration' : 'balancing',
rulesetName: 'Server ruleset',
counts,
myRsvp: own ? statusFromRaw(own.status) : undefined,
workflowState: raw.state,
version: raw.version,
rulesetId: raw.rulesetId,
activeSeriesId: raw.activeSeriesId || undefined,
tournamentId: raw.tournamentId || undefined,
}
}
export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, session?: Session): Registration {
@@ -220,6 +249,7 @@ export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, se
ratings: { tank: 1, damage: 1, support: 1, updatedAt: raw.updatedAt },
preferredRoles: [],
preferredPlayerIds: [],
avoidedPlayerIds: [],
}
return {
id: `${raw.eventId}:${raw.playerId}`,
@@ -246,6 +276,7 @@ export function adaptTeam(raw: RawTeam, players: Map<string, Player>, index = 0)
},
preferredRoles: [roleFromRaw(slot.role)],
preferredPlayerIds: [],
avoidedPlayerIds: [],
},
assignedRole: roleFromRaw(slot.role),
}))
@@ -294,38 +325,54 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
alpha: total.alpha + (result.outcome === 'TeamAWin' ? 1 : 0),
beta: total.beta + (result.outcome === 'TeamBWin' ? 1 : 0),
}), { alpha: 0, beta: 0 })
const complete = Boolean(raw.winnerTeamId)
const complete = raw.phase === 'Completed' || Boolean(raw.winnerTeamId)
const phase = raw.phase ?? (complete ? 'Completed' : 'Playing')
const status: Series['status'] = phase === 'CoinTossPending' ? 'coin_toss'
: phase === 'MapBan' || phase === 'MapPick' ? 'map_draft'
: phase === 'HeroBan' ? 'hero_draft'
: phase === 'Completed' ? 'completed' : 'playing'
const nextMapTeam = raw.mapDraft
? (raw.mapDraft.actions.length % 2 === 0 ? raw.mapDraft.firstTeamId : raw.mapDraft.teamIds.find((id) => id !== raw.mapDraft?.firstTeamId))
: undefined
const nextHeroTeam = raw.heroDraft
? (raw.heroDraft.currentBans.length % 2 === 0 ? raw.heroDraft.firstTeamId : raw.heroDraft.teamIds.find((id) => id !== raw.heroDraft?.firstTeamId))
: undefined
const activeTeamId = phase === 'MapBan' ? nextMapTeam : phase === 'MapPick' ? raw.nextMapPickerId : phase === 'HeroBan' ? nextHeroTeam : undefined
const kind: Series['currentStep']['kind'] = phase === 'CoinTossPending' ? 'coin_toss'
: phase === 'MapBan' ? 'map_ban' : phase === 'MapPick' ? 'map_pick' : phase === 'HeroBan' ? 'hero_ban' : phase === 'Completed' ? 'complete' : 'result'
const options: DraftOption[] = phase === 'MapBan' && raw.mapDraft
? raw.mapDraft.pool.filter((name) => !raw.mapDraft?.banned.includes(name)).map((name) => ({ id: name, name }))
: phase === 'MapPick'
? (raw.availableMaps ?? []).map((name) => ({ id: name, name }))
: phase === 'HeroBan' && raw.heroDraft
? raw.heroDraft.heroes.filter((hero) => !raw.heroDraft?.currentBans.some((ban) => ban.value === hero.name)).map((hero) => ({ id: hero.name, name: hero.name, role: roleFromRaw(hero.role) }))
: []
return {
id: raw.id,
tournamentId: raw.tournamentId,
status: complete ? 'completed' : raw.teamAId && raw.teamBId ? 'playing' : 'scheduled',
status,
roundLabel: 'Tournament series',
teamAlpha: alpha,
teamBeta: beta,
score,
version: raw.version,
currentStep: {
title: complete ? 'Series complete' : `Record map ${activeResults.length + 1}`,
instruction: complete ? 'The tournament bracket has advanced.' : 'An admin can record the authoritative map result.',
kind: complete ? 'complete' : 'result',
title: phase === 'CoinTossPending' ? 'Coin toss' : phase === 'MapBan' ? 'Map bans' : phase === 'MapPick' ? 'Loser picks the next map' : phase === 'HeroBan' ? 'Hero bans' : complete ? 'Series complete' : `Record ${raw.currentMap || `map ${activeResults.length + 1}`}`,
instruction: complete ? 'The series is complete.' : phase === 'Playing' ? 'Record the authoritative map result.' : 'The active captain or an admin can act.',
activeTeam: activeTeamId ? (activeTeamId === raw.teamAId ? 'alpha' : 'beta') : undefined,
kind,
version: raw.version,
},
options: [],
maps: activeResults.map((result, index) => ({
number: index + 1,
name: result.mapName,
options,
maps: (raw.maps ?? activeResults.map((result, index) => ({ number: index + 1, name: result.mapName, heroBans: [], result }))).map((map) => ({
number: map.number,
name: map.name,
mode: 'Map',
status: 'completed',
winner: result.outcome === 'TeamAWin' ? 'alpha' : result.outcome === 'TeamBWin' ? 'beta' : 'draw',
heroBans: [],
})),
audit: raw.results.map((result, index) => ({
id: `result-${index}`,
kind: 'result',
summary: `${result.mapName}: ${result.outcome === 'TeamAWin' ? alpha.name : result.outcome === 'TeamBWin' ? beta.name : 'Draw'}`,
actorName: result.actorAccountId,
createdAt: result.recordedAt,
status: map.result ? 'completed' : phase === 'Playing' ? 'playing' : phase === 'HeroBan' ? 'ready' : 'drafting',
winner: map.result ? (map.result.outcome === 'TeamAWin' ? 'alpha' : map.result.outcome === 'TeamBWin' ? 'beta' : 'draw') : undefined,
heroBans: map.heroBans.map((ban) => ({ hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: 'damage' as PlayerRole })),
})),
audit: (raw.audit ?? []).map((action, index) => ({ id: `action-${index}`, kind: action.kind, summary: action.summary, actorName: action.actorAccountId, createdAt: action.at })),
}
}
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
@@ -399,9 +446,8 @@ const raw = {
}
async function adaptSeriesWithTeams(series: RawSeries) {
if (!series.tournamentId) return adaptSeries(series)
const tournament = await request<RawTournament>(`/tournaments/${series.tournamentId}`)
const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
const eventId = series.eventId || (series.tournamentId ? (await request<RawTournament>(`/tournaments/${series.tournamentId}`)).eventId : '')
const [teams, players] = await Promise.all([raw.teams(eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return adaptSeries(series, teams.map((team, index) => adaptTeam(team, byId, index)))
}
@@ -415,6 +461,7 @@ export const api = {
ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
avoidedPlayerIds: string[],
) =>
adaptPlayer(await request<RawPlayer>('/me/player', {
method: 'PATCH',
@@ -423,6 +470,7 @@ export const api = {
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
avoidedPlayerIds,
}),
})),
players: async () => (await raw.players()).map(adaptPlayer),
@@ -467,8 +515,13 @@ export const api = {
])
return adaptRegistration(result, new Map(players.map(adaptPlayer).map((player) => [player.id, player])), adaptSession(session))
},
removeParticipant: (eventId: string, playerId: string) =>
request<void>(`/events/${eventId}/rsvps/${playerId}`, { method: 'DELETE' }),
createEvent: async (event: EventInput) => adaptEvent(await request<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request<RawEvent>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
cancelEvent: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/cancel`, { method: 'POST', body: JSON.stringify({ expectedVersion }) })),
deleteEvent: (eventId: string) => request<void>(`/events/${eventId}`, { method: 'DELETE' }),
generateBalance: async (eventId: string) => {
const [candidates, players] = await Promise.all([
request<RawBalanceCandidate[]>(`/events/${eventId}/balance`, { method: 'POST' }),
@@ -477,6 +530,50 @@ export const api = {
const adaptedPlayers = players.map(adaptPlayer)
return candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index))
},
closeRegistration: async (eventId: string, rulesetId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/registration/close`, {
method: 'POST',
body: JSON.stringify({ rulesetId, expectedVersion }),
})),
generateWorkflowBalance: async (eventId: string, expectedVersion: number) => {
const [result, players] = await Promise.all([
request<{ event: RawEvent; candidates: RawBalanceCandidate[] }>(`/events/${eventId}/balance/generate`, {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
}),
raw.players(),
])
const adaptedPlayers = players.map(adaptPlayer)
return {
event: adaptEvent(result.event),
candidates: result.candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index)),
}
},
roster: (eventId: string) => request<RawRosterDraft>(`/events/${eventId}/roster`),
selectWorkflowBalance: (eventId: string, candidate: BalanceCandidate, expectedVersion: number) =>
request<RawRosterDraft>(`/events/${eventId}/roster`, {
method: 'PUT',
body: JSON.stringify({ candidate: candidate.payload, expectedVersion }),
}),
swapRoster: (eventId: string, input: { teamAId: string; playerAId: string; teamBId: string; playerBId: string; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/swap`, { method: 'POST', body: JSON.stringify(input) }),
substituteRoster: (eventId: string, input: { teamId: string; outgoingPlayerId: string; reservePlayerId: string; expectedVersion: number }, emergency = false) =>
request<RawRosterDraft>(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }),
setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) =>
request<RawRosterDraft>(`/events/${eventId}/roster/captain`, {
method: 'PUT',
body: JSON.stringify({ teamId, playerId, expectedVersion }),
}),
confirmRosters: (eventId: string, expectedVersion: number, expectedRosterVersion: number) =>
request<RawEvent>(`/events/${eventId}/roster/confirm`, {
method: 'POST',
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
}),
startScrim: (eventId: string, expectedVersion: number) =>
request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
}),
selectBalance: (eventId: string, candidate: BalanceCandidate) =>
request<{ status: string }>(`/events/${eventId}/teams`, {
method: 'PUT',
@@ -512,6 +609,31 @@ export const api = {
method: 'POST',
body: JSON.stringify({ mapName, outcome, expectedVersion }),
})),
tossSeriesCoin: async (seriesId: string, seed: string, actingTeamId: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/coin-toss`, {
method: 'POST',
body: JSON.stringify({ seed, actingTeamId, expectedVersion }),
})),
banSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/map-bans`, {
method: 'POST',
body: JSON.stringify({ teamId, map, expectedVersion }),
})),
pickSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/map-pick`, {
method: 'POST',
body: JSON.stringify({ teamId, map, expectedVersion }),
})),
banSeriesHero: async (seriesId: string, teamId: string, hero: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/hero-bans`, {
method: 'POST',
body: JSON.stringify({ teamId, hero, expectedVersion }),
})),
recordSeriesMap: async (seriesId: string, outcome: MapOutcome, actingTeamId: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/map-result`, {
method: 'POST',
body: JSON.stringify({ outcome, actingTeamId, expectedVersion }),
})),
correctResult: async (seriesId: string, index: number, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/results/${index}`, {
method: 'PATCH',
@@ -536,6 +658,7 @@ export function subscribeToEvents(
onMessage: (event: RawRealtimeEvent) => void,
onStatus?: (status: 'connected' | 'reconnecting') => void,
) {
if (typeof EventSource === 'undefined') return () => undefined
const source = new EventSource(`${API_BASE}/events/stream?topic=${encodeURIComponent(topic)}`, { withCredentials: true })
source.onopen = () => onStatus?.('connected')
source.onerror = () => onStatus?.('reconnecting')

View File

@@ -31,11 +31,13 @@ const makePlayer = (
support: RankOrdinal,
): Player => ({
id,
accountId: `account-${id}`,
displayName,
battleTag: `${displayName.replace(/\s/g, '')}#${1000 + Number(id.replace(/\D/g, '') || 0)}`,
ratings: { tank, damage, support, updatedAt: daysFromNow(-4) },
preferredRoles: tank >= damage && tank >= support ? ['tank'] : damage >= support ? ['damage'] : ['support'],
preferredPlayerIds: [],
avoidedPlayerIds: [],
})
export const demoProfile: Player = {
@@ -66,6 +68,9 @@ export const demoEvents: MixEvent[] = [
rulesetName: 'Community Bo3 v2',
counts: { going: 18, maybe: 4, not_going: 3 },
myRsvp: 'going',
workflowState: 'RegistrationOpen',
version: 0,
rulesetId: 'standard-control-hybrid-control',
},
{
id: 'event-2',
@@ -78,6 +83,9 @@ export const demoEvents: MixEvent[] = [
rulesetName: 'Quick Mix v1',
counts: { going: 9, maybe: 6, not_going: 2 },
myRsvp: 'maybe',
workflowState: 'RegistrationOpen',
version: 0,
rulesetId: 'standard-control-push-escort',
},
{
id: 'event-3',
@@ -90,6 +98,38 @@ export const demoEvents: MixEvent[] = [
rulesetName: 'Tournament Bo3 v2',
counts: { going: 22, maybe: 0, not_going: 4 },
myRsvp: 'going',
workflowState: 'Live',
version: 6,
rulesetId: 'standard-control-hybrid-control',
activeSeriesId: 'series-1',
},
{
id: 'event-4',
title: 'Cancelled Community Mix',
description: 'This event was cancelled by the organizer',
startsAt: daysFromNow(7),
endsAt: daysFromNow(7, 23),
registrationDeadline: daysFromNow(6),
status: 'cancelled',
rulesetName: 'Community Bo3 v2',
counts: { going: 7, maybe: 2, not_going: 1 },
workflowState: 'Cancelled',
version: 2,
rulesetId: 'standard-control-hybrid-control',
},
{
id: 'event-5',
title: 'Completed Friday Mix',
description: 'Finished Bo3 community scrim',
startsAt: daysFromNow(-3),
endsAt: daysFromNow(-3, 23),
registrationDeadline: daysFromNow(-4),
status: 'completed',
rulesetName: 'Community Bo3 v2',
counts: { going: 10, maybe: 0, not_going: 1 },
workflowState: 'Completed',
version: 12,
rulesetId: 'standard-control-hybrid-control',
},
]
@@ -109,6 +149,7 @@ const players = [
]
players[1].preferredPlayerIds = ['p1']
players[4].preferredPlayerIds = ['p1']
players[0].avoidedPlayerIds = ['p3']
export const demoRegistrations: Registration[] = players.map((player, index) => ({
id: `reg-${index + 1}`,

View File

@@ -7,6 +7,7 @@ const translations: Record<string, string> = {
'Bracket': 'Сетка',
'Admin': 'Админ',
'Profile': 'Профиль',
'FAQ': 'FAQ',
'Language': 'Язык',
'Primary navigation': 'Основная навигация',
'Mobile navigation': 'Мобильная навигация',
@@ -15,6 +16,7 @@ const translations: Record<string, string> = {
'Sign out': 'Выйти',
'Toggle navigation': 'Открыть навигацию',
'Realtime connected': 'Онлайн-соединение установлено',
'Realtime reconnecting': 'Переподключение к обновлениям',
'Community scrims, organized': 'Организованные командные миксы',
'Ready up.': 'Приготовьтесь.',
'Play together.': 'Играйте вместе.',
@@ -88,6 +90,8 @@ const translations: Record<string, string> = {
'Choose every role you enjoy playing.': 'Выберите все роли, на которых вам нравится играть.',
'Preferred teammates': 'Предпочитаемые напарники',
'Choose up to 3 players. The balancer treats these as preferences, not guarantees.': 'Выберите до 3 игроков. Балансировщик учитывает это как пожелание, а не гарантию.',
'Avoided teammates': 'Избегаемые напарники',
'Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.': 'Выберите до 3 игроков. Это мягкое предпочтение, поэтому при необходимости балансировщик всё ещё может поставить вас вместе.',
'Loading admin control room…': 'Загрузка панели администратора…',
'Admin control room': 'Панель администратора',
'Create event': 'Создать событие',
@@ -101,6 +105,11 @@ const translations: Record<string, string> = {
'Event settings': 'Настройки события',
'Scheduling and registration': 'Расписание и регистрация',
'Published': 'Опубликовано',
'Cancelled': 'Отменено',
'Completed': 'Завершено',
'Cancel event': 'Отменить событие',
'Delete event': 'Удалить событие',
'LIVE': 'LIVE',
'Event name': 'Название события',
'Start time': 'Время начала',
'End time': 'Время окончания',
@@ -124,6 +133,7 @@ const translations: Record<string, string> = {
'Support rank': 'Ранг поддержки',
'Status': 'Статус',
'Adding…': 'Добавление…',
'Remove from event': 'Удалить из микса',
'Not going': 'Не идёт',
'Preferred with': 'Предпочитает играть с',
'No preferred roles': 'Нет предпочитаемых ролей',
@@ -191,6 +201,52 @@ const translations: Record<string, string> = {
'map_draft': 'бан карт',
'coin_toss': 'жеребьёвка',
'Map': 'Карта',
'Close registration': 'Закрыть регистрацию',
'Freeze the Going participant list before balancing.': 'Зафиксировать список участников перед балансировкой.',
'Generate fair teams': 'Сбалансировать команды',
'The optimizer considers ranks, preferred roles, and preferred teammates.': 'Оптимизатор учитывает ранги, предпочитаемые роли и напарников.',
'Generate balance': 'Сгенерировать баланс',
'Generate candidates': 'Сгенерировать варианты',
'Roster editor': 'Редактор составов',
'Captain': 'Капитан',
'Choose captain': 'Выбрать капитана',
'Swap…': 'Обменять…',
'Reserve…': 'Из резерва…',
'Confirm rosters': 'Подтвердить составы',
'Every team must have a valid 1/2/2 lineup and a captain.': 'В каждой команде должен быть состав 1/2/2 и капитан.',
'Start scrim': 'Начать скрим',
'This atomically creates the Bo3 series or tournament bracket.': 'Серия Bo3 или турнирная сетка будут созданы атомарно.',
'Scrim is live': 'Скрим начался',
'Coin toss, bans, and map results are now available.': 'Теперь доступны жеребьёвка, баны и результаты карт.',
'Open live series': 'Открыть серию',
'Open bracket': 'Открыть сетку',
'Scrim completed': 'Скрим завершён',
'The final result and full audit trail are preserved.': 'Итоговый результат и полный журнал действий сохранены.',
'Registration has been closed by the organizer.': 'Организатор закрыл регистрацию.',
'Toss coin': 'Подбросить монету',
'Confirming…': 'Подтверждение…',
'Working…': 'Выполняется…',
'Series completed and bracket updated.': 'Серия завершена, сетка обновлена.',
'Loser picks the next map': 'Проигравшая команда выбирает следующую карту',
'Rules and help': 'Правила и помощь',
'Frequently asked questions': 'Частые вопросы',
'Everything players and captains need before the scrim starts.': 'Всё, что нужно знать игрокам и капитанам до начала скрима.',
'How are teams balanced?': 'Как балансируются команды?',
'The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.': 'Сервер собирает команды 1 танк / 2 урона / 2 поддержки. Главная цель — баланс рангов. Предпочитаемые роли, напарники и до трёх avoid-игроков являются мягкими пожеланиями и не важнее честного корректного состава.',
'How does registration work?': 'Как работает регистрация?',
'Choose Going, Maybe, or Cant go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.': 'До закрытия регистрации выберите «Иду», «Возможно» или «Не иду». В балансировку попадают только игроки со статусом «Иду». После закрытия регистрации обычные изменения блокируются.',
'What happens before the scrim?': 'Что происходит перед скримом?',
'An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.': 'Администратор создаёт варианты баланса, выбирает один, при необходимости меняет местами игроков одной роли или использует резерв, назначает капитанов, подтверждает составы и запускает скрим.',
'How is the first map selected?': 'Как выбирается первая карта?',
'The initial coin toss decides which team bans first. Teams alternate map bans until one map remains. That map becomes the first map of the series.': 'Начальная жеребьёвка определяет, какая команда банит первой. Команды по очереди банят карты, пока не останется одна — она становится первой картой серии.',
'Who picks later maps?': 'Кто выбирает следующие карты?',
'After a captain or admin records a map result, the team that lost that map immediately chooses the next map from the next ruleset pool. Already played maps are unavailable. After a draw, the initial coin-toss loser receives the pick.': 'После фиксации результата капитаном или администратором проигравшая команда сразу выбирает следующую карту из следующего пула регламента. Сыгранные карты недоступны. После ничьей выбор получает команда, проигравшая начальный жребий.',
'How do hero bans work?': 'Как работают баны героев?',
'Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A teams two bans must use different roles, and a team cannot repeat its own hero ban during the same series. It may ban a hero previously banned by the opponent.': 'Перед каждой картой команды делают по два бана по очереди. Первой банит команда, проигравшая начальный жребий. Два бана команды должны относиться к разным ролям; повторять собственный бан в серии нельзя, но можно забанить героя, которого ранее банил соперник.',
'How does Bo3 scoring work?': 'Как считается Bo3?',
'The first team to win two maps wins the series. A captain selects the result after each map; admins can correct mistakes. Tournament winners advance automatically.': 'Первая команда с двумя победами выигрывает серию. После каждой карты капитан указывает результат, администратор может исправить ошибку. Победители турнира проходят дальше автоматически.',
'What can captains do?': 'Что могут делать капитаны?',
'Captains can perform actions for their team: map bans, later-map picks, and hero bans. Admins can perform or correct any authoritative action. Every command is versioned and recorded in the match history.': 'Капитаны могут действовать за свою команду: банить карты, выбирать следующие карты и банить героев. Администраторы могут выполнить или исправить любое действие. Все команды версионируются и записываются в историю матча.',
}
function translate(value: string) {
@@ -213,8 +269,11 @@ function translate(value: string) {
[/^(.+) wins$/, (m) => `${m[1]} побеждает`],
[/^Team (.+)$/, (m) => `Команда ${m[1]}`],
[/^RSVP for (.+)$/, (m) => `Статус участия для ${m[1]}`],
[/^Remove (.+) from event$/, (m) => `Удалить ${m[1]} из микса`],
[/^Open (.+)$/, (m) => `Открыть ${m[1]}`],
[/^(\d+) preferred role assignments$/, (m) => `${m[1]} назначений на предпочитаемые роли`],
[/^(\d+) preferred teammate pairs? kept together$/, (m) => `${m[1]} предпочитаемых пар сохранено`],
[/^(\d+) avoided teammate choices satisfied$/, (m) => `${m[1]} пожеланий избегать напарников учтено`],
[/^set by (.+)$/, (m) => `изменил ${m[1]}`],
]
for (const [pattern, formatter] of rules) {

View File

@@ -45,6 +45,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
.avatar, .mini-avatar, .large-avatar { display: grid; place-items: center; border-radius: 50%; background: linear-gradient(135deg,#fa8d36,#703514); color: #fff; font-weight: 800; }
.avatar { width: 36px; height: 36px; font-size: 12px; border: 2px solid #393f48; }
.live-dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: var(--green); box-shadow: 0 0 9px var(--green); }
.live-dot.reconnecting { background: var(--yellow); box-shadow: 0 0 9px var(--yellow); }
.mobile-menu, .mobile-nav, .bottom-nav { display: none !important; }
main { min-height: calc(100vh - 72px); }
.page { max-width: 1240px; margin: 0 auto; padding: 44px 22px 80px; }
@@ -66,6 +67,7 @@ main { min-height: calc(100vh - 72px); }
.badge-success { color: var(--green); border-color: #23583f; background: #10261c; }
.badge-warning { color: var(--yellow); border-color: #604b20; background: #281f0e; }
.badge-live { color: #ff9160; border-color: #69351f; background: #2a160e; }
.badge-danger { color: #ff8f8f; border-color: #783737; background: #321516; }
.badge-blue { color: var(--blue); border-color: #22547b; background: #102435; }
.segmented { display: flex; padding: 3px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.segmented button { min-height: 34px; padding: 0 14px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; cursor: pointer; }
@@ -83,8 +85,15 @@ main { min-height: calc(100vh - 72px); }
.section-title h2 { font-size: 17px; }
.section-title p { color: var(--muted); font-size: 12px; margin-top: 4px; }
.event-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.event-card { display: grid; grid-template-columns: 95px 1fr; min-height: 210px; overflow: hidden; transition: border-color .2s, transform .2s; }
.event-card { display: grid; grid-template-columns: 95px 1fr; min-height: 210px; overflow: hidden; color: inherit; text-decoration: none; transition: border-color .2s, transform .2s; }
.event-card:hover { transform: translateY(-2px); border-color: #3d4550; }
.event-card.status-cancelled { border-color: #673134; background: linear-gradient(145deg,#351719,#171417); }
.event-card.status-completed { border-color: #28583f; background: linear-gradient(145deg,#123022,#121a17); }
.event-card.status-live { border-color: #854157; background: linear-gradient(145deg,#421b2a,#1c151a); box-shadow: 0 0 28px rgba(222,67,119,.08); }
.event-card.status-live .date-block span { color: #ff6f9f; }
.event-detail.status-cancelled .event-hero,.event-detail.status-cancelled .roster-preview { border-color: #673134; background: linear-gradient(145deg,#351719,#171417); }
.event-detail.status-completed .event-hero,.event-detail.status-completed .roster-preview { border-color: #28583f; background: linear-gradient(145deg,#123022,#121a17); }
.event-detail.status-live .event-hero,.event-detail.status-live .roster-preview { border-color: #854157; background: linear-gradient(145deg,#421b2a,#1c151a); }
.date-block { display: flex; flex-direction: column; align-items: center; justify-content: center; border-right: 1px solid var(--border); background: rgba(255,255,255,.018); }
.date-block strong { font-size: 38px; line-height: 1; }
.date-block span { color: var(--orange); font-size: 11px; letter-spacing: .2em; margin-top: 7px; font-weight: 800; }
@@ -94,9 +103,9 @@ main { min-height: calc(100vh - 72px); }
.event-card h3 { font-size: 19px; margin-top: 18px; }
.event-card p { color: var(--muted); font-size: 12px; margin-top: 6px; }
.event-footer { margin-top: auto; padding-top: 18px; border-top: 1px solid var(--border); }
.event-footer span, .event-footer a { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 750; }
.event-footer span { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 750; }
.event-footer span { color: var(--muted); }
.event-footer a { color: var(--orange); }
.event-footer .event-open { color: var(--orange); }
.detail-layout { display: grid; grid-template-columns: 1.5fr .8fr; gap: 18px; align-items: start; }
.event-hero, .roster-preview { padding: 28px; }
@@ -116,9 +125,9 @@ main { min-height: calc(100vh - 72px); }
.roster-preview .section-title { margin-top: 0; }
.attendance-bar { display: flex; height: 8px; gap: 3px; margin: 28px 0 18px; }
.attendance-bar span { border-radius: 10px; }
.attendance-bar span:nth-child(1) { width: 72%; background: var(--green); }
.attendance-bar span:nth-child(2) { width: 18%; background: var(--yellow); }
.attendance-bar span:nth-child(3) { width: 10%; background: #59616b; }
.attendance-bar .going { background: var(--green); }
.attendance-bar .maybe { background: var(--yellow); }
.attendance-bar .not-going { background: #59616b; }
.attendance-stats { display: grid; gap: 12px; margin-bottom: 28px; }
.attendance-stats span { color: var(--muted); display: flex; align-items: center; gap: 7px; font-size: 12px; }
.attendance-stats strong { margin-left: auto; color: #fff; }
@@ -129,6 +138,12 @@ main { min-height: calc(100vh - 72px); }
.public-roster-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; }
.public-roster-player { min-height: 52px; display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); }
.public-roster-player strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.faq-page { max-width: 1050px; }
.faq-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 12px; }
.faq-card { min-height: 160px; padding: 22px; display: grid; grid-template-columns: 38px 1fr; gap: 14px; }
.faq-card > span { color: var(--orange); font-size: 11px; font-weight: 900; letter-spacing: .12em; }
.faq-card h2 { font-size: 17px; margin-bottom: 9px; }
.faq-card p { color: var(--muted); font-size: 12px; line-height: 1.65; }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; }
@@ -152,6 +167,8 @@ main { min-height: calc(100vh - 72px); }
.teammate-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 14px; }
.teammate-grid label { min-height: 48px; padding: 8px 10px; display: grid; grid-template-columns: auto 28px 1fr; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); cursor: pointer; font-size: 10px; }
.teammate-grid label.selected { border-color: var(--blue); background: var(--blue-soft); }.teammate-grid label:has(input:disabled) { opacity: .45; cursor: not-allowed; }
.teammate-grid label.selected.avoided { border-color: #c85f5f; background: #311719; }
.avoid-section h2 { color: #ef9696; }
.teammate-grid .mini-avatar { width: 26px; height: 26px; }
.form-footer { padding-top: 20px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.form-footer p { color: var(--muted); font-size: 11px; display: flex; align-items: center; gap: 6px; }
@@ -162,6 +179,9 @@ main { min-height: calc(100vh - 72px); }
.admin-grid { display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }
.settings-card, .admin-summary, .table-card { padding: 24px; }
.settings-card .section-title, .table-card .section-title { margin-top: 0; }
.event-admin-actions { display: flex; align-items: center; gap: 7px; }
.button.danger-outline { min-height: 36px; padding: 0 12px; color: #ff9b9b; border: 1px solid #71383a; background: #281416; }
.delete-event { color: #ff8f8f; }
.field-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 16px; }
.guest-participant-form { margin-bottom: 24px; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); }
.guest-participant-form > div:first-child { margin-bottom: 14px; }
@@ -180,11 +200,13 @@ textarea { min-height: 85px; resize: vertical; }
.admin-summary li { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 12px; }.admin-summary li svg { color: var(--green); }
.count-pills { display: flex; gap: 6px; }
.participant-list { display: grid; }
.participant-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(130px,1fr) auto 130px; align-items: center; gap: 12px; border-top: 1px solid var(--border); }
.participant-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(130px,1fr) auto 130px 38px; align-items: center; gap: 12px; border-top: 1px solid var(--border); }
.mini-avatar { width: 32px; height: 32px; font-size: 10px; }
.participant-name { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }.participant-name span { color: var(--muted); font-size: 10px; text-transform: capitalize; }.participant-name em { color: var(--yellow); font-style: normal; }
.mini-ratings { display: flex; gap: 5px; }.mini-ratings span { color: var(--muted); background: var(--surface-2); border-radius: 4px; padding: 4px 6px; font-size: 9px; }
.participant-row select { padding: 8px; font-size: 11px; }
.remove-participant { color: #d98282; }
.remove-participant:hover { color: #ff9b9b; border-color: #6f3939; background: #281515; }
.balance-layout { display: grid; grid-template-columns: 260px 1fr; gap: 20px; }
.candidate-list .section-title { margin-top: 0; }
@@ -207,6 +229,19 @@ textarea { min-height: 85px; resize: vertical; }
.captain-select { padding: 15px 18px 18px; display: flex; flex-direction: column; gap: 7px; }.captain-select span { display: flex; align-items: center; gap: 6px; }.captain-select select { padding: 9px; font-size: 11px; }
.reserve-bar { margin-top: 12px; padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 15px; }.reserve-bar > div { display: flex; align-items: center; gap: 9px; }.reserve-bar span { display: flex; flex-direction: column; }.reserve-bar small { color: var(--muted); margin-top: 3px; font-size: 9px; }
.sticky-action { margin-top: 12px; padding: 14px 0; display: flex; align-items: center; justify-content: flex-end; gap: 18px; }.sticky-action p { color: var(--muted); font-size: 10px; }
.workflow-control { display: grid; gap: 14px; }
.workflow-progress { padding: 16px; display: grid; grid-template-columns: repeat(7,1fr); gap: 6px; overflow-x: auto; }
.workflow-progress div { min-width: 105px; display: flex; align-items: center; gap: 7px; color: #555e69; font-size: 9px; text-transform: uppercase; }
.workflow-progress span { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3a414b; border-radius: 50%; }
.workflow-progress div.done { color: var(--green); }.workflow-progress div.done span { border-color: #2d7654; background: #10271c; }
.workflow-action { min-height: 105px; padding: 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.workflow-action h2 { font-size: 18px; }.workflow-action p { margin-top: 5px; color: var(--muted); font-size: 11px; }
.roster-editor { padding: 22px; }.roster-editor > .section-title { margin-top: 0; }
.workflow-team { border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: #101319; }
.workflow-team header { padding: 14px; display: flex; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); }
.workflow-team header label { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 9px; }.workflow-team header select { padding: 7px; }
.roster-slot { min-height: 54px; padding: 8px 12px; display: grid; grid-template-columns: 24px minmax(100px,1fr) auto minmax(105px,auto) minmax(105px,auto); align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 10px; }
.roster-slot > small { color: var(--muted); }.roster-slot select { max-width: 130px; padding: 7px; font-size: 9px; }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
@@ -261,10 +296,12 @@ textarea { min-height: 85px; resize: vertical; }
.featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
.faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
.teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
.workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-slot { grid-template-columns: 24px 1fr auto; }.roster-slot select { grid-column: 2/-1; max-width: none; width: 100%; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; }