Compare commits

...
4 Commits
Author SHA1 Message Date
florian.wilde 10be022180 Merge docs/zeitaufwand: Zeitübersicht je Version 2026-07-20 12:52:16 +02:00
florian.wilde 1096993120 docs: ZEITAUFWAND.md - Zeitübersicht je Version
Vergleich der Umsetzungszeit mit Claude gegen eine grobe Schätzung
für einen erfahrenen Entwickler ohne KI-Unterstützung, je Version
seit v1.0.0. Wird bei jeder neuen Version ergänzt.
2026-07-20 12:52:01 +02:00
florian.wilde 8b3921c50e Merge fix/closed-day-icon-and-banner: Schloss-Symbol + durchgehendes Geschlossen-Banner 2026-07-20 12:49:46 +02:00
florian.wilde 63870ba2e6 fix: Schloss-Symbol statt Text + durchgehendes Geschlossen-Banner
Ersetzt den "Tag schließen"-Textlink durch ein Schloss-Symbol
(🔓/🔒) und lässt den "Geschlossen"-Hinweis eines geschlossenen
Tages über alle Essenszeiten-Zeilen durchlaufen (per rowSpan), statt
ihn in jeder Zeile einzeln zu wiederholen – sowohl im Wochenplan als
auch auf der öffentlichen Speisekarte.
2026-07-20 12:49:04 +02:00
7 changed files with 203 additions and 93 deletions
+11
View File
@@ -4,6 +4,17 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.12.1] – 2026-07-20
### Geändert
- Bedienung geschlossener Tage im Wochenplan überarbeitet: Statt eines
Text-Links am Datum markiert jetzt ein Schloss-Symbol (🔓 zum
Schließen, 🔒 zeigt „geschlossen" an und öffnet per Klick wieder) den
Tag. Ein geschlossener Tag zeigt sowohl im Wochenplan als auch auf
der öffentlichen Speisekarte (Desktop-Tabelle) einen einzigen, über
alle Essenszeiten-Zeilen durchgehenden „Geschlossen"-Hinweis statt
einer Wiederholung in jeder einzelnen Zeile.
## [1.12.0] – 2026-07-20
### Hinzugefügt
+54
View File
@@ -0,0 +1,54 @@
# Zeitaufwand
Übersicht, wie lange die Umsetzung der einzelnen Versionen mit Claude
gedauert hat, zum Vergleich mit der Schätzung für einen erfahrenen
Entwickler ohne KI-Unterstützung. Wird bei jeder neuen Version
ergänzt.
## Methodik
- **„Zeit bei Claude"** basiert auf den Git-Commit-Zeitstempeln
zwischen zwei Versions-Tags. Das ist kein reiner Rechenzeit-Wert,
sondern enthält auch die Zeit, die der Nutzer zum Lesen/Antworten
gebraucht hat – aber es ist der einzige objektiv messbare Anhaltspunkt.
- Große Lücken zwischen zwei Tags (mehrere Stunden bis über einen Tag)
markieren eindeutig eine neue Sitzung, kein durchgehendes Arbeiten –
diese sind als „neue Sitzung" markiert statt als Zeitangabe.
- **„Erfahrener Entwickler"** ist eine grobe Schätzung für jemanden,
der mit dem Stack (Next.js, PostgreSQL, TypeScript) vertraut, aber
neu in diesem Projekt ist, inkl. Testen/Verifikation, exkl. Code-Review
durch Dritte.
## Übersicht
| Version | Datum | Kern der Version | Zeit bei Claude | Erfahrener Entwickler |
|---|---|---|---|---|
| v1.0.0/v1.1.0 | 17.07. | Grundgerüst: öffentliche Speisekarte, Login (Passwort+Passkey), Kantinen-/Admin-Bereich, Supabase-Schema+RLS, Docker-Setup, lokale PG-Kopie | nicht messbar (erster Commit) | 3–5 Tage (24–40 h) |
| v1.2.0 | 18.07. | Heute-Button, mobile Tages-/Wochenansicht, Allergen-Chips, Passwort-Wiederholung, Passkey-Pflicht, SITE_TITLE/Favicon, UX-Politur | 56 min | 1–1,5 Tage (8–12 h) |
| v1.3.0 | 18.07. | Volle Mehrsprachigkeit DE/EN/PL, DeepL-Anbindung, Rezeptvorschläge (öffentlich + Sichtung), 2 Bugfixes | 1 h 23 min | 1,5–2 Tage (12–16 h) |
| v1.4.0 | 18.07. | Essenszeiten variabel (CRUD + Sortierung), 2 Bugfixes | *(neue Sitzung)* | 0,5–1 Tag (4–8 h) |
| v1.5.0 | 18.07. | **Supabase komplett entfernt**: eigene DB/Auth/Passkeys/Storage, 2 Sicherheitsfixes (Passkey-Pflicht serverseitig, SVG-XSS) | 3 h 26 min | 2–3 Tage (16–24 h) |
| v1.6.0 | 18.07. | RSS-Feed, Vorschläge-Badge, DEPLOYMENT.md, Supabase-Doku-Reste entfernt | 33 min | 0,5–1 Tag (4–8 h) |
| v1.6.1 | 18.07. | DB-Port abgesichert, Troubleshooting-Doku | 1 h 58 min | 1–2 h |
| v1.6.2 | 18.07. | Bootstrap-Admin-Fehlerbehandlung + Doku-Fix | 22 min | 1–2 h |
| v1.6.3 | 18.07. | Docker `BRANDING_DIR`-Bugfix | 20 min | 0,5–1 h |
| v1.7.0 | 19.07. | CSV-Import/-Export (3 Ressourcen), RSS-Fix | *(neue Sitzung)* | 0,5–1 Tag (4–8 h) |
| v1.8.0 | 19.07. | Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe | 45 min | 0,5–1 Tag (4–6 h) |
| v1.8.1 | 20.07. | `update.sh` Deployment-Skript | 10 min | 1–2 h |
| v1.9.0 | 20.07. | SSO-Login per OpenID Connect (PKCE, JWKS-Prüfung) | 26 min | 1–1,5 Tage (8–12 h) |
| v1.9.5 | 20.07. | Interner Bereich mobil nutzbar (Hamburger-Nav, Tab-Wochenansicht, 5 Komponenten) | 21 min | 1 Tag (6–10 h) |
| v1.10.0 | 20.07. | Dynamisches Sprachpaket-System (Teil 1 Mehrsprachig-Umbau) | *(neue Sitzung)* | 1–1,5 Tage (8–12 h) |
| v1.11.0 | 20.07. | Speiseplan-Inhalte variabel (Teil 2, inkl. Produktionsdaten-Migration) | 35 min | 1–1,5 Tage (8–12 h) |
| v1.11.1 | 20.07. | Sprach-Fallback-Bugfix (deaktiviertes Deutsch) | 12 min | 0,5–1,5 h |
| v1.12.0 | 20.07. | Tage als geschlossen markierbar (Feiertage o. Ä.), mehrsprachiger Grund, RSS-Integration | 1 h 12 min | 0,5–1 Tag (4–8 h) |
| v1.12.1 | 20.07. | Schloss-Symbol statt Textlink, durchgehendes Geschlossen-Banner über alle Essenszeiten | 14 min | 2–4 h |
| **Gesamt** | | | **~12,3 h messbare aktive Zeit** (+ 1 nicht messbarer Grundgerüst-Block) | **~114–187 h ≈ 15–24 Arbeitstage** (real eher 1–2 Monate Kalenderzeit) |
## Warum der Unterschied?
Der Zeitvorteil kommt vor allem daher, dass zwischen Planung,
Umsetzung und Test keine Wartezeit liegt (Code-Review, Freigaben,
andere Aufgaben dazwischen) – nicht daher, dass grundsätzlich
"schneller gedacht" wird. Ein Mensch bräuchte für dieselbe Sorgfalt
vermutlich nicht weniger Zeit pro Aufgabe, aber deutlich mehr
Kalenderzeit durch Kontextwechsel.
+4
View File
@@ -102,6 +102,10 @@ body {
font-size: 0.85rem;
}
.menu-table tr:nth-child(even) td.cell { background: var(--c-row-bg-alt); }
.menu-table td.cell.closed-cell {
vertical-align: middle;
background: #fee2e2;
}
.menu-table td.cell .dish { margin-bottom: 0.35rem; }
.menu-table td.cell .dish sup {
font-size: 0.6rem;
+27 -23
View File
@@ -160,42 +160,45 @@ export default async function Home({
<thead>
<tr>
<th className="section-head w-56" aria-hidden />
{days.map((d) => {
const closed = closedDayFor(d.iso);
return (
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
{closed && (
<div className="mt-1 text-xs font-normal">
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 font-semibold">
{dict.publicMenu.closedLabel}
</span>
{localized(closed, locale) && (
<div className="mt-0.5 opacity-90">
{localized(closed, locale)}
</div>
)}
</div>
)}
</th>
);
})}
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
{sections.map((s, sIdx) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
{days.map((d) => {
const closed = closedDayFor(d.iso);
if (closed) {
if (sIdx > 0) return null;
return (
<td key={d.iso} className="cell closed-cell" rowSpan={sections.length}>
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && (
<span className="text-sm px-2 text-center">
{localized(closed, locale)}
</span>
)}
</div>
</td>
);
}
return (
<td key={d.iso} className="cell">
{closedDayFor(d.iso)
? null
: entriesFor(entries, d.iso, s.key).map((e) => {
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
@@ -211,7 +214,8 @@ export default async function Home({
);
})}
</td>
))}
);
})}
</tr>
))}
</tbody>
+2 -1
View File
@@ -69,7 +69,8 @@ export default function PublicMobileMenu({
{day.weekday} · {formatDateLong(day.iso, dict.dateLocale)}
</h3>
{closed ? (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 text-center">
<span aria-hidden>🔒</span>{" "}
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && <span> – {localized(closed, locale)}</span>}
</div>
+60 -24
View File
@@ -208,35 +208,46 @@ export default function WeekEditor({
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: dishes;
// Kleine Anzeige/Umschaltung unter dem Tagesdatum: "Geschlossen" +
// Wieder-öffnen-Button, sonst ein dezenter "Tag schließen"-Link.
function renderClosureBadge(iso: string) {
const closed = closedDayFor(iso);
if (closed) {
// Kleines Schloss-Symbol neben dem Tagesdatum: 🔓 zum Schließen
// anklicken, 🔒 zeigt einen geschlossenen Tag und öffnet ihn per Klick
// wieder – der Zustand des Symbols selbst ist die Beschriftung.
function renderClosureToggle(iso: string) {
const closed = Boolean(closedDayFor(iso));
return (
<div className="mt-1 flex flex-wrap items-center justify-center gap-1 text-xs">
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 font-semibold">
{dict.weekEditor.closedLabel}
</span>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)] underline"
onClick={() => reopenDay(iso)}
className="mt-0.5 text-base leading-none align-middle opacity-70 hover:opacity-100 disabled:opacity-40"
onClick={() => (closed ? reopenDay(iso) : startClosing(iso))}
disabled={closeBusy}
title={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
aria-label={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
>
{dict.weekEditor.reopenDay}
{closed ? "🔒" : "🔓"}
</button>
</div>
);
}
// Banner, das bei einem geschlossenen Tag alle Essenszeiten-Zellen
// dieses Tages ersetzt (Desktop: eine über rowSpan verbundene Zelle,
// Mobil: anstelle der Essenszeiten-Karten) – "Bearbeiten" öffnet das
// Formular erneut, um nur den Grund anzupassen, ohne den Tag über das
// Symbol im Header erst wieder öffnen zu müssen.
function renderClosedBanner(iso: string, closed: ClosedDay) {
return (
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
<span className="font-semibold">{dict.weekEditor.closedLabel}</span>
{closed.reason && <span className="text-sm px-2 text-center">{closed.reason}</span>}
<button
type="button"
className="mt-1 text-xs text-gray-400 hover:text-[var(--c-primary)] underline"
className="mt-1 text-xs text-gray-500 hover:text-[var(--c-primary)] underline"
onClick={() => startClosing(iso)}
>
{dict.weekEditor.closeDay}
{dict.common.edit}
</button>
</div>
);
}
@@ -330,7 +341,6 @@ export default function WeekEditor({
// unabhängig davon, welche Ansicht gerade rendert.
function renderCell(iso: string, sectionKey: string) {
const isAdding = adding?.iso === iso && adding?.section === sectionKey;
const closed = Boolean(closedDayFor(iso));
return (
<>
{entriesFor(entries, iso, sectionKey).map((e) => {
@@ -353,7 +363,7 @@ export default function WeekEditor({
</div>
);
})}
{closed ? null : isAdding ? (
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
{selectedDishObj ? (
<div className="flex items-center gap-1 text-sm">
@@ -484,9 +494,19 @@ export default function WeekEditor({
))}
</div>
<h2 className="font-bold text-[var(--c-primary)] mb-1">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}{" "}
{renderClosureToggle(selectedDay.iso)}
</h2>
<div className="mb-2">{renderClosureBadge(selectedDay.iso)}</div>
{(() => {
const closed = closedDayFor(selectedDay.iso);
if (closed) {
return (
<div className="rounded-md border border-red-200 bg-red-50">
{renderClosedBanner(selectedDay.iso, closed)}
</div>
);
}
return (
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
@@ -500,6 +520,8 @@ export default function WeekEditor({
</div>
))}
</div>
);
})()}
</div>
{/* Ab "sm": klassische Wochentabelle */}
@@ -512,24 +534,38 @@ export default function WeekEditor({
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
{renderClosureBadge(d.iso)}
{formatDateLong(d.iso, dict.dateLocale)} {renderClosureToggle(d.iso)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
{sections.map((s, sIdx) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
{days.map((d) => {
const closed = closedDayFor(d.iso);
if (closed) {
if (sIdx > 0) return null;
return (
<td
key={d.iso}
className="cell closed-cell"
rowSpan={sections.length}
>
{renderClosedBanner(d.iso, closed)}
</td>
);
}
return (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
))}
);
})}
</tr>
))}
</tbody>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.12.0",
"version": "1.12.1",
"private": true,
"scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",