feat: add formatting, storage, and validation utilities

This commit is contained in:
LaurensBartelds committed 2026-04-21 21:22:49 +02:00
1 parent e211594429
commit 00640fbc04
4 files changed
+105

No files matched your search

+26
View File
@@ -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/)
})
})
+18
View File
@@ -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' })
}
+45
View File
@@ -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 */ }
}
+16
View File
@@ -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<string, unknown>
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<string, unknown>[]) {
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