feat: Wer gedrueckt hat, und Strafzeiten je Zeit oder je Stage

startedBy und stoppedBy standen laengst im Datensatz und wurden bei
jedem Start und Stopp geschrieben — nur sah sie niemand. Bei einer
strittigen Messung ist das die erste Frage, also steht es jetzt unter
dem Namen an der Zeit.

Und die Korrektur: times.correction gab es, sie wird auf die Dauer
gerechnet und im CSV exportiert, aber setzen konnte sie kein Mensch.
Jetzt zwei Wege dorthin — an der einzelnen Zeit ueber die Spalte, die
schon da war, und ueber alle gewerteten Zeiten der Stage auf einmal.

Der Wert wird gesetzt und nicht addiert. Zweimal denselben Dialog
abzuschicken darf keine doppelte Strafe ergeben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 23:07:48 +02:00
parent bb56945fe3
commit d01eae078a

View file

@ -12,6 +12,7 @@
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time'
import { clockSync } from '$lib/stores/clockSync.svelte'
@ -21,13 +22,25 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as Table from '@/components/ui/table'
import { ArrowLeft, Clock, Download, Flag, Timer, Trophy, TriangleAlert } from 'lucide-svelte'
import { ArrowLeft, Clock, Download, Flag, Pencil, Timer, Trophy, TriangleAlert } from 'lucide-svelte'
import * as Dialog from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const events = getEventContext()
const stages = getStageContext()
const riders = getRiderContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
const teams = getTeamContext()
// Die Namen der Teammitglieder holen: Ein Team kennt nur die IDs seiner
// Leute, und ohne diesen Zug stuende an jeder Zeit statt "Start: Anna"
// eine abgeschnittene ID.
$effect(() => {
void teams.active
teams.loadMembers()
})
const clock = ticker()
@ -43,6 +56,8 @@
const running = $derived(valid && stage ? times.runningByStage(stage.id, clock.now) : [])
const leaderboard = $derived(valid && stage ? times.getLeaderboard(stage.id) : [])
const unranked = $derived(valid && stage ? times.unrankedByStage(stage.id) : [])
/** Alle gewerteten Zeiten dieser Stage — Ziel einer Strafe für alle. */
const stageTimes = $derived(leaderboard)
/**
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
@ -68,6 +83,54 @@
let timeDialog = $state(false)
let clockDialog = $state(false)
// --- Strafzeiten ------------------------------------------------------
//
// Die Korrektur steckt als Sekunden an der Zeit (times.correction) und
// wird auf die Dauer gerechnet. Zwei Wege dorthin: an einer Zeit einzeln,
// das ist der Normalfall, und über alle Zeiten dieser Stage auf einmal —
// etwa wenn eine Schikane für alle nicht zählte.
let penaltyDialog = $state(false)
let penaltyTime = $state<string | null>(null)
let penaltySeconds = $state(0)
let penaltyBusy = $state(false)
let penaltyError = $state<string | null>(null)
function openPenalty(timeId: string | null, current = 0) {
penaltyTime = timeId
penaltySeconds = current
penaltyError = null
penaltyDialog = true
}
async function savePenalty() {
if (penaltyBusy) return
penaltyBusy = true
penaltyError = null
try {
if (penaltyTime) {
await times.applyCorrection(penaltyTime, penaltySeconds)
} else {
// Auf die ganze Stage: Die Korrektur wird gesetzt, nicht
// addiert. Zweimal denselben Dialog abzuschicken darf keine
// doppelte Strafe ergeben.
for (const t of stageTimes) {
await times.applyCorrection(t.id, penaltySeconds)
}
}
penaltyDialog = false
} catch (e: any) {
penaltyError = e.message ?? 'Die Korrektur konnte nicht gespeichert werden.'
} finally {
penaltyBusy = false
}
}
/** Wer hat gedrückt? Der Kontoname, sonst eine gekürzte ID. */
function userLabel(id: string | undefined) {
return id ? teams.userLabel(id) : null
}
// --- Export ------------------------------------------------------------
function exportCsv() {
if (leaderboard.length === 0) return
@ -174,10 +237,18 @@
<!-- Zeiten: laufende oben, darunter die Wertung -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" />
Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''})
</CardTitle>
<div class="flex flex-wrap items-center justify-between gap-2">
<CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" />
Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''})
</CardTitle>
{#if leaderboard.length > 0}
<Button variant="outline" size="sm" onclick={() => openPenalty(null)}>
<Pencil class="size-4 mr-2" />
Strafzeit für alle
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-0">
{#if rows.length === 0}
@ -221,12 +292,44 @@
{:else}
{/if}
<!--
Wer gedrueckt hat, steht an der
Zeit. Bei einer strittigen Messung
ist das die erste Frage, und die
Daten dafuer lagen laengst im
Datensatz — nur sah sie niemand.
-->
{#if userLabel(t.startedBy) || userLabel(t.stoppedBy)}
<span class="block text-xs font-normal text-muted-foreground">
{#if t.startedBy && t.stoppedBy && t.startedBy === t.stoppedBy}
Start und Stopp: {userLabel(t.startedBy)}
{:else}
{#if t.startedBy}Start: {userLabel(t.startedBy)}{/if}
{#if t.startedBy && t.stoppedBy} · {/if}
{#if t.stoppedBy}Stopp: {userLabel(t.stoppedBy)}{/if}
{/if}
</span>
{/if}
</Table.Cell>
<Table.Cell class="text-right tabular-nums {live ? 'font-semibold' : ''}">
{t.formattedTime}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
<Table.Cell class="text-right">
<Button
variant="ghost"
size="sm"
class="tabular-nums font-normal {t.correction
? ''
: 'text-muted-foreground'}"
tooltip="Zeitkorrektur"
onclick={() => openPenalty(t.id, t.correction ?? 0)}
>
{t.correction
? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)} s`
: ''}
<Pencil class="size-3 ml-1 opacity-50" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
@ -273,3 +376,45 @@
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} stageId={stage?.id ?? null} />
<ClockSyncDialog bind:open={clockDialog} />
<!-- Zeitkorrektur: eine Zahl in Sekunden, plus wie minus. -->
<Dialog.Root bind:open={penaltyDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{penaltyTime ? 'Zeitkorrektur' : 'Strafzeit für alle'}
</Dialog.Title>
<Dialog.Description>
{#if penaltyTime}
Wird auf die gemessene Zeit gerechnet. Eine Strafe ist positiv, eine
Gutschrift negativ.
{:else}
Gilt für alle {stageTimes.length} gewerteten Zeiten dieser Stage. Der
Wert wird gesetzt, nicht addiert — zweimal abschicken ergibt keine
doppelte Strafe.
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="penalty">Sekunden</Label>
<Input id="penalty" type="number" step="0.1" bind:value={penaltySeconds} />
<p class="text-xs text-muted-foreground">
0 nimmt eine bestehende Korrektur wieder zurück.
</p>
</div>
{#if penaltyError}
<p class="text-sm text-destructive">{penaltyError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (penaltyDialog = false)}>Abbrechen</Button>
<Button onclick={savePenalty} disabled={penaltyBusy}>
{penaltyBusy ? 'Wird gespeichert …' : 'Übernehmen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>