190 lines
6.3 KiB
TypeScript
190 lines
6.3 KiB
TypeScript
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="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 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());
|
|
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.
|
|
}
|
|
}
|