AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
|
||||
const FRIENDLY_MESSAGES: Record<string, string> = {
|
||||
EVENT_NOT_RUNNING: 'Submissions are only accepted while the event is running.',
|
||||
FLAG_INCORRECT: 'Incorrect flag.',
|
||||
UNAUTHORIZED: 'Your session is no longer valid.',
|
||||
FORBIDDEN: 'You are not allowed to perform this action.',
|
||||
NOT_FOUND: 'Not found.',
|
||||
VALIDATION_FAILED: 'Invalid input.',
|
||||
};
|
||||
|
||||
function friendlyMessage(status: number, body: any): string {
|
||||
if (status === 0) return 'Network error. Please try again.';
|
||||
const code = body?.code as string | undefined;
|
||||
if (code && FRIENDLY_MESSAGES[code]) return FRIENDLY_MESSAGES[code];
|
||||
if (status === 401 || status === 403) return FRIENDLY_MESSAGES.UNAUTHORIZED;
|
||||
if (status === 404) return FRIENDLY_MESSAGES.NOT_FOUND;
|
||||
if (status >= 500) return 'Server error. Please try again later.';
|
||||
const msg = (body?.message as string | undefined) ?? '';
|
||||
return msg || `Request failed (${status}).`;
|
||||
}
|
||||
|
||||
export const errorNotificationInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const notify = inject(NotificationService);
|
||||
return next(req).pipe(
|
||||
catchError((err) => {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
const url = err.url ?? req.url;
|
||||
// Suppress duplicate notifications for endpoints that have their own
|
||||
// user-facing error presentation (the modal shows its own banner).
|
||||
if (!shouldSuppress(url, err.status)) {
|
||||
notify.error(friendlyMessage(err.status, err.error));
|
||||
}
|
||||
} else {
|
||||
notify.error('Unexpected error.');
|
||||
}
|
||||
return throwError(() => err);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
function shouldSuppress(url: string | undefined, status: number): boolean {
|
||||
if (!url) return false;
|
||||
// 401/403 from a /api/v1/auth/* call is expected (login failed). Don't toast it.
|
||||
if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true;
|
||||
// SSE / event-status snapshot errors are non-fatal (SSE will deliver the next frame).
|
||||
if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -39,7 +39,12 @@ export class AuthenticatedEventSourceService {
|
||||
buffered = [];
|
||||
for (const m of items) {
|
||||
const me = new MessageEvent(m.event, { data: m.data });
|
||||
// Dispatch to 'message' listeners first so existing consumers
|
||||
// that only listen for 'message' still receive every frame.
|
||||
dispatch('message', me);
|
||||
if (m.event && m.event !== 'message') {
|
||||
dispatch(m.event, me);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,8 +104,12 @@ export class AuthenticatedEventSourceService {
|
||||
}
|
||||
if (dataLines.length === 0) return;
|
||||
const payload = dataLines.join('\n');
|
||||
if (listeners.has('message')) {
|
||||
dispatch('message', new MessageEvent(event, { data: payload }));
|
||||
if (listeners.size > 0) {
|
||||
const me = new MessageEvent(event, { data: payload });
|
||||
dispatch('message', me);
|
||||
if (event && event !== 'message' && listeners.has(event)) {
|
||||
dispatch(event, me);
|
||||
}
|
||||
} else {
|
||||
buffered.push({ event, data: payload });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface EventStatePayload {
|
||||
|
||||
export interface EventSourceLike {
|
||||
addEventListener(
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized' | string,
|
||||
listener: (ev: MessageEvent | Event) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
|
||||
@@ -26,14 +26,28 @@ export class EventStatusStore {
|
||||
readonly serverNowUtc = signal<string | null>(null);
|
||||
readonly eventStartUtc = signal<string | null>(null);
|
||||
readonly eventEndUtc = signal<string | null>(null);
|
||||
private readonly anchorDeltaMs = signal<number>(0);
|
||||
private readonly clientAnchorMs = signal<number>(0);
|
||||
private readonly tick = signal<number>(0);
|
||||
|
||||
/**
|
||||
* Anchor-based clock: serverTimeUtc received at clientAnchorMs wall-clock.
|
||||
* `currentServerTimeUtc` advances from the server snapshot at the rate
|
||||
* the local clock advances since we received it.
|
||||
*/
|
||||
readonly currentServerTimeUtc = computed<string>(() => {
|
||||
void this.tick();
|
||||
const base = this.serverNowUtc();
|
||||
if (!base) return new Date().toISOString();
|
||||
const baseMs = new Date(base).getTime();
|
||||
const ms = baseMs + (Date.now() - this.clientAnchorMs());
|
||||
return new Date(ms).toISOString();
|
||||
});
|
||||
|
||||
private readonly secondsToStart = computed<number | null>(() => {
|
||||
void this.tick();
|
||||
const s = this.eventStartUtc();
|
||||
if (!s) return null;
|
||||
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
|
||||
const anchor = new Date(this.currentServerTimeUtc()).getTime();
|
||||
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||
return diff > 0 ? diff : 0;
|
||||
});
|
||||
@@ -42,7 +56,7 @@ export class EventStatusStore {
|
||||
void this.tick();
|
||||
const s = this.eventEndUtc();
|
||||
if (!s) return null;
|
||||
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
|
||||
const anchor = new Date(this.currentServerTimeUtc()).getTime();
|
||||
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||
return diff > 0 ? diff : 0;
|
||||
});
|
||||
@@ -51,8 +65,14 @@ export class EventStatusStore {
|
||||
return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd());
|
||||
});
|
||||
|
||||
readonly secondsToStartPublic = computed<number | null>(() => this.secondsToStart());
|
||||
readonly secondsToEndPublic = computed<number | null>(() => this.secondsToEnd());
|
||||
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private source: EventSourceLike | null = null;
|
||||
private zeroWatcher: ReturnType<typeof setInterval> | null = null;
|
||||
private reloadOnZero: (() => void) | null = null;
|
||||
private reloadOnZeroFired = false;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.stop());
|
||||
@@ -63,14 +83,18 @@ export class EventStatusStore {
|
||||
this.serverNowUtc.set(payload.serverNowUtc);
|
||||
this.eventStartUtc.set(payload.eventStartUtc);
|
||||
this.eventEndUtc.set(payload.eventEndUtc);
|
||||
this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime());
|
||||
this.clientAnchorMs.set(Date.now());
|
||||
// Reset the one-shot so a subsequent transition (e.g. countdown→running) can fire again.
|
||||
if (this.reloadOnZeroFired) {
|
||||
this.reloadOnZeroFired = false;
|
||||
}
|
||||
}
|
||||
|
||||
start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
|
||||
this.stop();
|
||||
const src = createSource();
|
||||
this.source = src;
|
||||
src.addEventListener('message', (ev) => {
|
||||
const handler = (ev: MessageEvent | Event) => {
|
||||
const me = ev as MessageEvent;
|
||||
try {
|
||||
const data = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
|
||||
@@ -80,7 +104,9 @@ export class EventStatusStore {
|
||||
} catch {
|
||||
// ignore malformed frame
|
||||
}
|
||||
});
|
||||
};
|
||||
src.addEventListener('message', handler);
|
||||
src.addEventListener('status', handler);
|
||||
if (onUnauthorized) {
|
||||
src.addEventListener('unauthorized', () => {
|
||||
try {
|
||||
@@ -93,6 +119,32 @@ export class EventStatusStore {
|
||||
if (!this.intervalId) {
|
||||
this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000);
|
||||
}
|
||||
if (!this.zeroWatcher) {
|
||||
this.zeroWatcher = setInterval(() => this.checkZero(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
reloadAtCountdownZero(handler: () => void): void {
|
||||
this.reloadOnZero = handler;
|
||||
this.reloadOnZeroFired = false;
|
||||
}
|
||||
|
||||
private checkZero(): void {
|
||||
if (!this.reloadOnZero || this.reloadOnZeroFired) return;
|
||||
const state = this.state();
|
||||
if (state === 'countdown') {
|
||||
const s = this.secondsToStart();
|
||||
if (s !== null && s <= 0) {
|
||||
this.reloadOnZeroFired = true;
|
||||
try { this.reloadOnZero(); } catch { /* ignore */ }
|
||||
}
|
||||
} else if (state === 'running') {
|
||||
const s = this.secondsToEnd();
|
||||
if (s !== null && s <= 0) {
|
||||
this.reloadOnZeroFired = true;
|
||||
try { this.reloadOnZero(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -108,5 +160,9 @@ export class EventStatusStore {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
if (this.zeroWatcher) {
|
||||
clearInterval(this.zeroWatcher);
|
||||
this.zeroWatcher = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type NotificationKind = 'error' | 'info';
|
||||
|
||||
export interface NotificationMessage {
|
||||
id: number;
|
||||
kind: NotificationKind;
|
||||
message: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotificationService {
|
||||
private nextId = 1;
|
||||
readonly messages = signal<NotificationMessage[]>([]);
|
||||
|
||||
error(message: string): void {
|
||||
const note = this.push('error', message);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[notification:error]', note.message);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
const note = this.push('info', message);
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[notification:info]', note.message);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.messages.update((cur) => cur.filter((m) => m.id !== id));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.messages.set([]);
|
||||
}
|
||||
|
||||
private push(kind: NotificationKind, message: string): NotificationMessage {
|
||||
const note: NotificationMessage = {
|
||||
id: this.nextId++,
|
||||
kind,
|
||||
message,
|
||||
ts: Date.now(),
|
||||
};
|
||||
this.messages.update((cur) => [...cur, note]);
|
||||
return note;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CategoryColumn } from './challenges.pure';
|
||||
import { ChallengeCardComponent } from './challenge-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-column',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ChallengeCardComponent],
|
||||
styles: [`
|
||||
:host { display: block; width: 280px; flex: 0 0 280px; }
|
||||
.col {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
padding: 12px; background: var(--color-surface-2, rgba(255,255,255,0.02));
|
||||
border: 1px solid var(--color-border, #2a2f3a); border-radius: var(--radius-md, 8px);
|
||||
min-height: 200px;
|
||||
}
|
||||
.head { display: flex; align-items: center; gap: 8px; }
|
||||
.icon { width: 32px; height: 32px; object-fit: contain; }
|
||||
.abbr { font-weight: 700; letter-spacing: 0.06em; }
|
||||
.name { font-size: 12px; opacity: 0.7; }
|
||||
.cards { display: flex; flex-direction: column; gap: 8px; }
|
||||
`],
|
||||
template: `
|
||||
<section class="col" [attr.data-testid]="'category-column-' + column.abbreviation">
|
||||
<header class="head">
|
||||
<img *ngIf="column.iconPath" class="icon" [src]="column.iconPath" [alt]="column.abbreviation" />
|
||||
<div>
|
||||
<div class="abbr">{{ column.abbreviation }}</div>
|
||||
<div class="name">{{ column.name }}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="cards">
|
||||
<app-challenge-card
|
||||
*ngFor="let card of column.cards; trackBy: trackByCard"
|
||||
[card]="card"
|
||||
(open)="openCard.emit($event)"
|
||||
></app-challenge-card>
|
||||
<p *ngIf="!column.cards.length" class="name">No challenges yet.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class CategoryColumnComponent {
|
||||
@Input({ required: true }) column!: CategoryColumn;
|
||||
@Output() openCard = new EventEmitter<string>();
|
||||
|
||||
trackByCard = (_idx: number, c: { id: string }) => c.id;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BoardCard } from './challenges.pure';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenge-card',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.card {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 10px 12px; border: 1px solid var(--color-border, #2a2f3a);
|
||||
border-radius: var(--radius-sm, 4px); background: var(--color-surface, #1c1f26);
|
||||
cursor: pointer; transition: transform 0.1s ease;
|
||||
min-height: 96px;
|
||||
}
|
||||
.card:hover { transform: translateY(-1px); border-color: var(--color-primary, #3b82f6); }
|
||||
.card.solved { border-color: var(--color-success, #22c55e); }
|
||||
.card-head { display: flex; align-items: center; gap: 8px; }
|
||||
.icon { width: 28px; height: 28px; object-fit: contain; }
|
||||
.name { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.check { color: var(--color-success, #22c55e); font-size: 18px; }
|
||||
.meta { display: flex; gap: 8px; font-size: 12px; opacity: 0.85; align-items: center; }
|
||||
.points { font-weight: 600; }
|
||||
.diff {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 999px; font-size: 10px; letter-spacing: 0.04em;
|
||||
}
|
||||
.diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; }
|
||||
.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; }
|
||||
.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; }
|
||||
`],
|
||||
template: `
|
||||
<div
|
||||
class="card"
|
||||
[class.solved]="card.solvedByMe"
|
||||
[attr.data-testid]="'challenge-card-' + card.id"
|
||||
(click)="open.emit(card.id)"
|
||||
>
|
||||
<div class="card-head">
|
||||
<img *ngIf="card.categoryIconPath" class="icon" [src]="card.categoryIconPath" [alt]="card.categoryAbbreviation" />
|
||||
<span class="name">{{ card.name }}</span>
|
||||
<span *ngIf="card.solvedByMe" class="check" aria-label="Solved">✓</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="diff" [class]="'diff-' + card.difficulty">{{ card.difficulty }}</span>
|
||||
<span class="points" [attr.data-testid]="'points-' + card.id">{{ card.livePoints }} pts</span>
|
||||
<span>·</span>
|
||||
<span>{{ card.solveCount }} solve<span *ngIf="card.solveCount !== 1">s</span></span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ChallengeCardComponent {
|
||||
@Input({ required: true }) card!: BoardCard;
|
||||
@Output() open = new EventEmitter<string>();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MarkdownService } from '../../core/services/markdown.service';
|
||||
import { ChallengesStore } from './challenges.store';
|
||||
import {
|
||||
BoardCard,
|
||||
SolveEventLivePayload,
|
||||
SolverRow,
|
||||
formatUtcToLocal,
|
||||
messageForSolveError,
|
||||
parseSolveError,
|
||||
} from './challenges.pure';
|
||||
import { ApiErrorEnvelope } from './challenges.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenge-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule],
|
||||
styles: [`
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.modal {
|
||||
width: min(720px, 92vw); max-height: 90vh; overflow: auto;
|
||||
background: var(--color-surface, #1c1f26); color: var(--color-text, #f4f4f5);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.header { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.header h3 { margin: 0; flex: 1; }
|
||||
.pill { padding: 2px 8px; border-radius: 999px; font-size: 12px; background: rgba(255,255,255,0.06); }
|
||||
.pill.diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; }
|
||||
.pill.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; }
|
||||
.pill.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; }
|
||||
.body { white-space: normal; line-height: 1.45; }
|
||||
.form { display: flex; gap: 8px; align-items: center; }
|
||||
.form input { flex: 1; padding: 8px 10px; border-radius: var(--radius-sm, 4px); border: 1px solid var(--color-border, #2a2f3a); background: var(--color-input, rgba(255,255,255,0.04)); color: inherit; }
|
||||
.actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.error { color: var(--color-danger, #ef4444); font-size: 14px; }
|
||||
.notice { color: var(--color-warning, #eab308); font-size: 14px; }
|
||||
.solvers { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.solvers li { display: grid; grid-template-columns: 28px 1fr auto auto; gap: 8px; align-items: center; padding: 4px 8px; border-radius: var(--radius-sm, 4px); background: rgba(255,255,255,0.03); }
|
||||
.rank-1 { color: #fbbf24; font-weight: 700; }
|
||||
.rank-2 { color: #d4d4d8; font-weight: 700; }
|
||||
.rank-3 { color: #b45309; font-weight: 700; }
|
||||
.rank-other { color: var(--color-success, #22c55e); }
|
||||
.points { font-variant-numeric: tabular-nums; }
|
||||
.close { background: transparent; border: 1px solid var(--color-border, #2a2f3a); color: inherit; padding: 4px 10px; border-radius: var(--radius-sm, 4px); cursor: pointer; }
|
||||
.solved-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(34,197,94,0.12); color: #16a34a; }
|
||||
.awarded-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(59,130,246,0.12); color: #60a5fa; }
|
||||
`],
|
||||
template: `
|
||||
<div class="backdrop" (click)="onBackdrop($event)">
|
||||
<div class="modal" role="dialog" aria-modal="true" [attr.data-testid]="'challenge-modal-' + (card?.id ?? '')">
|
||||
<div class="header" *ngIf="card">
|
||||
<h3>{{ card.name }}</h3>
|
||||
<span class="pill">{{ card.categoryAbbreviation }}</span>
|
||||
<span class="pill" [class]="'pill diff-' + card.difficulty">{{ card.difficulty }}</span>
|
||||
<span class="pill" data-testid="modal-points">{{ card.livePoints }} pts</span>
|
||||
<button type="button" class="close" (click)="close.emit()" aria-label="Close">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="solved-banner" *ngIf="card?.solvedByMe" data-testid="modal-solved-banner">
|
||||
You solved this challenge.
|
||||
</div>
|
||||
|
||||
<div class="awarded-banner" *ngIf="awarded() as a" data-testid="modal-awarded-banner">
|
||||
Awarded {{ a.awardedPoints }} pts
|
||||
<span *ngIf="a.rankBonus > 0">(base {{ a.basePoints }} + rank bonus {{ a.rankBonus }})</span>
|
||||
</div>
|
||||
|
||||
<div class="notice" *ngIf="notice() as n">{{ n }}</div>
|
||||
|
||||
<div class="body" [innerHTML]="renderedDescription()"></div>
|
||||
|
||||
<form class="form" (ngSubmit)="onSubmit()" *ngIf="card">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="flagValue"
|
||||
name="flag"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="flag{...}"
|
||||
[disabled]="submitting() || !canSubmit"
|
||||
data-testid="flag-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="submitting() || !canSubmit"
|
||||
data-testid="solve-button"
|
||||
>Solve</button>
|
||||
</form>
|
||||
|
||||
<div class="error" *ngIf="errorMessage() as e" data-testid="modal-error">{{ e }}</div>
|
||||
|
||||
<h4>Solvers ({{ solvers().length }})</h4>
|
||||
<ul class="solvers" data-testid="modal-solvers">
|
||||
<li *ngFor="let s of solvers(); trackBy: trackBySolver">
|
||||
<span [class]="rankClass(s.position)">#{{ s.position }}</span>
|
||||
<span>{{ s.playerName || ('Player ' + s.playerId.slice(0, 6)) }}</span>
|
||||
<span>{{ formatLocal(s.solvedAtUtc) }}</span>
|
||||
<span class="points">{{ s.awardedPoints }} pts</span>
|
||||
</li>
|
||||
<li *ngIf="!solvers().length"><em>No solvers yet.</em></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ChallengeModalComponent implements OnChanges {
|
||||
private readonly markdown = inject(MarkdownService);
|
||||
private readonly store = inject(ChallengesStore);
|
||||
|
||||
@Input({ required: true }) card: BoardCard | null = null;
|
||||
@Input() initialSolvers: SolverRow[] = [];
|
||||
@Input() canSubmit: boolean = false;
|
||||
@Input() noticeOverride: string | null = null;
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() submitted = new EventEmitter<{ flag: string }>();
|
||||
|
||||
private _flagValue = signal('');
|
||||
get flagValue(): string {
|
||||
return this._flagValue();
|
||||
}
|
||||
set flagValue(v: string) {
|
||||
this._flagValue.set(v ?? '');
|
||||
}
|
||||
readonly submitting = signal(false);
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
readonly awarded = signal<{ basePoints: number; rankBonus: number; awardedPoints: number; awardedAtUtc: string } | null>(null);
|
||||
readonly notice = signal<string | null>(null);
|
||||
|
||||
private _solvers = signal<SolverRow[]>([]);
|
||||
readonly solvers = this._solvers.asReadonly();
|
||||
|
||||
readonly renderedDescription = computed(() => {
|
||||
return this.markdown.render(this.card?.descriptionMd ?? '');
|
||||
});
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['card']) {
|
||||
this._flagValue.set('');
|
||||
this.submitting.set(false);
|
||||
this.errorMessage.set(null);
|
||||
this.awarded.set(null);
|
||||
this.notice.set(this.noticeOverride ?? null);
|
||||
this._solvers.set([...(this.initialSolvers ?? [])]);
|
||||
}
|
||||
if (changes['noticeOverride']) {
|
||||
this.notice.set(this.noticeOverride ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
onBackdrop(ev: MouseEvent): void {
|
||||
if (ev.target === ev.currentTarget) this.close.emit();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (!this.submitting()) this.close.emit();
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
if (!this.card || this.submitting() || !this.canSubmit) return;
|
||||
const flag = this._flagValue().trim();
|
||||
if (!flag) return;
|
||||
this.submitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
const resp = await this.store.submit(this.card.id, flag);
|
||||
this._flagValue.set('');
|
||||
this.awarded.set(resp.awarded);
|
||||
this._solvers.set(this.store.buildSolversFromResponse(resp.solvers));
|
||||
this.submitted.emit({ flag });
|
||||
if (resp.status === 'already_solved') {
|
||||
this.errorMessage.set(messageForSolveError('already_solved'));
|
||||
}
|
||||
} catch (err) {
|
||||
const envelope = err as ApiErrorEnvelope;
|
||||
const code = parseSolveError(envelope.status, envelope);
|
||||
this.errorMessage.set(messageForSolveError(code));
|
||||
} finally {
|
||||
this.submitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
applyLiveSolve(payload: SolveEventLivePayload): void {
|
||||
this._solvers.set(this.store.mergeLiveSolver(this._solvers(), payload));
|
||||
}
|
||||
|
||||
formatLocal(utc: string): string {
|
||||
return formatUtcToLocal(utc);
|
||||
}
|
||||
|
||||
rankClass(position: number): string {
|
||||
if (position === 1) return 'rank-1';
|
||||
if (position === 2) return 'rank-2';
|
||||
if (position === 3) return 'rank-3';
|
||||
return 'rank-other';
|
||||
}
|
||||
|
||||
trackBySolver(_idx: number, s: SolverRow): string {
|
||||
return `${s.playerId}-${s.solvedAtUtc}`;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
|
||||
export interface BoardCard {
|
||||
id: string;
|
||||
name: string;
|
||||
descriptionMd: string;
|
||||
categoryId: string;
|
||||
categoryAbbreviation: string;
|
||||
categoryIconPath: string;
|
||||
difficulty: Difficulty;
|
||||
livePoints: number;
|
||||
solveCount: number;
|
||||
solvedByMe: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryColumn {
|
||||
id: string;
|
||||
abbreviation: string;
|
||||
name: string;
|
||||
iconPath: string;
|
||||
cards: BoardCard[];
|
||||
}
|
||||
|
||||
export interface BoardResponse {
|
||||
columns: CategoryColumn[];
|
||||
solvedChallengeIds: string[];
|
||||
perChallengeLivePoints: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SolverRow {
|
||||
position: number;
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
solvedAtUtc: string;
|
||||
awardedPoints: number;
|
||||
basePoints: number;
|
||||
rankBonus: number;
|
||||
isFirst: boolean;
|
||||
isSecond: boolean;
|
||||
isThird: boolean;
|
||||
}
|
||||
|
||||
export interface AwardedSolve {
|
||||
position: number;
|
||||
basePoints: number;
|
||||
rankBonus: number;
|
||||
awardedPoints: number;
|
||||
awardedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface SolveResponse {
|
||||
status: 'solved' | 'already_solved';
|
||||
challenge: BoardCard;
|
||||
awarded: AwardedSolve;
|
||||
solvers: SolverRow[];
|
||||
}
|
||||
|
||||
export interface SolveEventPayload {
|
||||
challengeId: string;
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
awardedPoints: number;
|
||||
rankBonus: number;
|
||||
awardedAtUtc: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface SolveEventLivePayload extends SolveEventPayload {
|
||||
livePoints: number;
|
||||
solveCount: number;
|
||||
initialPoints: number;
|
||||
minimumPoints: number;
|
||||
decaySolves: number;
|
||||
}
|
||||
|
||||
export interface ChallengeDetail {
|
||||
challenge: BoardCard;
|
||||
solvers: SolverRow[];
|
||||
}
|
||||
|
||||
export interface EventStatePayloadLike {
|
||||
state: 'running' | 'countdown' | 'stopped' | 'unconfigured';
|
||||
serverNowUtc: string;
|
||||
eventStartUtc: string | null;
|
||||
eventEndUtc: string | null;
|
||||
secondsToStart: number | null;
|
||||
secondsToEnd: number | null;
|
||||
}
|
||||
|
||||
export const DIFFICULTY_RANK: Readonly<Record<Difficulty, number>> = {
|
||||
LOW: 0,
|
||||
MEDIUM: 1,
|
||||
HIGH: 2,
|
||||
};
|
||||
|
||||
export function compareDifficulty(a: Difficulty, b: Difficulty): number {
|
||||
return DIFFICULTY_RANK[a] - DIFFICULTY_RANK[b];
|
||||
}
|
||||
|
||||
export function sortCardsInColumn(cards: readonly BoardCard[]): BoardCard[] {
|
||||
return [...cards].sort((a, b) => {
|
||||
const d = compareDifficulty(a.difficulty, b.difficulty);
|
||||
if (d !== 0) return d;
|
||||
const an = (a.name ?? '').toLowerCase();
|
||||
const bn = (b.name ?? '').toLowerCase();
|
||||
if (an < bn) return -1;
|
||||
if (an > bn) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function sortColumnsByAbbrev(columns: readonly CategoryColumn[]): CategoryColumn[] {
|
||||
return [...columns].sort((a, b) => {
|
||||
const al = (a.abbreviation ?? '').toLowerCase();
|
||||
const bl = (b.abbreviation ?? '').toLowerCase();
|
||||
if (al < bl) return -1;
|
||||
if (al > bl) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDdHhMm(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00';
|
||||
const s = Math.floor(totalSeconds);
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${pad(days)}:${pad(hours)}:${pad(minutes)}`;
|
||||
}
|
||||
|
||||
export function formatUtcToLocal(utc: string | null | undefined): string {
|
||||
if (!utc) return '';
|
||||
const d = new Date(utc);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export type SolveErrorCode =
|
||||
| 'not_running'
|
||||
| 'incorrect'
|
||||
| 'already_solved'
|
||||
| 'not_found'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'validation'
|
||||
| 'network'
|
||||
| 'unknown';
|
||||
|
||||
export function parseSolveError(status: number, body: any): SolveErrorCode {
|
||||
const code = body?.code as string | undefined;
|
||||
if (code === 'EVENT_NOT_RUNNING') return 'not_running';
|
||||
if (code === 'FLAG_INCORRECT') return 'incorrect';
|
||||
if (status === 404 || code === 'NOT_FOUND') return 'not_found';
|
||||
if (status === 401 || code === 'UNAUTHORIZED') return 'unauthorized';
|
||||
if (status === 403 || code === 'FORBIDDEN') return 'forbidden';
|
||||
if (status === 0) return 'network';
|
||||
if (code === 'VALIDATION_FAILED') return 'validation';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function parseStatusPayload(raw: any): EventStatePayloadLike {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {
|
||||
state: 'unconfigured',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: null,
|
||||
eventEndUtc: null,
|
||||
secondsToStart: null,
|
||||
secondsToEnd: null,
|
||||
};
|
||||
}
|
||||
const eventStartUtc =
|
||||
raw.event_start_utc !== undefined ? raw.event_start_utc : raw.eventStartUtc ?? null;
|
||||
const eventEndUtc =
|
||||
raw.event_end_utc !== undefined ? raw.event_end_utc : raw.eventEndUtc ?? null;
|
||||
return {
|
||||
state: (raw.state as EventStatePayloadLike['state']) ?? 'unconfigured',
|
||||
serverNowUtc: raw.server_time_utc ?? raw.serverNowUtc ?? new Date().toISOString(),
|
||||
eventStartUtc,
|
||||
eventEndUtc,
|
||||
secondsToStart: raw.seconds_to_start ?? raw.secondsToStart ?? null,
|
||||
secondsToEnd: raw.seconds_to_end ?? raw.secondsToEnd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSolveEvent(raw: any): SolveEventLivePayload {
|
||||
const awardedAtUtc = raw?.awarded_at_utc ?? raw?.awardedAtUtc ?? raw?.solvedAt ?? '';
|
||||
const playerId = raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '';
|
||||
return {
|
||||
challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
|
||||
playerId,
|
||||
playerName: raw?.player_name ?? raw?.playerName ?? '',
|
||||
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
|
||||
rankBonus: Number(raw?.rank_bonus ?? raw?.rankBonus ?? 0),
|
||||
awardedAtUtc,
|
||||
position: Number(raw?.position ?? 0),
|
||||
livePoints: Number(raw?.live_points ?? raw?.livePoints ?? 0),
|
||||
solveCount: Number(raw?.solve_count ?? raw?.solveCount ?? 0),
|
||||
initialPoints: Number(raw?.initial_points ?? raw?.initialPoints ?? 0),
|
||||
minimumPoints: Number(raw?.minimum_points ?? raw?.minimumPoints ?? 0),
|
||||
decaySolves: Number(raw?.decay_solves ?? raw?.decaySolves ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function messageForSolveError(code: SolveErrorCode): string {
|
||||
switch (code) {
|
||||
case 'not_running':
|
||||
return 'Submissions are only accepted while the event is running.';
|
||||
case 'incorrect':
|
||||
return 'Incorrect flag. Try again.';
|
||||
case 'already_solved':
|
||||
return 'You already solved this challenge.';
|
||||
case 'not_found':
|
||||
return 'Challenge not found.';
|
||||
case 'unauthorized':
|
||||
return 'Your session has expired. Please sign in again.';
|
||||
case 'forbidden':
|
||||
return 'You are not allowed to perform this action.';
|
||||
case 'network':
|
||||
return 'Network error. Please try again.';
|
||||
case 'validation':
|
||||
return 'Invalid submission.';
|
||||
default:
|
||||
return 'Unexpected error. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSolveEventIntoSolvers(
|
||||
existing: readonly SolverRow[],
|
||||
payload: SolveEventPayload,
|
||||
): SolverRow[] {
|
||||
if (existing.some((s) => s.playerId === payload.playerId && s.solvedAtUtc === payload.awardedAtUtc)) {
|
||||
return [...existing];
|
||||
}
|
||||
const newRow: SolverRow = {
|
||||
position: 0,
|
||||
playerId: payload.playerId,
|
||||
playerName: payload.playerName,
|
||||
solvedAtUtc: payload.awardedAtUtc,
|
||||
awardedPoints: payload.awardedPoints,
|
||||
basePoints: 0,
|
||||
rankBonus: payload.rankBonus,
|
||||
isFirst: payload.position === 1,
|
||||
isSecond: payload.position === 2,
|
||||
isThird: payload.position === 3,
|
||||
};
|
||||
const merged = [...existing, newRow].sort((a, b) => {
|
||||
const at = new Date(a.solvedAtUtc).getTime();
|
||||
const bt = new Date(b.solvedAtUtc).getTime();
|
||||
return at - bt;
|
||||
});
|
||||
return merged.map((r, idx) => ({ ...r, position: idx + 1 }));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, catchError, throwError } from 'rxjs';
|
||||
import {
|
||||
BoardResponse,
|
||||
ChallengeDetail,
|
||||
EventStatePayloadLike,
|
||||
SolveResponse,
|
||||
} from './challenges.pure';
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
status: number;
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
function toEnvelope(err: unknown): ApiErrorEnvelope {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
return {
|
||||
status: err.status,
|
||||
code: err.error?.code,
|
||||
message: err.error?.message,
|
||||
details: err.error?.details,
|
||||
};
|
||||
}
|
||||
return { status: 0, code: 'NETWORK', message: (err as Error)?.message };
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChallengesApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getBoard(opts?: { includeSolvers?: boolean }): Observable<BoardResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts?.includeSolvers) params['include'] = 'solvers';
|
||||
return this.http
|
||||
.get<BoardResponse>('/api/v1/challenges/board', { withCredentials: true, params })
|
||||
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||
}
|
||||
|
||||
getDetail(challengeId: string): Observable<ChallengeDetail> {
|
||||
return this.http
|
||||
.get<ChallengeDetail>(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||
}
|
||||
|
||||
getEventState(): Observable<EventStatePayloadLike> {
|
||||
return this.http
|
||||
.get<EventStatePayloadLike>('/api/v1/challenges/status', { withCredentials: true })
|
||||
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||
}
|
||||
|
||||
submit(challengeId: string, flag: string): Observable<SolveResponse> {
|
||||
return this.http
|
||||
.post<SolveResponse>(
|
||||
`/api/v1/challenges/${encodeURIComponent(challengeId)}/solves`,
|
||||
{ flag },
|
||||
{ withCredentials: true },
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
BoardCard,
|
||||
BoardResponse,
|
||||
CategoryColumn,
|
||||
ChallengeDetail,
|
||||
EventStatePayloadLike,
|
||||
SolveEventLivePayload,
|
||||
SolveEventPayload,
|
||||
SolveResponse,
|
||||
SolverRow,
|
||||
mergeSolveEventIntoSolvers,
|
||||
parseSolveEvent,
|
||||
sortCardsInColumn,
|
||||
sortColumnsByAbbrev,
|
||||
} from './challenges.pure';
|
||||
import { ChallengesApiService } from './challenges.service';
|
||||
import { EventSourceLike } from '../../core/services/event-status.pure';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChallengesStore {
|
||||
private readonly api: ChallengesApiService;
|
||||
private readonly destroyRef: DestroyRef;
|
||||
|
||||
private readonly _board = signal<BoardResponse | null>(null);
|
||||
private readonly _eventState = signal<EventStatePayloadLike['state']>('unconfigured');
|
||||
private readonly _secondsToStart = signal<number | null>(null);
|
||||
private readonly _secondsToEnd = signal<number | null>(null);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _tick = signal(0);
|
||||
private readonly _myUserId = signal<string | null>(null);
|
||||
private readonly _selectedDetail = signal<ChallengeDetail | null>(null);
|
||||
|
||||
private readonly solveListeners = new Map<string, Set<(p: SolveEventLivePayload) => void>>();
|
||||
|
||||
constructor(api?: ChallengesApiService, destroyRef?: DestroyRef) {
|
||||
this.api = api ?? inject(ChallengesApiService);
|
||||
this.destroyRef = destroyRef ?? inject(DestroyRef);
|
||||
this.destroyRef.onDestroy(() => this.stop());
|
||||
}
|
||||
|
||||
readonly board = this._board.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
readonly eventState = this._eventState.asReadonly();
|
||||
readonly selectedChallengeDetail = this._selectedDetail.asReadonly();
|
||||
|
||||
readonly sortedColumns = computed<CategoryColumn[]>(() => {
|
||||
const b = this._board();
|
||||
if (!b) return [];
|
||||
return sortColumnsByAbbrev(
|
||||
b.columns.map((col) => ({
|
||||
...col,
|
||||
cards: sortCardsInColumn(col.cards),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
readonly isRunning = computed(() => this._eventState() === 'running');
|
||||
|
||||
readonly countdownSeconds = computed<number | null>(() => {
|
||||
void this._tick();
|
||||
const state = this._eventState();
|
||||
if (state === 'countdown') return this._secondsToStart();
|
||||
if (state === 'running') return this._secondsToEnd();
|
||||
return null;
|
||||
});
|
||||
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private sse: EventSourceLike | null = null;
|
||||
private countdownZeroHandler: (() => void) | null = null;
|
||||
|
||||
setMyUserId(id: string | null): void {
|
||||
this._myUserId.set(id);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
try {
|
||||
const board = await new Promise<BoardResponse>((resolve, reject) => {
|
||||
this.api.getBoard().subscribe({ next: resolve, error: reject });
|
||||
});
|
||||
this._board.set(board);
|
||||
} catch (err: any) {
|
||||
this._error.set(err?.message ?? 'Failed to load challenges');
|
||||
} finally {
|
||||
this._loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchEventState(): Observable<EventStatePayloadLike> {
|
||||
return this.api.getEventState();
|
||||
}
|
||||
|
||||
async loadDetail(id: string): Promise<ChallengeDetail | null> {
|
||||
try {
|
||||
const detail = await new Promise<ChallengeDetail>((resolve, reject) => {
|
||||
this.api.getDetail(id).subscribe({ next: resolve, error: reject });
|
||||
});
|
||||
this._selectedDetail.set(detail);
|
||||
this.applySubmitResponse({
|
||||
status: 'solved',
|
||||
challenge: detail.challenge,
|
||||
awarded: {
|
||||
position: 0,
|
||||
basePoints: 0,
|
||||
rankBonus: 0,
|
||||
awardedPoints: 0,
|
||||
awardedAtUtc: '',
|
||||
},
|
||||
solvers: detail.solvers,
|
||||
});
|
||||
return detail;
|
||||
} catch {
|
||||
this._selectedDetail.set(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
clearSelectedDetail(): void {
|
||||
this._selectedDetail.set(null);
|
||||
}
|
||||
|
||||
registerSolveListener(challengeId: string, listener: (p: SolveEventLivePayload) => void): () => void {
|
||||
const set = this.solveListeners.get(challengeId) ?? new Set();
|
||||
set.add(listener);
|
||||
this.solveListeners.set(challengeId, set);
|
||||
return () => {
|
||||
const cur = this.solveListeners.get(challengeId);
|
||||
if (!cur) return;
|
||||
cur.delete(listener);
|
||||
if (cur.size === 0) this.solveListeners.delete(challengeId);
|
||||
};
|
||||
}
|
||||
|
||||
applyStatusPayload(payload: EventStatePayloadLike): void {
|
||||
this._eventState.set(payload.state);
|
||||
this._secondsToStart.set(payload.secondsToStart);
|
||||
this._secondsToEnd.set(payload.secondsToEnd);
|
||||
}
|
||||
|
||||
wireSse(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
|
||||
this.stop();
|
||||
const src = createSource();
|
||||
this.sse = src;
|
||||
// The challenges store only cares about 'solve' frames. The status stream is
|
||||
// consumed separately by EventStatusStore (via its own 'message' listener).
|
||||
// Registering on multiple event names would cause each frame to be dispatched
|
||||
// twice (the transport fan-outs to both the named listener and 'message').
|
||||
const handler = (ev: MessageEvent | Event) => this.handleSseFrame(ev);
|
||||
src.addEventListener('solve', handler);
|
||||
if (onUnauthorized) {
|
||||
src.addEventListener('unauthorized', () => {
|
||||
try {
|
||||
onUnauthorized();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!this.intervalId) {
|
||||
this.intervalId = setInterval(() => this._tick.update((v) => v + 1), 1000);
|
||||
}
|
||||
queueMicrotask(() => this.checkCountdownZero());
|
||||
}
|
||||
|
||||
/** Exposed for tests; safe to call externally as it is read-only on state. */
|
||||
handleSseFrame(ev: MessageEvent | Event): void {
|
||||
const me = ev as MessageEvent;
|
||||
if (me.type !== 'solve') return;
|
||||
try {
|
||||
const raw = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
|
||||
if (!raw) return;
|
||||
const payload = parseSolveEvent(raw);
|
||||
this.applySolveEvent(payload);
|
||||
const listeners = this.solveListeners.get(payload.challengeId);
|
||||
if (listeners) {
|
||||
for (const fn of listeners) {
|
||||
try { fn(payload); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frame
|
||||
}
|
||||
}
|
||||
|
||||
onCountdownZero(handler: () => void): void {
|
||||
this.countdownZeroHandler = handler;
|
||||
}
|
||||
|
||||
private checkCountdownZero(): void {
|
||||
const state = this._eventState();
|
||||
if (state === 'countdown') {
|
||||
const s = this._secondsToStart();
|
||||
if (s !== null && s <= 0 && this.countdownZeroHandler) {
|
||||
this.countdownZeroHandler();
|
||||
}
|
||||
} else if (state === 'running') {
|
||||
const s = this._secondsToEnd();
|
||||
if (s !== null && s <= 0 && this.countdownZeroHandler) {
|
||||
this.countdownZeroHandler();
|
||||
}
|
||||
}
|
||||
if (this.intervalId) {
|
||||
setTimeout(() => this.checkCountdownZero(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
applySolveEvent(payload: SolveEventLivePayload): void {
|
||||
const board = this._board();
|
||||
const me = this._myUserId();
|
||||
const isMine = me !== null && payload.playerId === me;
|
||||
if (board) {
|
||||
const updated: BoardResponse = {
|
||||
...board,
|
||||
columns: board.columns.map((col) => ({
|
||||
...col,
|
||||
cards: col.cards.map((card): BoardCard => {
|
||||
if (card.id !== payload.challengeId) return card;
|
||||
return {
|
||||
...card,
|
||||
livePoints: payload.livePoints ?? card.livePoints,
|
||||
solveCount: payload.solveCount ?? card.solveCount + 1,
|
||||
solvedByMe: card.solvedByMe || isMine,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
solvedChallengeIds: isMine && !board.solvedChallengeIds.includes(payload.challengeId)
|
||||
? [...board.solvedChallengeIds, payload.challengeId]
|
||||
: board.solvedChallengeIds,
|
||||
perChallengeLivePoints: payload.livePoints != null
|
||||
? { ...board.perChallengeLivePoints, [payload.challengeId]: payload.livePoints }
|
||||
: board.perChallengeLivePoints,
|
||||
};
|
||||
this._board.set(updated);
|
||||
}
|
||||
const detail = this._selectedDetail();
|
||||
if (detail && detail.challenge.id === payload.challengeId) {
|
||||
const livePoints = payload.livePoints ?? detail.challenge.livePoints;
|
||||
const solveCount = payload.solveCount ?? detail.challenge.solveCount + 1;
|
||||
this._selectedDetail.set({
|
||||
challenge: {
|
||||
...detail.challenge,
|
||||
livePoints,
|
||||
solveCount,
|
||||
solvedByMe: detail.challenge.solvedByMe || isMine,
|
||||
},
|
||||
solvers: mergeSolveEventIntoSolvers(detail.solvers, payload),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applySubmitResponse(response: SolveResponse): void {
|
||||
const board = this._board();
|
||||
if (board) {
|
||||
const updated: BoardResponse = {
|
||||
...board,
|
||||
columns: board.columns.map((col) => ({
|
||||
...col,
|
||||
cards: col.cards.map((card): BoardCard => {
|
||||
if (card.id !== response.challenge.id) return card;
|
||||
return {
|
||||
...card,
|
||||
livePoints: response.challenge.livePoints,
|
||||
solveCount: response.challenge.solveCount,
|
||||
solvedByMe: response.challenge.solvedByMe || card.solvedByMe,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
solvedChallengeIds: response.challenge.solvedByMe && !board.solvedChallengeIds.includes(response.challenge.id)
|
||||
? [...board.solvedChallengeIds, response.challenge.id]
|
||||
: board.solvedChallengeIds,
|
||||
perChallengeLivePoints: {
|
||||
...board.perChallengeLivePoints,
|
||||
[response.challenge.id]: response.challenge.livePoints,
|
||||
},
|
||||
};
|
||||
this._board.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
findCard(id: string): BoardCard | null {
|
||||
const board = this._board();
|
||||
if (!board) return null;
|
||||
for (const col of board.columns) {
|
||||
const card = col.cards.find((c) => c.id === id);
|
||||
if (card) return card;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.sse) {
|
||||
try {
|
||||
this.sse.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.sse = null;
|
||||
}
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async submit(challengeId: string, flag: string): Promise<SolveResponse> {
|
||||
return new Promise<SolveResponse>((resolve, reject) => {
|
||||
this.api.submit(challengeId, flag).subscribe({
|
||||
next: (resp) => {
|
||||
this.applySubmitResponse(resp);
|
||||
resolve(resp);
|
||||
},
|
||||
error: (err) => reject(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
buildSolversFromResponse(solvers: SolverRow[]): SolverRow[] {
|
||||
return [...solvers].sort((a, b) => {
|
||||
const at = new Date(a.solvedAtUtc).getTime();
|
||||
const bt = new Date(b.solvedAtUtc).getTime();
|
||||
return at - bt;
|
||||
}).map((s, idx) => ({ ...s, position: idx + 1 }));
|
||||
}
|
||||
|
||||
mergeLiveSolver(solvers: SolverRow[], payload: SolveEventPayload): SolverRow[] {
|
||||
return mergeSolveEventIntoSolvers(solvers, payload);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,13 @@ import { AppComponent } from './app/app.component';
|
||||
import { APP_ROUTES } from './app/app.routes';
|
||||
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
||||
import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor';
|
||||
import { errorNotificationInterceptor } from './app/core/interceptors/error-notification.interceptor';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])),
|
||||
provideHttpClient(
|
||||
withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor]),
|
||||
),
|
||||
provideRouter(APP_ROUTES, withComponentInputBinding()),
|
||||
],
|
||||
}).catch((err) => console.error(err));
|
||||
Reference in New Issue
Block a user