AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)

This commit was merged in pull request #45.
This commit is contained in:
2026-07-23 00:13:59 +00:00
parent dcda3dfd4d
commit 0bd1d9472a
40 changed files with 4710 additions and 130 deletions
@@ -1,14 +1,188 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
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: `
<section class="page-challenges">
<h2>Challenges</h2>
<p>Challenge list will appear here.</p>
<section class="wrap" data-testid="challenges-page">
<ng-container *ngIf="isRunning(); else gate">
<h2>Challenges</h2>
<div class="grid" data-testid="challenges-grid">
<app-category-column
*ngFor="let col of store.sortedColumns(); trackBy: trackByCol"
[column]="col"
(openCard)="openChallenge($event)"
></app-category-column>
</div>
</ng-container>
<ng-template #gate>
<div class="gate" data-testid="challenges-gate">
<h2 *ngIf="eventState() === 'countdown'">Event starts in</h2>
<h2 *ngIf="eventState() === 'stopped'">Event ended</h2>
<h2 *ngIf="eventState() === 'unconfigured'">Awaiting configuration</h2>
<div class="countdown" data-testid="challenges-countdown">
{{ countdownText() }}
</div>
<div class="label" *ngIf="eventState() === 'countdown'">The board will be available once the event is running.</div>
<div class="label" *ngIf="eventState() === 'stopped'">The board is closed.</div>
</div>
</ng-template>
<div class="error" *ngIf="store.error() as e">{{ e }}</div>
<app-challenge-modal
*ngIf="selectedCard() as card"
#modal
[card]="card"
[initialSolvers]="selectedSolvers()"
[canSubmit]="canSubmit()"
[noticeOverride]="modalNotice()"
(close)="closeChallenge()"
(submitted)="onSubmitted()"
></app-challenge-modal>
</section>
`,
})
export class ChallengesPage {}
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<ReturnType<ChallengesStore['findCard']>>(null);
readonly selectedSolvers = signal<SolverRow[]>([]);
private sseWired = false;
private currentSolveUnsub: (() => void) | null = null;
constructor() {
this.destroyRef.onDestroy(() => this.store.stop());
}
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'),
);
}
this.eventStatus.reloadAtCountdownZero(() => {
if (typeof window !== 'undefined') window.location.reload();
});
}
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.
}
}