import { ChangeDetectionStrategy, Component, DestroyRef, OnDestroy, OnInit, ViewChild, computed, effect, inject, signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AuthService } from '../../core/services/auth.service'; import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service'; import { EventStatusStore } from '../../core/services/event-status.store'; import { ChallengesStore } from './challenges.store'; import { CategoryColumnComponent } from './category-column.component'; import { ChallengeModalComponent } from './challenge-modal.component'; import { SolverRow, formatDdHhMm } from './challenges.pure'; @Component({ selector: 'app-challenges-page', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [CommonModule, CategoryColumnComponent, ChallengeModalComponent], styles: [` :host { display: block; } .wrap { padding: 12px 0; } .grid { display: flex; gap: 12px; overflow-x: auto; overflow-y: visible; padding-bottom: 12px; } .gate { display: flex; align-items: center; justify-content: center; min-height: 240px; flex-direction: column; gap: 8px; padding: 32px; } .gate h2 { margin: 0; } .countdown { font-variant-numeric: tabular-nums; font-size: 28px; font-weight: 700; letter-spacing: 0.04em; } .label { opacity: 0.7; } .error { color: var(--color-danger, #ef4444); } `], template: ` Challenges Event starts in Event ended Awaiting configuration {{ countdownText() }} The board will be available once the event is running. The board is closed. {{ e }} `, }) export class ChallengesPage implements OnInit, OnDestroy { readonly store = inject(ChallengesStore); private readonly eventStatus = inject(EventStatusStore); private readonly auth = inject(AuthService); private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService); private readonly destroyRef = inject(DestroyRef); @ViewChild('modal') modalRef?: ChallengeModalComponent; readonly eventState = this.eventStatus.state; readonly secondsToStart = this.eventStatus.secondsToStartPublic; readonly secondsToEnd = this.eventStatus.secondsToEndPublic; readonly isRunning = computed(() => this.eventState() === 'running'); readonly canSubmit = computed(() => this.eventStatus.state() === 'running'); readonly modalNotice = computed(() => this.eventStatus.state() === 'running' ? null : 'Submissions are only accepted while the event is running.', ); readonly countdownText = computed(() => { const s = this.eventState(); if (s === 'countdown') { return formatDdHhMm(this.secondsToStart() ?? 0); } if (s === 'running') { return formatDdHhMm(this.secondsToEnd() ?? 0); } if (s === 'stopped') return 'Event ended'; return ''; }); readonly selectedCard = signal>(null); readonly selectedSolvers = signal([]); private sseWired = false; private currentSolveUnsub: (() => void) | null = null; constructor() { this.destroyRef.onDestroy(() => this.store.stop()); const unsubscribeReload = this.eventStatus.subscribeReloadAtCountdownZero(() => { if (typeof window !== 'undefined') window.location.reload(); }); this.destroyRef.onDestroy(() => unsubscribeReload()); } ngOnInit(): void { const user = this.auth.currentUser(); if (user?.id) this.store.setMyUserId(user.id); void this.store.load(); // REST snapshot first so the page has synchronous state even if the SSE // stream hasn't delivered its first frame yet (e.g. slow networks, mobile). this.store.fetchEventState().subscribe({ next: (payload) => this.eventStatus.applyServerStatus(payload), error: () => undefined, }); if (!this.sseWired) { this.sseWired = true; this.store.wireSse( () => this.authenticatedEventSource.open('/api/v1/events'), ); } } ngOnDestroy(): void { this.store.stop(); this.currentSolveUnsub?.(); } trackByCol = (_idx: number, col: { id: string }) => col.id; openChallenge(id: string): void { const card = this.store.findCard(id); this.selectedCard.set(card); this.selectedSolvers.set([]); this.currentSolveUnsub?.(); this.currentSolveUnsub = this.store.registerSolveListener(id, (payload) => { this.modalRef?.applyLiveSolve(payload); }); void this.store.loadDetail(id).then((detail) => { if (!detail) return; if (this.selectedCard()?.id !== id) return; this.selectedSolvers.set(detail.solvers); }); } closeChallenge(): void { this.selectedCard.set(null); this.selectedSolvers.set([]); this.currentSolveUnsub?.(); this.currentSolveUnsub = null; this.store.clearSelectedDetail(); } onSubmitted(): void { // No-op: the store already mutated the board; the modal updates itself. } }