From f4d04a7073d581d3d48431dd1e369864d299d686 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 4 Dec 2025 03:02:58 -0800 Subject: [PATCH] gw event page improvements - total honors instead of total score - muted style for players with existing scores - by-event score endpoints --- src/lib/api/adapters/gw.adapter.ts | 322 ++++++++ src/lib/types/api/gw.ts | 156 ++++ .../crew/events/[eventNumber]/+page.svelte | 732 ++++++++++++++++++ 3 files changed, 1210 insertions(+) create mode 100644 src/lib/api/adapters/gw.adapter.ts create mode 100644 src/lib/types/api/gw.ts create mode 100644 src/routes/(app)/crew/events/[eventNumber]/+page.svelte diff --git a/src/lib/api/adapters/gw.adapter.ts b/src/lib/api/adapters/gw.adapter.ts new file mode 100644 index 00000000..9ad65425 --- /dev/null +++ b/src/lib/api/adapters/gw.adapter.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + options?: RequestOptions + ): Promise { + 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 { + await this.request( + `/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 { + 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 { + 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) diff --git a/src/lib/types/api/gw.ts b/src/lib/types/api/gw.ts new file mode 100644 index 00000000..9916214d --- /dev/null +++ b/src/lib/types/api/gw.ts @@ -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 = { + 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 +} + +export interface GwChartDataPoint { + round: GwRound + roundLabel: string + crewScore: number + opponentScore: number | null + memberContributions: Array<{ + playerId: string + playerName: string + score: number + }> +} diff --git a/src/routes/(app)/crew/events/[eventNumber]/+page.svelte b/src/routes/(app)/crew/events/[eventNumber]/+page.svelte new file mode 100644 index 00000000..9a961511 --- /dev/null +++ b/src/routes/(app)/crew/events/[eventNumber]/+page.svelte @@ -0,0 +1,732 @@ + + + + + + GW #{eventNumber} | Crew | Hensei + + +
+
+ {#if eventQuery.isLoading} +
+

Loading...

+
+ {:else if !gwEvent} +
+

Event not found

+ +
+ {:else} + + {#snippet belowTitle()} +
+ + {elementLabels[gwEvent.element] ?? 'Unknown'} + + + {formatDate(gwEvent.startDate)} – {formatDate(gwEvent.endDate)} + +
+ {/snippet} + {#snippet actions()} + {#if crewStore.isOfficer && gwEvent.status !== 'upcoming'} + + {/if} + {/snippet} +
+ + {#if !participation} +
+

No scores recorded yet for this event.

+ {#if crewStore.isOfficer && gwEvent.status !== 'upcoming'} +

Click "Add Score" above to start recording scores.

+ {/if} +
+ {:else} + + {#if participation.totalScore !== undefined} +
+
+ {formatScore(participation.totalScore)} + Total Honors +
+
+ {participation.wins ?? 0} + Wins +
+
+ {participation.losses ?? 0} + Losses +
+
+ {/if} + + +
+ Individual Scores +
+ + {#if playerScores.length > 0} +
    + {#each playerScores as player, index} +
  • +
    + {index + 1} + {player.name} + {#if player.isRetired} + Retired + {/if} +
    + {#if player.type === 'phantom'} + Phantom + {/if} + {formatScore(player.totalScore)} +
  • + {/each} +
+ {:else} +

No scores recorded yet

+ {/if} + {/if} + {/if} +
+
+ + + !open && closeScoreModal()}> + + +
+ + + + + {#if !isCumulative} +
+ {#each [0, 1, 2, 3, 4, 5] as round (round)} + + {/each} +
+ {/if} + + {#if addScoreMutation.isError} +

+ {addScoreMutation.error?.message ?? 'Failed to add score'} +

+ {/if} +
+
+ + + + +
+ +