Initial commit: MVP tidtakingssystem

- Backend: FastAPI, EXIF-parser, EasyOCR, SQLite
- Frontend: React admin (startliste, passeringer, gjennomgang, resultater)
- Docker: docker-compose med depot/processed/data-volumer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 15:01:33 +01:00
commit 330ba7a93d
35 changed files with 5038 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
}
export default function PassagesPage() {
const [passages, setPassages] = useState([])
const [filter, setFilter] = useState({ station: '', needs_review: '' })
const load = async () => {
const params = {}
if (filter.station) params.station = filter.station
if (filter.needs_review !== '') params.needs_review = filter.needs_review === 'true'
const data = await api.getPassages(params)
setPassages(data)
}
useEffect(() => { load() }, [filter])
const handleDelete = async (id) => {
if (!confirm('Slett passering?')) return
await api.deletePassage(id)
await load()
}
return (
<>
<h1>Passeringer</h1>
<div className="card">
<div className="form-row">
<div className="form-group">
<label>Stasjon</label>
<input
type="text"
placeholder="f.eks. cp1"
style={{ width: 150 }}
value={filter.station}
onChange={e => setFilter(f => ({ ...f, station: e.target.value }))}
/>
</div>
<div className="form-group">
<label>Status</label>
<select
style={{ width: 180 }}
value={filter.needs_review}
onChange={e => setFilter(f => ({ ...f, needs_review: e.target.value }))}
>
<option value="">Alle</option>
<option value="false">Bekreftet</option>
<option value="true">Trenger gjennomgang</option>
</select>
</div>
</div>
</div>
<div className="card">
<h2>Passeringer ({passages.length})</h2>
{passages.length === 0 ? (
<p className="empty-state">Ingen passeringer funnet.</p>
) : (
<table>
<thead>
<tr>
<th>Startnr</th>
<th>Navn</th>
<th>Stasjon</th>
<th>Tidspunkt (UTC)</th>
<th>Konfidens</th>
<th>Metode</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{passages.map(p => (
<tr key={p.passage_id}>
<td><strong>{p.bib_number || '?'}</strong></td>
<td>{p.name || '—'}</td>
<td>{p.station}</td>
<td style={{ whiteSpace: 'nowrap' }}>{fmtTs(p.timestamp_utc)}</td>
<td>{p.confidence != null ? (p.confidence * 100).toFixed(0) + '%' : '—'}</td>
<td><code style={{ fontSize: '0.75rem' }}>{p.id_method}</code></td>
<td>
{p.needs_review
? <span className="badge badge-warning">Gjennomgang</span>
: <span className="badge badge-success">OK</span>}
</td>
<td>
<button
className="btn-danger btn-sm"
onClick={() => handleDelete(p.passage_id)}
>
Slett
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts).toLocaleTimeString('no-NO', { timeZone: 'UTC', hour12: false })
}
export default function ResultsPage() {
const [results, setResults] = useState([])
const [expanded, setExpanded] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getResults().then(data => {
setResults(data)
setLoading(false)
})
}, [])
const toggleExpand = (id) => setExpanded(e => e === id ? null : id)
if (loading) return <p>Laster...</p>
const finished = results.filter(r => !r.dns && !r.dnf)
const dnf = results.filter(r => r.dnf)
const dns = results.filter(r => r.dns)
return (
<>
<h1>Resultater</h1>
{results.length === 0 ? (
<div className="card">
<p className="empty-state">Ingen resultater tilgjengelig ennå.</p>
</div>
) : (
<div className="card">
<table>
<thead>
<tr>
<th style={{ width: '2.5rem' }}>Pl.</th>
<th>Startnr</th>
<th>Navn</th>
<th>Klubb</th>
<th>Start</th>
<th>Mål</th>
<th>Totaltid</th>
<th></th>
</tr>
</thead>
<tbody>
{finished.map((r, i) => (
<>
<tr key={r.profile_id} onClick={() => toggleExpand(r.profile_id)} style={{ cursor: 'pointer' }}>
<td className={`rank rank-${i + 1}`}>{i + 1}.</td>
<td><strong>{r.bib_number}</strong></td>
<td>{r.name}</td>
<td>{r.club || '—'}</td>
<td>{fmtTs(r.start_time)}</td>
<td>{fmtTs(r.finish_time)}</td>
<td><strong>{r.total_formatted}</strong></td>
<td style={{ color: '#aaa', fontSize: '0.8rem' }}>
{expanded === r.profile_id ? '▲' : '▼'}
</td>
</tr>
{expanded === r.profile_id && r.splits.length > 0 && (
<tr key={r.profile_id + '_splits'}>
<td colSpan={8} style={{ padding: '0.5rem 1rem 1rem' }}>
<table className="split-table" style={{ width: 'auto', minWidth: 300 }}>
<thead>
<tr>
<th>Fra</th>
<th>Til</th>
<th>Tid</th>
</tr>
</thead>
<tbody>
{r.splits.map((s, j) => (
<tr key={j}>
<td>{s.from}</td>
<td>{s.to}</td>
<td>{s.split_formatted || '—'}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
))}
{dnf.map(r => (
<tr key={r.profile_id} style={{ color: '#999' }}>
<td className="rank">DNF</td>
<td>{r.bib_number}</td>
<td>{r.name}</td>
<td>{r.club || '—'}</td>
<td>{fmtTs(r.start_time)}</td>
<td></td>
<td></td>
<td></td>
</tr>
))}
{dns.map(r => (
<tr key={r.profile_id} style={{ color: '#bbb' }}>
<td className="rank">DNS</td>
<td>{r.bib_number}</td>
<td>{r.name}</td>
<td>{r.club || '—'}</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
}
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
const [bib, setBib] = useState(passage.bib_number || '')
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const matchedAthlete = athletes.find(a => a.bib_number === bib)
const handleResolve = async () => {
setSaving(true)
try {
await api.resolvePassage(passage.passage_id, {
bib_number: bib || null,
profile_id: matchedAthlete?.profile_id || null,
review_note: note || null,
})
onResolved()
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!confirm('Slett passering?')) return
await api.deletePassage(passage.passage_id)
onDeleted()
}
// Bygg bildesti relativt til /api/images/
const imgSrc = passage.source_image
? `/api/images/${passage.source_image.replace(/^\/processed\//, '')}`
: null
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<div>
{imgSrc ? (
<img src={imgSrc} alt="Passeringsbilde" className="image-preview" />
) : (
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>
Bilde ikke tilgjengelig
</div>
)}
</div>
<div>
<table style={{ marginBottom: '1rem' }}>
<tbody>
<tr><td><strong>Stasjon</strong></td><td>{passage.station}</td></tr>
<tr><td><strong>Tidspunkt</strong></td><td>{fmtTs(passage.timestamp_utc)}</td></tr>
<tr><td><strong>OCR-resultat</strong></td><td>{passage.bib_number || 'ingen'}</td></tr>
<tr><td><strong>Konfidens</strong></td><td>{passage.confidence != null ? (passage.confidence * 100).toFixed(0) + '%' : '—'}</td></tr>
<tr><td><strong>Merknad</strong></td><td>{passage.review_note || '—'}</td></tr>
</tbody>
</table>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label>Startnummer</label>
<input
type="text"
value={bib}
onChange={e => setBib(e.target.value)}
placeholder="Skriv inn riktig startnummer"
/>
{matchedAthlete && (
<span style={{ fontSize: '0.8rem', color: '#2ecc71', marginTop: 2 }}>
{matchedAthlete.name} {matchedAthlete.club ? `(${matchedAthlete.club})` : ''}
</span>
)}
{bib && !matchedAthlete && (
<span style={{ fontSize: '0.8rem', color: '#e67e22', marginTop: 2 }}>
Startnummer ikke i startliste
</span>
)}
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label>Notat (valgfritt)</label>
<input
type="text"
value={note}
onChange={e => setNote(e.target.value)}
placeholder="f.eks. startnummer skjult av arm"
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn-success"
onClick={handleResolve}
disabled={saving}
>
{saving ? 'Lagrer...' : 'Bekreft'}
</button>
<button className="btn-danger" onClick={handleDelete}>
Slett
</button>
</div>
</div>
</div>
</div>
)
}
export default function ReviewPage() {
const [queue, setQueue] = useState([])
const [athletes, setAthletes] = useState([])
const [loading, setLoading] = useState(true)
const load = async () => {
setLoading(true)
const [q, a] = await Promise.all([api.getReviewQueue(), api.getAthletes()])
setQueue(q)
setAthletes(a)
setLoading(false)
}
useEffect(() => { load() }, [])
const removeFromQueue = (passageId) => {
setQueue(q => q.filter(p => p.passage_id !== passageId))
}
return (
<>
<h1>Manuell gjennomgang</h1>
{loading ? (
<p>Laster...</p>
) : queue.length === 0 ? (
<div className="card">
<p className="empty-state">Ingen passeringer venter gjennomgang.</p>
</div>
) : (
<>
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
{queue.length} passering(er) venter manuell behandling.
</div>
{queue.map(p => (
<ReviewCard
key={p.passage_id}
passage={p}
athletes={athletes}
onResolved={() => removeFromQueue(p.passage_id)}
onDeleted={() => removeFromQueue(p.passage_id)}
/>
))}
</>
)}
</>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
export default function StartlistPage() {
const [athletes, setAthletes] = useState([])
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(false)
const fileRef = useRef()
const load = async () => {
const data = await api.getAthletes()
setAthletes(data)
}
useEffect(() => { load() }, [])
const handleImport = async (e) => {
e.preventDefault()
const file = fileRef.current.files[0]
if (!file) return
setLoading(true)
setStatus(null)
try {
const res = await api.importCsv(file)
setStatus({
type: 'success',
msg: `Importert ${res.imported} utøver(e).` +
(res.errors.length ? ` Feil: ${res.errors.join(', ')}` : ''),
})
await load()
} catch (err) {
setStatus({ type: 'error', msg: err.message })
} finally {
setLoading(false)
fileRef.current.value = ''
}
}
const handleDelete = async (id) => {
if (!confirm('Slett utøver?')) return
await api.deleteAthlete(id)
await load()
}
const handleClear = async () => {
if (!confirm('Slett hele startlisten?')) return
await api.clearAthletes()
setAthletes([])
setStatus({ type: 'success', msg: 'Startliste slettet.' })
}
return (
<>
<h1>Startliste</h1>
<div className="card">
<h2>Last opp CSV</h2>
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' }}>
Påkrevde kolonner: <code>bib_number</code> (eller <code>bib</code>), <code>name</code>.
Valgfritt: <code>club</code>.
</p>
<form onSubmit={handleImport} className="form-row">
<div className="form-group" style={{ flex: 1 }}>
<label>CSV-fil</label>
<input type="file" accept=".csv" ref={fileRef} required />
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Laster...' : 'Importer'}
</button>
</form>
{status && (
<div className={`alert alert-${status.type === 'error' ? 'error' : 'success'}`}>
{status.msg}
</div>
)}
</div>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h2>Utøvere ({athletes.length})</h2>
{athletes.length > 0 && (
<button className="btn-danger btn-sm" onClick={handleClear}>Slett alle</button>
)}
</div>
{athletes.length === 0 ? (
<p className="empty-state">Ingen utøvere registrert. Last opp en startliste.</p>
) : (
<table>
<thead>
<tr>
<th>Startnr</th>
<th>Navn</th>
<th>Klubb</th>
<th></th>
</tr>
</thead>
<tbody>
{athletes.map((a) => (
<tr key={a.profile_id}>
<td><strong>{a.bib_number}</strong></td>
<td>{a.name}</td>
<td>{a.club || '—'}</td>
<td>
<button
className="btn-danger btn-sm"
onClick={() => handleDelete(a.profile_id)}
>
Slett
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}