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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user