chore: feat/zeiten → main (Version 1.16.0)

This commit is contained in:
2026-08-09 10:14:44 +02:00
5 changed files with 80 additions and 7 deletions
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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).
- **`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`).
## Data model
+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">
+10
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "logbuch",
"version": "1.15.0",
"version": "1.16.0",
"private": true,
"scripts": {
"dev": "next dev",