Compare commits

...

22 Commits

Author SHA1 Message Date
kitadmin 45f1e0e7f6 fix(deploy): requireTLS for SMTP relay + bridge network for docker-compose
- mailer.js: add requireTLS to force STARTTLS before AUTH on internal relay
- docker-compose.yml: use network_mode:bridge to reach 172.17.0.1:2587 SMTP relay
  and switch to ${SMTP_HOST:-172.17.0.1}/${SMTP_PORT:-2587} defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 03:28:54 +00:00
kitadmin 12522f2478 fix(server): catch SMTP errors in auth/request gracefully
Return 200 ok even if email send fails — token is saved in DB and
magic link is logged to console. Prevents 500 on transient SMTP issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 03:27:01 +00:00
kitadmin bbebc31624 fix(mailer): add TLS settings for internal SMTP relay
- rejectUnauthorized: false (relay cert is for mail.keylinkit.net, not 172.17.0.1)
- connection/greeting/socket timeouts for reliability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 03:23:44 +00:00
kitadmin b31d6637a7 feat: Docker, docker-compose, and deployment config (Task 13)
- Dockerfile: two-stage build (builder installs all deps + vite build; runtime copies dist + server)
- docker-compose.yml: 127.0.0.1:3004:8080, named volume for SQLite, env var interpolation
- .env.example: all required environment variables documented
- .dockerignore: exclude node_modules, dist, data, .env from build context
- vite.config.ts: outDir → ../client/dist, /api proxy to :3001 for dev
- .gitignore: remove Docker entries (files should be tracked for deployment)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:26:32 +00:00
kitadmin c9c12a4c2c feat: admin panel with user table and stats (Task 12)
- AdminPage: password gate (sessionStorage), user table, stats banner
- Auto-login using stored sessionStorage password on mount
- App.tsx: route to admin when pathname === '/admin'
- Fix: remove redundant setPassword in auto-login effect (useState already seeds from sessionStorage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:23:15 +00:00
kitadmin 10afc2bbb0 feat: game practice loop with audio, scoring, and level-up modal (Task 11)
- useMorseAudio: wraps MorseAudioEngine, exposes currentSignal for FlashArea
- useProgress: fetches /api/progress + /api/mnemonics, recordAnswer with local stats
- FlashArea: gold flash on signal, height varies dot/dash
- ScoreBar: level name, score, streak display
- PracticeCard: play button, answer input, correct/incorrect feedback with mnemonic
- GamePage: full practice loop, level-up modal at streak%10, anti-repeat history
- Fix: incorrect attempt recorded only once per challenge (no stat inflation on retries)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:19:58 +00:00
kitadmin 8415e569dd feat: onboarding mnemonic builder with step-through UI (Task 10)
- MnemonicBuilder: 12 NOVICE_LETTERS, real-time validatePhrase, save/skip
- OnboardingPage: wraps builder, passes token and onComplete
- App.tsx: post-auth fetch /api/mnemonics, route to onboarding if <12 saved
- Fix: validatePhrase called with letter (not Morse pattern) as second arg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:14:46 +00:00
kitadmin 7c9386f125 feat: auth hook and login page (Task 9)
- useAuth: session token from URL/localStorage, /api/auth/me validation, logout
- LoginPage: email form with POST /api/auth/request, error handling
- App.tsx: use useAuth hook, LoginPage, 'sent' confirmation route
- Fix: auto-route to game covers both 'login' and 'sent' routes when authenticated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:10:38 +00:00
kitadmin 2dd6402c86 feat: React app shell with global styles and state-based routing (Task 8)
- index.css: brand CSS custom properties, .btn, input styles, .app-shell layout
- App.tsx: session token extraction from URL, /api/auth/me validation, 4-route shell
- main.tsx: renders App with StrictMode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:07:19 +00:00
kitadmin 85ba992432 feat: server HTTP API with auth, progress, and admin routes (Tasks 6+7)
- server.js: Node HTTP server with all API routes, static file serving, SPA fallback
- Path traversal protection on static file serving
- Async handler wrapped with .catch() to prevent unhandled rejections
- readBody: size limit (1MB) + error handler
- letter validation: single [A-Z] char check on progress/mnemonics routes
- phrase length limit (500 chars) on POST /api/mnemonics
- requireAdmin reads ADMIN_PASSWORD at request time for testability
- logout uses session.token from requireAuth (not re-read from header)
- 51 server tests passing (auth, progress, admin routes + edge cases)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:04:57 +00:00
kitadmin 339fa60455 feat: mailer with magic link email (allegiance pattern, MorseQuest branding) 2026-04-30 01:02:43 +00:00
kitadmin b229e6871f fix(db): transactional useMagicToken, O(2) getSession, ?? for displayName
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:00:20 +00:00
kitadmin d8de79ce7d fix(db): atomic saveMnemonic, const normalizedLetter, updateProfileSeen guard, ?? 0 for avgScore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:46:05 +00:00
kitadmin 2d96d37866 fix(db): instance-scoped closures, FK constraints, letter normalization, test coverage
- Remove module-level `let db` singleton; all query functions now close
  over the local `instance` from their `initDb` call, preventing
  cross-contamination when multiple callers each invoke `initDb`
- Add REFERENCES … ON DELETE CASCADE to profile_id in magic_tokens,
  sessions, user_progress, user_mnemonics, and letter_stats so that
  the already-enabled foreign_keys pragma is actually enforced by DDL
- recordAnswer: normalise letter to uppercase before storing stats so
  lowercase 'a' and uppercase 'A' are never tracked separately
- updateLevel: call getOrCreateProgress first to avoid a silent no-op
  UPDATE when no progress row exists yet
- saveMnemonic: replace SELECT-then-INSERT with an atomic UPSERT
  (INSERT … ON CONFLICT DO UPDATE) and detect first-save by checking
  whether created_at equals the current timestamp
- getSession: re-fetch the row after updating last_seen so the returned
  object reflects the freshly written timestamp
- db.test.js: add tests for findProfileById, findProfileById (null),
  updateProfileSeen, updateLevel (normal + no-row-yet), getAdminUsers,
  and getAdminStats (21 tests total, all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:41:40 +00:00
kitadmin 9b31119c7a feat: database layer with better-sqlite3, full schema and query functions
Implements Task 4 (TDD): wrote failing tests first, then the full SQLite
database layer covering profiles, magic tokens, sessions, user progress,
mnemonics, and letter stats. All 14 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:34:59 +00:00
kitadmin cf2081f643 fix(audio): correct re-play-after-stop bug, strengthen tests
Use pendingCancel flag (consumed once) to suppress stop-before-play while
allowing subsequent play() calls to proceed normally. Add afterEach timer
cleanup, re-play test, and behavioral assertion for off().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:29:08 +00:00
kitadmin f62490820a feat: MorseAudioEngine with Web Audio API and event emitter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:23:30 +00:00
kitadmin 1bfd5a22fe test(morse): add missing coverage for DEFAULT_WPM, DEFAULT_TIMING, and derived timing fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:17:33 +00:00
kitadmin f7ad8aa3cc feat: morse data library with validation and challenge selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:10:58 +00:00
kitadmin 6e83b16c4b feat: project scaffold — Vite + React + TS + test setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:06:14 +00:00
kitadmin f4fab89809 Add MVP implementation plan (14 tasks, full code)
Covers scaffold, morse data, audio engine, DB layer, mailer, server
routes, React app shell, auth, onboarding, game loop, admin panel,
and Docker deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 23:57:46 +00:00
kitadmin cb0ffb7e6b Add MVP design spec and update .gitignore
Covers architecture, schema, user flows, audio engine, mnemonic
validation, scoring, content data, Docker deployment, and brand
implementation for the Pack 404 pilot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 22:41:15 +00:00
38 changed files with 6209 additions and 5 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
client/dist
data
.env
.git
*.md
+17
View File
@@ -0,0 +1,17 @@
# MorseQuest Environment Variables
# Copy to .env and fill in values
PORT=3001
NODE_ENV=development
# SMTP (for magic link emails)
SMTP_HOST=mail.keylinkit.net
SMTP_PORT=587
SMTP_USER=noreply@morsequest.keylinkit.net
SMTP_PASS=
# Admin panel password
ADMIN_PASSWORD=
# App URL (used in magic link emails)
APP_URL=http://localhost:3001
+5 -5
View File
@@ -180,16 +180,13 @@ dist
*.db
*.sqlite
*.sqlite3
data/
data/*
!data/.gitkeep
# Build artifacts
build/
dist/
# Docker
.dockerignore
Dockerfile*
docker-compose*.yml
# IDE
.vscode/
@@ -216,3 +213,6 @@ public/uploads/
forge-config.json
.forge/
deployment-keys/
# Superpowers brainstorm sessions
.superpowers/
+24
View File
@@ -0,0 +1,24 @@
# --- Build stage ---
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY . .
RUN npm run build
# --- Runtime stage ---
FROM node:20-alpine
ENV NODE_ENV=production PORT=8080
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY --from=builder /app/client/dist ./client/dist
COPY server/ ./server/
RUN mkdir -p /app/data && chown -R node:node /app
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/ || exit 1
CMD ["node", "server/server.js"]
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/assets/logos/logo-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MorseQuest</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Open+Sans:wght@400;600&family=Source+Code+Pro:wght@400;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react'
import { useAuth } from './hooks/useAuth'
import { LoginPage } from './pages/LoginPage'
import { OnboardingPage } from './pages/OnboardingPage'
import { GamePage } from './pages/GamePage'
import { AdminPage } from './pages/AdminPage'
import './index.css'
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
export default function App() {
const { profile, token, loading, logout } = useAuth()
const [route, setRoute] = useState<AppRoute>('login')
const [checkingOnboarding, setCheckingOnboarding] = useState(false)
// Check for /admin path
useEffect(() => {
if (window.location.pathname === '/admin') {
setRoute('admin')
}
}, [])
// After auth resolves, check onboarding status
useEffect(() => {
if (!profile || !token) return
if (route !== 'login' && route !== 'sent') return
setCheckingOnboarding(true)
fetch('/api/mnemonics', {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.json())
.then((mnemonics: Record<string, string>) => {
const count = Object.keys(mnemonics).length
setRoute(count >= 12 ? 'game' : 'onboarding')
})
.catch(() => setRoute('game'))
.finally(() => setCheckingOnboarding(false))
}, [profile, token])
if (loading || checkingOnboarding) {
return (
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
</div>
)
}
return (
<div className="app-shell">
{route === 'login' && (
<LoginPage onLoginSent={() => setRoute('sent')} />
)}
{route === 'sent' && (
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>Check your email</h2>
<p>We sent a magic link to your inbox. Click it to begin your quest!</p>
</div>
)}
{route === 'onboarding' && token && (
<OnboardingPage token={token} onComplete={() => setRoute('game')} />
)}
{route === 'game' && token && (
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
)}
{route === 'admin' && <AdminPage />}
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
type FlashAreaProps = { signal: 'dot' | 'dash' | null }
export function FlashArea({ signal }: FlashAreaProps) {
const active = signal !== null
return (
<div style={{
width: '100%',
height: signal === 'dash' ? 120 : 80,
background: active ? 'var(--color-gold)' : '#2a1a0a',
border: '3px solid var(--color-forest)',
transition: 'background 0.05s, height 0.05s',
borderRadius: 4,
}} />
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useState } from 'react'
import { NOVICE_LETTERS, MORSE, validatePhrase } from '../lib/morse'
type Props = {
token: string
onComplete: () => void
}
const LETTERS = NOVICE_LETTERS // ['A','E','T','I','N','S','H','R','D','L','U','O']
export function MnemonicBuilder({ token, onComplete }: Props) {
const [index, setIndex] = useState(0)
const [phrase, setPhrase] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const letter = LETTERS[index]
const pattern = MORSE[letter] ?? ''
// Display pattern as dots/dashes: '.' → '·', '-' → '—'
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
async function save() {
if (!validation?.valid) return
setSaving(true)
setError(null)
try {
const r = await fetch('/api/mnemonics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ letter, phrase: phrase.trim() }),
})
if (!r.ok) throw new Error('Failed to save')
advance()
} catch {
setError('Failed to save. Try again.')
} finally {
setSaving(false)
}
}
function advance() {
if (index + 1 >= LETTERS.length) {
onComplete()
} else {
setIndex(i => i + 1)
setPhrase('')
setError(null)
}
}
return (
<div style={{ maxWidth: 520, margin: '0 auto' }}>
{/* Progress */}
<p style={{ color: 'var(--color-brown)', marginBottom: '1.5rem' }}>
Letter {index + 1} of {LETTERS.length}
</p>
{/* Letter display */}
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div style={{
fontSize: '4rem',
fontFamily: 'var(--font-header)',
color: 'var(--color-forest)',
lineHeight: 1,
}}>
{letter}
</div>
<div style={{
fontSize: '1.8rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-gold)',
marginTop: '0.5rem',
letterSpacing: '0.3em',
}}>
{display}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginTop: '0.25rem' }}>
{pattern.length} {pattern.length === 1 ? 'word' : 'words'} needed
</div>
</div>
{/* Phrase input */}
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.4rem' }}>
Create your mnemonic phrase:
</label>
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
{pattern.length} word{pattern.length !== 1 ? 's' : ''} short words (1-2 letters) for dots,
long words (3+ letters) for dashes.
</p>
<input
type="text"
value={phrase}
onChange={e => { setPhrase(e.target.value); setError(null) }}
placeholder={`${pattern.length} word${pattern.length !== 1 ? 's' : ''}, e.g. "${examplePhrase(pattern)}"`}
disabled={saving}
style={{ marginBottom: '0.5rem' }}
/>
{/* Validation feedback */}
{validation && !validation.valid && (
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
{validation.message}
</p>
)}
{validation?.valid && (
<p style={{ color: 'green', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
Looks good!
</p>
)}
{error && (
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>{error}</p>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
<button
className="btn"
onClick={save}
disabled={saving || !validation?.valid}
style={{ flex: 1 }}
>
{saving ? 'Saving...' : 'Save (+50 pts)'}
</button>
<button
className="btn btn-ghost"
onClick={advance}
disabled={saving}
>
Skip
</button>
</div>
</div>
)
}
// Generate a simple example phrase for the placeholder
function examplePhrase(pattern: string): string {
return pattern.split('').map(c => c === '.' ? 'go' : 'WALKING').join(' ')
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useEffect, useRef } from 'react'
import { MORSE } from '../lib/morse'
import { FlashArea } from './FlashArea'
type PracticeCardProps = {
challenge: string // the letter or word to guess
signal: 'dot' | 'dash' | null
isPlaying: boolean
onPlay: () => void
onAnswer: (answer: string) => void // called when user submits
result: 'correct' | 'incorrect' | null
mnemonic: string | null
}
export function PracticeCard({
challenge, signal, isPlaying, onPlay, onAnswer, result, mnemonic
}: PracticeCardProps) {
const [answer, setAnswer] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
// Clear answer on new challenge (when result goes from non-null back to null)
useEffect(() => {
if (result === null) {
setAnswer('')
inputRef.current?.focus()
}
}, [result, challenge])
function submit() {
if (!answer.trim()) return
onAnswer(answer.trim().toUpperCase())
}
const morseDisplay = MORSE[challenge.toUpperCase()]
? challenge.toUpperCase().split('').map(c => MORSE[c] ?? '').join(' ')
: ''
return (
<div>
<FlashArea signal={signal} />
<div style={{ textAlign: 'center', margin: '1.5rem 0' }}>
<button
className="btn"
onClick={onPlay}
disabled={isPlaying}
style={{ minWidth: 120 }}
>
{isPlaying ? 'Playing...' : 'Play'}
</button>
</div>
{/* Show morse pattern hint */}
{morseDisplay && (
<div style={{
textAlign: 'center',
fontFamily: 'var(--font-mono)',
fontSize: '1.2rem',
color: 'var(--color-gold)',
marginBottom: '1rem',
letterSpacing: '0.2em',
}}>
{morseDisplay.split('').map(c => c === '.' ? '·' : c === '-' ? '—' : ' ').join('')}
</div>
)}
{/* Answer input */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
ref={inputRef}
type="text"
value={answer}
onChange={e => setAnswer(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit() }}
placeholder="Type your answer..."
disabled={result === 'correct'}
style={{ flex: 1 }}
/>
<button
className="btn"
onClick={submit}
disabled={!answer.trim() || result === 'correct'}
>
Submit
</button>
</div>
{/* Result feedback */}
{result === 'correct' && (
<div style={{ padding: '1rem', background: '#d4edda', border: '2px solid #28a745', borderRadius: 4 }}>
<strong style={{ color: '#155724' }}>Correct! The answer was: {challenge.toUpperCase()}</strong>
{mnemonic && (
<p style={{ margin: '0.5rem 0 0', color: '#155724', fontStyle: 'italic' }}>
Your mnemonic: "{mnemonic}"
</p>
)}
</div>
)}
{result === 'incorrect' && (
<div style={{ padding: '1rem', background: '#fff3cd', border: '2px solid #ffc107', borderRadius: 4 }}>
<strong style={{ color: '#856404' }}>Try again, adventurer!</strong>
</div>
)}
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
type ScoreBarProps = {
score: number
streak: number
level: number
}
const LEVEL_NAMES: Record<number, string> = { 1: 'Novice', 2: 'Operator' }
export function ScoreBar({ score, streak, level }: ScoreBarProps) {
return (
<div style={{
display: 'flex',
gap: '1.5rem',
padding: '0.75rem 1rem',
background: 'var(--color-forest)',
color: 'var(--color-parchment)',
fontFamily: 'var(--font-body)',
fontSize: '0.9rem',
marginBottom: '1.5rem',
borderRadius: 4,
}}>
<span><strong style={{ color: 'var(--color-gold)' }}>{LEVEL_NAMES[level] ?? `Level ${level}`}</strong></span>
<span>Score: <strong>{score}</strong></span>
<span>Streak: <strong style={{ color: streak >= 5 ? 'var(--color-gold)' : 'inherit' }}>{streak}</strong></span>
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { useState, useEffect, useCallback } from 'react'
type Profile = { id: number; email: string; display_name: string | null }
type AuthState = { profile: Profile | null; token: string | null; loading: boolean }
export function useAuth() {
const [state, setState] = useState<AuthState>({ profile: null, token: null, loading: true })
const validate = useCallback(async (token: string) => {
const r = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (!r.ok) throw new Error('invalid session')
const profile: Profile = await r.json()
return profile
}, [])
useEffect(() => {
// Check URL for ?session= from magic link redirect
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('session')
if (urlToken) {
localStorage.setItem('session', urlToken)
window.history.replaceState({}, '', window.location.pathname)
}
const token = urlToken || localStorage.getItem('session')
if (!token) {
setState({ profile: null, token: null, loading: false })
return
}
validate(token)
.then(profile => setState({ profile, token, loading: false }))
.catch(() => {
localStorage.removeItem('session')
setState({ profile: null, token: null, loading: false })
})
}, [validate])
const logout = useCallback(async () => {
const token = localStorage.getItem('session')
if (token) {
await fetch('/api/auth/session', {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {})
localStorage.removeItem('session')
}
setState({ profile: null, token: null, loading: false })
}, [])
return { ...state, logout }
}
+38
View File
@@ -0,0 +1,38 @@
import { useRef, useState, useCallback } from 'react'
import { MorseAudioEngine } from '../lib/audio'
import { DEFAULT_WPM } from '../lib/morse'
type SignalType = 'dot' | 'dash' | null
export function useMorseAudio() {
const engineRef = useRef<MorseAudioEngine | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentSignal, setCurrentSignal] = useState<SignalType>(null)
function getEngine() {
if (!engineRef.current) {
const engine = new MorseAudioEngine()
engine.on('signal-start', ({ type }: { type: 'dot' | 'dash' }) => setCurrentSignal(type))
engine.on('signal-end', () => setCurrentSignal(null))
engine.on('play-end', () => { setIsPlaying(false); setCurrentSignal(null) })
engineRef.current = engine
}
return engineRef.current
}
const play = useCallback((text: string) => {
const engine = getEngine()
engine.stop()
setIsPlaying(true)
setCurrentSignal(null)
engine.play(text, DEFAULT_WPM)
}, [])
const stop = useCallback(() => {
engineRef.current?.stop()
setIsPlaying(false)
setCurrentSignal(null)
}, [])
return { play, stop, isPlaying, currentSignal }
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback } from 'react'
type Progress = {
level: number
score: number
streak: number
best_streak: number
total_correct: number
total_attempts: number
}
type LetterStats = Record<string, { correct: number; attempts: number }>
export function useProgress(token: string | null) {
const [progress, setProgress] = useState<Progress | null>(null)
const [letterStats, setLetterStats] = useState<LetterStats>({})
const [mnemonics, setMnemonics] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!token) return
const headers = { Authorization: `Bearer ${token}` }
Promise.all([
fetch('/api/progress', { headers }).then(r => r.json()),
fetch('/api/mnemonics', { headers }).then(r => r.json()),
])
.then(([prog, mnems]) => {
setProgress(prog)
setMnemonics(mnems)
})
.finally(() => setLoading(false))
}, [token])
const recordAnswer = useCallback(async (letter: string, correct: boolean): Promise<Progress> => {
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const r = await fetch('/api/progress/answer', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ letter, correct }),
})
const updated: Progress = await r.json()
setProgress(updated)
// Update local letter stats
setLetterStats(prev => {
const stat = prev[letter.toUpperCase()] ?? { correct: 0, attempts: 0 }
return {
...prev,
[letter.toUpperCase()]: {
correct: stat.correct + (correct ? 1 : 0),
attempts: stat.attempts + 1,
},
}
})
return updated
}, [token])
return { progress, letterStats, mnemonics, loading, recordAnswer }
}
+63
View File
@@ -0,0 +1,63 @@
/* CSS custom properties */
:root {
--color-gold: #D4AF37;
--color-blue: #4169E1;
--color-parchment: #F5E6D3;
--color-forest: #3C2415;
--color-brown: #654321;
--font-header: 'Cinzel', serif;
--font-body: 'Open Sans', sans-serif;
--font-mono: 'Source Code Pro', monospace;
}
/* Reset + base */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-body);
background: var(--color-parchment);
color: var(--color-forest);
min-height: 100vh;
}
/* Typography */
h1, h2, h3 { font-family: var(--font-header); color: var(--color-forest); }
code, .mono { font-family: var(--font-mono); }
/* Shared button style */
.btn {
display: inline-block;
padding: 0.7rem 1.6rem;
background: var(--color-gold);
color: var(--color-forest);
border: 2px solid var(--color-forest);
font-family: var(--font-header);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn:hover { background: var(--color-forest); color: var(--color-parchment); }
.btn-ghost {
background: transparent;
border-color: var(--color-gold);
color: var(--color-forest);
}
/* Input */
input[type="text"], input[type="email"] {
width: 100%;
padding: 0.6rem 0.8rem;
border: 2px solid var(--color-brown);
background: white;
font-family: var(--font-body);
font-size: 1rem;
color: var(--color-forest);
}
input:focus { outline: 2px solid var(--color-gold); outline-offset: 1px; }
/* Layout shell */
.app-shell {
max-width: 680px;
margin: 0 auto;
padding: 1rem;
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { MorseAudioEngine } from './audio'
describe('MorseAudioEngine', () => {
let engine: MorseAudioEngine
beforeEach(() => {
engine = new MorseAudioEngine()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('emits signal-start and signal-end events when playing E (.)', async () => {
const starts: string[] = []
const ends: string[] = []
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
engine.on('signal-end', ({ type }: { type: string }) => ends.push(type))
const playPromise = engine.play('E', 10)
await vi.runAllTimersAsync()
await playPromise
expect(starts).toEqual(['dot'])
expect(ends).toEqual(['dot'])
})
it('emits play-end after playback completes', async () => {
let ended = false
engine.on('play-end', () => { ended = true })
const playPromise = engine.play('E', 10)
await vi.runAllTimersAsync()
await playPromise
expect(ended).toBe(true)
})
it('stop() before play() prevents signal emission', async () => {
const starts: string[] = []
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
engine.stop()
const playPromise = engine.play('SOS', 10)
await vi.runAllTimersAsync()
await playPromise
expect(starts).toHaveLength(0)
})
it('play() works normally after a stop-before-play suppression', async () => {
const starts: string[] = []
// First play is suppressed
engine.stop()
await engine.play('SOS', 10)
// Second play should proceed normally
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
const playPromise = engine.play('E', 10)
await vi.runAllTimersAsync()
await playPromise
expect(starts).toEqual(['dot'])
})
it('off() removes listener so it is not called', async () => {
const cb = vi.fn()
engine.on('play-end', cb)
engine.off('play-end', cb)
const playPromise = engine.play('E', 10)
await vi.runAllTimersAsync()
await playPromise
expect(cb).not.toHaveBeenCalled()
})
})
+121
View File
@@ -0,0 +1,121 @@
import { MORSE, wpmToTiming } from './morse'
type SignalType = 'dot' | 'dash'
type AudioEventName = 'signal-start' | 'signal-end' | 'play-end'
type AudioEventPayload = { type: SignalType } | undefined
export class MorseAudioEngine {
private ctx: AudioContext | null = null
private listeners = new Map<AudioEventName, Array<(data?: AudioEventPayload) => void>>()
// stop() increments stopGeneration. play() captures it to detect mid-play cancellation.
private stopGeneration = 0
// If stop() is called while no play is active, suppress exactly one upcoming play() call.
private pendingCancel = false
private isPlaying = false
private getCtx(): AudioContext {
if (!this.ctx || this.ctx.state === 'closed') {
this.ctx = new AudioContext()
}
return this.ctx
}
on(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), cb])
}
off(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
this.listeners.set(event, (this.listeners.get(event) ?? []).filter(l => l !== cb))
}
private emit(event: AudioEventName, data?: AudioEventPayload): void {
;(this.listeners.get(event) ?? []).forEach(cb => cb(data))
}
stop(): void {
// Only suppress the next play() if nothing is currently playing.
// If a play is in progress, stopGeneration increment is enough to cancel it.
if (!this.isPlaying) {
this.pendingCancel = true
}
this.stopGeneration++
this.ctx?.suspend()
}
async play(text: string, wpm = 10): Promise<void> {
// Consume a pending pre-play cancellation (stop() was called before this play()).
// After consuming, the flag is clear so subsequent play() calls proceed normally.
if (this.pendingCancel) {
this.pendingCancel = false
return
}
this.isPlaying = true
const myGen = this.stopGeneration
const isStopped = () => this.stopGeneration !== myGen
try {
const ctx = this.getCtx()
if (ctx.state === 'suspended') await ctx.resume()
const t = wpmToTiming(wpm)
let scheduleTime = ctx.currentTime + 0.05
const events: Array<{ delayMs: number; event: AudioEventName; data?: AudioEventPayload }> = []
for (const char of text.toUpperCase()) {
if (isStopped()) break
if (char === ' ') {
scheduleTime += t.wordGap / 1000
continue
}
const pattern = MORSE[char]
if (!pattern) continue
let firstElement = true
for (const el of pattern) {
if (isStopped()) break
if (!firstElement) scheduleTime += t.elementGap / 1000
firstElement = false
const isDot = el === '.'
const type: SignalType = isDot ? 'dot' : 'dash'
const dur = (isDot ? t.dot : t.dash) / 1000
// Schedule audio
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.frequency.value = 700
osc.type = 'sine'
gain.gain.setValueAtTime(0, scheduleTime)
gain.gain.linearRampToValueAtTime(0.5, scheduleTime + 0.005)
gain.gain.linearRampToValueAtTime(0, scheduleTime + dur - 0.005)
osc.start(scheduleTime)
osc.stop(scheduleTime + dur)
const nowMs = (scheduleTime - ctx.currentTime) * 1000
events.push({ delayMs: nowMs, event: 'signal-start', data: { type } })
events.push({ delayMs: nowMs + dur * 1000, event: 'signal-end', data: { type } })
scheduleTime += dur
}
scheduleTime += (t.charGap - t.elementGap) / 1000
}
const totalMs = Math.max(0, (scheduleTime - ctx.currentTime) * 1000)
// Fire events via setTimeout aligned to AudioContext schedule
for (const { delayMs, event, data } of events) {
setTimeout(() => {
if (!isStopped()) this.emit(event, data)
}, delayMs)
}
await new Promise<void>(resolve => setTimeout(resolve, totalMs))
if (!isStopped()) this.emit('play-end')
} finally {
this.isPlaying = false
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest'
import {
MORSE,
NOVICE_LETTERS,
OPERATOR_WORDS,
wpmToTiming,
DEFAULT_WPM,
DEFAULT_TIMING,
validatePhrase,
pickChallenge,
} from './morse'
describe('MORSE map', () => {
it('has all 26 letters', () => {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
letters.forEach(l => expect(MORSE[l]).toBeDefined())
})
it('A is .-', () => expect(MORSE['A']).toBe('.-'))
it('S is ...', () => expect(MORSE['S']).toBe('...'))
it('O is ---', () => expect(MORSE['O']).toBe('---'))
})
describe('NOVICE_LETTERS', () => {
it('has 12 letters', () => expect(NOVICE_LETTERS).toHaveLength(12))
it('includes A E T I N S H R D L U O', () => {
;['A','E','T','I','N','S','H','R','D','L','U','O'].forEach(l =>
expect(NOVICE_LETTERS).toContain(l)
)
})
})
describe('OPERATOR_WORDS', () => {
it('has at least 20 words', () => expect(OPERATOR_WORDS.length).toBeGreaterThanOrEqual(20))
it('all words use only Novice letters', () => {
const novice = new Set(['A','E','T','I','N','S','H','R','D','L','U','O'])
OPERATOR_WORDS.forEach(word => {
word.split('').forEach(l => expect(novice.has(l)).toBe(true))
})
})
})
describe('DEFAULT_WPM and DEFAULT_TIMING', () => {
it('DEFAULT_WPM is 10', () => expect(DEFAULT_WPM).toBe(10))
it('DEFAULT_TIMING equals wpmToTiming(10)', () => {
expect(DEFAULT_TIMING).toEqual(wpmToTiming(10))
})
})
describe('wpmToTiming', () => {
it('10 WPM gives dot=120ms', () => {
expect(wpmToTiming(10).dot).toBe(120)
})
it('dash is 3x dot', () => {
const t = wpmToTiming(10)
expect(t.dash).toBe(t.dot * 3)
})
it('20 WPM gives dot=60ms', () => {
expect(wpmToTiming(20).dot).toBe(60)
})
it('elementGap equals dot', () => {
const t = wpmToTiming(10)
expect(t.elementGap).toBe(t.dot)
})
it('charGap is 3x dot', () => {
const t = wpmToTiming(10)
expect(t.charGap).toBe(t.dot * 3)
})
it('wordGap is 7x dot', () => {
const t = wpmToTiming(10)
expect(t.wordGap).toBe(t.dot * 7)
})
})
describe('validatePhrase', () => {
it('accepts valid phrase for E (.)', () => {
expect(validatePhrase('go', 'E')).toEqual({ valid: true })
})
it('accepts valid phrase for A (.-)', () => {
expect(validatePhrase('go WALKING', 'A')).toEqual({ valid: true })
})
it('accepts valid phrase for S (...)', () => {
expect(validatePhrase('go go go', 'S')).toEqual({ valid: true })
})
it('rejects wrong word count', () => {
const r = validatePhrase('go go', 'S')
expect(r.valid).toBe(false)
expect(r.message).toContain('3')
})
it('rejects long word for dot position', () => {
const r = validatePhrase('WALKING go go', 'S')
expect(r.valid).toBe(false)
})
it('rejects short word for dash position', () => {
const r = validatePhrase('go go', 'A')
expect(r.valid).toBe(false)
})
it('rejects unknown letter', () => {
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
})
it('handles empty phrase', () => {
expect(validatePhrase('', 'E')).toEqual({ valid: false, message: expect.stringContaining('1') })
})
})
describe('pickChallenge', () => {
it('always returns item from pool', () => {
const pool = ['A', 'E', 'T', 'I']
const result = pickChallenge(pool, {}, [])
expect(pool).toContain(result)
})
it('avoids last 3 items when pool is large enough', () => {
const pool = ['A', 'E', 'T', 'I', 'N', 'S']
const history = ['A', 'E', 'T']
for (let i = 0; i < 20; i++) {
const result = pickChallenge(pool, {}, history)
expect(['A','E','T']).not.toContain(result)
}
})
it('prefers items with lower accuracy', () => {
const pool = ['A', 'B']
const stats = {
A: { correct: 9, attempts: 10 },
B: { correct: 1, attempts: 10 },
}
let bCount = 0
for (let i = 0; i < 100; i++) {
if (pickChallenge(pool, stats, []) === 'B') bCount++
}
expect(bCount).toBeGreaterThan(60)
})
})
+100
View File
@@ -0,0 +1,100 @@
export const MORSE: Record<string, string> = {
A: '.-', B: '-...', C: '-.-.', D: '-..', E: '.',
F: '..-.', G: '--.', H: '....', I: '..', J: '.---',
K: '-.-', L: '.-..', M: '--', N: '-.', O: '---',
P: '.--.', Q: '--.-', R: '.-.', S: '...', T: '-',
U: '..-', V: '...-', W: '.--', X: '-..-', Y: '-.--',
Z: '--..',
'0': '-----', '1': '.----', '2': '..---', '3': '...--',
'4': '....-', '5': '.....', '6': '-....', '7': '--...',
'8': '---..', '9': '----.',
}
export const NOVICE_LETTERS = ['A','E','T','I','N','S','H','R','D','L','U','O']
export const OPERATOR_WORDS = [
'SEA','SUN','TAN','RAN','HIT','RUN','ANT','DEN',
'HEN','OAR','OUR','USE','TIN','SIN','HIS','AIR',
'EAR','AND','THE','HOT','NET','SET','TEN','LIT',
'SIT','LET','NIT','ROT','NUT','DUE','RID','IRE',
]
export interface Timing {
dot: number
dash: number
elementGap: number
charGap: number
wordGap: number
}
export function wpmToTiming(wpm: number): Timing {
const dot = Math.round(1200 / wpm)
return {
dot,
dash: dot * 3,
elementGap: dot,
charGap: dot * 3,
wordGap: dot * 7,
}
}
export const DEFAULT_WPM = 10
export const DEFAULT_TIMING = wpmToTiming(DEFAULT_WPM)
export interface ValidationResult {
valid: boolean
message?: string
}
export function validatePhrase(phrase: string, letter: string): ValidationResult {
const upper = letter.toUpperCase()
const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined
if (!pattern) return { valid: false, message: 'Unknown letter' }
const words = phrase.trim().split(/\s+/).filter(Boolean)
const elements = pattern.split('')
if (words.length !== elements.length) {
return {
valid: false,
message: `Need ${elements.length} word${elements.length !== 1 ? 's' : ''}, got ${words.length}`,
}
}
for (let i = 0; i < words.length; i++) {
const isDot = elements[i] === '.'
const isShort = words[i].length <= 2
if (isDot && !isShort) {
return { valid: false, message: `Word "${words[i]}" should be short (1-2 chars) for a dot` }
}
if (!isDot && isShort) {
return { valid: false, message: `Word "${words[i]}" should be longer (3+ chars) for a dash` }
}
}
return { valid: true }
}
export function pickChallenge(
pool: string[],
stats: Record<string, { correct: number; attempts: number }>,
recentHistory: string[]
): string {
const available =
pool.length > 3 ? pool.filter(item => !recentHistory.slice(-3).includes(item)) : pool
const weights = available.map(item => {
const s = stats[item]
if (!s || s.attempts === 0) return 2 // unseen: prioritise over even 0%-accuracy items
const accuracy = s.correct / s.attempts
return Math.max(0.1, 1 - accuracy) + 0.1
})
const total = weights.reduce((a, b) => a + b, 0)
let r = Math.random() * total
for (let i = 0; i < available.length; i++) {
r -= weights[i]
if (r <= 0) return available[i]
}
return available[available.length - 1]
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+161
View File
@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
type AdminUser = {
id: number
email: string
display_name: string | null
level: number | null
score: number | null
total_correct: number | null
total_attempts: number | null
created_at: number
last_seen: number
}
type AdminStats = {
totalUsers: number
avgScore: number
mostMissed: Array<{ letter: string; misses: number }>
}
export function AdminPage() {
const [password, setPassword] = useState(() => sessionStorage.getItem('admin_pwd') ?? '')
const [authed, setAuthed] = useState(false)
const [users, setUsers] = useState<AdminUser[] | null>(null)
const [stats, setStats] = useState<AdminStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function login(e: React.FormEvent) {
e.preventDefault()
if (!password) return
setLoading(true)
setError(null)
try {
const r = await fetch('/api/admin/users', {
headers: { Authorization: `Bearer ${password}` },
})
if (!r.ok) throw new Error('Invalid password')
sessionStorage.setItem('admin_pwd', password)
setAuthed(true)
} catch {
setError('Invalid admin password')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!authed || !password) return
const headers = { Authorization: `Bearer ${password}` }
Promise.all([
fetch('/api/admin/users', { headers }).then(r => r.json()),
fetch('/api/admin/stats', { headers }).then(r => r.json()),
]).then(([u, s]) => {
setUsers(u)
setStats(s)
}).catch(() => setError('Failed to load data'))
}, [authed, password])
// Try auto-login if stored password exists (password state already seeded by useState initializer)
useEffect(() => {
const stored = sessionStorage.getItem('admin_pwd')
if (stored) {
fetch('/api/admin/users', { headers: { Authorization: `Bearer ${stored}` } })
.then(r => { if (r.ok) setAuthed(true) })
.catch(() => {})
}
}, [])
if (!authed) {
return (
<div style={{ maxWidth: 380, margin: '4rem auto 0' }}>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '1.5rem' }}>Admin</h1>
<form onSubmit={login}>
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
Admin password
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Enter admin password"
style={{ marginBottom: '1rem' }}
/>
{error && <p style={{ color: 'crimson', marginBottom: '0.75rem' }}>{error}</p>}
<button type="submit" className="btn" disabled={loading || !password} style={{ width: '100%' }}>
{loading ? 'Checking...' : 'Enter'}
</button>
</form>
</div>
)
}
return (
<div>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '1.5rem' }}>Admin Panel</h1>
{/* Stats summary */}
{stats && (
<div style={{
display: 'flex', gap: '2rem', flexWrap: 'wrap',
padding: '1rem', background: 'var(--color-forest)',
color: 'var(--color-parchment)', borderRadius: 4, marginBottom: '1.5rem',
}}>
<div><div style={{ fontSize: '1.6rem', fontWeight: 700, color: 'var(--color-gold)' }}>{stats.totalUsers}</div><div>Total users</div></div>
<div><div style={{ fontSize: '1.6rem', fontWeight: 700, color: 'var(--color-gold)' }}>{stats.avgScore}</div><div>Avg score</div></div>
<div>
<div style={{ fontSize: '0.85rem', color: 'var(--color-gold)', marginBottom: '0.25rem' }}>Most missed</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '1rem' }}>
{stats.mostMissed.slice(0, 5).map(m => `${m.letter}(${m.misses})`).join(' ')}
</div>
</div>
</div>
)}
{/* User table */}
{users ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<thead>
<tr style={{ background: 'var(--color-forest)', color: 'var(--color-parchment)' }}>
{['Email', 'Name', 'Level', 'Score', 'Correct/Attempts', 'Joined', 'Last seen'].map(h => (
<th key={h} style={{ padding: '0.5rem 0.75rem', textAlign: 'left', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((u, i) => (
<tr key={u.id} style={{ background: i % 2 === 0 ? 'white' : 'var(--color-parchment)' }}>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.email}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.display_name ?? '—'}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.level ?? '—'}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.score ?? '—'}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>
{u.total_correct ?? 0} / {u.total_attempts ?? 0}
</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{new Date(u.created_at).toLocaleDateString()}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{new Date(u.last_seen).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && <p style={{ padding: '1rem', textAlign: 'center' }}>No users yet.</p>}
</div>
) : (
<p>Loading users...</p>
)}
<div style={{ marginTop: '2rem' }}>
<button className="btn btn-ghost" onClick={() => {
sessionStorage.removeItem('admin_pwd')
setAuthed(false)
setUsers(null)
setStats(null)
}}>
Log out
</button>
</div>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { pickChallenge, NOVICE_LETTERS, OPERATOR_WORDS } from '../lib/morse'
import { useMorseAudio } from '../hooks/useMorseAudio'
import { useProgress } from '../hooks/useProgress'
import { ScoreBar } from '../components/ScoreBar'
import { PracticeCard } from '../components/PracticeCard'
type GamePageProps = {
token: string
onLogout: () => void
}
export function GamePage({ token, onLogout }: GamePageProps) {
const audio = useMorseAudio()
const { progress, letterStats, mnemonics, loading, recordAnswer } = useProgress(token)
const [challenge, setChallenge] = useState<string | null>(null)
const [result, setResult] = useState<'correct' | 'incorrect' | null>(null)
const [showLevelUp, setShowLevelUp] = useState(false)
const recentHistoryRef = useRef<string[]>([])
const incorrectRecordedRef = useRef(false)
// Pick a new challenge based on current level
const nextChallenge = useCallback(() => {
const level = progress?.level ?? 1
const pool = level >= 2 ? OPERATOR_WORDS : NOVICE_LETTERS
const c = pickChallenge(pool, letterStats, recentHistoryRef.current)
recentHistoryRef.current = [...recentHistoryRef.current.slice(-4), c]
setChallenge(c)
setResult(null)
incorrectRecordedRef.current = false
audio.stop()
}, [progress?.level, letterStats])
// Load first challenge after progress loads
useEffect(() => {
if (!loading && progress && !challenge) {
nextChallenge()
}
}, [loading, progress])
async function handleAnswer(answer: string) {
if (!challenge || result === 'correct') return
const isCorrect = answer === challenge.toUpperCase()
setResult(isCorrect ? 'correct' : 'incorrect')
if (isCorrect) {
const updated = await recordAnswer(challenge, true)
// Level-up trigger: 10 consecutive correct
if (updated.streak > 0 && updated.streak % 10 === 0) {
setShowLevelUp(true)
}
} else if (!incorrectRecordedRef.current) {
// Only record first incorrect attempt per challenge (allow re-tries without inflating stats)
incorrectRecordedRef.current = true
await recordAnswer(challenge, false)
}
}
function playChallenge() {
if (!challenge) return
audio.play(challenge)
}
if (loading || !progress || !challenge) {
return (
<div style={{ textAlign: 'center', paddingTop: '3rem' }}>
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
</div>
)
}
return (
<div>
<ScoreBar score={progress.score} streak={progress.streak} level={progress.level} />
<PracticeCard
challenge={challenge}
signal={audio.currentSignal}
isPlaying={audio.isPlaying}
onPlay={playChallenge}
onAnswer={handleAnswer}
result={result}
mnemonic={mnemonics[challenge.toUpperCase()] ?? null}
/>
{result === 'correct' && (
<div style={{ textAlign: 'center', marginTop: '1.5rem' }}>
<button className="btn" onClick={nextChallenge}>
Next Challenge
</button>
</div>
)}
{/* Level-up modal */}
{showLevelUp && (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}}>
<div style={{
background: 'var(--color-parchment)',
border: '3px solid var(--color-gold)',
padding: '2rem',
maxWidth: 380,
textAlign: 'center',
}}>
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>
Quest Complete!
</h2>
<p style={{ marginBottom: '1.5rem' }}>
10 in a row! {progress.level < 2 ? "You've advanced to Operator level!" : 'Outstanding skill!'}
</p>
<button className="btn" onClick={() => { setShowLevelUp(false); nextChallenge() }}>
Continue
</button>
</div>
</div>
)}
{/* Nav */}
<div style={{ marginTop: '2rem', borderTop: '1px solid var(--color-brown)', paddingTop: '1rem' }}>
<button className="btn btn-ghost" onClick={onLogout} style={{ fontSize: '0.85rem' }}>
Log out
</button>
</div>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react'
type LoginPageProps = { onLoginSent: () => void }
export function LoginPage({ onLoginSent }: LoginPageProps) {
const [email, setEmail] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email.includes('@') || !email.includes('.')) {
setError('Please enter a valid email address.')
return
}
setSubmitting(true)
setError(null)
try {
const r = await fetch('/api/auth/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!r.ok) {
const body = await r.json().catch(() => ({}))
throw new Error(body.error || 'Something went wrong')
}
onLoginSent()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setSubmitting(false)
}
}
return (
<div style={{ maxWidth: 420, margin: '4rem auto 0' }}>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>MorseQuest</h1>
<p style={{ marginBottom: '2rem', color: 'var(--color-brown)' }}>
Learn Morse code, adventurer. Enter your email to begin your quest.
</p>
<form onSubmit={handleSubmit}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
Email address
</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
disabled={submitting}
required
style={{ marginBottom: '1rem' }}
/>
{error && (
<p style={{ color: 'crimson', marginBottom: '1rem', fontSize: '0.9rem' }}>{error}</p>
)}
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
{submitting ? 'Sending...' : 'Send Magic Link'}
</button>
</form>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { MnemonicBuilder } from '../components/MnemonicBuilder'
type Props = {
token: string
onComplete: () => void
}
export function OnboardingPage({ token, onComplete }: Props) {
return (
<div>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>
Build Your Mnemonics
</h1>
<p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}>
Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes.
</p>
<MnemonicBuilder token={token} onComplete={onComplete} />
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
// Mock AudioContext for tests
class MockAudioContext {
currentTime = 0
destination = {}
state: AudioContextState = 'running'
createOscillator() {
return {
connect: () => {},
start: () => {},
stop: () => {},
frequency: { value: 0 },
type: 'sine' as OscillatorType,
}
}
createGain() {
return {
connect: () => {},
gain: {
value: 1,
setValueAtTime: () => {},
linearRampToValueAtTime: () => {},
},
}
}
async resume() {}
async suspend() {}
}
;(global as any).AudioContext = MockAudioContext
View File
+22
View File
@@ -0,0 +1,22 @@
services:
morsequest:
image: morsequest:latest
container_name: morsequest
restart: unless-stopped
network_mode: bridge
ports:
- "127.0.0.1:3004:8080"
volumes:
- morsequest-data:/app/data
environment:
PORT: "8080"
NODE_ENV: production
SMTP_HOST: 172.17.0.1
SMTP_PORT: "2587"
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
APP_URL: https://morsequest.keylinkit.net
volumes:
morsequest-data:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,390 @@
# MorseQuest MVP Design Spec
**Date:** 2026-04-29
**Deployment:** morsequest.keylinkit.net (Docker on VPS)
**Purpose:** Pack 404 pilot — feedback-first, rough edges acceptable
---
## 1. Scope
### In scope (MVP)
- Magic link email authentication (allegiance pattern)
- Mnemonic creation system — the core differentiator
- Novice level: 12 letters (A E T I N S H R D L U O)
- Operator level: simple 3-letter words from Novice letters
- Full Web Audio API + synchronized visual flash playback
- Points, streaks, daily bonus scoring
- Level-up flow (Novice → Operator)
- Basic admin panel (user list, stats, per-user progress)
- Docker deployment matching allegiance pattern
### Out of scope (Phase 2)
- Levels 36 (General, Extra, Expert, Legend)
- Ham radio license modules
- Payment / Authorize.net integration
- Leaderboards / social features
- PWA offline mode
- White-label / multi-tenant
---
## 2. Architecture
**Frontend:** React 18 + TypeScript, Vite bundler
**Backend:** Node.js HTTP server (no framework), better-sqlite3
**Auth:** Magic link email via nodemailer (mail.keylinkit.net:587)
**Deployment:** Single Docker container, node:20-alpine, named volume
### Project structure
```
morsequest/
├── client/
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx # routing: login / onboarding / game / admin
│ ├── lib/
│ │ ├── morse.ts # MORSE map, timing constants, word pools
│ │ └── audio.ts # MorseAudioEngine class
│ ├── hooks/
│ │ ├── useAuth.ts # session token, /api/auth/me
│ │ ├── useMorseAudio.ts # wraps MorseAudioEngine, exposes currentSignal
│ │ └── useProgress.ts # score, streak, level from /api/progress
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── OnboardingPage.tsx # mnemonic builder
│ │ ├── GamePage.tsx # practice loop
│ │ └── AdminPage.tsx
│ └── components/
│ ├── FlashArea.tsx # visual signal display
│ ├── MnemonicBuilder.tsx # step-through letter mnemonic entry
│ ├── PracticeCard.tsx # challenge + answer input
│ └── ScoreBar.tsx
├── server/
│ ├── server.js # Node HTTP, serves /client/dist, handles /api routes
│ ├── db.js # better-sqlite3, schema init, all query functions
│ └── mailer.js # nodemailer magic link (allegiance pattern)
├── data/ # SQLite db lives here (Docker volume)
├── Dockerfile
├── docker-compose.yml
└── package.json # root scripts: dev, build, start
```
### Dev vs production
- **Dev:** `vite dev` on :5173 with proxy → Node on :3001
- **Production:** `npm run build` compiles React to `/client/dist`; Node serves `/client/dist` as static + all `/api` routes on port 8080
---
## 3. Database Schema
`better-sqlite3`, schema created on startup in `db.js`.
```sql
-- Auth (allegiance pattern)
CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE magic_tokens (
token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
-- Progress
CREATE TABLE user_progress (
profile_id INTEGER PRIMARY KEY,
level INTEGER NOT NULL DEFAULT 1, -- 1=Novice, 2=Operator
score INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
total_correct INTEGER NOT NULL DEFAULT 0,
total_attempts INTEGER NOT NULL DEFAULT 0,
last_session_at INTEGER,
updated_at INTEGER NOT NULL
);
-- Mnemonics — one row per letter per user
CREATE TABLE user_mnemonics (
profile_id INTEGER NOT NULL,
letter TEXT NOT NULL,
phrase TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, letter)
);
-- Per-letter accuracy
CREATE TABLE letter_stats (
profile_id INTEGER NOT NULL,
letter TEXT NOT NULL,
correct INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, letter)
);
```
---
## 4. API Routes
All routes return JSON. Auth routes match allegiance pattern. Protected routes require `Authorization: Bearer <session_token>` header.
```
POST /api/auth/request { email } → sends magic link
GET /api/auth/verify?token= → 302 to /?session=<token>
GET /api/auth/me → { id, email, display_name } [auth]
DELETE /api/auth/session → log out [auth]
GET /api/progress → { level, score, streak, ... } [auth]
POST /api/progress/answer { letter, correct } [auth]
GET /api/mnemonics → { A: "phrase", ... } [auth]
POST /api/mnemonics { letter, phrase } [auth]
GET /admin → admin HTML (ADMIN_PASSWORD gate)
GET /api/admin/users → user list with stats [admin-token]
GET /api/admin/stats → global aggregate stats [admin-token]
# [admin-token]: request must include Authorization: Bearer <ADMIN_PASSWORD>
# AdminPage.tsx stores the password in sessionStorage after the user enters it,
# and attaches it as a bearer token to all /api/admin/* requests.
# Server checks: token === process.env.ADMIN_PASSWORD
```
---
## 5. Core User Flows
### First visit
1. Landing page — enter email → POST /api/auth/request
2. "Check your email" screen
3. Click magic link → GET /api/auth/verify → session token in URL → stored in localStorage
4. Redirect to `/onboarding` (mnemonic builder)
5. Step through all 12 Novice letters:
- Show letter + Morse pattern (dot/dash display)
- Play audio + flash on request
- Player types phrase — real-time validation:
- Word count must equal signal element count
- Words ≤2 chars = dot, words ≥3 chars = dash
- Save → 50 pts, next letter
- Skip available — skipped letters marked, caps rank at Operator
6. Onboarding complete → `/game` (Novice)
### Practice loop
1. Load challenge: random letter/word from current level, weighted by accuracy (lower accuracy = more frequent)
2. Display signal: visual flash (FlashArea) + audio (useMorseAudio), with Play button
3. Player types answer, submits
4. **Correct:** show their mnemonic phrase as reinforcement, +points, streak++
5. **Incorrect:** "Try again, adventurer!" — replay, no penalty
6. 10 consecutive correct → level-up modal
### Session token extraction
After the magic link redirect, `App.tsx` reads `?session=<token>` from the URL on mount, writes it to `localStorage`, strips it from the URL with `history.replaceState`, then fetches `/api/auth/me`. All subsequent API calls attach `Authorization: Bearer <token>`.
### Returning user
1. Landing page — if `localStorage` has a session token, `App.tsx` immediately fetches `/api/auth/me`
2. Valid session → redirect to `/game` at current level (no email entry needed)
3. Expired/missing → show login page
### Admin
- `/admin` path, guarded by ADMIN_PASSWORD (same env var pattern as allegiance)
- Table: all users, email, display name, level, score, joined, last seen
- Click user → per-letter accuracy breakdown, mnemonic completion count
---
## 6. Audio Engine
`client/src/lib/audio.ts` — pure TypeScript, no dependencies.
```typescript
// Timing: ITU standard, pilot starts at 10 WPM
// dot=120ms, dash=360ms, element gap=120ms, char gap=360ms, word gap=840ms
const MORSE: Record<string, string> = { A:'.-', B:'-...', /* ... */ }
class MorseAudioEngine {
private ctx: AudioContext
private stopFlag = false
// Emits events: 'signal-start' | 'signal-end' with payload {type:'dot'|'dash'}
on(event: string, cb: Function): void
async play(text: string, wpm?: number): Promise<void>
stop(): void
}
```
`useMorseAudio()` hook:
- Wraps `MorseAudioEngine`
- Exposes `{ play, stop, isPlaying, currentSignal }` where `currentSignal: 'dot'|'dash'|null`
- `currentSignal` drives `FlashArea` CSS class toggle
`FlashArea` component:
- Large colored div — off-state: dark parchment, active-state: quest gold (#D4AF37)
- Dot flash: 120ms, Dash flash: 360ms
- Synchronized to audio engine events (not setTimeout-based)
---
## 7. Mnemonic Validation Logic
Real-time validation as player types:
```typescript
function validatePhrase(phrase: string, morsePattern: string): ValidationResult {
const words = phrase.trim().split(/\s+/)
const elements = morsePattern.split('') // '.', '-'
if (words.length !== elements.length) {
return { valid: false, message: `Need ${elements.length} words, got ${words.length}` }
}
for (let i = 0; i < words.length; i++) {
const isDot = elements[i] === '.'
const isShort = words[i].length <= 2 // 1-2 chars = dot
if (isDot && !isShort) return { valid: false, message: `Word ${i+1} should be short (dot)` }
if (!isDot && isShort) return { valid: false, message: `Word ${i+1} should be long (dash)` }
}
return { valid: true }
}
```
Letters with Morse patterns for Novice set:
```
A .- E . T - I ..
N -. S ... H .... R .-.
D -.. L .-.. U ..- O ---
```
---
## 8. Scoring
```typescript
function calculatePoints(level: number, streak: number, isFirstSessionToday: boolean): number {
const base = level * 10 // Novice=10, Operator=20
const streakMult = streak >= 10 ? 2 : 1
const dailyMult = isFirstSessionToday ? 1.25 : 1
return Math.floor(base * streakMult * dailyMult)
}
// Mnemonic creation: flat 50 pts per letter
// Level-up trigger: 10 consecutive correct
```
---
## 9. Content Data
Static in `client/src/lib/morse.ts`:
```typescript
// Novice word pool (Operator level) — 3-letter words from A,E,T,I,N,S,H,R,D,L,U,O
export const OPERATOR_WORDS = [
'SEA', 'SUN', 'TAN', 'RAN', 'HIT', 'RUN', 'ANT', 'DEN',
'HEN', 'INN', 'OAR', 'OUR', 'USE', 'TIN', 'SIN', 'HIS',
'AIR', 'EAR', 'OIL', 'AND', 'THE', 'HOT', 'NET', 'SET'
]
```
Challenge selection weights: letters/words with lower `correct/attempts` ratio selected more frequently. Avoids repeating the same letter 3× in a row.
---
## 10. Docker & Deployment
### Dockerfile
Two-stage build: build stage installs all deps (including Vite) and compiles the React app; runtime stage copies only the built output and production deps.
```dockerfile
# --- Build stage ---
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund # all deps, including vite
COPY . .
RUN npm run build # vite build → client/dist
# --- Runtime stage ---
FROM node:20-alpine
ENV NODE_ENV=production PORT=8080
WORKDIR /app
RUN apk add --no-cache python3 make g++ # for better-sqlite3 native rebuild
COPY package.json package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY --from=builder /app/client/dist ./client/dist
COPY server/ ./server/
RUN mkdir -p /app/data && chown -R node:node /app
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/ || exit 1
CMD ["node", "server/server.js"]
```
### docker-compose.yml
```yaml
services:
morsequest:
image: morsequest:latest
container_name: morsequest
restart: unless-stopped
ports:
- "127.0.0.1:3004:8080"
volumes:
- morsequest-data:/app/data
environment:
PORT: "8080"
NODE_ENV: production
SMTP_HOST: mail.keylinkit.net
SMTP_PORT: "587"
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
APP_URL: https://morsequest.keylinkit.net
volumes:
morsequest-data:
```
Host nginx proxies `morsequest.keylinkit.net``127.0.0.1:3004` (same pattern as allegiance).
---
## 11. Brand Implementation
From brand guidelines:
- **Colors as CSS custom properties:** `--color-gold: #D4AF37`, `--color-blue: #4169E1`, `--color-parchment: #F5E6D3`, `--color-forest: #3C2415`
- **Fonts:** Cinzel (headers, Google Fonts), Open Sans (body), Source Code Pro (Morse display)
- **Voice:** "Try again, adventurer!" not "Incorrect". "Quest complete!" not "Level up".
- **Existing SVG assets** from `assets/` used directly (logos, icons, badges)
---
## 12. Environment Variables
```
SMTP_HOST mail.keylinkit.net
SMTP_PORT 587
SMTP_USER noreply@morsequest.keylinkit.net (or similar)
SMTP_PASS <password>
ADMIN_PASSWORD <admin panel password>
APP_URL https://morsequest.keylinkit.net
PORT 8080
NODE_ENV production
```
+30
View File
@@ -0,0 +1,30 @@
{
"name": "morsequest",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "concurrently \"node --watch server/server.js\" \"vite\"",
"build": "vite build",
"start": "node server/server.js",
"test": "npm run test:server && npm run test:client",
"test:server": "node --test server/tests/db.test.js server/tests/auth.test.js server/tests/progress.test.js server/tests/admin.test.js",
"test:client": "vitest run"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"nodemailer": "^6.9.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"concurrently": "^8.2.0",
"jsdom": "^24.0.0",
"typescript": "^5.3.0",
"vite": "^5.1.0",
"vitest": "^1.3.0"
}
}
+253
View File
@@ -0,0 +1,253 @@
'use strict'
const Database = require('better-sqlite3')
const path = require('path')
function initDb(dbPath) {
const resolvedPath = dbPath || path.join(__dirname, '../data/morsequest.db')
const instance = new Database(resolvedPath)
instance.pragma('journal_mode = WAL')
instance.pragma('foreign_keys = ON')
instance.exec(`
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS magic_tokens (
token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_progress (
profile_id INTEGER PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
level INTEGER NOT NULL DEFAULT 1,
score INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
total_correct INTEGER NOT NULL DEFAULT 0,
total_attempts INTEGER NOT NULL DEFAULT 0,
last_session_at INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_mnemonics (
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
letter TEXT NOT NULL,
phrase TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, letter)
);
CREATE TABLE IF NOT EXISTS letter_stats (
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
letter TEXT NOT NULL,
correct INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, letter)
);
`)
function createProfile(email, displayName) {
const now = Date.now()
const stmt = instance.prepare(
'INSERT INTO profiles (email, display_name, created_at, last_seen) VALUES (?,?,?,?) RETURNING *'
)
return stmt.get(email, displayName ?? null, now, now)
}
function findProfileByEmail(email) {
return instance.prepare('SELECT * FROM profiles WHERE email = ?').get(email) ?? null
}
function findProfileById(id) {
return instance.prepare('SELECT * FROM profiles WHERE id = ?').get(id) ?? null
}
function updateProfileSeen(id) {
const result = instance.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id)
if (result.changes === 0) console.warn(`[db] updateProfileSeen: no profile id=${id}`)
}
function createMagicToken(token, profileId, expiresAt) {
instance.prepare('INSERT INTO magic_tokens (token, profile_id, expires_at) VALUES (?,?,?)').run(
token, profileId, expiresAt
)
}
const useMagicToken = instance.transaction((token) => {
const row = instance
.prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0 AND expires_at > ?')
.get(token, Date.now())
if (!row) return null
instance.prepare('UPDATE magic_tokens SET used = 1 WHERE token = ?').run(token)
return row.profile_id
})
function createSession(token, profileId) {
const now = Date.now()
instance.prepare(
'INSERT INTO sessions (token, profile_id, created_at, last_seen) VALUES (?,?,?,?)'
).run(token, profileId, now, now)
}
function getSession(token) {
const row = instance.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
if (!row) return null
const now = Date.now()
instance.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(now, token)
return { ...row, last_seen: now }
}
function deleteSession(token) {
instance.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}
function getOrCreateProgress(profileId) {
const existing = instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
if (existing) return existing
const now = Date.now()
instance.prepare(
`INSERT INTO user_progress (profile_id, level, score, streak, best_streak,
total_correct, total_attempts, updated_at) VALUES (?,1,0,0,0,0,0,?)`
).run(profileId, now)
return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function updateLevel(profileId, level) {
getOrCreateProgress(profileId)
instance.prepare('UPDATE user_progress SET level = ?, updated_at = ? WHERE profile_id = ?').run(
level, Date.now(), profileId
)
}
function recordAnswer(profileId, letter, correct) {
const normalizedLetter = letter.toUpperCase()
const progress = getOrCreateProgress(profileId)
const now = Date.now()
const isFirstToday = !progress.last_session_at ||
new Date(progress.last_session_at).toDateString() !== new Date(now).toDateString()
const base = progress.level * 10
const newStreak = correct ? progress.streak + 1 : 0
const streakMult = newStreak >= 10 ? 2 : 1
const dailyMult = isFirstToday && correct ? 1.25 : 1
const points = correct ? Math.floor(base * streakMult * dailyMult) : 0
const newBestStreak = Math.max(progress.best_streak, newStreak)
instance.prepare(
`UPDATE user_progress SET
score = score + ?,
streak = ?,
best_streak = ?,
total_correct = total_correct + ?,
total_attempts = total_attempts + 1,
last_session_at = ?,
updated_at = ?
WHERE profile_id = ?`
).run(points, newStreak, newBestStreak, correct ? 1 : 0, now, now, profileId)
// Update letter stats
const existing = instance
.prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?')
.get(profileId, normalizedLetter)
if (existing) {
instance.prepare(
'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?'
).run(correct ? 1 : 0, now, profileId, normalizedLetter)
} else {
instance.prepare(
'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)'
).run(profileId, normalizedLetter, correct ? 1 : 0, now)
}
return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function getMnemonics(profileId) {
const rows = instance
.prepare('SELECT letter, phrase FROM user_mnemonics WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, r.phrase]))
}
function saveMnemonic(profileId, letter, phrase) {
const now = Date.now()
const upperLetter = letter.toUpperCase()
// Try insert first — changes=1 means new row (first save), changes=0 means update path
const insertResult = instance.prepare(
'INSERT OR IGNORE INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?)'
).run(profileId, upperLetter, phrase, now)
if (insertResult.changes === 0) {
// Existing mnemonic — update phrase only, no point award
instance.prepare('UPDATE user_mnemonics SET phrase = ? WHERE profile_id = ? AND letter = ?')
.run(phrase, profileId, upperLetter)
} else {
// First save — award 50 points
getOrCreateProgress(profileId)
instance.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?')
.run(now, profileId)
}
}
function getLetterStats(profileId) {
const rows = instance
.prepare('SELECT letter, correct, attempts FROM letter_stats WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, { correct: r.correct, attempts: r.attempts }]))
}
function getAdminUsers() {
return instance.prepare(`
SELECT p.id, p.email, p.display_name, p.created_at, p.last_seen,
pr.level, pr.score, pr.streak, pr.best_streak,
pr.total_correct, pr.total_attempts
FROM profiles p
LEFT JOIN user_progress pr ON pr.profile_id = p.id
ORDER BY p.created_at DESC
`).all()
}
function getAdminStats() {
const totalUsers = instance.prepare('SELECT COUNT(*) as c FROM profiles').get().c
const avgScore = instance.prepare('SELECT AVG(score) as a FROM user_progress').get().a ?? 0
const mostMissed = instance.prepare(`
SELECT letter, SUM(attempts - correct) as misses
FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5
`).all()
return { totalUsers, avgScore: Math.round(avgScore), mostMissed }
}
return {
_db: instance,
createProfile,
findProfileByEmail,
findProfileById,
updateProfileSeen,
createMagicToken,
useMagicToken,
createSession,
getSession,
deleteSession,
getOrCreateProgress,
updateLevel,
recordAnswer,
getMnemonics,
saveMnemonic,
getLetterStats,
getAdminUsers,
getAdminStats,
}
}
module.exports = { initDb }
+67
View File
@@ -0,0 +1,67 @@
'use strict'
const nodemailer = require('nodemailer')
const SMTP_HOST = process.env.SMTP_HOST || ''
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587', 10)
const SMTP_USER = process.env.SMTP_USER || ''
const SMTP_PASS = process.env.SMTP_PASS || ''
const APP_URL = (process.env.APP_URL || 'http://localhost:3001').replace(/\/$/, '')
const FROM_ADDR = SMTP_USER
? `"MorseQuest" <${SMTP_USER}>`
: '"MorseQuest" <noreply@morsequest.keylinkit.net>'
let transport = null
if (SMTP_HOST && SMTP_USER && SMTP_PASS) {
transport = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth: { user: SMTP_USER, pass: SMTP_PASS },
requireTLS: true, // force STARTTLS before AUTH
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 15_000,
tls: { rejectUnauthorized: false }, // relay cert is for mail.keylinkit.net, not 172.17.0.1
})
console.log(`[mailer] SMTP configured: ${SMTP_HOST}:${SMTP_PORT}`)
} else {
console.warn('[mailer] SMTP not configured — magic links will be logged only')
}
async function send(to, subject, html) {
if (!transport) {
console.log(`[mailer] Would send to ${to}: ${subject}`)
return
}
await transport.sendMail({ from: FROM_ADDR, to, subject, html })
}
async function sendMagicLink(email, token) {
const link = `${APP_URL}/api/auth/verify?token=${token}`
const subject = 'Your MorseQuest Login Link'
const html = `
<div style="font-family:'Open Sans',sans-serif;max-width:480px;margin:0 auto;
padding:2rem;background:#F5E6D3;border:2px solid #D4AF37">
<h1 style="font-family:'Cinzel',serif;color:#3C2415;font-size:1.4rem;margin-bottom:1rem">
&#x26A1; MorseQuest Login
</h1>
<p style="color:#3C2415;line-height:1.6">
Click the button below to log in to MorseQuest. This link is valid for 24 hours
and can only be used once.
</p>
<div style="text-align:center;margin:2rem 0">
<a href="${link}"
style="display:inline-block;padding:0.8rem 2rem;background:#D4AF37;
color:#3C2415;text-decoration:none;font-family:'Cinzel',serif;
font-size:1rem;font-weight:600;border:2px solid #3C2415">
Begin Your Quest &rsaquo;
</a>
</div>
<p style="color:#654321;font-size:0.85rem;line-height:1.5">
If you didn't request this link, you can ignore this email safely.
</p>
</div>`
await send(email, subject, html)
}
module.exports = { sendMagicLink }
+234
View File
@@ -0,0 +1,234 @@
'use strict'
const http = require('node:http')
const path = require('node:path')
const fs = require('node:fs')
const crypto = require('node:crypto')
const { initDb } = require('./db.js')
const { sendMagicLink } = require('./mailer.js')
const PORT = parseInt(process.env.PORT || '3001', 10)
const DB_PATH = process.env.DB_PATH || undefined
const DIST_DIR = path.join(__dirname, '../client/dist')
function send(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = ''
req.on('error', reject)
req.on('data', c => {
data += c
if (data.length > 1024 * 1024) {
req.destroy(new Error('body too large'))
}
})
req.on('end', () => {
try { resolve(JSON.parse(data)) }
catch { resolve({}) }
})
})
}
function createServer(db) {
if (!db) {
db = initDb(DB_PATH)
}
function requireAuth(req, res) {
const auth = req.headers['authorization'] || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
if (!token) { send(res, 401, { error: 'unauthorized' }); return null }
const session = db.getSession(token)
if (!session) { send(res, 401, { error: 'unauthorized' }); return null }
return session
}
function requireAdmin(req, res) {
const pwd = process.env.ADMIN_PASSWORD
const auth = req.headers['authorization'] || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
if (!pwd || token !== pwd) { send(res, 401, { error: 'unauthorized' }); return false }
return true
}
async function handleRequest(req, res) {
const url = new URL(req.url, 'http://127.0.0.1')
const pathname = url.pathname
const method = req.method
// ── Auth routes ──────────────────────────────────────────────────────────
if (pathname === '/api/auth/request' && method === 'POST') {
const body = await readBody(req)
const email = body.email
if (!email || typeof email !== 'string' || !email.includes('@') || !email.includes('.')) {
return send(res, 400, { error: 'email required' })
}
let profile = db.findProfileByEmail(email)
if (!profile) {
profile = db.createProfile(email, null)
}
const token = crypto.randomBytes(32).toString('hex')
const expiresAt = Date.now() + 24 * 60 * 60 * 1000
db.createMagicToken(token, profile.id, expiresAt)
await sendMagicLink(email, token).catch(err => {
// Log SMTP errors but don't fail the request — token is in DB, link logged
console.error('[server] email send failed:', err.message ?? err)
})
return send(res, 200, { ok: true })
}
if (pathname === '/api/auth/verify' && method === 'GET') {
const token = url.searchParams.get('token')
if (!token) { return send(res, 400, { error: 'token required' }) }
const profileId = db.useMagicToken(token)
if (!profileId) { return send(res, 400, { error: 'invalid or expired token' }) }
const sessionToken = crypto.randomBytes(32).toString('hex')
db.createSession(sessionToken, profileId)
res.writeHead(302, { Location: `/?session=${sessionToken}` })
return res.end()
}
if (pathname === '/api/auth/me' && method === 'GET') {
const session = requireAuth(req, res)
if (!session) return
const profile = db.findProfileById(session.profile_id)
if (!profile) { return send(res, 404, { error: 'not found' }) }
return send(res, 200, {
id: profile.id,
email: profile.email,
display_name: profile.display_name,
})
}
if (pathname === '/api/auth/session' && method === 'DELETE') {
const session = requireAuth(req, res)
if (!session) return
db.deleteSession(session.token)
return send(res, 200, { ok: true })
}
// ── Progress routes ───────────────────────────────────────────────────────
if (pathname === '/api/progress' && method === 'GET') {
const session = requireAuth(req, res)
if (!session) return
const progress = db.getOrCreateProgress(session.profile_id)
return send(res, 200, progress)
}
if (pathname === '/api/progress/answer' && method === 'POST') {
const session = requireAuth(req, res)
if (!session) return
const body = await readBody(req)
const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : ''
if (!/^[A-Z]$/.test(letter)) {
return send(res, 400, { error: 'letter must be a single letter A-Z' })
}
if (typeof body.correct !== 'boolean') {
return send(res, 400, { error: 'correct required' })
}
const progress = db.recordAnswer(session.profile_id, letter, body.correct)
return send(res, 200, progress)
}
if (pathname === '/api/mnemonics' && method === 'GET') {
const session = requireAuth(req, res)
if (!session) return
const mnemonics = db.getMnemonics(session.profile_id)
return send(res, 200, mnemonics)
}
if (pathname === '/api/mnemonics' && method === 'POST') {
const session = requireAuth(req, res)
if (!session) return
const body = await readBody(req)
const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : ''
if (!/^[A-Z]$/.test(letter)) {
return send(res, 400, { error: 'letter must be a single letter A-Z' })
}
if (!body.phrase || typeof body.phrase !== 'string' || body.phrase.length > 500) {
return send(res, 400, { error: 'phrase required (max 500 chars)' })
}
db.saveMnemonic(session.profile_id, letter, body.phrase)
return send(res, 200, { ok: true })
}
// ── Admin routes ──────────────────────────────────────────────────────────
if (pathname === '/api/admin/users' && method === 'GET') {
if (!requireAdmin(req, res)) return
const users = db.getAdminUsers()
return send(res, 200, users)
}
if (pathname === '/api/admin/stats' && method === 'GET') {
if (!requireAdmin(req, res)) return
const stats = db.getAdminStats()
return send(res, 200, stats)
}
// ── Unknown /api/* ────────────────────────────────────────────────────────
if (pathname.startsWith('/api/')) {
return send(res, 404, { error: 'not found' })
}
// ── Static files (SPA) ───────────────────────────────────────────────────
// Prevent path traversal — ensure resolved path stays within DIST_DIR
const relative = pathname.replace(/^\/+/, '')
const filePath = path.join(DIST_DIR, relative)
if (!filePath.startsWith(DIST_DIR + path.sep) && filePath !== DIST_DIR) {
return send(res, 400, { error: 'bad request' })
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath)
const mimeTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
}
const contentType = mimeTypes[ext] || 'application/octet-stream'
res.writeHead(200, { 'Content-Type': contentType })
return fs.createReadStream(filePath).pipe(res)
}
// SPA fallback — serve index.html
const indexPath = path.join(DIST_DIR, 'index.html')
if (fs.existsSync(indexPath)) {
res.writeHead(200, { 'Content-Type': 'text/html' })
return fs.createReadStream(indexPath).pipe(res)
}
// No dist yet
return send(res, 404, { error: 'not found' })
}
const server = http.createServer((req, res) => {
handleRequest(req, res).catch(err => {
console.error('[server] unhandled:', err)
if (!res.headersSent) { res.writeHead(500); res.end() }
})
})
return server
}
if (require.main === module) {
const db = initDb(DB_PATH)
const server = createServer(db)
server.listen(PORT, () => console.log(`[server] listening on :${PORT}`))
}
module.exports = { createServer }
+91
View File
@@ -0,0 +1,91 @@
'use strict'
const { test, describe, before, after } = require('node:test')
const assert = require('node:assert/strict')
const http = require('node:http')
const { initDb } = require('../db.js')
const { createServer } = require('../server.js')
function request(server, method, path, body, headers = {}) {
return new Promise((resolve, reject) => {
const addr = server.address()
const opts = {
hostname: '127.0.0.1',
port: addr.port,
path,
method,
headers: { 'Content-Type': 'application/json', ...headers },
}
const req = http.request(opts, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => {
let json = null
try { json = JSON.parse(data) } catch {}
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
})
})
req.on('error', reject)
if (body) req.write(JSON.stringify(body))
req.end()
})
}
let db, server
const ADMIN_PWD = 'testadminpwd'
before(() => {
process.env.ADMIN_PASSWORD = ADMIN_PWD
db = initDb(':memory:')
server = createServer(db)
const p1 = db.createProfile('adm1@example.com', 'Admin1')
const p2 = db.createProfile('adm2@example.com', 'Admin2')
db.recordAnswer(p1.id, 'A', true)
db.recordAnswer(p2.id, 'B', false)
return new Promise(resolve => server.listen(0, resolve))
})
after(() => {
delete process.env.ADMIN_PASSWORD
db._db.close()
return new Promise(resolve => server.close(resolve))
})
describe('GET /api/admin/users', () => {
test('returns user list with valid admin password', async () => {
const r = await request(server, 'GET', '/api/admin/users', null, {
Authorization: `Bearer ${ADMIN_PWD}`,
})
assert.equal(r.status, 200)
assert.ok(Array.isArray(r.body))
const emails = r.body.map(u => u.email)
assert.ok(emails.includes('adm1@example.com'))
})
test('returns 401 without admin password', async () => {
const r = await request(server, 'GET', '/api/admin/users', null)
assert.equal(r.status, 401)
})
test('returns 401 with wrong admin password', async () => {
const r = await request(server, 'GET', '/api/admin/users', null, {
Authorization: 'Bearer wrongpwd',
})
assert.equal(r.status, 401)
})
})
describe('GET /api/admin/stats', () => {
test('returns stats with valid admin password', async () => {
const r = await request(server, 'GET', '/api/admin/stats', null, {
Authorization: `Bearer ${ADMIN_PWD}`,
})
assert.equal(r.status, 200)
assert.ok(typeof r.body.totalUsers === 'number')
assert.ok(Array.isArray(r.body.mostMissed))
})
test('returns 401 without admin password', async () => {
const r = await request(server, 'GET', '/api/admin/stats', null)
assert.equal(r.status, 401)
})
})
+144
View File
@@ -0,0 +1,144 @@
'use strict'
const { test, describe, before, after } = require('node:test')
const assert = require('node:assert/strict')
const http = require('node:http')
const { initDb } = require('../db.js')
const { createServer } = require('../server.js')
function request(server, method, path, body, headers = {}) {
return new Promise((resolve, reject) => {
const addr = server.address()
const opts = {
hostname: '127.0.0.1',
port: addr.port,
path,
method,
headers: { 'Content-Type': 'application/json', ...headers },
}
const req = http.request(opts, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => {
let json = null
try { json = JSON.parse(data) } catch {}
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
})
})
req.on('error', reject)
if (body) req.write(JSON.stringify(body))
req.end()
})
}
let db, server
before(() => {
db = initDb(':memory:')
server = createServer(db)
return new Promise(resolve => server.listen(0, resolve))
})
after(() => {
db._db.close()
return new Promise(resolve => server.close(resolve))
})
describe('POST /api/auth/request', () => {
test('returns 200 ok for valid email', async () => {
const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })
assert.equal(r.status, 200)
assert.equal(r.body.ok, true)
})
test('creates profile if new email', async () => {
await request(server, 'POST', '/api/auth/request', { email: 'new@example.com' })
const p = db.findProfileByEmail('new@example.com')
assert.ok(p)
assert.equal(p.email, 'new@example.com')
})
test('returns 400 if email missing', async () => {
const r = await request(server, 'POST', '/api/auth/request', {})
assert.equal(r.status, 400)
})
})
describe('GET /api/auth/verify', () => {
let profileId
before(() => {
const p = db.createProfile('verify@example.com', 'Verify')
profileId = p.id
db.createMagicToken('testtoken123', profileId, Date.now() + 86400000)
})
test('redirects to /?session=... on valid token', async () => {
const r = await request(server, 'GET', '/api/auth/verify?token=testtoken123', null, {})
assert.equal(r.status, 302)
assert.ok(r.headers.location?.startsWith('/?session='))
})
test('returns 400 for invalid token', async () => {
const r = await request(server, 'GET', '/api/auth/verify?token=badtoken', null)
assert.equal(r.status, 400)
})
test('returns 400 for already-used token', async () => {
// testtoken123 was already used in the first verify test
const r = await request(server, 'GET', '/api/auth/verify?token=testtoken123', null)
assert.equal(r.status, 400)
})
})
describe('GET /api/auth/me', () => {
let sessionToken
before(() => {
const p = db.createProfile('me@example.com', 'Me')
sessionToken = 'sessmetoken'
db.createSession(sessionToken, p.id)
})
test('returns profile for valid session', async () => {
const r = await request(server, 'GET', '/api/auth/me', null, {
Authorization: `Bearer ${sessionToken}`,
})
assert.equal(r.status, 200)
assert.equal(r.body.email, 'me@example.com')
})
test('returns 401 without auth header', async () => {
const r = await request(server, 'GET', '/api/auth/me', null)
assert.equal(r.status, 401)
})
})
describe('DELETE /api/auth/session', () => {
let sessionToken
before(() => {
const p = db.createProfile('logout@example.com', 'Logout')
sessionToken = 'sesslogouttoken'
db.createSession(sessionToken, p.id)
})
test('returns 200 and session is gone', async () => {
const r = await request(server, 'DELETE', '/api/auth/session', null, {
Authorization: `Bearer ${sessionToken}`,
})
assert.equal(r.status, 200)
assert.equal(db.getSession(sessionToken), null)
})
test('returns 401 without auth header', async () => {
const r = await request(server, 'DELETE', '/api/auth/session', null)
assert.equal(r.status, 401)
})
test('returns 401 when using deleted session token', async () => {
const r = await request(server, 'GET', '/api/auth/me', null, {
Authorization: `Bearer ${sessionToken}`,
})
assert.equal(r.status, 401)
})
})
+216
View File
@@ -0,0 +1,216 @@
'use strict'
const { test, describe, before, after } = require('node:test')
const assert = require('node:assert/strict')
const { initDb } = require('../db.js')
let db
before(() => {
db = initDb(':memory:')
})
after(() => {
db._db.close()
})
describe('profiles', () => {
test('createProfile creates a profile', () => {
const p = db.createProfile('test@example.com', 'Tester')
assert.equal(p.email, 'test@example.com')
assert.equal(p.display_name, 'Tester')
assert.ok(p.id)
})
test('findProfileByEmail returns the profile', () => {
const p = db.findProfileByEmail('test@example.com')
assert.equal(p.email, 'test@example.com')
})
test('findProfileByEmail returns null for unknown email', () => {
assert.equal(db.findProfileByEmail('nobody@example.com'), null)
})
test('findProfileById returns profile by id', () => {
const created = db.createProfile('byid@example.com', 'ById')
const found = db.findProfileById(created.id)
assert.equal(found.id, created.id)
assert.equal(found.email, 'byid@example.com')
})
test('findProfileById returns null for unknown id', () => {
assert.equal(db.findProfileById(999999), null)
})
test('updateProfileSeen updates last_seen timestamp', () => {
const created = db.createProfile('seen@example.com', 'Seen')
const callTime = Date.now()
db.updateProfileSeen(created.id)
const updated = db.findProfileById(created.id)
assert.ok(updated.last_seen >= callTime)
})
})
describe('magic tokens', () => {
let profileId
before(() => {
profileId = db.createProfile('magic@example.com', 'Magic').id
})
test('createMagicToken and useMagicToken returns profileId', () => {
const expires = Date.now() + 86400000
db.createMagicToken('tok123', profileId, expires)
const result = db.useMagicToken('tok123')
assert.equal(result, profileId)
})
test('useMagicToken returns null for used token', () => {
assert.equal(db.useMagicToken('tok123'), null)
})
test('useMagicToken returns null for expired token', () => {
db.createMagicToken('expiredtok', profileId, Date.now() - 1000)
assert.equal(db.useMagicToken('expiredtok'), null)
})
})
describe('sessions', () => {
let profileId
before(() => {
profileId = db.createProfile('session@example.com', 'Session').id
})
test('createSession and getSession returns profile_id', () => {
db.createSession('sess1', profileId)
const s = db.getSession('sess1')
assert.equal(s.profile_id, profileId)
})
test('getSession returns null for unknown token', () => {
assert.equal(db.getSession('unknown'), null)
})
test('deleteSession removes session', () => {
db.deleteSession('sess1')
assert.equal(db.getSession('sess1'), null)
})
})
describe('progress', () => {
let profileId
before(() => {
profileId = db.createProfile('progress@example.com', 'Progress').id
})
test('getOrCreateProgress returns default progress', () => {
const p = db.getOrCreateProgress(profileId)
assert.equal(p.level, 1)
assert.equal(p.score, 0)
assert.equal(p.streak, 0)
})
test('recordAnswer increments score and streak on correct', () => {
db.recordAnswer(profileId, 'A', true)
const p = db.getOrCreateProgress(profileId)
assert.ok(p.score > 0)
assert.equal(p.streak, 1)
assert.equal(p.total_correct, 1)
assert.equal(p.total_attempts, 1)
})
test('recordAnswer resets streak on incorrect', () => {
db.recordAnswer(profileId, 'A', false)
const p = db.getOrCreateProgress(profileId)
assert.equal(p.streak, 0)
assert.equal(p.total_attempts, 2)
})
test('updateLevel changes the level', () => {
db.updateLevel(profileId, 5)
const p = db.getOrCreateProgress(profileId)
assert.equal(p.level, 5)
})
test('updateLevel works even when no progress row exists yet', () => {
const newProfile = db.createProfile('updatelevel@example.com', 'UpdateLevel')
db.updateLevel(newProfile.id, 3)
const p = db.getOrCreateProgress(newProfile.id)
assert.equal(p.level, 3)
})
})
describe('mnemonics', () => {
let profileId
before(() => {
profileId = db.createProfile('mnemonic@example.com', 'Mnemonic').id
})
test('saveMnemonic and getMnemonics returns map', () => {
db.saveMnemonic(profileId, 'A', 'go WALKING')
db.saveMnemonic(profileId, 'E', 'go')
const m = db.getMnemonics(profileId)
assert.equal(m.A, 'go WALKING')
assert.equal(m.E, 'go')
})
test('saveMnemonic awards 50 pts on first save, not on update', () => {
const p1 = db.getOrCreateProgress(profileId)
db.saveMnemonic(profileId, 'T', 'dash') // first save → +50 pts
const p2 = db.getOrCreateProgress(profileId)
assert.equal(p2.score, p1.score + 50)
db.saveMnemonic(profileId, 'T', 'updated dash') // update → no extra pts
const p3 = db.getOrCreateProgress(profileId)
assert.equal(p3.score, p2.score)
})
})
describe('letter stats', () => {
let profileId
before(() => {
profileId = db.createProfile('stats@example.com', 'Stats').id
})
test('recordAnswer tracks letter stats', () => {
db.recordAnswer(profileId, 'S', true)
db.recordAnswer(profileId, 'S', false)
const stats = db.getLetterStats(profileId)
assert.equal(stats.S.correct, 1)
assert.equal(stats.S.attempts, 2)
})
})
describe('admin', () => {
before(() => {
const p1 = db.createProfile('admin1@example.com', 'Admin1')
const p2 = db.createProfile('admin2@example.com', 'Admin2')
db.recordAnswer(p1.id, 'A', true)
db.recordAnswer(p2.id, 'B', false)
db.recordAnswer(p2.id, 'B', false)
})
test('getAdminUsers returns users list', () => {
const users = db.getAdminUsers()
assert.ok(Array.isArray(users))
assert.ok(users.length >= 2)
// Each user should have email and id
const emails = users.map(u => u.email)
assert.ok(emails.includes('admin1@example.com'))
assert.ok(emails.includes('admin2@example.com'))
})
test('getAdminStats returns totalUsers, avgScore, mostMissed', () => {
const stats = db.getAdminStats()
assert.ok(typeof stats.totalUsers === 'number')
assert.ok(stats.totalUsers >= 2)
assert.ok(typeof stats.avgScore === 'number')
assert.ok(Array.isArray(stats.mostMissed))
// B was missed twice — should appear in mostMissed
const missedLetters = stats.mostMissed.map(m => m.letter)
assert.ok(missedLetters.includes('B'))
})
})
+145
View File
@@ -0,0 +1,145 @@
'use strict'
const { test, describe, before, after } = require('node:test')
const assert = require('node:assert/strict')
const http = require('node:http')
const { initDb } = require('../db.js')
const { createServer } = require('../server.js')
function request(server, method, path, body, headers = {}) {
return new Promise((resolve, reject) => {
const addr = server.address()
const opts = {
hostname: '127.0.0.1',
port: addr.port,
path,
method,
headers: { 'Content-Type': 'application/json', ...headers },
}
const req = http.request(opts, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => {
let json = null
try { json = JSON.parse(data) } catch {}
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
})
})
req.on('error', reject)
if (body) req.write(JSON.stringify(body))
req.end()
})
}
let db, server, sessionToken, profileId
before(() => {
db = initDb(':memory:')
server = createServer(db)
const p = db.createProfile('prog@example.com', 'Progress')
profileId = p.id
sessionToken = 'progtoken'
db.createSession(sessionToken, profileId)
return new Promise(resolve => server.listen(0, resolve))
})
after(() => {
db._db.close()
return new Promise(resolve => server.close(resolve))
})
const authHeader = () => ({ Authorization: `Bearer ${sessionToken}` })
describe('GET /api/progress', () => {
test('returns progress for authed user', async () => {
const r = await request(server, 'GET', '/api/progress', null, authHeader())
assert.equal(r.status, 200)
assert.equal(r.body.level, 1)
assert.equal(r.body.score, 0)
})
test('returns 401 without auth', async () => {
const r = await request(server, 'GET', '/api/progress', null)
assert.equal(r.status, 401)
})
})
describe('POST /api/progress/answer', () => {
test('records correct answer and returns updated progress', async () => {
const r = await request(server, 'POST', '/api/progress/answer',
{ letter: 'A', correct: true }, authHeader())
assert.equal(r.status, 200)
assert.ok(r.body.score > 0)
assert.equal(r.body.streak, 1)
})
test('records incorrect answer (resets streak)', async () => {
const r = await request(server, 'POST', '/api/progress/answer',
{ letter: 'A', correct: false }, authHeader())
assert.equal(r.status, 200)
assert.equal(r.body.streak, 0)
})
test('returns 401 without auth', async () => {
const r = await request(server, 'POST', '/api/progress/answer',
{ letter: 'A', correct: true })
assert.equal(r.status, 401)
})
test('returns 400 if letter missing', async () => {
const r = await request(server, 'POST', '/api/progress/answer',
{ correct: true }, authHeader())
assert.equal(r.status, 400)
})
test('returns 400 if correct is missing', async () => {
const r = await request(server, 'POST', '/api/progress/answer',
{ letter: 'A' }, authHeader())
assert.equal(r.status, 400)
})
test('returns 400 if correct is non-boolean', async () => {
const r = await request(server, 'POST', '/api/progress/answer',
{ letter: 'A', correct: 'true' }, authHeader())
assert.equal(r.status, 400)
})
})
describe('GET /api/mnemonics', () => {
before(() => {
db.saveMnemonic(profileId, 'A', 'a phrase')
})
test('returns mnemonic map', async () => {
const r = await request(server, 'GET', '/api/mnemonics', null, authHeader())
assert.equal(r.status, 200)
assert.equal(r.body.A, 'a phrase')
})
test('returns 401 without auth', async () => {
const r = await request(server, 'GET', '/api/mnemonics', null)
assert.equal(r.status, 401)
})
})
describe('POST /api/mnemonics', () => {
test('saves mnemonic and returns ok', async () => {
const r = await request(server, 'POST', '/api/mnemonics',
{ letter: 'E', phrase: 'yes' }, authHeader())
assert.equal(r.status, 200)
assert.equal(r.body.ok, true)
const m = db.getMnemonics(profileId)
assert.equal(m.E, 'yes')
})
test('returns 400 if letter or phrase missing', async () => {
const r = await request(server, 'POST', '/api/mnemonics',
{ letter: 'T' }, authHeader())
assert.equal(r.status, 400)
})
test('returns 401 without auth', async () => {
const r = await request(server, 'POST', '/api/mnemonics',
{ letter: 'E', phrase: 'yes' })
assert.equal(r.status, 401)
})
})
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["client/src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: 'client',
plugins: [react()],
build: {
outDir: '../client/dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['src/test-setup.ts'],
},
})