AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user