From 00640fbc04f94e4014e95cdecfcadeef5afc051e Mon Sep 17 00:00:00 2001 From: LaurensBartelds Date: Tue, 21 Apr 2026 21:22:49 +0200 Subject: [PATCH] feat: add formatting, storage, and validation utilities --- frontend/src/utils/formatting.test.ts | 26 ++++++++++++++++ frontend/src/utils/formatting.ts | 18 +++++++++++ frontend/src/utils/storage.ts | 45 +++++++++++++++++++++++++++ frontend/src/utils/validation.ts | 16 ++++++++++ 4 files changed, 105 insertions(+) create mode 100644 frontend/src/utils/formatting.test.ts create mode 100644 frontend/src/utils/formatting.ts create mode 100644 frontend/src/utils/storage.ts create mode 100644 frontend/src/utils/validation.ts diff --git a/frontend/src/utils/formatting.test.ts b/frontend/src/utils/formatting.test.ts new file mode 100644 index 0000000..7268682 --- /dev/null +++ b/frontend/src/utils/formatting.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { formatTime, formatDate } from './formatting' + +describe('formatTime', () => { + it('formats seconds', () => { + expect(formatTime(65000)).toBe('01:05') + }) + it('formats hours', () => { + expect(formatTime(3661000)).toBe('1:01:01') + }) + it('handles 0', () => { + expect(formatTime(0)).toBe('00:00') + }) + it('handles negative (clamps to 0)', () => { + expect(formatTime(-1000)).toBe('00:00') + }) +}) + +describe('formatDate', () => { + it('returns "just now" for very recent timestamps', () => { + expect(formatDate(Date.now() - 5000)).toBe('just now') + }) + it('returns minutes ago for recent timestamps', () => { + expect(formatDate(Date.now() - 120_000)).toMatch(/m ago/) + }) +}) diff --git a/frontend/src/utils/formatting.ts b/frontend/src/utils/formatting.ts new file mode 100644 index 0000000..586815a --- /dev/null +++ b/frontend/src/utils/formatting.ts @@ -0,0 +1,18 @@ +export const formatTime = (ms: number | null | undefined): string => { + if (ms == null || ms < 0) ms = 0 + const totalSec = Math.floor(ms / 1000) + const h = Math.floor(totalSec / 3600) + const m = Math.floor((totalSec % 3600) / 60) + const s = totalSec % 60 + const pad = (n: number) => String(n).padStart(2, '0') + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}` +} + +export const formatDate = (ts: number): string => { + const d = new Date(ts) + const diff = Date.now() - d.getTime() + if (diff < 60_000) return 'just now' + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago` + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago` + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts new file mode 100644 index 0000000..c532f95 --- /dev/null +++ b/frontend/src/utils/storage.ts @@ -0,0 +1,45 @@ +import type { Exam, ExamState, HistoryEntry } from '../types' + +const STORAGE_KEY = 'brainwave.exam.state' +const HISTORY_KEY = 'brainwave.exam.history' + +export interface PersistedExam { + exam: Exam + state: ExamState +} + +export const loadState = (): PersistedExam | null => { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as PersistedExam | null + } catch { + return null + } +} + +export const saveState = (data: PersistedExam): void => { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) } catch { /* quota */ } +} + +export const clearState = (): void => { + try { localStorage.removeItem(STORAGE_KEY) } catch { /* ignore */ } +} + +export const loadHistory = (): HistoryEntry[] => { + try { + return JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]') as HistoryEntry[] + } catch { + return [] + } +} + +export const appendHistory = (entry: HistoryEntry): void => { + try { + const hist = loadHistory() + hist.unshift(entry) + localStorage.setItem(HISTORY_KEY, JSON.stringify(hist.slice(0, 30))) + } catch { /* ignore */ } +} + +export const clearHistory = (): void => { + try { localStorage.removeItem(HISTORY_KEY) } catch { /* ignore */ } +} diff --git a/frontend/src/utils/validation.ts b/frontend/src/utils/validation.ts new file mode 100644 index 0000000..c110114 --- /dev/null +++ b/frontend/src/utils/validation.ts @@ -0,0 +1,16 @@ +import type { Exam } from '../types' + +export const validateExam = (data: unknown): string | null => { + if (!data || typeof data !== 'object') return 'Not a JSON object' + const d = data as Record + if (!d['title']) return 'Missing "title"' + if (!Array.isArray(d['questions']) || d['questions'].length === 0) return 'Missing "questions" array' + for (const q of d['questions'] as Record[]) { + if (!q['id']) return 'Question missing id' + if (!q['type']) return `Question "${String(q['id'])}" missing type` + if (!q['prompt']) return `Question "${String(q['id'])}" missing prompt` + } + return null +} + +export const parseExam = (data: unknown): Exam => data as Exam