From bb3f6f05a3e0f70cb95ea642c6e85aaf37dc9b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Sun, 9 Aug 2026 10:14:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20F=C3=BChrungen=20=C3=BCber=20Mitternacht?= =?UTF-8?q?=20richtig=20datieren=20=E2=80=94=20Version=201.16.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ANLEITUNG.md | 4 ++- CLAUDE.md | 2 +- components/LogbuchForm.tsx | 69 +++++++++++++++++++++++++++++++++++--- migrate_zeiten.sql | 10 ++++++ package.json | 2 +- 5 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 migrate_zeiten.sql diff --git a/ANLEITUNG.md b/ANLEITUNG.md index a534260..4c8d069 100644 --- a/ANLEITUNG.md +++ b/ANLEITUNG.md @@ -70,10 +70,12 @@ Unterhalb der Kuppelauswahl gibt es vier Tabs: | ToT | Teleskop ohne Termin | | Sonstiges | Sonstige Veranstaltung | -**Datum** – Datum der Veranstaltung (Standardwert: heute). +**Datum** – Datum des **Beginns** (Standardwert: heute). Unter diesem Datum erscheint der Eintrag später in Liste und Statistik. **Startzeit / Endzeit** – Uhrzeit von Beginn und Ende. Die Startzeit ist beim Laden leer und erhält automatisch den Fokus; die Endzeit wird auf die aktuelle Uhrzeit aufgerundet auf die nächste 5-Minuten-Marke vorausgefüllt. Wird nur die Stundenzahl eingegeben (z. B. `8` oder `23`) und das Feld verlassen, werden die Minuten automatisch auf `00` gesetzt. +> [!HINWEIS] **Führungen über Mitternacht** brauchen keine Sonderbehandlung. Liegt die Startzeit nach der Endzeit, hat die Führung am Vortag begonnen — das Datumsfeld springt dann automatisch einen Tag zurück, und darunter erscheint eine Zeile wie „Führung über Mitternacht — endet am 09.08. um 00:30 Uhr". Wer also um 00:30 Uhr eine Führung von 21:00 Uhr einträgt, muss nichts von Hand korrigieren. Ergibt sich dabei eine Dauer von mehr als 12 Stunden, weist ein Hinweis darauf hin — meist steckt dann ein Vertipper in einer der beiden Uhrzeiten. + **Besucher** – Anzahl der Besucher (nicht bei BEOS und TD). ### Optionale Felder diff --git a/CLAUDE.md b/CLAUDE.md index 2fc0c42..ed5131a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Next.js 16 App Router application. All pages are server components; interactive - **`CustomSelect`**: replaces native ``. Shows HH:MM with ▲/▼ buttons, 5-minute steps, auto-repeat on hold (400 ms delay → 1-hour steps at 350 ms). Keyboard: ↑/↓. -- **`LogbuchForm`**: Beginn/Ende stored as `"YYYY-MM-DDTHH:MM"` strings. Date and time are split into separate `` + ``. Beginn date change syncs Ende date automatically. +- **`LogbuchForm`**: Beginn/Ende stored as `"YYYY-MM-DDTHH:MM"` strings. Date and time are split into separate `` + ``. The single date field is the **Beginn** date; Ende's date is derived, never entered. Rule for sessions crossing midnight: **start time-of-day later than end time-of-day ⇒ Beginn is the previous day**, anchored on Ende (the moment the form is filled in, hence the reliable one) — `beginnAusEnde()` / `endeAusBeginn()`. So typing a start time can move the visible date back a day; a grey line below the row explains it. A duration over 12 h shows a soft hint but never blocks saving. Entries created before 1.16.0 may have `Beginn > Ende`; `migrate_zeiten.sql` fixes them. - **`LogbuchList`**: accepts `compact` and `limit` props. Compact mode used for the 5-entry preview below the form on desktop (`hidden lg:block`). ## Data model diff --git a/components/LogbuchForm.tsx b/components/LogbuchForm.tsx index a15efb6..d1a9b6d 100644 --- a/components/LogbuchForm.tsx +++ b/components/LogbuchForm.tsx @@ -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 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 setEnde(ende.slice(0, 10) + 'T' + t)} + onChange={(t) => setEnde(endeAusBeginn(beginn, t))} className="w-24" /> @@ -354,6 +403,18 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved )} + {/* Erklärt den Sprung des Datumsfeldes bei Führungen über Mitternacht */} + {ueberMitternacht && ( +
+ Führung über Mitternacht — endet am {datumKurz(ende)} um {ende.slice(11, 16)} Uhr. +
+ )} + {dauerZuLang && ( +
+ Dauer {dauerText} Std. — bitte Start- und Endzeit prüfen. +
+ )} + {/* Name/Gruppe und Spende bei Sonderführung */} {artFuehrung === 'SF' && (
diff --git a/migrate_zeiten.sql b/migrate_zeiten.sql new file mode 100644 index 0000000..f18ea1c --- /dev/null +++ b/migrate_zeiten.sql @@ -0,0 +1,10 @@ +-- Migration: Führungen über Mitternacht richtig datieren (Version 1.16.0) +-- +-- Bis Version 1.15.0 setzte das Eingabeformular für Beginn und Ende dasselbe Datum. +-- Wurde ein Eintrag nach Mitternacht erfasst, bekam der Beginn dadurch das Datum des +-- Endes und liegt seither zeitlich NACH dem Ende. +-- +-- Vor dem Ausführen die betroffenen Zeilen ansehen — ein Vertipper bei der Startzeit +-- sieht in den Daten genauso aus wie ein echter Mitternachtsfall: +-- SELECT ID, Kuppel, ArtFuehrung, Beginn, Ende FROM logbuch WHERE Beginn > Ende ORDER BY Beginn; +UPDATE logbuch SET Beginn = Beginn - INTERVAL 1 DAY WHERE Beginn > Ende; diff --git a/package.json b/package.json index a1e0aaf..ce35134 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "logbuch", - "version": "1.15.0", + "version": "1.16.0", "private": true, "scripts": { "dev": "next dev",