diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..82dc86a --- /dev/null +++ b/CLAUDE.md @@ -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 ``. 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 `` + ``. 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. diff --git a/app/MainClient.tsx b/app/MainClient.tsx index c5eb63f..fef5bd4 100644 --- a/app/MainClient.tsx +++ b/app/MainClient.tsx @@ -48,14 +48,11 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) { {/* Header */}
-

- Logbuch — Sternwarte Welzheim +

+ Sternwarte-Welzheim   + Logbuch für {activeKuppel}-Kuppel

- - {kuerzel} — {beoName} - - {kuerzel}
- {/* Beginn / Ende / Besucher — eine Zeile */} -
-
+ {/* Beginn / Ende / Besucher */} +
+
- setBeginn(snapTo15(e.target.value))} - required - step={900} - 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" - /> +
+ { + 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 + 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" + /> + setBeginn(beginn.slice(0, 10) + 'T' + t)} + className="w-24" + /> +
-
+
- setEnde(snapTo15(e.target.value))} - required - step={900} - 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" - /> +
+ { + if (!e.target.value) return; + setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00')); + }} + required + 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" + /> + setEnde(ende.slice(0, 10) + 'T' + t)} + className="w-24" + /> +
{showBesucher && ( -
+
Beginn Ende Art - {!compact && Besucher} + Besucher BEOs Objekte {!compact && Bemerkungen} @@ -81,7 +81,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, co {formatDateTime(e.Beginn, compact)} {formatDateTime(e.Ende, compact)} {e.ArtFuehrung} - {!compact && {e.Besucher}} + {e.Besucher} {e.BEOs || '—'} {e.Objekte || '—'} {!compact && ( diff --git a/components/TimePicker5.tsx b/components/TimePicker5.tsx new file mode 100644 index 0000000..9c0d493 --- /dev/null +++ b/components/TimePicker5.tsx @@ -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 | null>(null); + const timeoutRef = useRef | 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 ( +
{ + 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}`} + > + {value} +
+ + +
+
+ ); +} diff --git a/create_table.sql b/create_table.sql index fd90719..6a4dbe4 100644 --- a/create_table.sql +++ b/create_table.sql @@ -11,7 +11,7 @@ CREATE TABLE objekte ( CREATE TABLE logbuch ( ID INT AUTO_INCREMENT PRIMARY KEY, 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, Ende DATETIME NOT NULL, Besucher INT DEFAULT 0, diff --git a/lib/session.ts b/lib/session.ts index 2b1c350..dd2908f 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -2,7 +2,7 @@ import { cookies } from 'next/headers'; import { SignJWT, jwtVerify } from 'jose'; 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 key = new TextEncoder().encode(secretKey); diff --git a/types/logbuch.ts b/types/logbuch.ts index dcb414e..02df80e 100644 --- a/types/logbuch.ts +++ b/types/logbuch.ts @@ -1,11 +1,12 @@ 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 ARTEN_MAP: Record = { RF: 'Reguläre Führung', SF: 'Sonderführung', + PrF: 'Private Führung', BEOS: 'BEO-Sitzung', SonF: 'Sonnenführung', TD: 'Technischer Dienst',