add crew scores tab to gw events page

This commit is contained in:
Justin Edmund 2025-12-17 22:40:15 -08:00
parent 60b31e2a71
commit 2800bf0554
5 changed files with 1050 additions and 50 deletions

View file

@ -194,6 +194,24 @@ export class GwAdapter extends BaseAdapter {
return response.crewScore
}
/**
* Delete a crew score
*/
async deleteCrewScore(
participationId: string,
scoreId: string,
options?: RequestOptions
): Promise<void> {
await this.request<void>(
`/crew/gw_participations/${participationId}/crew_scores/${scoreId}`,
{
...options,
method: 'DELETE'
}
)
this.clearCache(`/crew/gw_participations/${participationId}`)
}
// ==================== Individual Score Operations ====================
/**

View file

@ -0,0 +1,267 @@
<svelte:options runes={true} />
<script lang="ts">
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
import Dialog from '$lib/components/ui/Dialog.svelte'
import Button from '$lib/components/ui/Button.svelte'
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte'
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
import Input from '$lib/components/ui/Input.svelte'
import { GW_ROUND_LABELS, type GwCrewScore, type GwRound } from '$lib/types/api/gw'
interface Props {
open: boolean
participationId: string
eventNumber: string
round: GwRound
existingScore?: GwCrewScore | null
}
let {
open = $bindable(false),
participationId,
eventNumber,
round,
existingScore = null
}: Props = $props()
const queryClient = useQueryClient()
// Form state
let crewScore = $state('')
let opponentScore = $state('')
let opponentName = $state('')
let opponentCrewId = $state('')
let isSubmitting = $state(false)
let error = $state<string | null>(null)
// Mutations
const addCrewScoreMutation = createMutation(() => ({
mutationFn: async () => {
const score = parseScore(crewScore)
if (isNaN(score)) throw new Error('Invalid crew score')
const oppScore = opponentScore ? parseScore(opponentScore) : undefined
return gwAdapter.addCrewScore(participationId, {
round,
crewScore: score,
opponentScore: oppScore,
opponentName: opponentName || undefined,
opponentGranblueId: opponentCrewId || undefined
})
}
}))
const updateCrewScoreMutation = createMutation(() => ({
mutationFn: async () => {
if (!existingScore) throw new Error('No score to update')
const score = parseScore(crewScore)
if (isNaN(score)) throw new Error('Invalid crew score')
const oppScore = opponentScore ? parseScore(opponentScore) : undefined
return gwAdapter.updateCrewScore(participationId, existingScore.id, {
crewScore: score,
opponentScore: oppScore,
opponentName: opponentName || undefined,
opponentGranblueId: opponentCrewId || undefined
})
}
}))
const deleteCrewScoreMutation = createMutation(() => ({
mutationFn: async () => {
if (!existingScore) throw new Error('No score to delete')
return gwAdapter.deleteCrewScore(participationId, existingScore.id)
}
}))
// Format number with commas
function formatScore(score: number): string {
return score.toLocaleString()
}
// Parse score string, removing commas
function parseScore(value: string): number {
return parseInt(value.replace(/,/g, ''), 10)
}
// Initialize form values
function initializeForm() {
if (existingScore) {
crewScore = formatScore(existingScore.crewScore)
opponentScore = existingScore.opponentScore !== null ? formatScore(existingScore.opponentScore) : ''
opponentName = existingScore.opponentName ?? ''
opponentCrewId = existingScore.opponentGranblueId ?? ''
} else {
crewScore = ''
opponentScore = ''
opponentName = ''
opponentCrewId = ''
}
error = null
}
// Check if form has changes
const hasChanges = $derived.by(() => {
if (!existingScore) {
return crewScore.length > 0
}
const currentCrewScore = parseScore(crewScore)
const currentOppScore = opponentScore ? parseScore(opponentScore) : null
if (currentCrewScore !== existingScore.crewScore) return true
if (currentOppScore !== existingScore.opponentScore) return true
if (opponentName !== (existingScore.opponentName ?? '')) return true
if (opponentCrewId !== (existingScore.opponentGranblueId ?? '')) return true
return false
})
async function handleSave() {
isSubmitting = true
error = null
try {
if (existingScore) {
await updateCrewScoreMutation.mutateAsync()
} else {
await addCrewScoreMutation.mutateAsync()
}
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
open = false
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save score'
} finally {
isSubmitting = false
}
}
async function handleDelete() {
if (!confirm('Are you sure you want to delete this crew score?')) return
try {
await deleteCrewScoreMutation.mutateAsync()
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
open = false
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete score'
}
}
function handleCancel() {
open = false
}
// Initialize values when modal opens
$effect(() => {
if (open) {
initializeForm()
}
})
</script>
<Dialog bind:open>
<ModalHeader
title={GW_ROUND_LABELS[round]}
description={existingScore ? 'Edit score' : 'Add score'}
/>
<ModalBody>
<div class="form-content">
<Input
label="Crew Score"
type="text"
inputmode="numeric"
bind:value={crewScore}
placeholder="Enter your crew's score"
fullWidth
contained
/>
<Input
label="Opponent Score"
type="text"
inputmode="numeric"
bind:value={opponentScore}
placeholder="Enter opponent's score (optional)"
fullWidth
contained
/>
<Input
label="Opponent Name"
type="text"
bind:value={opponentName}
placeholder="Enter opponent crew name (optional)"
fullWidth
contained
/>
<Input
label="Opponent Crew ID"
type="text"
bind:value={opponentCrewId}
placeholder="Enter opponent crew ID (optional)"
fullWidth
contained
/>
{#if error}
<div class="error-message">
<p>{error}</p>
</div>
{/if}
</div>
</ModalBody>
<ModalFooter
onCancel={handleCancel}
primaryAction={{
label: isSubmitting ? 'Saving...' : 'Save',
onclick: handleSave,
disabled: isSubmitting || !hasChanges || !crewScore
}}
>
{#snippet left()}
{#if existingScore}
<Button
variant="destructive-ghost"
onclick={handleDelete}
disabled={deleteCrewScoreMutation.isPending}
>
Delete score
</Button>
{/if}
{/snippet}
</ModalFooter>
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.form-content {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
.error-message {
padding: spacing.$unit;
background: colors.$error--bg--light;
border-radius: layout.$item-corner-small;
p {
margin: 0;
font-size: typography.$font-small;
color: colors.$error;
}
}
</style>

View file

@ -0,0 +1,366 @@
<svelte:options runes={true} />
<script lang="ts">
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
import Dialog from '$lib/components/ui/Dialog.svelte'
import Button from '$lib/components/ui/Button.svelte'
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte'
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
import Input from '$lib/components/ui/Input.svelte'
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
import { GW_ROUND_LABELS, type GwIndividualScore, type GwRound } from '$lib/types/api/gw'
interface Props {
open: boolean
participationId: string
eventNumber: string
playerName: string
scores: GwIndividualScore[]
}
let { open = $bindable(false), participationId, eventNumber, playerName, scores }: Props =
$props()
const queryClient = useQueryClient()
// Edit state - maps scoreId to edited values
let editValues = $state<Record<string, string>>({})
let excusedValues = $state<Record<string, boolean>>({})
let excuseReasonValues = $state<Record<string, string>>({})
let isSubmitting = $state(false)
let error = $state<string | null>(null)
// Update individual score mutation
const updateScoreMutation = createMutation(() => ({
mutationFn: async ({
scoreId,
score,
excused,
excuseReason
}: {
scoreId: string
score: number
excused: boolean
excuseReason: string
}) => {
return gwAdapter.updateIndividualScore(participationId, scoreId, {
score,
excused,
excuseReason: excused ? excuseReason : undefined
})
}
}))
// Delete individual score mutation
const deleteScoreMutation = createMutation(() => ({
mutationFn: async (scoreId: string) => {
return gwAdapter.deleteIndividualScore(participationId, scoreId)
}
}))
// Format number with commas
function formatScore(score: number): string {
return score.toLocaleString()
}
// Parse score string, removing commas
function parseScore(value: string): number {
return parseInt(value.replace(/,/g, ''), 10)
}
// Get label for a score
function getScoreLabel(score: GwIndividualScore): string {
if (score.isCumulative) {
return 'Total'
}
return GW_ROUND_LABELS[score.round as GwRound]
}
// Initialize edit values from current scores
function initializeEditValues() {
const values: Record<string, string> = {}
const excused: Record<string, boolean> = {}
const reasons: Record<string, string> = {}
for (const score of scores) {
values[score.id] = formatScore(score.score)
excused[score.id] = score.excused
reasons[score.id] = score.excuseReason ?? ''
}
editValues = values
excusedValues = excused
excuseReasonValues = reasons
error = null
}
// Check if any values have changed
const hasChanges = $derived.by(() => {
for (const score of scores) {
const editedValue = editValues[score.id]
if (editedValue !== undefined) {
const newValue = parseScore(editedValue)
if (!isNaN(newValue) && newValue !== score.score) {
return true
}
}
// Check excused changes
if (excusedValues[score.id] !== score.excused) {
return true
}
// Check excuse reason changes
if (excuseReasonValues[score.id] !== (score.excuseReason ?? '')) {
return true
}
}
return false
})
async function handleSave() {
isSubmitting = true
error = null
try {
// Update each score that has changed
for (const score of scores) {
const editedValue = editValues[score.id]
const newScoreValue = editedValue !== undefined ? parseScore(editedValue) : score.score
const newExcused = excusedValues[score.id] ?? score.excused
const newExcuseReason = excuseReasonValues[score.id] ?? ''
// Check if anything changed for this score
const scoreChanged = !isNaN(newScoreValue) && newScoreValue !== score.score
const excusedChanged = newExcused !== score.excused
const reasonChanged = newExcuseReason !== (score.excuseReason ?? '')
if (scoreChanged || excusedChanged || reasonChanged) {
await updateScoreMutation.mutateAsync({
scoreId: score.id,
score: newScoreValue,
excused: newExcused,
excuseReason: newExcuseReason
})
}
}
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
open = false
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save scores'
} finally {
isSubmitting = false
}
}
async function handleDelete(scoreId: string) {
if (!confirm('Are you sure you want to delete this score?')) return
try {
await deleteScoreMutation.mutateAsync(scoreId)
// Remove from edit values
const { [scoreId]: _, ...rest } = editValues
editValues = rest
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
// Close modal if no scores left
if (Object.keys(editValues).length === 0) {
open = false
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete score'
}
}
async function handleDeleteAll() {
if (!confirm('Are you sure you want to delete all scores for this player?')) return
try {
for (const score of scores) {
await deleteScoreMutation.mutateAsync(score.id)
}
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
open = false
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete scores'
}
}
function handleCancel() {
open = false
}
// Initialize values when modal opens
$effect(() => {
if (open) {
initializeEditValues()
}
})
</script>
<Dialog bind:open>
<ModalHeader title={playerName} description="Edit score" />
<ModalBody>
<div class="edit-content">
{#if scores.length === 0}
<p class="empty-message">No scores to edit</p>
{:else}
<div class="score-list">
{#each scores as score (score.id)}
{#if editValues[score.id] !== undefined}
<div class="score-entry">
<Input
label={getScoreLabel(score)}
type="text"
inputmode="numeric"
bind:value={editValues[score.id]}
fullWidth
contained
/>
<div class="excused-section">
<label class="checkbox-row">
<Checkbox bind:checked={excusedValues[score.id]} size="small" contained />
<span>Excused?</span>
</label>
{#if excusedValues[score.id]}
<textarea
class="excuse-textarea"
bind:value={excuseReasonValues[score.id]}
placeholder="Excusal reason (optional)"
rows="2"
></textarea>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/if}
{#if error}
<div class="error-message">
<p>{error}</p>
</div>
{/if}
</div>
</ModalBody>
<ModalFooter
onCancel={handleCancel}
primaryAction={{
label: isSubmitting ? 'Saving...' : 'Save',
onclick: handleSave,
disabled: isSubmitting || !hasChanges
}}
>
{#snippet left()}
{#if scores.length === 1}
<Button
variant="destructive-ghost"
onclick={() => handleDelete(scores[0].id)}
disabled={deleteScoreMutation.isPending}
>
Delete score
</Button>
{:else if scores.length > 1}
<Button
variant="destructive-ghost"
onclick={handleDeleteAll}
disabled={deleteScoreMutation.isPending}
>
Delete all scores
</Button>
{/if}
{/snippet}
</ModalFooter>
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.edit-content {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
.empty-message {
margin: 0;
font-size: typography.$font-small;
color: var(--text-secondary);
text-align: center;
padding: spacing.$unit-2x;
}
.score-list {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
.score-entry {
display: flex;
flex-direction: column;
gap: spacing.$unit;
padding-bottom: spacing.$unit-2x;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.excused-section {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.checkbox-row {
display: flex;
align-items: center;
gap: spacing.$unit;
cursor: pointer;
font-size: typography.$font-regular;
}
.excuse-textarea {
width: 100%;
padding: calc(spacing.$unit * 1.25) spacing.$unit-2x;
border: none;
border-radius: layout.$input-corner;
font-size: typography.$font-regular;
font-family: inherit;
background: var(--input-bound-bg);
color: var(--text-primary);
resize: vertical;
min-height: 60px;
&::placeholder {
color: var(--text-tertiary);
}
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
background: var(--input-bound-bg-hover);
}
}
.error-message {
padding: spacing.$unit;
background: colors.$error--bg--light;
border-radius: layout.$item-corner-small;
p {
margin: 0;
font-size: typography.$font-small;
color: colors.$error;
}
}
</style>

View file

@ -72,6 +72,8 @@ export interface GwIndividualScore {
round: GwRound
score: number
isCumulative: boolean
excused: boolean
excuseReason?: string // Only returned to crew officers
playerName: string
playerType: PlayerType
createdAt?: string
@ -125,6 +127,8 @@ export interface CreateIndividualScoreInput {
round: GwRound
score: number
isCumulative?: boolean
excused?: boolean
excuseReason?: string
}
// Batch score entry
@ -135,6 +139,8 @@ export interface BatchScoreEntry {
round: GwRound
score: number
isCumulative?: boolean
excused?: boolean
excuseReason?: string
}
export interface BatchIndividualScoresInput {

View file

@ -1,7 +1,7 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import { goto, replaceState } from '$app/navigation'
import { page } from '$app/stores'
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
@ -18,7 +18,17 @@
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
import EditScoreModal from '$lib/components/crew/EditScoreModal.svelte'
import { GW_ROUND_LABELS, type GwRound, type GwIndividualScore } from '$lib/types/api/gw'
import EditCrewScoreModal from '$lib/components/crew/EditCrewScoreModal.svelte'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import {
GW_ROUND_LABELS,
type GwRound,
type GwIndividualScore,
type GwCrewScore,
type CreateCrewScoreInput,
type UpdateCrewScoreInput
} from '$lib/types/api/gw'
import type { PageData } from './$types'
interface Props {
@ -44,6 +54,26 @@
const membersDuringEvent = $derived(eventQuery.data?.membersDuringEvent ?? [])
const phantomPlayers = $derived(eventQuery.data?.phantomPlayers ?? [])
// Tab state for switching between Individual and Crew honors
// Initialize from URL query param
const urlHasCrew = $page.url.searchParams.has('crew')
let activeTab = $state<'individual' | 'crew'>(urlHasCrew ? 'crew' : 'individual')
// Update URL when tab changes
function handleTabChange(newTab: 'individual' | 'crew') {
activeTab = newTab
const url = new URL($page.url)
if (newTab === 'crew') {
url.searchParams.set('crew', '')
} else {
url.searchParams.delete('crew')
}
replaceState(url, {})
}
// Crew scores from participation (Finals Day 1-4 only: rounds 2-5)
const crewScores = $derived(participation?.crewScores ?? [])
// Element labels (matches GranblueEnums::ELEMENTS)
const elementLabels: Record<number, string> = {
0: 'Null',
@ -156,6 +186,8 @@
4: '',
5: ''
})
let isExcused = $state(false)
let excuseReason = $state('')
let isSubmitting = $state(false)
// Player type tracking - value format is "member:id" or "phantom:id"
@ -255,7 +287,9 @@
...input,
round: 0, // Cumulative scores go to preliminaries
score,
isCumulative: true
isCumulative: true,
excused: isExcused,
excuseReason: isExcused ? excuseReason : undefined
})
} else {
// Batch add per-round scores
@ -272,7 +306,9 @@
...entry,
round,
score: value,
isCumulative: false
isCumulative: false,
excused: isExcused,
excuseReason: isExcused ? excuseReason : undefined
})
}
}
@ -305,6 +341,8 @@
isCumulative = true
cumulativeScore = ''
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
isExcused = false
excuseReason = ''
isSubmitting = false
}
@ -325,6 +363,23 @@
editingPlayer = player
showEditScoreModal = true
}
// ==================== Crew Score Modal ====================
let showCrewScoreModal = $state(false)
let editingCrewScoreRound = $state<GwRound | null>(null)
let editingCrewScore = $state<GwCrewScore | null>(null)
function openCrewScoreModal(round: GwRound, existingScore?: GwCrewScore) {
editingCrewScoreRound = round
editingCrewScore = existingScore ?? null
showCrewScoreModal = true
}
function closeCrewScoreModal() {
showCrewScoreModal = false
editingCrewScoreRound = null
editingCrewScore = null
}
</script>
<svelte:head>
@ -343,7 +398,7 @@
<Button variant="secondary" size="small" onclick={handleBack}>Back to Crew</Button>
</div>
{:else}
<CrewHeader title="GW #{gwEvent.eventNumber}">
<CrewHeader title="GW #{gwEvent.eventNumber}" backHref="/crew">
{#snippet belowTitle()}
<div class="event-meta">
<span class="element-badge element-{elementColors[gwEvent.element]}">
@ -353,9 +408,15 @@
{formatDate(gwEvent.startDate)} {formatDate(gwEvent.endDate)}
</span>
</div>
<div class="tab-control">
<SegmentedControl value={activeTab} onValueChange={(v) => handleTabChange(v as 'individual' | 'crew')} size="small" variant="background" grow>
<Segment value="individual">Individual</Segment>
<Segment value="crew">Crew</Segment>
</SegmentedControl>
</div>
{/snippet}
{#snippet actions()}
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming'}
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming' && activeTab === 'individual'}
<Button variant="primary" size="small" onclick={openScoreModal}>Add Score</Button>
{/if}
{/snippet}
@ -369,8 +430,8 @@
{/if}
</div>
{:else}
<!-- Score summary -->
{#if participation.totalScore !== undefined}
<!-- Score summary (crew tab only) -->
{#if activeTab === 'crew' && participation.totalScore !== undefined}
<div class="stats-row">
<div class="stat">
<span class="stat-value">{formatScore(participation.totalScore)}</span>
@ -387,52 +448,142 @@
</div>
{/if}
<!-- Player scores -->
<div class="section-header">
<span class="section-title">Individual Scores</span>
</div>
<!-- Individual Honors Tab -->
{#if activeTab === 'individual'}
<div class="section-header">
<span class="section-title">Individual Scores</span>
</div>
{#if playerScores.length > 0}
<ul class="player-list">
{#each playerScores as player, index}
<li class="player-item" class:retired={player.isRetired}>
<div class="player-info">
<span class="player-rank">{index + 1}</span>
<span class="player-name">{player.name}</span>
{#if player.isRetired}
<span class="player-badge retired">Retired</span>
{#if playerScores.length > 0}
<ul class="player-list">
{#each playerScores as player, index}
<li class="player-item" class:retired={player.isRetired}>
<div class="player-info">
<span class="player-rank">{index + 1}</span>
<span class="player-name">{player.name}</span>
{#if player.isRetired}
<span class="player-badge retired">Retired</span>
{/if}
{#if player.scores.some((s) => s.excused)}
<span class="player-badge excused">Excused</span>
{/if}
</div>
{#if player.type === 'phantom'}
<span class="player-type">Phantom</span>
{/if}
<div class="player-actions">
<span class="player-score">{formatScore(player.totalScore)}</span>
{#if crewStore.isOfficer && player.scores.length > 0}
<DropdownMenu>
{#snippet trigger({ props })}
<Button
variant="secondary"
size="small"
iconOnly
icon="ellipsis"
{...props}
/>
{/snippet}
{#snippet menu()}
{#if player.type === 'member'}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => goto(`/${player.name}`)}
>
View profile
</DropdownMenuBase.Item>
{/if}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openEditScoreModal(player)}
>
Edit score...
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
{/if}
</div>
</li>
{/each}
</ul>
{:else}
<p class="empty-state">No scores recorded yet</p>
{/if}
{/if}
<!-- Crew Honors Tab -->
{#if activeTab === 'crew'}
<div class="crew-score-table">
<div class="crew-score-header">
<span class="col-round">Round</span>
<span class="col-our-score">Our Score</span>
<span class="col-their-score">Their Score</span>
<span class="col-actions"></span>
</div>
{#each [2, 3, 4, 5] as round (round)}
{@const score = crewScores.find((s) => s.round === round)}
<div class="crew-score-row">
<div class="col-round">
<span class="round-label">{GW_ROUND_LABELS[round as GwRound]}</span>
{#if score?.victory !== undefined && score.victory !== null}
<span class="result-badge" class:win={score.victory} class:loss={!score.victory}>
{score.victory ? 'Win' : 'Loss'}
</span>
{/if}
</div>
{#if player.type === 'phantom'}
<span class="player-type">Phantom</span>
{/if}
<span class="player-score">{formatScore(player.totalScore)}</span>
{#if crewStore.isOfficer && player.scores.length > 0}
<DropdownMenu>
{#snippet trigger({ props })}
<Button
variant="secondary"
size="small"
iconOnly
icon="ellipsis"
{...props}
/>
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openEditScoreModal(player)}
>
Edit score...
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
{/if}
</li>
<div class="col-our-score">
{#if score}
<span class="score-value" class:winner={score.victory === true}>{formatScore(score.crewScore)}</span>
{:else}
<span class="score-empty"></span>
{/if}
</div>
<div class="col-their-score">
{#if score}
{#if score.opponentScore !== null}
<span class="score-value" class:winner={score.victory === false}>{formatScore(score.opponentScore)}</span>
{/if}
{#if score.opponentName}
<span class="opponent-name">({score.opponentName})</span>
{/if}
{:else}
<span class="score-empty"></span>
{/if}
</div>
<div class="col-actions">
{#if score}
{#if crewStore.isOfficer}
<DropdownMenu>
{#snippet trigger({ props })}
<Button
variant="secondary"
size="small"
iconOnly
icon="ellipsis"
{...props}
/>
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openCrewScoreModal(round as GwRound, score)}
>
Edit score...
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
{/if}
{:else}
{#if crewStore.isOfficer}
<Button size="small" onclick={() => openCrewScoreModal(round as GwRound)}>
Record
</Button>
{/if}
{/if}
</div>
</div>
{/each}
</ul>
{:else}
<p class="empty-state">No scores recorded yet</p>
</div>
{/if}
{/if}
{/if}
@ -485,6 +636,21 @@
</div>
{/if}
<div class="excused-section">
<label class="checkbox-row">
<Checkbox bind:checked={isExcused} size="small" contained />
<span>Excused?</span>
</label>
{#if isExcused}
<textarea
class="excuse-textarea"
bind:value={excuseReason}
placeholder="Excusal reason (optional)"
rows="2"
></textarea>
{/if}
</div>
{#if addScoreMutation.isError}
<p class="error-message">
{addScoreMutation.error?.message ?? 'Failed to add score'}
@ -505,6 +671,28 @@
/>
</Dialog>
<!-- Edit Score Modal -->
{#if participation?.id && editingPlayer}
<EditScoreModal
bind:open={showEditScoreModal}
participationId={participation.id}
eventNumber={eventNumber ?? ''}
playerName={editingPlayer.name}
scores={editingPlayer.scores}
/>
{/if}
<!-- Edit Crew Score Modal -->
{#if participation?.id && editingCrewScoreRound !== null}
<EditCrewScoreModal
bind:open={showCrewScoreModal}
participationId={participation.id}
eventNumber={eventNumber ?? ''}
round={editingCrewScoreRound}
existingScore={editingCrewScore}
/>
{/if}
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/effects' as effects;
@ -710,6 +898,11 @@
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
}
&.excused {
background: var(--color-yellow-light, #fef9c3);
color: var(--color-yellow-dark, #854d0e);
}
}
.player-type {
@ -719,6 +912,12 @@
margin-right: spacing.$unit;
}
.player-actions {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.player-score {
font-size: typography.$font-small;
font-weight: typography.$medium;
@ -758,9 +957,153 @@
border-radius: layout.$card-corner;
}
.excused-section {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.excuse-textarea {
width: 100%;
padding: spacing.$unit;
border: none;
border-radius: layout.$input-corner;
font-size: typography.$font-small;
font-family: inherit;
background: var(--input-bound-bg);
color: var(--text-primary);
resize: vertical;
min-height: 60px;
&::placeholder {
color: var(--text-tertiary);
}
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
background: var(--input-bound-bg-hover);
}
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
margin: 0;
}
// Tab control
.tab-control {
margin-top: spacing.$unit;
}
// Crew scores table
.crew-score-table {
display: flex;
flex-direction: column;
}
.crew-score-header {
display: flex;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-secondary);
}
.crew-score-row {
display: flex;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
transition: background-color 0.15s;
&:last-child {
border-bottom: none;
}
&:hover {
background: rgba(0, 0, 0, 0.02);
}
}
.col-round {
flex: 2;
display: flex;
align-items: center;
gap: spacing.$unit;
}
.col-our-score {
width: 120px;
display: flex;
align-items: center;
gap: spacing.$unit-half;
}
.col-their-score {
flex: 1;
display: flex;
align-items: center;
gap: spacing.$unit-half;
}
.col-actions {
width: 80px;
display: flex;
justify-content: flex-end;
}
.round-label {
font-size: typography.$font-small;
font-weight: typography.$medium;
}
.score-value {
font-size: typography.$font-small;
font-variant-numeric: tabular-nums;
&.winner {
font-weight: typography.$medium;
}
}
.score-empty {
font-size: typography.$font-small;
color: var(--text-tertiary);
}
.opponent-name {
font-size: typography.$font-small;
color: var(--text-tertiary);
}
.result-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 spacing.$unit-half;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
font-weight: typography.$medium;
&.win {
background: var(--color-green-light, #dcfce7);
color: var(--color-green-dark, #166534);
}
&.loss {
background: colors.$error--bg--light;
color: colors.$error;
}
}
</style>