fix: Führungen über Mitternacht richtig datieren — Version 1.16.0

Das Formular hat Beginn und Ende bisher stumpf dasselbe Datum gegeben. Wer
einen Eintrag nach Mitternacht erfasste, bekam damit einen Beginn, der nach
dem Ende lag: Datum 9.8., Ende 00:30, Startzeit 21:00 ergab den 9.8. 21:00
als Beginn.

Beginn und Ende werden jetzt auseinander abgeleitet. Anker ist das Ende — der
Zeitpunkt, zu dem das Logbuch ausgefüllt wird und der von der Vorbelegung her
verlässlich ist. Ist die Startzeit später als die Endzeit, liegt der Beginn am
Vortag; wird die Endzeit unter die Startzeit gesetzt, wandert das Ende auf den
Folgetag. Das Datumsfeld bleibt das Startdatum und springt sichtbar zurück,
erklärt durch eine Zeile unter den Feldern. Über 12 Stunden Dauer erscheint ein
Hinweis, der das Speichern nicht blockiert.

migrate_zeiten.sql datiert Altbestände mit Beginn > Ende um einen Tag zurück.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:14:44 +02:00
parent 5e438863ab
commit bb3f6f05a3
5 changed files with 80 additions and 7 deletions
+65 -4
View File
@@ -35,6 +35,46 @@ function nowRounded5(): string {
return `${pad(Math.floor(rounded / 60) % 24)}:${pad(rounded % 60)}`;
}
function tagVerschieben(datum: string, tage: number): string {
const d = new Date(datum + 'T12:00:00Z'); // Mittag — unempfindlich gegen Zeitzonen
d.setUTCDate(d.getUTCDate() + tage);
return d.toISOString().slice(0, 10);
}
/**
* Beginn aus dem Ende ableiten: Ist die Startzeit später als die Endzeit, ging die
* Führung über Mitternacht und hat am Vortag begonnen. Anker ist das Ende — der
* Zeitpunkt, zu dem das Logbuch ausgefüllt wird, und damit der verlässliche.
*/
function beginnAusEnde(ende: string, startZeit: string): string {
const datum = startZeit > ende.slice(11, 16)
? tagVerschieben(ende.slice(0, 10), -1)
: ende.slice(0, 10);
return `${datum}T${startZeit}`;
}
/** Ende aus dem Beginn ableiten: Endzeit vor der Startzeit ⇒ Folgetag. */
function endeAusBeginn(beginn: string, endZeit: string): string {
const startZeit = beginn.slice(11, 16);
const datum = startZeit && endZeit < startZeit
? tagVerschieben(beginn.slice(0, 10), 1)
: beginn.slice(0, 10);
return `${datum}T${endZeit}`;
}
/** "2026-08-09T00:30" → "09.08." */
function datumKurz(zeitpunkt: string): string {
const [j, m, t] = zeitpunkt.slice(0, 10).split('-');
return t && m && j ? `${t}.${m}.` : zeitpunkt.slice(0, 10);
}
/** Dauer in Minuten; null, solange keine Startzeit eingetragen ist. */
function dauerMinuten(beginn: string, ende: string): number | null {
if (!beginn.slice(11, 16) || !ende.slice(11, 16)) return null;
const d = (Date.parse(ende + ':00Z') - Date.parse(beginn + ':00Z')) / 60000;
return Number.isFinite(d) ? d : null;
}
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
const SONNE_ART: ArtFuehrung = 'SonF';
@@ -73,6 +113,13 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
if (idx >= 0) fields[(idx + 1) % fields.length]?.focus();
}
const dauer = dauerMinuten(beginn, ende);
const ueberMitternacht = beginn.slice(11, 16) !== '' && beginn.slice(0, 10) !== ende.slice(0, 10);
const dauerZuLang = dauer !== null && dauer > 12 * 60;
const dauerText = dauer === null
? ''
: `${Math.floor(dauer / 60)}:${String(Math.round(dauer % 60)).padStart(2, '0')}`;
const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
const isSonne = artFuehrung === SONNE_ART;
@@ -315,8 +362,10 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
value={beginn.slice(0, 10)}
onChange={(e) => {
if (!e.target.value) return;
setBeginn(e.target.value + 'T' + (beginn.slice(11, 16) || '00:00'));
setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00'));
// Das Feld bezeichnet den Beginn; das Ende folgt daraus.
const neuerBeginn = e.target.value + 'T' + (beginn.slice(11, 16) || '00:00');
setBeginn(neuerBeginn);
setEnde(endeAusBeginn(neuerBeginn, ende.slice(11, 16) || '00:00'));
}}
required
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
@@ -326,7 +375,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<label className={labelCls}>Startzeit</label>
<TimeInput
value={beginn.slice(11, 16)}
onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
onChange={(t) => setBeginn(beginnAusEnde(ende, t))}
autoFocus
className="w-24"
/>
@@ -335,7 +384,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<label className={labelCls}>Endzeit</label>
<TimeInput
value={ende.slice(11, 16)}
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
onChange={(t) => setEnde(endeAusBeginn(beginn, t))}
className="w-24"
/>
</div>
@@ -354,6 +403,18 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
)}
</div>
{/* Erklärt den Sprung des Datumsfeldes bei Führungen über Mitternacht */}
{ueberMitternacht && (
<div className="text-xs text-gray-500">
Führung über Mitternacht endet am {datumKurz(ende)} um {ende.slice(11, 16)} Uhr.
</div>
)}
{dauerZuLang && (
<div className="text-xs text-amber-700">
Dauer {dauerText} Std. bitte Start- und Endzeit prüfen.
</div>
)}
{/* Name/Gruppe und Spende bei Sonderführung */}
{artFuehrung === 'SF' && (
<div className="flex flex-wrap gap-3 items-end">