Various UX improvements and bug fixes

- Fix mustChangePassword session flag for users with pw=NULL
- Add PrF (Private Führung) as new ArtFuehrung type
- Split datetime-local into separate date + TimePicker5 (5-min steps, auto-repeat)
- Responsive Beginn/Ende layout: stacked on mobile, inline on desktop
- Sort BEOs alphabetically by Kürzel in selector
- Title shows active kuppel; hide user display in header
- Selected BEOs show Kürzel only (name stays in dropdown)
- Session timeout reduced to 1 hour
- Add CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 18:02:47 +02:00
parent 2469715756
commit a0fb6d8089
10 changed files with 189 additions and 44 deletions

40
CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # Development server
npm run build # Production build (run after every change to verify)
npm run lint # ESLint
```
No test suite exists. Deploy via `./deploy.sh [tag]` — builds multiplatform Docker image (amd64 + arm64) and pushes to `docker.citysensor.de`.
## Architecture
Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `logbuch123` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement.
**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **latin1** — avoid non-ASCII characters in SQL WHERE clauses; use `LIKE 'Ascii%'` prefix patterns instead.
**SQL in JS**: MySQL backticks inside JS template literals cause parse errors. Write complex queries using string concatenation (`+`), not template literals. `LIMIT` cannot be a parameterized placeholder in complex grouped queries — embed it directly after validating: `LIST_SQL + \` LIMIT ${limit}\``.
**API routes** (`app/api/`): all check `getSession()` and return 401 if unauthenticated. The logbuch list query uses `GROUP_CONCAT` to aggregate BEOs and Objekte into comma-separated strings per entry.
## Key components
- **`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.
- **`LogbuchList`**: accepts `compact` and `limit` props. Compact mode used for the 5-entry preview below the form on desktop (`hidden lg:block`).
## Data model
`ArtFuehrung` is stored as abbreviations in the DB (`RF`, `SF`, `PrF`, `BEOS`, `SonF`, `TD`, `Beob`, `ToT`, `Sonst`). Display names are in `ARTEN_MAP` in `types/logbuch.ts`. `BEOS` and `TD` hide the Besucher and Objekte fields. `SonF` pre-selects "Sonne" as the only object.
## Deployment
`output: 'standalone'` is set in `next.config.ts` for Docker. The MySQL container name in production is `db` — set `DB_HOST=db` in the server's environment.

View File

@@ -48,14 +48,11 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
{/* Header */} {/* Header */}
<div className="flex justify-between items-start sm:items-center mb-4 gap-2"> <div className="flex justify-between items-start sm:items-center mb-4 gap-2">
<h1 className="text-xl sm:text-3xl font-bold leading-tight"> <h1 className="text-xl sm:text-2xl font-bold leading-tight">
Logbuch<span className="hidden sm:inline"> Sternwarte Welzheim</span> <span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span>
Logbuch für {activeKuppel}-Kuppel
</h1> </h1>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-xs sm:text-sm text-gray-600 hidden sm:inline">
{kuerzel} {beoName}
</span>
<span className="text-xs text-gray-600 sm:hidden">{kuerzel}</span>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"

View File

@@ -21,15 +21,17 @@ export async function login(
return { error: 'Ungültiges Kürzel oder Passwort.' }; return { error: 'Ungültiges Kürzel oder Passwort.' };
} }
const mustChange = result.beo.MustChangePassword === 1 || !result.beo.pw;
await createSession({ await createSession({
kuerzel: result.beo.kürzel ?? kuerzel, kuerzel: result.beo.kürzel ?? kuerzel,
beoId: result.beo.id, beoId: result.beo.id,
beoName: getBeoDisplayName(result.beo), beoName: getBeoDisplayName(result.beo),
mustChangePassword: result.beo.MustChangePassword === 1, mustChangePassword: mustChange,
isAuthenticated: true, isAuthenticated: true,
}); });
if (result.beo.MustChangePassword === 1 || !result.beo.pw) { if (mustChange) {
redirect('/change-password'); redirect('/change-password');
} }

View File

@@ -20,7 +20,9 @@ export default function BeoSelector({ selected, onChange }: Props) {
}, []); }, []);
const selectedIds = new Set(selected.map((b) => b.ID)); const selectedIds = new Set(selected.map((b) => b.ID));
const available = all.filter((b) => !selectedIds.has(b.ID)); const available = all
.filter((b) => !selectedIds.has(b.ID))
.sort((a, b) => a.Kuerzel.localeCompare(b.Kuerzel));
function add(value: string) { function add(value: string) {
const beo = all.find((b) => b.ID === parseInt(value)); const beo = all.find((b) => b.ID === parseInt(value));
@@ -39,7 +41,7 @@ export default function BeoSelector({ selected, onChange }: Props) {
key={b.ID} key={b.ID}
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-1.5 rounded-full" className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-1.5 rounded-full"
> >
{b.Kuerzel} {b.Name} {b.Kuerzel}
<button <button
type="button" type="button"
onClick={() => remove(b.ID)} onClick={() => remove(b.ID)}

View File

@@ -6,6 +6,7 @@ import { ARTEN, ARTEN_MAP } from '@/types/logbuch';
import BeoSelector from './BeoSelector'; import BeoSelector from './BeoSelector';
import ObjektSelector from './ObjektSelector'; import ObjektSelector from './ObjektSelector';
import CustomSelect from './CustomSelect'; import CustomSelect from './CustomSelect';
import TimePicker5 from './TimePicker5';
interface Props { interface Props {
kuppel: Kuppel; kuppel: Kuppel;
@@ -19,24 +20,39 @@ function toLocalDatetimeValue(isoOrDatetime: string): string {
return isoOrDatetime.slice(0, 16); return isoOrDatetime.slice(0, 16);
} }
function snapTo15(value: string): string { function snapTo5(value: string): string {
if (!value) return value; if (!value) return value;
const d = new Date(value); // Fix 4-digit years that are actually < 100 (e.g. "0024" → "2024")
const fixed = value.replace(/^(\d{4})(-.+)$/, (_, y, rest) => {
const year = parseInt(y, 10);
return (year < 100 ? String(year + 2000) : y) + rest;
});
const d = new Date(fixed);
if (isNaN(d.getTime())) return value; if (isNaN(d.getTime())) return value;
const rem = d.getMinutes() % 15; d.setMinutes(Math.round(d.getMinutes() / 5) * 5);
if (rem !== 0) {
d.setMinutes(d.getMinutes() + (15 - rem));
d.setSeconds(0); d.setSeconds(0);
}
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
function snapTimeTo5(time: string): string {
if (!time) return time;
const [hStr, mStr] = time.split(':');
const h = parseInt(hStr, 10);
const m = parseInt(mStr, 10);
if (isNaN(h) || isNaN(m)) return time;
const snappedM = Math.round(m / 5) * 5;
const finalH = snappedM >= 60 ? (h + 1) % 24 : h;
const finalM = snappedM >= 60 ? 0 : snappedM;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(finalH)}:${pad(finalM)}`;
}
function nowLocalDatetime(): string { function nowLocalDatetime(): string {
const now = new Date(); const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
const raw = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; const raw = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
return snapTo15(raw); return snapTo5(raw);
} }
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD']; const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
@@ -188,32 +204,51 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
/> />
</div> </div>
{/* Beginn / Ende / Besucher — eine Zeile */} {/* Beginn / Ende / Besucher */}
<div className="flex flex-wrap gap-3 items-end"> <div className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="flex-1 min-w-0"> <div className="w-full sm:flex-1">
<label className={labelCls}>Beginn</label> <label className={labelCls}>Beginn</label>
<div className="flex gap-2">
<input <input
type="datetime-local" type="date"
value={beginn} value={beginn.slice(0, 10)}
onChange={(e) => setBeginn(snapTo15(e.target.value))} 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'));
}}
required required
step={900} className="flex-1 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
className="w-full px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" />
<TimePicker5
value={beginn.slice(11, 16)}
onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
className="w-24"
/> />
</div> </div>
<div className="flex-1 min-w-0"> </div>
<div className="w-full sm:flex-1">
<label className={labelCls}>Ende</label> <label className={labelCls}>Ende</label>
<div className="flex gap-2">
<input <input
type="datetime-local" type="date"
value={ende} value={ende.slice(0, 10)}
onChange={(e) => setEnde(snapTo15(e.target.value))} onChange={(e) => {
if (!e.target.value) return;
setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00'));
}}
required required
step={900} className="flex-1 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
className="w-full px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/> />
<TimePicker5
value={ende.slice(11, 16)}
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
className="w-24"
/>
</div>
</div> </div>
{showBesucher && ( {showBesucher && (
<div className="shrink-0"> <div className="sm:shrink-0">
<label className={labelCls}>Besucher</label> <label className={labelCls}>Besucher</label>
<input <input
type="number" type="number"

View File

@@ -68,7 +68,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, co
<th className={`${head} whitespace-nowrap`}>Beginn</th> <th className={`${head} whitespace-nowrap`}>Beginn</th>
<th className={`${head} whitespace-nowrap`}>Ende</th> <th className={`${head} whitespace-nowrap`}>Ende</th>
<th className={head}>Art</th> <th className={head}>Art</th>
{!compact && <th className={`${head} text-center`}>Besucher</th>} <th className={`${head} text-center`}>Besucher</th>
<th className={head}>BEOs</th> <th className={head}>BEOs</th>
<th className={head}>Objekte</th> <th className={head}>Objekte</th>
{!compact && <th className={head}>Bemerkungen</th>} {!compact && <th className={head}>Bemerkungen</th>}
@@ -81,7 +81,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, co
<td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Beginn, compact)}</td> <td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Beginn, compact)}</td>
<td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Ende, compact)}</td> <td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Ende, compact)}</td>
<td className={cell}>{e.ArtFuehrung}</td> <td className={cell}>{e.ArtFuehrung}</td>
{!compact && <td className={`${cell} text-center`}>{e.Besucher}</td>} <td className={`${cell} text-center`}>{e.Besucher}</td>
<td className={cell}>{e.BEOs || '—'}</td> <td className={cell}>{e.BEOs || '—'}</td>
<td className={cell}>{e.Objekte || '—'}</td> <td className={cell}>{e.Objekte || '—'}</td>
{!compact && ( {!compact && (

View File

@@ -0,0 +1,68 @@
'use client';
import { useRef } from 'react';
interface Props {
value: string; // "HH:MM"
onChange: (value: string) => void;
className?: string;
}
function addMinutes(time: string, delta: number): string {
const [h, m] = time.split(':').map(Number);
const total = ((h * 60 + m + delta) % (24 * 60) + 24 * 60) % (24 * 60);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
}
export default function TimePicker5({ value, onChange, className = '' }: Props) {
const valueRef = useRef(value);
valueRef.current = value;
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function startRepeat(delta: number) {
onChange(addMinutes(valueRef.current, delta));
timeoutRef.current = setTimeout(() => {
const hourDelta = delta > 0 ? 60 : -60;
intervalRef.current = setInterval(() => {
onChange(addMinutes(valueRef.current, hourDelta));
}, 350);
}, 400);
}
function stopRepeat() {
if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
if (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.current = null; }
}
function buttonProps(delta: number) {
return {
type: 'button' as const,
tabIndex: -1,
onMouseDown: () => startRepeat(delta),
onMouseUp: stopRepeat,
onMouseLeave: stopRepeat,
onTouchStart: (e: React.TouchEvent) => { e.preventDefault(); startRepeat(delta); },
onTouchEnd: stopRepeat,
};
}
return (
<div
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') { e.preventDefault(); onChange(addMinutes(value, 5)); }
if (e.key === 'ArrowDown') { e.preventDefault(); onChange(addMinutes(value, -5)); }
}}
className={`flex items-center border-2 border-gray-400 rounded-lg bg-white focus:border-blue-500 focus:outline-none select-none ${className}`}
>
<span className="flex-1 px-3 py-2 text-sm font-mono text-center">{value}</span>
<div className="flex flex-col border-l border-gray-300 shrink-0">
<button {...buttonProps(5)} className="px-2 pt-1 pb-0.5 hover:bg-gray-100 text-gray-500 text-xs leading-none"></button>
<button {...buttonProps(-5)} className="px-2 pt-0.5 pb-1 hover:bg-gray-100 text-gray-500 text-xs leading-none"></button>
</div>
</div>
);
}

View File

@@ -11,7 +11,7 @@ CREATE TABLE objekte (
CREATE TABLE logbuch ( CREATE TABLE logbuch (
ID INT AUTO_INCREMENT PRIMARY KEY, ID INT AUTO_INCREMENT PRIMARY KEY,
Kuppel ENUM('West','Ost','Süd','Pluto') NOT NULL DEFAULT 'West', Kuppel ENUM('West','Ost','Süd','Pluto') NOT NULL DEFAULT 'West',
ArtFuehrung ENUM('RF','SF','BEOS','SonF','TD','Beob','ToT','Sonst') NOT NULL DEFAULT 'RF', ArtFuehrung ENUM('RF','SF','PrF','BEOS','SonF','TD','Beob','ToT','Sonst') NOT NULL DEFAULT 'RF',
Beginn DATETIME NOT NULL, Beginn DATETIME NOT NULL,
Ende DATETIME NOT NULL, Ende DATETIME NOT NULL,
Besucher INT DEFAULT 0, Besucher INT DEFAULT 0,

View File

@@ -2,7 +2,7 @@ import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose'; import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session'; const SESSION_COOKIE_NAME = 'logbuch_session';
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; const SESSION_DURATION = 60 * 60 * 1000;
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production'; const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
const key = new TextEncoder().encode(secretKey); const key = new TextEncoder().encode(secretKey);

View File

@@ -1,11 +1,12 @@
export type Kuppel = 'West' | 'Ost' | 'Süd' | 'Pluto'; export type Kuppel = 'West' | 'Ost' | 'Süd' | 'Pluto';
export type ArtFuehrung = 'RF' | 'SF' | 'BEOS' | 'SonF' | 'TD' | 'Beob' | 'ToT' | 'Sonst'; export type ArtFuehrung = 'RF' | 'SF' | 'PrF' | 'BEOS' | 'SonF' | 'TD' | 'Beob' | 'ToT' | 'Sonst';
export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto']; export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto'];
export const ARTEN_MAP: Record<ArtFuehrung, string> = { export const ARTEN_MAP: Record<ArtFuehrung, string> = {
RF: 'Reguläre Führung', RF: 'Reguläre Führung',
SF: 'Sonderführung', SF: 'Sonderführung',
PrF: 'Private Führung',
BEOS: 'BEO-Sitzung', BEOS: 'BEO-Sitzung',
SonF: 'Sonnenführung', SonF: 'Sonnenführung',
TD: 'Technischer Dienst', TD: 'Technischer Dienst',