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",