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