From 2cd3bfbb47ab6b76c83ae2784cd778cd8ea615f7 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 12 Dec 2025 14:23:40 +0100 Subject: [PATCH] wip --- .../components/leagues/CreateLeagueWizard.tsx | 156 +++++- .../components/leagues/LeagueAdmin.tsx | 127 ++++- .../leagues/LeagueReviewSummary.tsx | 205 ++++--- .../leagues/LeagueSponsorshipsSection.tsx | 5 +- .../sponsors/PendingSponsorshipRequests.tsx | 4 +- .../sponsors/SponsorInsightsCard.tsx | 11 +- apps/website/lib/di-config.ts | 15 +- apps/website/lib/di-container.ts | 10 + apps/website/lib/di-tokens.ts | 1 + .../lib/presenters/LeagueAdminPresenter.ts | 27 + .../use-cases/RecordEngagementUseCase.ts | 10 +- .../use-cases/RecordPageViewUseCase.ts | 14 +- .../domain/entities/AnalyticsSnapshot.ts | 1 + .../analytics/domain/entities/PageView.ts | 30 +- .../StartAutomationSessionUseCase.ts | 10 +- .../value-objects/CheckoutConfirmation.ts | 12 + .../domain/value-objects/SessionState.ts | 4 - .../automation/domain/value-objects/StepId.ts | 4 - .../auth/PlaywrightAuthSessionService.ts | 8 +- .../automation/auth/SessionCookieStore.ts | 4 +- .../core/PlaywrightAutomationAdapter.ts | 45 +- .../automation/core/WizardStepOrchestrator.ts | 4 +- .../automation/dom/IRacingDomInteractor.ts | 16 +- .../automation/dom/IRacingDomNavigator.ts | 11 +- .../automation/dom/SafeClickService.ts | 7 +- .../engine/AutomationEngineAdapter.ts | 1 - .../automation/engine/FixtureServer.ts | 2 +- .../engine/MockAutomationEngineAdapter.ts | 1 - .../engine/MockBrowserAutomationAdapter.ts | 2 +- .../identity/domain/entities/Achievement.ts | 4 +- .../domain/entities/SponsorAccount.ts | 8 +- packages/identity/domain/entities/User.ts | 8 +- .../domain/entities/UserAchievement.ts | 14 +- .../adapters/DiscordNotificationAdapter.ts | 2 +- .../AcceptSponsorshipRequestUseCase.ts | 10 +- .../application/use-cases/SeasonUseCases.ts | 460 ++++++++++++++++ .../racing/domain/entities/DriverLivery.ts | 67 ++- .../racing/domain/entities/LiveryTemplate.ts | 10 +- packages/racing/domain/entities/Prize.ts | 16 +- packages/racing/domain/entities/Season.ts | 326 ++++++++++- .../domain/entities/SeasonSponsorship.ts | 106 +++- .../racing/domain/entities/Transaction.ts | 12 +- .../domain/repositories/ISeasonRepository.ts | 28 + .../ISeasonSponsorshipRepository.ts | 6 + .../value-objects/MonthlyRecurrencePattern.ts | 18 +- .../domain/value-objects/SeasonDropPolicy.ts | 59 ++ .../value-objects/SeasonScoringConfig.ts | 66 +++ .../value-objects/SeasonStewardingConfig.ts | 131 +++++ .../racing/domain/value-objects/WeekdaySet.ts | 4 + .../InMemoryScoringRepositories.ts | 24 + .../InMemorySeasonSponsorshipRepository.ts | 4 + .../use-cases/GetCurrentUserSocialUseCase.ts | 4 - .../use-cases/GetUserFeedUseCase.ts | 27 +- .../racing-application/SeasonUseCases.test.ts | 449 +++++++++++++++ tests/unit/racing/domain/Season.test.ts | 509 ++++++++++++++++++ tsconfig.base.json | 3 +- tsconfig.json | 3 + vitest.config.ts | 1 + 58 files changed, 2866 insertions(+), 260 deletions(-) create mode 100644 packages/racing/application/use-cases/SeasonUseCases.ts create mode 100644 packages/racing/domain/value-objects/SeasonDropPolicy.ts create mode 100644 packages/racing/domain/value-objects/SeasonScoringConfig.ts create mode 100644 packages/racing/domain/value-objects/SeasonStewardingConfig.ts create mode 100644 tests/unit/racing-application/SeasonUseCases.test.ts create mode 100644 tests/unit/racing/domain/Season.test.ts diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index ec7733208..e11607e26 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -21,6 +21,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Heading from '@/components/ui/Heading'; import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary'; +import Input from '@/components/ui/Input'; import { getListLeagueScoringPresetsQuery, } from '@/lib/di-container'; @@ -52,7 +53,7 @@ import { LeagueStewardingSection } from './LeagueStewardingSection'; const STORAGE_KEY = 'gridpilot_league_wizard_draft'; const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step'; -function saveFormToStorage(form: LeagueConfigFormModel): void { +function saveFormToStorage(form: LeagueWizardFormModel): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); } catch { @@ -60,11 +61,16 @@ function saveFormToStorage(form: LeagueConfigFormModel): void { } } -function loadFormFromStorage(): LeagueConfigFormModel | null { +function loadFormFromStorage(): LeagueWizardFormModel | null { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { - return JSON.parse(stored) as LeagueConfigFormModel; + const parsed = JSON.parse(stored) as LeagueWizardFormModel; + if (!parsed.seasonName) { + const seasonStartDate = parsed.timings?.seasonStartDate; + parsed.seasonName = getDefaultSeasonName(seasonStartDate); + } + return parsed; } } catch { // Ignore parse errors @@ -105,6 +111,10 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7; type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review'; +type LeagueWizardFormModel = LeagueConfigFormModel & { + seasonName?: string; +}; + interface CreateLeagueWizardProps { stepName: StepName; onStepChange: (stepName: StepName) => void; @@ -160,8 +170,21 @@ function getDefaultSeasonStartDate(): string { return datePart ?? ''; } -function createDefaultForm(): LeagueConfigFormModel { +function getDefaultSeasonName(seasonStartDate?: string): string { + if (seasonStartDate) { + const parsed = new Date(seasonStartDate); + if (!Number.isNaN(parsed.getTime())) { + const year = parsed.getFullYear(); + return `Season 1 (${year})`; + } + } + const fallbackYear = new Date().getFullYear(); + return `Season 1 (${fallbackYear})`; +} + +function createDefaultForm(): LeagueWizardFormModel { const defaultPatternId = 'sprint-main-driver'; + const defaultSeasonStartDate = getDefaultSeasonStartDate(); return { basics: { @@ -201,7 +224,7 @@ function createDefaultForm(): LeagueConfigFormModel { recurrenceStrategy: 'weekly' as const, raceStartTime: '20:00', timezoneId: 'UTC', - seasonStartDate: getDefaultSeasonStartDate(), + seasonStartDate: defaultSeasonStartDate, }, stewarding: { decisionMode: 'admin_only', @@ -214,6 +237,7 @@ function createDefaultForm(): LeagueConfigFormModel { notifyAccusedOnProtest: true, notifyOnVoteRequired: true, }, + seasonName: getDefaultSeasonName(defaultSeasonStartDate), }; } @@ -229,7 +253,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea const [isHydrated, setIsHydrated] = useState(false); // Initialize form from localStorage or defaults - const [form, setForm] = useState(() => + const [form, setForm] = useState(() => createDefaultForm(), ); @@ -405,20 +429,30 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea case 2: return 'Will you compete for global rankings or race with friends?'; case 3: - return 'Will drivers compete individually or as part of teams?'; + return 'Define how races in this season will run.'; case 4: - return 'Configure session durations and plan your season calendar.'; + return 'Plan when this season’s races happen.'; case 5: - return 'Select a scoring preset, enable championships, and set drop rules.'; + return 'Choose how points and drop scores work for this season.'; case 6: - return 'Configure how protests are handled and penalties decided.'; + return 'Set how protests and stewarding work for this season.'; case 7: - return 'Everything looks good? Launch your new league!'; + return 'Review your league and first season before launching.'; default: return ''; } }; + const getStepContextLabel = (currentStep: Step): string => { + if (currentStep === 1 || currentStep === 2) { + return 'League setup'; + } + if (currentStep >= 3 && currentStep <= 6) { + return 'Season setup'; + } + return 'League & Season summary'; + }; + const currentStepData = steps.find((s) => s.id === step); const CurrentStepIcon = currentStepData?.icon ?? FileText; @@ -435,7 +469,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea Create a new league

- Set up your racing series in {steps.length} easy steps + We'll also set up your first season in {steps.length} easy steps. +

+

+ A league is your long-term brand. Each season is a block of races you can run again and again.

@@ -557,7 +594,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
- {getStepTitle(step)} +
+ {getStepTitle(step)} + + {getStepContextLabel(step)} + +

{getStepSubtitle(step)} @@ -575,15 +617,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea {/* Step content with min-height for consistency */}

- {step === 1 && ( -
- -
- )} + {step === 1 && ( +
+ +
+
+
+

+ First season +

+

+ Name the first season that will run in this league. +

+
+
+
+ + + setForm((prev) => ({ + ...prev, + seasonName: e.target.value, + })) + } + placeholder="e.g., Season 1 (2025)" + /> +

+ Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later. +

+
+
+
+ )} {step === 2 && (
@@ -600,7 +672,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea )} {step === 3 && ( -
+
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
+
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
{/* Scoring Pattern Selection */} +
+
+

+ Applies to: First season of this league. +

+

+ These settings only affect this season. Future seasons can use different formats. +

+
- You can edit all settings after creating your league + This will create your league and its first season. You can edit both later.

); diff --git a/apps/website/components/leagues/LeagueAdmin.tsx b/apps/website/components/leagues/LeagueAdmin.tsx index 86d47f9b9..034f7d15d 100644 --- a/apps/website/components/leagues/LeagueAdmin.tsx +++ b/apps/website/components/leagues/LeagueAdmin.tsx @@ -15,9 +15,11 @@ import { loadLeagueProtests, removeLeagueMember as removeLeagueMemberCommand, updateLeagueMemberRole as updateLeagueMemberRoleCommand, + loadLeagueSeasons, type LeagueJoinRequestViewModel, type LeagueOwnerSummaryViewModel, type LeagueAdminProtestsViewModel, + type LeagueSeasonSummaryViewModel, } from '@/lib/presenters/LeagueAdminPresenter'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter'; @@ -51,13 +53,15 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps const [ownerSummary, setOwnerSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members'); + const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'seasons' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members'); const [downloadingLiveryPack, setDownloadingLiveryPack] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [configForm, setConfigForm] = useState(null); const [configLoading, setConfigLoading] = useState(false); const [protestsViewModel, setProtestsViewModel] = useState(null); const [protestsLoading, setProtestsLoading] = useState(false); + const [seasons, setSeasons] = useState([]); + const [seasonsLoading, setSeasonsLoading] = useState(false); const loadJoinRequests = useCallback(async () => { setLoading(true); @@ -104,6 +108,22 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps loadConfig(); }, [league.id]); + useEffect(() => { + async function loadSeasonsVm() { + setSeasonsLoading(true); + try { + const items = await loadLeagueSeasons(league.id); + setSeasons(items); + } catch (err) { + console.error('Failed to load seasons:', err); + } finally { + setSeasonsLoading(false); + } + } + + loadSeasonsVm(); + }, [league.id]); + // Load protests for this league's races useEffect(() => { async function loadProtests() { @@ -257,6 +277,16 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps > Races +