gw event page improvements
- total honors instead of total score - muted style for players with existing scores - by-event score endpoints
This commit is contained in:
parent
e7dfca992a
commit
f4d04a7073
3 changed files with 1210 additions and 0 deletions
322
src/lib/api/adapters/gw.adapter.ts
Normal file
322
src/lib/api/adapters/gw.adapter.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { BaseAdapter } from './base.adapter'
|
||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||
import type { RequestOptions } from './types'
|
||||
import type {
|
||||
GwEvent,
|
||||
CrewGwParticipation,
|
||||
GwCrewScore,
|
||||
GwIndividualScore,
|
||||
CreateGwEventInput,
|
||||
UpdateGwEventInput,
|
||||
UpdateParticipationRankingInput,
|
||||
CreateCrewScoreInput,
|
||||
UpdateCrewScoreInput,
|
||||
CreateIndividualScoreInput,
|
||||
BatchIndividualScoresInput
|
||||
} from '$lib/types/api/gw'
|
||||
|
||||
/**
|
||||
* Adapter for Guild War (Unite and Fight) API operations
|
||||
*/
|
||||
export class GwAdapter extends BaseAdapter {
|
||||
// ==================== GW Event Operations ====================
|
||||
|
||||
/**
|
||||
* Get all GW events
|
||||
*/
|
||||
async getEvents(options?: RequestOptions): Promise<GwEvent[]> {
|
||||
const response = await this.request<{ gwEvents: GwEvent[] }>('/gw_events', options)
|
||||
return response.gwEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single GW event
|
||||
*/
|
||||
async getEvent(eventId: string, options?: RequestOptions): Promise<GwEvent> {
|
||||
const response = await this.request<{ gwEvent: GwEvent }>(`/gw_events/${eventId}`, options)
|
||||
return response.gwEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GW event (admin only)
|
||||
*/
|
||||
async createEvent(input: CreateGwEventInput, options?: RequestOptions): Promise<GwEvent> {
|
||||
const response = await this.request<{ gwEvent: GwEvent }>('/gw_events', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ gw_event: input })
|
||||
})
|
||||
this.clearCache('/gw_events')
|
||||
return response.gwEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a GW event (admin only)
|
||||
*/
|
||||
async updateEvent(eventId: string, input: UpdateGwEventInput, options?: RequestOptions): Promise<GwEvent> {
|
||||
const response = await this.request<{ gwEvent: GwEvent }>(`/gw_events/${eventId}`, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ gw_event: input })
|
||||
})
|
||||
this.clearCache('/gw_events')
|
||||
this.clearCache(`/gw_events/${eventId}`)
|
||||
return response.gwEvent
|
||||
}
|
||||
|
||||
// ==================== Participation Operations ====================
|
||||
|
||||
/**
|
||||
* Join a GW event (creates participation for current crew)
|
||||
*/
|
||||
async joinEvent(eventId: string, options?: RequestOptions): Promise<CrewGwParticipation> {
|
||||
const response = await this.request<{ participation: CrewGwParticipation }>(
|
||||
`/gw_events/${eventId}/participations`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/gw_participations')
|
||||
return response.participation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all crew's GW participations
|
||||
*/
|
||||
async getParticipations(options?: RequestOptions): Promise<CrewGwParticipation[]> {
|
||||
const response = await this.request<{ participations: CrewGwParticipation[] }>(
|
||||
'/crew/gw_participations',
|
||||
options
|
||||
)
|
||||
return response.participations
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single participation with scores
|
||||
*/
|
||||
async getParticipation(participationId: string, options?: RequestOptions): Promise<CrewGwParticipation> {
|
||||
const response = await this.request<{ crewGwParticipation: CrewGwParticipation }>(
|
||||
`/crew/gw_participations/${participationId}`,
|
||||
options
|
||||
)
|
||||
return response.crewGwParticipation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event and participation by event ID or event number
|
||||
* Returns the event (if found), the crew's participation (if any), members active during the event, and phantom players
|
||||
*/
|
||||
async getEventWithParticipation(
|
||||
eventIdOrNumber: string | number,
|
||||
options?: RequestOptions
|
||||
): Promise<{
|
||||
gwEvent: GwEvent | null
|
||||
participation: CrewGwParticipation | null
|
||||
membersDuringEvent: Array<{ id: string; user?: { id: string; username: string }; retired: boolean }>
|
||||
phantomPlayers: Array<{ id: string; name: string; retired: boolean }>
|
||||
}> {
|
||||
const response = await this.request<{
|
||||
gwEvent: GwEvent | null
|
||||
crewGwParticipation: CrewGwParticipation | null
|
||||
membersDuringEvent: Array<{ id: string; user?: { id: string; username: string }; retired: boolean }>
|
||||
phantomPlayers: Array<{ id: string; name: string; retired: boolean }>
|
||||
}>(`/crew/gw_participations/by_event/${eventIdOrNumber}`, options)
|
||||
return {
|
||||
gwEvent: response.gwEvent,
|
||||
participation: response.crewGwParticipation,
|
||||
membersDuringEvent: response.membersDuringEvent ?? [],
|
||||
phantomPlayers: response.phantomPlayers ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update participation rankings
|
||||
*/
|
||||
async updateParticipationRanking(
|
||||
participationId: string,
|
||||
input: UpdateParticipationRankingInput,
|
||||
options?: RequestOptions
|
||||
): Promise<CrewGwParticipation> {
|
||||
const response = await this.request<{ participation: CrewGwParticipation }>(
|
||||
`/crew/gw_participations/${participationId}`,
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ participation: input })
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/gw_participations')
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
return response.participation
|
||||
}
|
||||
|
||||
// ==================== Crew Score Operations ====================
|
||||
|
||||
/**
|
||||
* Add a crew score for a round
|
||||
*/
|
||||
async addCrewScore(
|
||||
participationId: string,
|
||||
input: CreateCrewScoreInput,
|
||||
options?: RequestOptions
|
||||
): Promise<GwCrewScore> {
|
||||
const response = await this.request<{ crewScore: GwCrewScore }>(
|
||||
`/crew/gw_participations/${participationId}/crew_scores`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ crew_score: input })
|
||||
}
|
||||
)
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
return response.crewScore
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a crew score
|
||||
*/
|
||||
async updateCrewScore(
|
||||
participationId: string,
|
||||
scoreId: string,
|
||||
input: UpdateCrewScoreInput,
|
||||
options?: RequestOptions
|
||||
): Promise<GwCrewScore> {
|
||||
const response = await this.request<{ crewScore: GwCrewScore }>(
|
||||
`/crew/gw_participations/${participationId}/crew_scores/${scoreId}`,
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ crew_score: input })
|
||||
}
|
||||
)
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
return response.crewScore
|
||||
}
|
||||
|
||||
// ==================== Individual Score Operations ====================
|
||||
|
||||
/**
|
||||
* Add an individual score
|
||||
*/
|
||||
async addIndividualScore(
|
||||
participationId: string,
|
||||
input: CreateIndividualScoreInput,
|
||||
options?: RequestOptions
|
||||
): Promise<GwIndividualScore> {
|
||||
const response = await this.request<{ individualScore: GwIndividualScore }>(
|
||||
`/crew/gw_participations/${participationId}/individual_scores`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ individual_score: input })
|
||||
}
|
||||
)
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
return response.individualScore
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch add individual scores
|
||||
*/
|
||||
async batchAddIndividualScores(
|
||||
participationId: string,
|
||||
input: BatchIndividualScoresInput,
|
||||
options?: RequestOptions
|
||||
): Promise<GwIndividualScore[]> {
|
||||
const response = await this.request<{ individualScores: GwIndividualScore[] }>(
|
||||
`/crew/gw_participations/${participationId}/individual_scores/batch`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
)
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
return response.individualScores
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an individual score
|
||||
*/
|
||||
async updateIndividualScore(
|
||||
participationId: string,
|
||||
scoreId: string,
|
||||
input: Partial<CreateIndividualScoreInput>,
|
||||
options?: RequestOptions
|
||||
): Promise<GwIndividualScore> {
|
||||
const response = await this.request<{ individualScore: GwIndividualScore }>(
|
||||
`/crew/gw_participations/${participationId}/individual_scores/${scoreId}`,
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ individual_score: input })
|
||||
}
|
||||
)
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
return response.individualScore
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an individual score
|
||||
*/
|
||||
async deleteIndividualScore(
|
||||
participationId: string,
|
||||
scoreId: string,
|
||||
options?: RequestOptions
|
||||
): Promise<void> {
|
||||
await this.request<void>(
|
||||
`/crew/gw_participations/${participationId}/individual_scores/${scoreId}`,
|
||||
{
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
this.clearCache(`/crew/gw_participations/${participationId}`)
|
||||
}
|
||||
|
||||
// ==================== Individual Score Operations (by Event) ====================
|
||||
// These endpoints auto-create participation if needed (officers only)
|
||||
|
||||
/**
|
||||
* Add an individual score by event ID (auto-creates participation)
|
||||
*/
|
||||
async addIndividualScoreByEvent(
|
||||
eventId: string,
|
||||
input: CreateIndividualScoreInput,
|
||||
options?: RequestOptions
|
||||
): Promise<GwIndividualScore> {
|
||||
const response = await this.request<{ individualScore: GwIndividualScore }>(
|
||||
`/crew/gw_events/${eventId}/individual_scores`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ individual_score: input })
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/gw_participations')
|
||||
return response.individualScore
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch add individual scores by event ID (auto-creates participation)
|
||||
*/
|
||||
async batchAddIndividualScoresByEvent(
|
||||
eventId: string,
|
||||
input: BatchIndividualScoresInput,
|
||||
options?: RequestOptions
|
||||
): Promise<GwIndividualScore[]> {
|
||||
const response = await this.request<{ individualScores: GwIndividualScore[] }>(
|
||||
`/crew/gw_events/${eventId}/individual_scores/batch`,
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
)
|
||||
this.clearCache('/crew/gw_participations')
|
||||
return response.individualScores
|
||||
}
|
||||
}
|
||||
|
||||
export const gwAdapter = new GwAdapter(DEFAULT_ADAPTER_CONFIG)
|
||||
156
src/lib/types/api/gw.ts
Normal file
156
src/lib/types/api/gw.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Guild War (Unite and Fight) types based on Rails blueprints
|
||||
// These define the GW event and scoring structure
|
||||
|
||||
import type { CrewMembership, PhantomPlayer } from './crew'
|
||||
|
||||
// GW round numbers
|
||||
// 0 = Preliminaries
|
||||
// 1 = Interlude
|
||||
// 2-5 = Finals Day 1-4
|
||||
export type GwRound = 0 | 1 | 2 | 3 | 4 | 5
|
||||
|
||||
// Round labels for display
|
||||
export const GW_ROUND_LABELS: Record<GwRound, string> = {
|
||||
0: 'Preliminaries',
|
||||
1: 'Interlude',
|
||||
2: 'Finals Day 1',
|
||||
3: 'Finals Day 2',
|
||||
4: 'Finals Day 3',
|
||||
5: 'Finals Day 4'
|
||||
}
|
||||
|
||||
// GwEvent from GwEventBlueprint
|
||||
export interface GwEvent {
|
||||
id: string
|
||||
element: number // Uses GranblueEnums.ELEMENTS (0-5)
|
||||
startDate: string
|
||||
endDate: string
|
||||
eventNumber: number // GW #XX
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
// CrewGwParticipation from CrewGwParticipationBlueprint
|
||||
export interface CrewGwParticipation {
|
||||
id: string
|
||||
preliminaryRanking: number | null
|
||||
finalRanking: number | null
|
||||
createdAt?: string
|
||||
// From :with_event view
|
||||
gwEvent?: GwEvent
|
||||
// From :with_scores view
|
||||
crewScores?: GwCrewScore[]
|
||||
individualScores?: GwIndividualScore[]
|
||||
}
|
||||
|
||||
// GwCrewScore from GwCrewScoreBlueprint
|
||||
export interface GwCrewScore {
|
||||
id: string
|
||||
round: GwRound
|
||||
crewScore: number
|
||||
opponentScore: number | null
|
||||
opponentName: string | null
|
||||
opponentGranblueId: string | null
|
||||
victory: boolean | null
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// Player type in individual scores
|
||||
export type PlayerType = 'member' | 'phantom'
|
||||
|
||||
// GwIndividualScore from GwIndividualScoreBlueprint
|
||||
export interface GwIndividualScore {
|
||||
id: string
|
||||
round: GwRound
|
||||
score: number
|
||||
isCumulative: boolean
|
||||
playerName: string
|
||||
playerType: PlayerType
|
||||
createdAt?: string
|
||||
// From :with_member view
|
||||
member?: CrewMembership
|
||||
phantom?: PhantomPlayer
|
||||
}
|
||||
|
||||
// Input types for mutations
|
||||
|
||||
export interface CreateGwEventInput {
|
||||
element: number
|
||||
startDate: string
|
||||
endDate: string
|
||||
eventNumber: number
|
||||
}
|
||||
|
||||
export interface UpdateGwEventInput {
|
||||
element?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
eventNumber?: number
|
||||
}
|
||||
|
||||
export interface UpdateParticipationRankingInput {
|
||||
preliminaryRanking?: number
|
||||
finalRanking?: number
|
||||
}
|
||||
|
||||
export interface CreateCrewScoreInput {
|
||||
round: GwRound
|
||||
crewScore: number
|
||||
opponentScore?: number
|
||||
opponentName?: string
|
||||
opponentGranblueId?: string
|
||||
victory?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateCrewScoreInput {
|
||||
crewScore?: number
|
||||
opponentScore?: number
|
||||
opponentName?: string
|
||||
opponentGranblueId?: string
|
||||
victory?: boolean
|
||||
}
|
||||
|
||||
export interface CreateIndividualScoreInput {
|
||||
// Either crewMembershipId OR phantomPlayerId, not both
|
||||
crewMembershipId?: string
|
||||
phantomPlayerId?: string
|
||||
round: GwRound
|
||||
score: number
|
||||
isCumulative?: boolean
|
||||
}
|
||||
|
||||
// Batch score entry
|
||||
export interface BatchScoreEntry {
|
||||
// For member type, use crewMembershipId; for phantom type, use phantomPlayerId
|
||||
crewMembershipId?: string
|
||||
phantomPlayerId?: string
|
||||
round: GwRound
|
||||
score: number
|
||||
isCumulative?: boolean
|
||||
}
|
||||
|
||||
export interface BatchIndividualScoresInput {
|
||||
scores: BatchScoreEntry[]
|
||||
}
|
||||
|
||||
// Aggregated data for visualization
|
||||
|
||||
export interface GwLeaderboardEntry {
|
||||
playerId: string
|
||||
playerName: string
|
||||
playerType: PlayerType
|
||||
totalScore: number
|
||||
roundScores: Record<GwRound, number>
|
||||
}
|
||||
|
||||
export interface GwChartDataPoint {
|
||||
round: GwRound
|
||||
roundLabel: string
|
||||
crewScore: number
|
||||
opponentScore: number | null
|
||||
memberContributions: Array<{
|
||||
playerId: string
|
||||
playerName: string
|
||||
score: number
|
||||
}>
|
||||
}
|
||||
732
src/routes/(app)/crew/events/[eventNumber]/+page.svelte
Normal file
732
src/routes/(app)/crew/events/[eventNumber]/+page.svelte
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Dialog from '$lib/components/ui/Dialog.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 Select from '$lib/components/ui/Select.svelte'
|
||||
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||
import { GW_ROUND_LABELS, type GwRound } from '$lib/types/api/gw'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Get event number from URL
|
||||
const eventNumber = $derived($page.params.eventNumber)
|
||||
|
||||
// Query for event and participation data
|
||||
const eventQuery = createQuery(() => ({
|
||||
queryKey: ['crew', 'gw', 'event', eventNumber],
|
||||
queryFn: () => gwAdapter.getEventWithParticipation(eventNumber),
|
||||
enabled: !!eventNumber && crewStore.isInCrew
|
||||
}))
|
||||
|
||||
const gwEvent = $derived(eventQuery.data?.gwEvent)
|
||||
const participation = $derived(eventQuery.data?.participation)
|
||||
const membersDuringEvent = $derived(eventQuery.data?.membersDuringEvent ?? [])
|
||||
const phantomPlayers = $derived(eventQuery.data?.phantomPlayers ?? [])
|
||||
|
||||
// Element labels (matches GranblueEnums::ELEMENTS)
|
||||
const elementLabels: Record<number, string> = {
|
||||
0: 'Null',
|
||||
1: 'Wind',
|
||||
2: 'Fire',
|
||||
3: 'Water',
|
||||
4: 'Earth',
|
||||
5: 'Dark',
|
||||
6: 'Light'
|
||||
}
|
||||
|
||||
const elementColors: Record<number, string> = {
|
||||
0: 'null',
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
|
||||
// Aggregate scores by player, including members with 0 score
|
||||
interface PlayerScore {
|
||||
id: string
|
||||
name: string
|
||||
type: 'member' | 'phantom'
|
||||
totalScore: number
|
||||
isRetired?: boolean
|
||||
}
|
||||
|
||||
const playerScores = $derived.by(() => {
|
||||
const scoreMap = new Map<string, PlayerScore>()
|
||||
|
||||
// First, add all members who were active during the event (with 0 score)
|
||||
for (const member of membersDuringEvent) {
|
||||
if (member.user) {
|
||||
scoreMap.set(member.id, {
|
||||
id: member.id,
|
||||
name: member.user.username,
|
||||
type: 'member',
|
||||
totalScore: 0,
|
||||
isRetired: member.retired
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Then add/update with actual scores
|
||||
if (participation?.individualScores) {
|
||||
for (const score of participation.individualScores) {
|
||||
const playerId = score.member?.id ?? score.phantom?.id ?? score.playerName
|
||||
const existing = scoreMap.get(playerId)
|
||||
|
||||
if (existing) {
|
||||
existing.totalScore += score.score
|
||||
} else {
|
||||
// Phantom player or member not in membersDuringEvent
|
||||
scoreMap.set(playerId, {
|
||||
id: playerId,
|
||||
name: score.playerName,
|
||||
type: score.playerType,
|
||||
totalScore: score.score,
|
||||
isRetired: score.member?.retired
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by total score descending
|
||||
return Array.from(scoreMap.values()).sort((a, b) => b.totalScore - a.totalScore)
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
function handleBack() {
|
||||
goto('/crew')
|
||||
}
|
||||
|
||||
// ==================== Add Score Modal ====================
|
||||
let showScoreModal = $state(false)
|
||||
let selectedPlayerId = $state<string | undefined>(undefined)
|
||||
let isCumulative = $state(true)
|
||||
let cumulativeScore = $state('')
|
||||
let roundScores = $state<Record<GwRound, string>>({
|
||||
0: '',
|
||||
1: '',
|
||||
2: '',
|
||||
3: '',
|
||||
4: '',
|
||||
5: ''
|
||||
})
|
||||
let isSubmitting = $state(false)
|
||||
|
||||
// Player type tracking - value format is "member:id" or "phantom:id"
|
||||
const selectedPlayerType = $derived.by(() => {
|
||||
if (!selectedPlayerId) return null
|
||||
const [type] = selectedPlayerId.split(':')
|
||||
return type as 'member' | 'phantom'
|
||||
})
|
||||
|
||||
const selectedPlayerActualId = $derived.by(() => {
|
||||
if (!selectedPlayerId) return null
|
||||
const [, id] = selectedPlayerId.split(':')
|
||||
return id
|
||||
})
|
||||
|
||||
// Track which players already have scores for this event
|
||||
const playersWithScores = $derived.by(() => {
|
||||
const ids = new Set<string>()
|
||||
if (participation?.individualScores) {
|
||||
for (const score of participation.individualScores) {
|
||||
if (score.member?.id) {
|
||||
ids.add(`member:${score.member.id}`)
|
||||
} else if (score.phantom?.id) {
|
||||
ids.add(`phantom:${score.phantom.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
// Player options for dropdown - members first, then phantoms
|
||||
const playerOptions = $derived.by(() => {
|
||||
const options: Array<{ value: string; label: string; suffix?: string; muted?: boolean }> = []
|
||||
|
||||
// Add members
|
||||
for (const m of membersDuringEvent) {
|
||||
if (m.user) {
|
||||
const hasScore = playersWithScores.has(`member:${m.id}`)
|
||||
options.push({
|
||||
value: `member:${m.id}`,
|
||||
label: m.user.username + (m.retired ? ' (Retired)' : ''),
|
||||
muted: hasScore
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add phantoms
|
||||
for (const p of phantomPlayers) {
|
||||
const hasScore = playersWithScores.has(`phantom:${p.id}`)
|
||||
options.push({
|
||||
value: `phantom:${p.id}`,
|
||||
label: p.name + (p.retired ? ' (Retired)' : ''),
|
||||
suffix: 'Phantom',
|
||||
muted: hasScore
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Calculate cumulative from round scores
|
||||
const calculatedCumulativeScore = $derived.by(() => {
|
||||
if (isCumulative) return 0
|
||||
let total = 0
|
||||
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
|
||||
const value = parseScore(roundScores[round] || '0')
|
||||
if (!isNaN(value)) total += value
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
// Sync cumulative when round scores change
|
||||
$effect(() => {
|
||||
if (!isCumulative) {
|
||||
cumulativeScore = formatScore(calculatedCumulativeScore)
|
||||
}
|
||||
})
|
||||
|
||||
// Add score mutation - uses by-event endpoint which auto-creates participation
|
||||
const addScoreMutation = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!gwEvent?.id || !selectedPlayerActualId || !selectedPlayerType) {
|
||||
throw new Error('Missing event or player')
|
||||
}
|
||||
|
||||
if (isCumulative) {
|
||||
// Single cumulative score
|
||||
const score = parseScore(cumulativeScore)
|
||||
if (isNaN(score)) throw new Error('Invalid score')
|
||||
|
||||
const input =
|
||||
selectedPlayerType === 'member'
|
||||
? { crewMembershipId: selectedPlayerActualId }
|
||||
: { phantomPlayerId: selectedPlayerActualId }
|
||||
|
||||
return gwAdapter.addIndividualScoreByEvent(gwEvent.id, {
|
||||
...input,
|
||||
round: 0, // Cumulative scores go to preliminaries
|
||||
score,
|
||||
isCumulative: true
|
||||
})
|
||||
} else {
|
||||
// Batch add per-round scores
|
||||
const scores = []
|
||||
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
|
||||
const value = parseScore(roundScores[round] || '0')
|
||||
if (!isNaN(value) && value > 0) {
|
||||
const entry =
|
||||
selectedPlayerType === 'member'
|
||||
? { crewMembershipId: selectedPlayerActualId }
|
||||
: { phantomPlayerId: selectedPlayerActualId }
|
||||
|
||||
scores.push({
|
||||
...entry,
|
||||
round,
|
||||
score: value,
|
||||
isCumulative: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (scores.length === 0) {
|
||||
throw new Error('No scores entered')
|
||||
}
|
||||
|
||||
return gwAdapter.batchAddIndividualScoresByEvent(gwEvent.id, { scores })
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
|
||||
closeScoreModal()
|
||||
}
|
||||
}))
|
||||
|
||||
function openScoreModal() {
|
||||
showScoreModal = true
|
||||
resetScoreForm()
|
||||
}
|
||||
|
||||
function closeScoreModal() {
|
||||
showScoreModal = false
|
||||
resetScoreForm()
|
||||
}
|
||||
|
||||
function resetScoreForm() {
|
||||
selectedPlayerId = undefined
|
||||
isCumulative = true
|
||||
cumulativeScore = ''
|
||||
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
|
||||
isSubmitting = false
|
||||
}
|
||||
|
||||
async function handleSubmitScore() {
|
||||
isSubmitting = true
|
||||
try {
|
||||
await addScoreMutation.mutateAsync()
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>GW #{eventNumber} | Crew | Hensei</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
{#if eventQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else if !gwEvent}
|
||||
<div class="error-state">
|
||||
<p>Event not found</p>
|
||||
<Button variant="secondary" size="small" onclick={handleBack}>Back to Crew</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CrewHeader title="GW #{gwEvent.eventNumber}">
|
||||
{#snippet belowTitle()}
|
||||
<div class="event-meta">
|
||||
<span class="element-badge element-{elementColors[gwEvent.element]}">
|
||||
{elementLabels[gwEvent.element] ?? 'Unknown'}
|
||||
</span>
|
||||
<span class="event-dates">
|
||||
{formatDate(gwEvent.startDate)} – {formatDate(gwEvent.endDate)}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming'}
|
||||
<Button variant="primary" size="small" onclick={openScoreModal}>Add Score</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CrewHeader>
|
||||
|
||||
{#if !participation}
|
||||
<div class="not-participating">
|
||||
<p>No scores recorded yet for this event.</p>
|
||||
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming'}
|
||||
<p class="hint">Click "Add Score" above to start recording scores.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Score summary -->
|
||||
{#if participation.totalScore !== undefined}
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{formatScore(participation.totalScore)}</span>
|
||||
<span class="stat-label">Total Honors</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{participation.wins ?? 0}</span>
|
||||
<span class="stat-label">Wins</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{participation.losses ?? 0}</span>
|
||||
<span class="stat-label">Losses</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Player scores -->
|
||||
<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}
|
||||
</div>
|
||||
{#if player.type === 'phantom'}
|
||||
<span class="player-type">Phantom</span>
|
||||
{/if}
|
||||
<span class="player-score">{formatScore(player.totalScore)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty-state">No scores recorded yet</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Score Modal -->
|
||||
<Dialog bind:open={showScoreModal} onOpenChange={(open) => !open && closeScoreModal()}>
|
||||
<ModalHeader title="Add Score" onClose={closeScoreModal} />
|
||||
<ModalBody>
|
||||
<div class="score-form">
|
||||
<Select
|
||||
options={playerOptions}
|
||||
bind:value={selectedPlayerId}
|
||||
placeholder="Select player"
|
||||
label="Player"
|
||||
fullWidth
|
||||
contained
|
||||
portal
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Cumulative Score"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={cumulativeScore}
|
||||
placeholder="Enter total score"
|
||||
disabled={!isCumulative}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<Checkbox bind:checked={isCumulative} size="small" />
|
||||
<span>Cumulative score</span>
|
||||
</label>
|
||||
|
||||
{#if !isCumulative}
|
||||
<div class="round-scores">
|
||||
{#each [0, 1, 2, 3, 4, 5] as round (round)}
|
||||
<Input
|
||||
label={GW_ROUND_LABELS[round as GwRound]}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={roundScores[round as GwRound]}
|
||||
placeholder="0"
|
||||
fullWidth
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if addScoreMutation.isError}
|
||||
<p class="error-message">
|
||||
{addScoreMutation.error?.message ?? 'Failed to add score'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" size="small" onclick={closeScoreModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onclick={handleSubmitScore}
|
||||
disabled={isSubmitting ||
|
||||
!selectedPlayerId ||
|
||||
(!isCumulative && calculatedCumulativeScore === 0) ||
|
||||
(isCumulative && !cumulativeScore)}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Dialog>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
@use '$src/themes/layout' as layout;
|
||||
|
||||
.page {
|
||||
padding: spacing.$unit-2x 0;
|
||||
margin: 0 auto;
|
||||
max-width: var(--main-max-width);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: spacing.$unit-4x;
|
||||
gap: spacing.$unit-2x;
|
||||
color: var(--text-secondary);
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.event-dates {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.element-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
|
||||
&.element-null {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.element-fire {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.element-water {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.element-earth {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.element-wind {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.element-light {
|
||||
background: #fef9c3;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&.element-dark {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.not-participating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
padding: spacing.$unit-4x;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: typography.$font-small;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: spacing.$unit-2x;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: typography.$font-medium;
|
||||
font-weight: typography.$medium;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section-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);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.player-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: layout.$item-corner;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
&.retired {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-rank {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-secondary);
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
|
||||
.player-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
|
||||
&.phantom {
|
||||
background: var(--color-purple-light, #ede9fe);
|
||||
color: var(--color-purple-dark, #7c3aed);
|
||||
}
|
||||
|
||||
&.retired {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.player-type {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
margin-right: spacing.$unit;
|
||||
}
|
||||
|
||||
.player-score {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 108px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: spacing.$unit-3x;
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
// Score form styles
|
||||
.score-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
cursor: pointer;
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
.round-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
padding: spacing.$unit-2x;
|
||||
background: var(--input-section-bg);
|
||||
border-radius: layout.$card-corner;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: colors.$error;
|
||||
font-size: typography.$font-small;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue