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;
}
}