Compare commits
4 Commits
5e438863ab
...
aed69a1d59
| Author | SHA1 | Date | |
|---|---|---|---|
| aed69a1d59 | |||
| 38e403bd39 | |||
| 74885a987a | |||
| bb3f6f05a3 |
+3
-1
@@ -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
|
||||
|
||||
@@ -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. Changing ArtFuehrung only clears what no longer fits — Objekte just when the category flips (`SonF` ↔ rest), never BEOs. The `currentUserBeo` prop must be referentially stable (it is `useMemo`'d in `MainClient`): it sits in the prefill effect's deps, so a fresh object on every parent render would wipe the form mid-entry.
|
||||
- **`LogbuchList`**: accepts `compact` and `limit` props. Compact mode used for the 5-entry preview below the form on desktop (`hidden lg:block`).
|
||||
|
||||
## Data model
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { KUPPELN } from '@/types/logbuch';
|
||||
import type { Kuppel, LogbuchEintrag, SofueStatus } from '@/types/logbuch';
|
||||
import LogbuchForm from '@/components/LogbuchForm';
|
||||
@@ -50,7 +50,12 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
||||
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
|
||||
const currentUserBeo = { ID: beoId, Kuerzel: kuerzel, Name: beoName };
|
||||
// Muss stabil bleiben: LogbuchForm setzt bei jeder Änderung dieses Objekts das
|
||||
// Formular zurück. Frisch erzeugt würde jedes Rendern hier die Eingaben löschen.
|
||||
const currentUserBeo = useMemo(
|
||||
() => ({ ID: beoId, Kuerzel: kuerzel, Name: beoName }),
|
||||
[beoId, kuerzel, beoName]
|
||||
);
|
||||
|
||||
function handleSaved(sofue?: SofueStatus | null) {
|
||||
setRefreshKey((k) => k + 1);
|
||||
|
||||
@@ -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;
|
||||
@@ -128,6 +175,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
||||
return () => { verworfen = true; };
|
||||
}, [ende]);
|
||||
|
||||
// Vorbelegen beim Bearbeiten, sonst leeres Formular. currentUserBeo muss vom
|
||||
// Aufrufer stabil gehalten werden, sonst setzt jedes Rendern die Eingaben zurück.
|
||||
useEffect(() => {
|
||||
if (editEntry) {
|
||||
setArtFuehrung(editEntry.ArtFuehrung);
|
||||
@@ -186,17 +235,23 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
||||
}
|
||||
}, [editEntry]);
|
||||
|
||||
// Objekte, Besucher und Spende zurücksetzen beim Wechsel der Art der Führung
|
||||
// Beim Wechsel der Art der Führung nur das zurücksetzen, was zur neuen Art nicht
|
||||
// mehr passt. Der erste Lauf bleibt außen vor: dort steht noch der Startwert 'RF',
|
||||
// während die Vorbelegung aus editEntry gerade erst gesetzt wird.
|
||||
useEffect(() => {
|
||||
const vorherigeArt = prevArtRef.current;
|
||||
prevArtRef.current = artFuehrung;
|
||||
setObjekte([]);
|
||||
if (vorherigeArt === null) return;
|
||||
|
||||
// Objekte nur leeren, wenn die Kategorie wechselt (Sonne ↔ Sterne) — sonst gingen
|
||||
// bereits eingetragene Objekte verloren, wenn die Art erst danach gewählt wird.
|
||||
if ((vorherigeArt === SONNE_ART) !== (artFuehrung === SONNE_ART)) {
|
||||
setObjekte([]);
|
||||
}
|
||||
if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
|
||||
setBesucher('');
|
||||
}
|
||||
// Nicht beim ersten Lauf leeren — sonst überschreibt der Start-Wert 'RF'
|
||||
// die aus editEntry vorbelegte Spende.
|
||||
if (vorherigeArt !== null && artFuehrung !== 'SF') {
|
||||
if (artFuehrung !== 'SF') {
|
||||
setSpende('');
|
||||
setSpendeBetragRaw('');
|
||||
}
|
||||
@@ -315,8 +370,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 +383,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 +392,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 +411,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">
|
||||
|
||||
@@ -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",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user