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
@@ -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);
}
}
+4 -1
View File
@@ -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));