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:
+3
-1
@@ -70,10 +70,12 @@ Unterhalb der Kuppelauswahl gibt es vier Tabs:
|
|||||||
| ToT | Teleskop ohne Termin |
|
| ToT | Teleskop ohne Termin |
|
||||||
| Sonstiges | Sonstige Veranstaltung |
|
| 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.
|
**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).
|
**Besucher** – Anzahl der Besucher (nicht bei BEOS und TD).
|
||||||
|
|
||||||
### Optionale Felder
|
### Optionale Felder
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Next.js 16 App Router application. All pages are server components; interactive
|
|||||||
|
|
||||||
- **`CustomSelect`**: replaces native `<select>` everywhere — iOS/Android native popups ignore CSS sizing. Supports `keepOpen` prop for multi-select use cases (BEOs, Objekte).
|
- **`CustomSelect`**: replaces native `<select>` everywhere — iOS/Android native popups ignore CSS sizing. Supports `keepOpen` prop for multi-select use cases (BEOs, Objekte).
|
||||||
- **`TimePicker5`**: custom time picker, no native `<input type="time">`. Shows HH:MM with ▲/▼ buttons, 5-minute steps, auto-repeat on hold (400 ms delay → 1-hour steps at 350 ms). Keyboard: ↑/↓.
|
- **`TimePicker5`**: custom time picker, no native `<input type="time">`. 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 `<input type="date">` + `<TimePicker5>`. Beginn date change syncs Ende date automatically.
|
- **`LogbuchForm`**: Beginn/Ende stored as `"YYYY-MM-DDTHH:MM"` strings. Date and time are split into separate `<input type="date">` + `<TimeInput>`. 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`).
|
- **`LogbuchList`**: accepts `compact` and `limit` props. Compact mode used for the 5-entry preview below the form on desktop (`hidden lg:block`).
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|||||||
@@ -35,6 +35,46 @@ function nowRounded5(): string {
|
|||||||
return `${pad(Math.floor(rounded / 60) % 24)}:${pad(rounded % 60)}`;
|
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 NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
|
||||||
const SONNE_ART: ArtFuehrung = 'SonF';
|
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();
|
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 showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
|
||||||
const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
|
const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
|
||||||
const isSonne = artFuehrung === SONNE_ART;
|
const isSonne = artFuehrung === SONNE_ART;
|
||||||
@@ -315,8 +362,10 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
value={beginn.slice(0, 10)}
|
value={beginn.slice(0, 10)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (!e.target.value) return;
|
if (!e.target.value) return;
|
||||||
setBeginn(e.target.value + 'T' + (beginn.slice(11, 16) || '00:00'));
|
// Das Feld bezeichnet den Beginn; das Ende folgt daraus.
|
||||||
setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00'));
|
const neuerBeginn = e.target.value + 'T' + (beginn.slice(11, 16) || '00:00');
|
||||||
|
setBeginn(neuerBeginn);
|
||||||
|
setEnde(endeAusBeginn(neuerBeginn, ende.slice(11, 16) || '00:00'));
|
||||||
}}
|
}}
|
||||||
required
|
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"
|
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>
|
<label className={labelCls}>Startzeit</label>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
value={beginn.slice(11, 16)}
|
value={beginn.slice(11, 16)}
|
||||||
onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
|
onChange={(t) => setBeginn(beginnAusEnde(ende, t))}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-24"
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
@@ -335,7 +384,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
<label className={labelCls}>Endzeit</label>
|
<label className={labelCls}>Endzeit</label>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
value={ende.slice(11, 16)}
|
value={ende.slice(11, 16)}
|
||||||
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
|
onChange={(t) => setEnde(endeAusBeginn(beginn, t))}
|
||||||
className="w-24"
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +403,18 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Name/Gruppe und Spende bei Sonderführung */}
|
||||||
{artFuehrung === 'SF' && (
|
{artFuehrung === 'SF' && (
|
||||||
<div className="flex flex-wrap gap-3 items-end">
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
|
|||||||
@@ -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;
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.15.0",
|
"version": "1.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user