Compare commits

...

4 Commits

Author SHA1 Message Date
admin aed69a1d59 chore: fix/art-reset → main (Version 1.16.1) 2026-08-09 10:38:49 +02:00
admin 38e403bd39 fix: BEOs und Objekte bleiben beim Wechsel der Art erhalten — Version 1.16.1
Zwei getrennte Ursachen dafür, dass ausgefüllte Felder beim nachträglichen
Wählen der Art der Führung verschwanden:

- Objekte wurden bei jedem Wechsel der Art unbedingt geleert. Jetzt nur noch,
  wenn die Objektkategorie tatsächlich umschlägt (SonF ↔ Rest).
- currentUserBeo wurde in MainClient bei jedem Rendern neu erzeugt und steht in
  den Abhängigkeiten des Vorbelegungs-Effekts in LogbuchForm. Dadurch setzte
  jedes Neurendern der Elternkomponente das ganze Formular zurück — BEOs auf den
  eigenen Namen, dazu Objekte, Zeiten und Bemerkungen. Ein konkreter Auslöser
  ist die Backup-Schaltfläche auf demselben Bildschirm. Das Objekt ist jetzt
  useMemo-stabil.

Nebeneffekt: beim Bearbeiten lief das bisherige setObjekte([]) gegen das
asynchrone Nachladen der gespeicherten Objekte — dieser Wettlauf entfällt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:38:46 +02:00
admin 74885a987a chore: feat/zeiten → main (Version 1.16.0) 2026-08-09 10:14:44 +02:00
admin bb3f6f05a3 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>
2026-08-09 10:14:44 +02:00
6 changed files with 100 additions and 14 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. 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
View File
@@ -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);
+78 -9
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;
@@ -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">
+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.1",
"private": true,
"scripts": {
"dev": "next dev",