Compare commits
4
Commits
v1.12.0
...
10be022180
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10be022180 | ||
|
|
1096993120 | ||
|
|
8b3921c50e | ||
|
|
63870ba2e6 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user