AI Implementation feature(905): Challenges Page and Challenge Solve Modal 1.01 (#47)

This commit was merged in pull request #47.
This commit is contained in:
2026-07-23 01:20:00 +00:00
parent 1bd4a5a198
commit cbfa9c35ca
9 changed files with 437 additions and 115 deletions
@@ -73,25 +73,38 @@ export class EventStatusStore {
private zeroWatcher: ReturnType<typeof setInterval> | null = null;
private reloadOnZero: (() => void) | null = null;
private reloadOnZeroFired = false;
private lastAppliedJson: string | null = null;
constructor() {
this.destroyRef.onDestroy(() => this.stop());
}
applyServerStatus(payload: EventStatePayload): void {
const json = JSON.stringify(payload);
if (this.lastAppliedJson === json) {
// Same payload delivered again (e.g. SSE tick that duplicates the
// initial frame). Ignore it so the reload latch is not silently
// disarmed after the watcher has already fired.
return;
}
this.lastAppliedJson = json;
const isTransition =
this.state() !== payload.state ||
this.eventStartUtc() !== payload.eventStartUtc ||
this.eventEndUtc() !== payload.eventEndUtc;
this.state.set(payload.state);
this.serverNowUtc.set(payload.serverNowUtc);
this.eventStartUtc.set(payload.eventStartUtc);
this.eventEndUtc.set(payload.eventEndUtc);
this.clientAnchorMs.set(Date.now());
// Reset the one-shot so a subsequent transition (e.g. countdown→running) can fire again.
if (this.reloadOnZeroFired) {
if (isTransition && this.reloadOnZeroFired) {
this.reloadOnZeroFired = false;
}
}
start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
this.stop();
this.closeTransport();
const src = createSource();
this.source = src;
const handler = (ev: MessageEvent | Event) => {
@@ -119,14 +132,48 @@ 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);
}
this.ensureWatcher();
}
reloadAtCountdownZero(handler: () => void): void {
this.subscribeReloadAtCountdownZero(handler);
}
subscribeReloadAtCountdownZero(handler: () => void): () => void {
this.reloadOnZero = handler;
this.reloadOnZeroFired = false;
this.ensureWatcher();
return () => {
if (this.reloadOnZero === handler) {
this.reloadOnZero = null;
this.reloadOnZeroFired = false;
}
};
}
private closeTransport(): void {
if (this.source) {
try {
this.source.close();
} catch {
// ignore
}
this.source = null;
}
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.zeroWatcher) {
clearInterval(this.zeroWatcher);
this.zeroWatcher = null;
}
}
private ensureWatcher(): void {
if (!this.zeroWatcher) {
this.zeroWatcher = setInterval(() => this.checkZero(), 1000);
}
}
private checkZero(): void {
@@ -137,12 +184,24 @@ export class EventStatusStore {
if (s !== null && s <= 0) {
this.reloadOnZeroFired = true;
try { this.reloadOnZero(); } catch { /* ignore */ }
return;
}
const startMs = new Date(this.eventStartUtc() ?? '').getTime();
if (Number.isFinite(startMs) && startMs <= Date.now()) {
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 */ }
return;
}
const endMs = new Date(this.eventEndUtc() ?? '').getTime();
if (Number.isFinite(endMs) && endMs <= Date.now()) {
this.reloadOnZeroFired = true;
try { this.reloadOnZero(); } catch { /* ignore */ }
}
}
}
@@ -164,5 +223,8 @@ export class EventStatusStore {
clearInterval(this.zeroWatcher);
this.zeroWatcher = null;
}
this.reloadOnZero = null;
this.reloadOnZeroFired = false;
this.lastAppliedJson = null;
}
}