Keep all burst images, use last timestamp as passage time
Build & Deploy / build-and-deploy (push) Failing after 2m41s
Build & Deploy / build-and-deploy (push) Failing after 2m41s
- passage_images table stores every image in a burst sequence
- Passage timestamp = last image (chronologically) in the burst
- Review UI: image slider to browse all burst images, slider ends
at the official passage time (rightmost = last image)
- API: GET /api/passages/{id}/images
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -16,7 +16,7 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ingest import watch_depot, process_existing
|
from ingest import watch_depot, process_existing
|
||||||
from passage_log import delete_passage, get_passages, log_passage, resolve_passage
|
from passage_log import delete_passage, get_passage_images, get_passages, log_passage, resolve_passage
|
||||||
from profile_db import (
|
from profile_db import (
|
||||||
clear_startlist,
|
clear_startlist,
|
||||||
delete_athlete,
|
delete_athlete,
|
||||||
@@ -133,6 +133,12 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/passages/{passage_id}/images")
|
||||||
|
async def passage_images(passage_id: str, db=Depends(get_connection)):
|
||||||
|
"""Hent alle bilder for en passering, kronologisk sortert."""
|
||||||
|
return await get_passage_images(db, passage_id)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/passages/{passage_id}")
|
@app.delete("/api/passages/{passage_id}")
|
||||||
async def remove_passage(passage_id: str, db=Depends(get_connection)):
|
async def remove_passage(passage_id: str, db=Depends(get_connection)):
|
||||||
ok = await delete_passage(db, passage_id)
|
ok = await delete_passage(db, passage_id)
|
||||||
|
|||||||
+87
-80
@@ -1,41 +1,47 @@
|
|||||||
"""
|
"""
|
||||||
Skriv og query passeringslogg i SQLite.
|
Skriv og query passeringslogg i SQLite.
|
||||||
|
|
||||||
|
Deduplisering:
|
||||||
|
Bilder av samme utøver (bib) ved samme stasjon innen DEDUP_WINDOW_SECONDS
|
||||||
|
grupperes under én passering. Alle bilder bevares i passage_images.
|
||||||
|
Passeringstidsstempel = siste bilde i burst-sekvensen (nyeste timestamp).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
# Tidsvindu for deduplisering: bilder av samme utøver ved samme stasjon
|
|
||||||
DEDUP_WINDOW_SECONDS = 2
|
DEDUP_WINDOW_SECONDS = 2
|
||||||
|
|
||||||
|
|
||||||
async def _find_duplicate(
|
async def _find_active_passage(
|
||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
bib_number: str,
|
bib_number: str,
|
||||||
station: str,
|
station: str,
|
||||||
timestamp_utc: datetime,
|
timestamp_utc: datetime,
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Finn eksisterende passering med samme bib og stasjon innen DEDUP_WINDOW_SECONDS.
|
Finn eksisterende passering med samme bib og stasjon der nytt bilde faller
|
||||||
Returnerer raden, eller None.
|
innen DEDUP_WINDOW_SECONDS etter siste registrerte bilde i passeringen.
|
||||||
"""
|
"""
|
||||||
window_start = (timestamp_utc - timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat()
|
|
||||||
window_end = (timestamp_utc + timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat()
|
|
||||||
|
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT passage_id, proximity_score, source_image
|
SELECT p.passage_id, MAX(pi.timestamp_utc) AS last_image_ts
|
||||||
FROM passages
|
FROM passages p
|
||||||
WHERE bib_number = ? AND station = ?
|
JOIN passage_images pi ON pi.passage_id = p.passage_id
|
||||||
AND timestamp_utc BETWEEN ? AND ?
|
WHERE p.bib_number = ? AND p.station = ?
|
||||||
ORDER BY proximity_score DESC
|
GROUP BY p.passage_id
|
||||||
|
HAVING last_image_ts >= ?
|
||||||
|
ORDER BY last_image_ts DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(bib_number, station, window_start, window_end),
|
(
|
||||||
|
bib_number,
|
||||||
|
station,
|
||||||
|
(timestamp_utc - timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat(),
|
||||||
|
),
|
||||||
) as cur:
|
) as cur:
|
||||||
row = await cur.fetchone()
|
row = await cur.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
@@ -59,83 +65,90 @@ async def log_passage(
|
|||||||
review_note: Optional[str] = None,
|
review_note: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Logg én passering med deduplisering.
|
Logg ett bilde som en passering.
|
||||||
|
|
||||||
Hvis et bilde av samme utøver ved samme stasjon allerede er logget innen
|
- Er bildet del av en pågående burst (bib+stasjon innen vinduet)?
|
||||||
DEDUP_WINDOW_SECONDS, beholder vi bildet nærmest kamera (høyest proximity_score)
|
→ Legg til i passage_images, oppdater passeringstidsstempel til dette
|
||||||
ettersom det gir det mest nøyaktige tidsstempelet.
|
bildet (siste i tid = offisiell passeringstid).
|
||||||
|
- Nytt bib/stasjon eller utenfor vinduet?
|
||||||
|
→ Opprett ny passering.
|
||||||
|
|
||||||
Returnerer passage_id (enten ny eller eksisterende).
|
Returnerer passage_id.
|
||||||
"""
|
"""
|
||||||
if bib_number:
|
image_id = str(uuid.uuid4())
|
||||||
duplicate = await _find_duplicate(db, bib_number, station, timestamp_utc)
|
|
||||||
if duplicate:
|
|
||||||
if proximity_score > duplicate["proximity_score"]:
|
|
||||||
# Nytt bilde er nærmere kamera — oppdater tidsstempel og bildesti
|
|
||||||
old_image = duplicate["source_image"]
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE passages
|
|
||||||
SET timestamp_utc = ?, proximity_score = ?,
|
|
||||||
source_image = ?, confidence = ?, id_method = ?
|
|
||||||
WHERE passage_id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
timestamp_utc.isoformat(),
|
|
||||||
proximity_score,
|
|
||||||
source_image,
|
|
||||||
confidence,
|
|
||||||
id_method,
|
|
||||||
duplicate["passage_id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
# Slett det gamle, dårligere bildet
|
|
||||||
_delete_image_file(old_image)
|
|
||||||
return duplicate["passage_id"]
|
|
||||||
else:
|
|
||||||
# Eksisterende bilde er nærmere kamera — forkast nytt bilde
|
|
||||||
_delete_image_file(source_image)
|
|
||||||
return duplicate["passage_id"]
|
|
||||||
|
|
||||||
|
if bib_number:
|
||||||
|
existing = await _find_active_passage(db, bib_number, station, timestamp_utc)
|
||||||
|
if existing:
|
||||||
|
passage_id = existing["passage_id"]
|
||||||
|
# Legg til bilde i sekvensen
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO passage_images (image_id, passage_id, image_path, timestamp_utc, proximity_score)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(image_id, passage_id, source_image, timestamp_utc.isoformat(), proximity_score),
|
||||||
|
)
|
||||||
|
# Oppdater passeringen med siste bilde som offisiell tid og bildesti
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE passages
|
||||||
|
SET timestamp_utc = ?, source_image = ?, confidence = ?, id_method = ?
|
||||||
|
WHERE passage_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
timestamp_utc.isoformat(),
|
||||||
|
source_image,
|
||||||
|
confidence,
|
||||||
|
id_method,
|
||||||
|
passage_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return passage_id
|
||||||
|
|
||||||
|
# Ny passering
|
||||||
passage_id = str(uuid.uuid4())
|
passage_id = str(uuid.uuid4())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO passages (
|
INSERT INTO passages (
|
||||||
passage_id, profile_id, bib_number, station,
|
passage_id, profile_id, bib_number, station,
|
||||||
timestamp_utc, gps_lat, gps_lon, gps_alt,
|
timestamp_utc, gps_lat, gps_lon, gps_alt,
|
||||||
confidence, proximity_score, id_method, source_image,
|
confidence, id_method, source_image,
|
||||||
needs_review, review_note
|
needs_review, review_note
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
passage_id,
|
passage_id, profile_id, bib_number, station,
|
||||||
profile_id,
|
timestamp_utc.isoformat(), gps_lat, gps_lon, gps_alt,
|
||||||
bib_number,
|
confidence, id_method, source_image,
|
||||||
station,
|
int(needs_review), review_note,
|
||||||
timestamp_utc.isoformat(),
|
|
||||||
gps_lat,
|
|
||||||
gps_lon,
|
|
||||||
gps_alt,
|
|
||||||
confidence,
|
|
||||||
proximity_score,
|
|
||||||
id_method,
|
|
||||||
source_image,
|
|
||||||
int(needs_review),
|
|
||||||
review_note,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO passage_images (image_id, passage_id, image_path, timestamp_utc, proximity_score)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(image_id, passage_id, source_image, timestamp_utc.isoformat(), proximity_score),
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return passage_id
|
return passage_id
|
||||||
|
|
||||||
|
|
||||||
def _delete_image_file(path: str) -> None:
|
async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[dict]:
|
||||||
"""Slett bildefil stille — logg advarsel ved feil."""
|
"""Hent alle bilder for en passering, kronologisk sortert."""
|
||||||
import logging
|
async with db.execute(
|
||||||
try:
|
"""
|
||||||
Path(path).unlink(missing_ok=True)
|
SELECT image_id, image_path, timestamp_utc, proximity_score
|
||||||
except Exception as e:
|
FROM passage_images
|
||||||
logging.getLogger(__name__).warning("Kunne ikke slette duplikatbilde %s: %s", path, e)
|
WHERE passage_id = ?
|
||||||
|
ORDER BY timestamp_utc
|
||||||
|
""",
|
||||||
|
(passage_id,),
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_passages(
|
async def get_passages(
|
||||||
@@ -144,10 +157,8 @@ async def get_passages(
|
|||||||
station: Optional[str] = None,
|
station: Optional[str] = None,
|
||||||
needs_review: Optional[bool] = None,
|
needs_review: Optional[bool] = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Hent passeringer med valgfrie filtre."""
|
|
||||||
clauses = []
|
clauses = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
clauses.append("p.profile_id = ?")
|
clauses.append("p.profile_id = ?")
|
||||||
params.append(profile_id)
|
params.append(profile_id)
|
||||||
@@ -159,7 +170,6 @@ async def get_passages(
|
|||||||
params.append(int(needs_review))
|
params.append(int(needs_review))
|
||||||
|
|
||||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT p.*, a.name, a.club
|
SELECT p.*, a.name, a.club
|
||||||
FROM passages p
|
FROM passages p
|
||||||
@@ -179,7 +189,6 @@ async def resolve_passage(
|
|||||||
bib_number: Optional[str],
|
bib_number: Optional[str],
|
||||||
review_note: Optional[str] = None,
|
review_note: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Manuell oppdatering av en passering etter gjennomgang."""
|
|
||||||
cur = await db.execute(
|
cur = await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE passages
|
UPDATE passages
|
||||||
@@ -194,8 +203,6 @@ async def resolve_passage(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool:
|
async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool:
|
||||||
cur = await db.execute(
|
cur = await db.execute("DELETE FROM passages WHERE passage_id = ?", (passage_id,))
|
||||||
"DELETE FROM passages WHERE passage_id = ?", (passage_id,)
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return cur.rowcount > 0
|
return cur.rowcount > 0
|
||||||
|
|||||||
+10
-1
@@ -33,7 +33,6 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
|||||||
gps_lon REAL,
|
gps_lon REAL,
|
||||||
gps_alt REAL,
|
gps_alt REAL,
|
||||||
confidence REAL,
|
confidence REAL,
|
||||||
proximity_score REAL NOT NULL DEFAULT 0,
|
|
||||||
id_method TEXT,
|
id_method TEXT,
|
||||||
source_image TEXT,
|
source_image TEXT,
|
||||||
needs_review INTEGER NOT NULL DEFAULT 0,
|
needs_review INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -41,9 +40,19 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS passage_images (
|
||||||
|
image_id TEXT PRIMARY KEY,
|
||||||
|
passage_id TEXT NOT NULL REFERENCES passages(passage_id) ON DELETE CASCADE,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
timestamp_utc TEXT NOT NULL,
|
||||||
|
proximity_score REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_id);
|
CREATE INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_passages_station ON passages(station);
|
CREATE INDEX IF NOT EXISTS idx_passages_station ON passages(station);
|
||||||
CREATE INDEX IF NOT EXISTS idx_passages_needs_review ON passages(needs_review);
|
CREATE INDEX IF NOT EXISTS idx_passages_needs_review ON passages(needs_review);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_passage_images_passage ON passage_images(passage_id);
|
||||||
""")
|
""")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const api = {
|
|||||||
return request(`/passages${qs ? '?' + qs : ''}`)
|
return request(`/passages${qs ? '?' + qs : ''}`)
|
||||||
},
|
},
|
||||||
getReviewQueue: () => request('/passages/review'),
|
getReviewQueue: () => request('/passages/review'),
|
||||||
|
getPassageImages: (id) => request(`/passages/${id}/images`),
|
||||||
resolvePassage: (id, body) =>
|
resolvePassage: (id, body) =>
|
||||||
request(`/passages/${id}/resolve`, {
|
request(`/passages/${id}/resolve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -3,7 +3,64 @@ import { api } from '../api.js'
|
|||||||
|
|
||||||
function fmtTs(ts) {
|
function fmtTs(ts) {
|
||||||
if (!ts) return '—'
|
if (!ts) return '—'
|
||||||
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
|
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgSrc(path) {
|
||||||
|
if (!path) return null
|
||||||
|
return `/api/images/${path.replace(/^\/processed\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageSlider({ passageId, currentImage }) {
|
||||||
|
const [images, setImages] = useState([])
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getPassageImages(passageId).then(imgs => {
|
||||||
|
setImages(imgs)
|
||||||
|
// Start på siste bilde (passeringstidspunktet)
|
||||||
|
setIndex(imgs.length > 0 ? imgs.length - 1 : 0)
|
||||||
|
})
|
||||||
|
}, [passageId])
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
const src = imgSrc(currentImage)
|
||||||
|
return src
|
||||||
|
? <img src={src} alt="Passeringsbilde" className="image-preview" />
|
||||||
|
: <div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = images[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={imgSrc(current.image_path)}
|
||||||
|
alt={`Bilde ${index + 1} av ${images.length}`}
|
||||||
|
className="image-preview"
|
||||||
|
/>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={images.length - 1}
|
||||||
|
value={index}
|
||||||
|
onChange={e => setIndex(Number(e.target.value))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#888', marginTop: '0.2rem' }}>
|
||||||
|
<span>{fmtTs(images[0].timestamp_utc)}</span>
|
||||||
|
<span style={{ color: index === images.length - 1 ? '#2ecc71' : '#888', fontWeight: index === images.length - 1 ? 600 : 400 }}>
|
||||||
|
{index + 1} / {images.length}
|
||||||
|
{index === images.length - 1 && ' — passeringstid'}
|
||||||
|
</span>
|
||||||
|
<span>{fmtTs(images[images.length - 1].timestamp_utc)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||||
@@ -33,28 +90,17 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
|||||||
onDeleted()
|
onDeleted()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bygg bildesti relativt til /api/images/
|
|
||||||
const imgSrc = passage.source_image
|
|
||||||
? `/api/images/${passage.source_image.replace(/^\/processed\//, '')}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||||
<div>
|
<div>
|
||||||
{imgSrc ? (
|
<ImageSlider passageId={passage.passage_id} currentImage={passage.source_image} />
|
||||||
<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>
|
||||||
<div>
|
<div>
|
||||||
<table style={{ marginBottom: '1rem' }}>
|
<table style={{ marginBottom: '1rem' }}>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><strong>Stasjon</strong></td><td>{passage.station}</td></tr>
|
<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>Passeringstid</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>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>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>
|
<tr><td><strong>Merknad</strong></td><td>{passage.review_note || '—'}</td></tr>
|
||||||
@@ -71,7 +117,7 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
|||||||
/>
|
/>
|
||||||
{matchedAthlete && (
|
{matchedAthlete && (
|
||||||
<span style={{ fontSize: '0.8rem', color: '#2ecc71', marginTop: 2 }}>
|
<span style={{ fontSize: '0.8rem', color: '#2ecc71', marginTop: 2 }}>
|
||||||
{matchedAthlete.name} {matchedAthlete.club ? `(${matchedAthlete.club})` : ''}
|
{matchedAthlete.name}{matchedAthlete.club ? ` (${matchedAthlete.club})` : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{bib && !matchedAthlete && (
|
{bib && !matchedAthlete && (
|
||||||
@@ -92,16 +138,10 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<button
|
<button className="btn-success" onClick={handleResolve} disabled={saving}>
|
||||||
className="btn-success"
|
|
||||||
onClick={handleResolve}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Lagrer...' : 'Bekreft'}
|
{saving ? 'Lagrer...' : 'Bekreft'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-danger" onClick={handleDelete}>
|
<button className="btn-danger" onClick={handleDelete}>Slett</button>
|
||||||
Slett
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,20 +164,15 @@ export default function ReviewPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
const removeFromQueue = (passageId) => {
|
const removeFromQueue = (passageId) => setQueue(q => q.filter(p => p.passage_id !== passageId))
|
||||||
setQueue(q => q.filter(p => p.passage_id !== passageId))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>Manuell gjennomgang</h1>
|
<h1>Manuell gjennomgang</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>Laster...</p>
|
<p>Laster...</p>
|
||||||
) : queue.length === 0 ? (
|
) : queue.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card"><p className="empty-state">Ingen passeringer venter på gjennomgang.</p></div>
|
||||||
<p className="empty-state">Ingen passeringer venter på gjennomgang.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
|
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user