Compare commits

...

4 Commits

Author SHA1 Message Date
bc41ef340d docs: Changelog für v1.121.0-v1.121.2 nachtragen
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run
v1.121.3

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 09:34:32 +02:00
85667b68c1 refactor: Spaltenaufteilung ueberlappender Termine aus WeeklyView herausloesen
Erster kleiner Schnitt aus Phase A des CodeScene-Auftrags: die Ueberlappungs-
Berechnung war ein rein funktionaler, aber verschachtelter Block mitten in der
Komponente. Sie liegt jetzt als layoutDaySegments/computeEventOverlapLayout in
src/lib/weekly/event-layout.ts und ist mit node:test abgedeckt (einzelner
Termin, Ueberschneidung, direkte Abfolge, drei gleichzeitige Termine,
Reihenfolgeunabhaengigkeit).

Kein sichtbares Verhalten geaendert; der Algorithmus ist unveraendert
uebernommen. WeeklyView.tsx: 9683 -> 9642 Zeilen.

v1.121.2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:30:29 +02:00
00010059a2 fix: Apple-Sync-Route absichern und Passwort-Logging entfernen
Zwei Befunde aus dem Sicherheitsdurchgang zum CodeScene-Auftrag:

- /api/calendar/apple/sync war ohne Anmeldung erreichbar und hat beliebige
  Apple-Zugangsdaten aus dem Request gegen Apple CalDAV geschickt. Damit liess
  sich die Instanz als Relay zum Durchprobieren fremder Zugangsdaten nutzen.
  Die Route verlangt jetzt dieselbe Session-Pruefung wie /connect.
- AuthForm.tsx hat E-Mail und Passwort im Klartext in die Browser-Konsole
  geschrieben. Die Debug-Ausgaben sind entfernt.

Keine Aenderung an Payloads oder Antwortformaten.

v1.121.1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:27:35 +02:00
d52e780967 feat: Termine ueber Mitternacht am Tageswechsel teilen
Ein Termin von 22:00 bis 02:00 wurde bisher als ein ueberlanger Block in den
Starttag gezeichnet und hat die Tagesspalte verlaengert. Jetzt wird er an der
Tagesgrenze geteilt: 22:00-24:00 am Starttag, 00:00-02:00 am Folgetag -- so,
wie es Kalender-Apps zeigen.

Die Regel liegt als reine Funktion splitEventByDay in src/lib/weekly/date-utils.ts
und ist mit node:test abgedeckt (Teilung, Mehrtagestermine, Ende exakt um
Mitternacht, Ganztagstermine, ungueltige Zeiten). Die Wochenansicht arbeitet in
Raster und Liste nur noch mit diesen Tagesabschnitten; Klick, Drag und Resize
greifen weiterhin auf den ungeteilten Termin zu, damit Payloads unveraendert
bleiben. Die Slot-Sperre (protectEventTimes) erfasst dadurch auch die
Nachtstunden des Folgetags.

v1.121.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:25:12 +02:00
12 changed files with 551 additions and 584 deletions

View File

@ -2,6 +2,38 @@
All notable changes to this project are documented in this file.
## [1.121.2] - 2026-09-12
### Changed
- Die Spaltenaufteilung sich überlappender Termine im Tagesraster ist aus
`WeeklyView.tsx` in `src/lib/weekly/event-layout.ts` ausgelagert
(`layoutDaySegments`/`computeEventOverlapLayout`), mit eigenen Tests
abgesichert. Erster Schnitt der CodeScene-getriebenen Sanierung; der
Algorithmus selbst ist unverändert, kein sichtbares Verhalten geändert.
## [1.121.1] - 2026-09-12
### Fixed
- **`/api/calendar/apple/sync` war ohne Anmeldung erreichbar** und hat beliebige
Apple-Zugangsdaten aus dem Request gegen Apple CalDAV geschickt. Die Instanz
liess sich damit als Relay zum Durchprobieren fremder Zugangsdaten
missbrauchen. Die Route verlangt jetzt dieselbe Session-Prüfung wie
`/api/calendar/apple/connect`.
- `AuthForm.tsx` schrieb E-Mail-Adresse und Passwort im Klartext in die
Browser-Konsole. Die Debug-Ausgaben sind entfernt.
## [1.121.0] - 2026-09-12
### Fixed
- **Termine über Mitternacht verlängerten den Starttag**, statt am Folgetag
weiterzulaufen. Ein Termin von 22:00 bis 02:00 erschien bisher als ein
überlanger Block im Starttag; jetzt wird er an der Tagesgrenze geteilt:
22:00–24:00 am Starttag, 00:00–02:00 am Folgetag — so, wie es Kalender-Apps
zeigen. Die Aufteilungsregel (`splitEventByDay` in
`src/lib/weekly/date-utils.ts`) ist mit Unit-Tests abgesichert. Klick, Drag
und Resize wirken weiterhin auf den ungeteilten Termin; Payloads und API
sind unverändert.
## [1.120.1] - 2026-09-07
### Fixed

431
TODO.md
View File

@ -1,431 +0,0 @@
# To-do — Repository-Audit vom 2026-09-06
Abarbeitungsliste aus dem Repository-Audit. Reihenfolge ist bewusst gewählt:
schnelle Sicherheitsgewinne zuerst, das große Refactoring zuletzt.
Abhaken mit `[x]`. Die Nummern in Klammern entsprechen den Punkten aus dem Audit.
---
## Block A — Schnell erledigt, sofortiger Gewinn
### A1. Tote Mock-Login-Route entfernen (Audit 3)
- [x] `src/app/api/auth/login/route.ts` löschen
- [x] Prüfen, dass niemand sie aufruft (`grep -rn "api/auth/login" src/`)
- [ ] Commit + Version bumpen
**Warum:** Altcode aus der Anfangszeit. Vergleicht gegen ein hartcodiertes Fake-Passwort
(`$2b$10$examplehashedpassword`), erzeugt ein bedeutungsloses `mock-jwt-token-…` und setzt es
als Cookie. Weil die Middleware `/api/auth/*` bewusst durchlässt, ist die Route **ohne Login
von außen erreichbar** und schreibt jede probierte E-Mail per `console.log` ins Log.
Kein Login-Bypass (der Vergleich schlägt immer fehl), aber unnötige Angriffsfläche.
### A2. `npm test` reparieren (Audit 6)
- [x] Entscheiden: Jest raus oder Jest einrichten
- [x] `package.json`: `"test"` auf das zeigen lassen, was wirklich existiert
- [x] `"test:e2e"` für Playwright ergänzen
- [x] Debug-Skripte in `tests/` aussortieren
- [x] **63 Unit-Tests** für die ausgelagerten reinen Funktionen (`tests/unit/`), laufen in
~170 ms ohne Browser und ohne Datenbank über Nodes eingebauten Test-Runner
- [ ] E2E-Tests mit echtem Login schreiben (braucht laufende App + Datenbank)
- [ ] Testlauf in den nächtlichen Qualitätslauf aufnehmen (`scripts/quality-check.sh`)
**Warum:** `"test": "jest"` — aber es gibt **keine Jest-Config und keinen einzigen Jest-Test**.
Die 15 Dateien in `tests/` sind Playwright-Specs, und die Namen verraten den Charakter:
`login-debug.spec.ts`, `css-debug-test.spec.ts`, `login-simple-debug.spec.ts`. Wegwerf-Skripte,
keine Regressionstests. `npm test` läuft ins Leere.
**Aufräumen:** Die reinen Debug-Skripte löschen, die brauchbaren behalten und benennen.
### A3. `dev`-Branch auflösen (Audit 5)
- [ ] Die vier Maßnahmen aus `f6c4a21` frisch auf `main` anwenden (siehe A1, B1, B2)
- [ ] `dev` auf `main` zurücksetzen **oder** löschen
- [ ] `CLAUDE.md`: Branch-Strategie an die gelebte Praxis anpassen
**Warum:** Commit `f6c4a21` vom 04.07.2026 behebt bereits Rate-Limiting, Token-Hashing,
die Mock-Route und die Secret-Datei — wurde aber nie gemerged. `dev` fehlen inzwischen
**70 Commits** von `main`, ein Merge würde alte Dateistände zurückholen. Deshalb:
Commit als *Vorlage* nehmen, Änderungen neu anwenden, `dev` danach entsorgen.
`CLAUDE.md` beschreibt einen `dev` → `main`-Workflow, der faktisch nicht gelebt wird
(alles läuft direkt auf `main`). Doku an die Realität anpassen ist ehrlicher.
---
## Block B — Sicherheit
### B1. Rate-Limiting auf den Auth-Endpunkten (Audit 4)
- [x] Sliding-Window-Limiter in `src/lib/rate-limit.ts` anlegen
- [x] Einbinden in `signup`
- [x] Einbinden in `resend-verification`
- [x] Einbinden in `reset-password`
- [x] Einbinden in `verify-email`
- [x] Limits dokumentieren (Vorschlag: 5 Versuche / 15 Minuten je IP)
**Warum:** Im gesamten `src/` ist **kein Limiter** vorhanden. Signup, Passwort-Reset und
Verification-Mails sind unbegrenzt aufrufbar — Einladung zum Missbrauch deines SMTP-Zugangs.
### B2. Passwort-Reset-Token nur als SHA-256-Hash speichern (Audit 4)
- [x] Beim Erzeugen: Klartext per Mail, **Hash** in die DB
- [x] Beim Einlösen: eingehenden Token hashen, dann vergleichen
- [ ] Migration für bestehende Tokens (einfachster Weg: alle invalidieren)
**Warum:** `src/app/api/auth/reset-password/route.ts:28` legt `passwordResetToken` im
**Klartext** ab. Wer Lesezugriff auf die Datenbank hat, kann fremde Konten übernehmen.
Der Token selbst kommt aus `crypto.randomUUID()` — das ist in Ordnung, nur die Ablage nicht.
### B3. Secrets aus dem Git entfernen und rotieren (Audit 2)
- [x] `.env.production.save` und `.env.local` gelöscht (mit `shred` überschrieben)
- [x] `.gitignore` auf `.env*` erweitert (Ausnahme `.env.example`)
- [x] `NEXTAUTH_SECRET` neu erzeugt — 44 Zeichen, geprüft
- [x] `SMTP_PASSWORD` neu gesetzt
- [x] `deployment-notes.md` und `INFRASTRUCTURE_REQUIREMENTS.md` aus dem Index entfernt
- [x] Klartext-Werte in beiden Dateien durch Platzhalter ersetzt
- [x] Passwort aus `tests/debug/db-connection-test.spec.ts` entfernt
- [x] Nach dem Wechsel neu eingeloggt
- [ ] Entscheiden: Historie umschreiben oder belassen (Empfehlung: belassen)
- [ ] Prüfen, ob eines der geleakten DB-Passwörter noch aktiv ist (siehe F1)
**Warum:** `.env.production.save` ist eingecheckt und enthält **echte Werte** —
`DATABASE_URL` (70 Zeichen), `NEXTAUTH_SECRET` (20 Zeichen), `SMTP_PASSWORD` (20 Zeichen).
`.gitignore` listet `.env.production`, die `.save`-Endung fällt durchs Raster.
Drin seit Commit `da8d019`.
**Entwarnung:** Das GitHub-Repo ist **privat** — nicht öffentlich abgreifbar. Die Werte
stehen aber in der kompletten Historie, in jedem Klon und auf Gitea.
Anleitung siehe [`docs/secret-rotation.md`](docs/secret-rotation.md).
---
## Block C — Build und Typsicherheit
### C1. Build darf Fehler nicht mehr verschlucken (Audit 1)
- [x] **Schritt 1:** `eslint.ignoreDuringBuilds` in `next.config.js` auf `false`
- [x] Gemeldete ESLint-Fehler abarbeiten — es waren nur **2** (`prefer-const` in
`SettingsSidebar.tsx`), verifiziert: `next lint` endet jetzt mit Exit 0
- [x] **Schritt 2:** `npm run type-check` — es waren **7 Fehler**, alle behoben
- [x] **Schritt 3:** `typescript.ignoreBuildErrors` auf `false` — Build prüft jetzt Typen
- [x] `tsconfig.json`: `include` von `**/*` auf `src/**/*` eingegrenzt. **Das war die
Ursache der Langsamkeit**, nicht die Dateigröße: Der Lauf dauert jetzt **19 Sekunden**
statt über 30 Minuten. Meine gegenteilige Vermutung war falsch.
- [x] **3 Dateien mit `@ts-nocheck`** entschärft — im Projekt gibt es jetzt **kein einziges
`@ts-nocheck` mehr**. `calendar-cache.ts` brauchte es gar nicht (0 Fehler), in
`microsoft-todo.ts` fehlten Typannotationen, und in `outlook-calendar.ts` steckte
ein möglicher Laufzeitfehler dahinter.
- [ ] `any`-Stellen reduzieren — ESLint zählt **470** `no-explicit-any` — fortlaufend, kein Blocker
- [ ] **209** ungenutzte Variablen (`no-unused-vars`) aufräumen — fortlaufend
- [ ] **11** unvollständige Hook-Abhängigkeiten (`react-hooks/exhaustive-deps`) prüfen —
die sind die gefährlichsten der Warnungen, weil daraus echte Bugs entstehen
**Warum:** `next.config.js` hat `ignoreDuringBuilds: true` **und** `ignoreBuildErrors: true`.
`tsconfig.json` sagt zwar `strict: true`, das ist aber wirkungslos, weil der Build die Fehler
gar nicht meldet. **Ein Typfehler fällt nicht beim Bauen auf, sondern erst als kaputte Seite
im Browser.** Ein erfolgreicher Build ist derzeit *kein* Beweis, dass der Code korrekt ist.
**Achtung Reihenfolge:** Erst ESLint, dann TypeScript. Beides gleichzeitig abzuschalten
blockiert jeden Deploy, bis alles behoben ist.
---
## Block D — Struktur
### D1. Dateien über 4000 Zeilen aufteilen (Audit 7)
Betroffen sind genau zwei Dateien:
| Datei | Zeilen |
|---|---:|
| `src/components/WeeklyView.tsx` | **9.711** *(war 11.906)* |
| `src/components/SettingsSidebar.tsx` | 5.445 |
**Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe
Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt.
#### WeeklyView.tsx → Zielstruktur
- [x] Typen nach `src/lib/weekly/types.ts` (7 Typen, 97 Zeilen) — Build verifiziert
- [x] `src/components/weekly/` angelegt
- [x] **Entdeckung:** In `WeeklyView.tsx` steckten **acht eigenständige Komponenten**, die
gar nichts mit der Wochenansicht zu tun haben — zusammen rund 1.870 Zeilen. Drei davon
sind heraus: `ProjectIcon`, `ProjectsSidebar`, `NotesSidebar`.
- [x] Restliche fünf herausgelöst: `TaskInput`, `SomedayAddTask`, `TaskItem`,
`ListVisualsPopover`, `TabVisualsPopover` — dazu die Prioritäts-Abzeichen, die
`GridTaskBlock` von `WeeklyView` importierte
- [ ] Someday-Listen aus dem Haupt-JSX herauslösen (jetzt echtes Props-Design nötig)
- [ ] All-Day-Leiste herauslösen
- [ ] Zeitraster und Tagesspalten herauslösen
- [ ] Einstellungs-Flyouts herauslösen
- [ ] Drag & Drop in einen Hook (~15 Abhängigkeiten — erst sinnvoll, wenn der State
geordneter ist; ein Hook mit 15 Parametern wäre keine Verbesserung)
- [x] Datums-/Zeitraster-Helfer nach `src/lib/weekly/date-utils.ts` (13 Funktionen, 202 Zeilen)
- [x] Cookie-/Geräteeinstellungen nach `src/lib/weekly/device-settings.ts`
- [x] Formatierungs-Helfer nach `src/lib/weekly/format.ts`
- [ ] `getWeekNumber` existiert **dreimal** im Projekt (WeeklyView, `WeekPrintModal.tsx`,
`api/user/export-week-view/route.ts`) — Duplikate gegen `date-utils.ts` ersetzen,
vorher die drei Implementierungen auf Gleichheit prüfen
- [ ] `calendar-cache.ts` hat mit `getWeekStart` eine **vierte** eigene Wochenlogik —
ebenfalls gegen `date-utils.ts` ersetzen
- [ ] Daten-Laden in `useWeeklyData.ts`
- [ ] `WeeklyView.tsx` bleibt als Orchestrator übrig
#### SettingsSidebar.tsx → Zielstruktur
- [ ] `src/components/settings/` anlegen
- [ ] Ein Panel pro Themenbereich als eigene Datei
- [ ] Gemeinsame Bausteine (Farbwähler, Schalterzeile) in `settings/controls/`
**Vorgehen:** In kleinen Schritten, jeder Schritt ein eigener Commit, nach jedem Schritt
`npm run build`. Kein "großer Wurf" — die Datei ist zu zentral.
**Zwischenstand nach drei Schnitten:** 11.906 → 11.556 Zeilen, also **rund 3 %**. Damit sind
die reinen Funktionen ohne Komponenten-State weitgehend heraus — das war der leichte Teil.
Die verbleibenden ~11.500 Zeilen sind die Komponente selbst: JSX und Event-Handler, die auf
gemeinsamen State zugreifen. Jeder weitere Schnitt braucht echtes Props-Design statt
Verschieben. Realistisch pro Schnitt ein eigener Durchgang, und **erst mit Tests (A2) oder
zumindest laufendem Typecheck sinnvoll** — ein Fehler beim Herauslösen von Drag & Drop fällt
sonst wieder erst im Browser auf.
**Warum:** `WeeklyView.tsx` ist ein Viertel des gesamten Quellcodes. Der Weekday-Bug von
Anfang September saß genau dort und war nur deshalb schwer zu finden.
---
## Block E — Dauerhafte Absicherung
### E1. Regelmäßige automatische Prüfung (Audit 8)
- [x] SonarQube-Analyse gegen https://sq.martin-bierschenk.de einrichten
- [x] Regelmäßigen Lauf aufsetzen — nightly 03:00, `todo-sonar.timer` (systemd user unit)
- [x] `npm audit` in denselben Lauf aufnehmen (`scripts/quality-check.sh`)
- [ ] `todo-sonar.timer` auf `scripts/quality-check.sh` umstellen statt nur `todo-sonar-scan`
- [ ] Playwright-Tests in den nächtlichen Lauf aufnehmen (braucht laufende App — erst nach A2)
- [ ] Quality Gate im SonarQube definieren
- [ ] Ergebnis irgendwohin melden, wo es auffällt (bisher nur Konsolenausgabe)
- [ ] **Entscheiden:** `.github/workflows/sonarqube.yml` löschen oder behalten
**Stand:** Die Analyse läuft bereits — nächtlich um 03:00 auf dem Spark, per systemd-Timer,
Ergebnis auf https://sq.martin-bierschenk.de. Was fehlte, war `npm audit` (Sicherheitslücken
in Abhängigkeiten findet SonarQube nicht) und ein Bündel-Skript. Beides ist jetzt da.
**Zur Entscheidung:** GitHub Actions wurde am 02.09. bewusst verworfen — das Repo ist privat,
Actions-Minuten kosten, und der SonarQube-Server läuft ohnehin selbst gehostet. Trotzdem liegt
seit dem 03.09. ein Workflow im Repo, der bei jedem Push auf `main` anspringt. Er ruft
`npm run type-check` und `npm run lint` auf — beides schlägt derzeit fehl (siehe C1), der
Workflow wäre also dauerhaft rot. Sein Projekt-Key war zudem falsch und hätte in ein zweites,
leeres SonarQube-Projekt geschrieben; das ist korrigiert. Entweder löschen (passt zur
Entscheidung vom 02.09.) oder bewusst behalten und C1 vorziehen.
**Warum:** Bisher fallen Regressionen erst in Produktion auf. In Kombination mit C1
(Build meldet Fehler) und A2 (Tests laufen) entsteht daraus ein echtes Netz.
### E2. Abhängigkeiten auf Sicherheitslücken prüfen
- [ ] `npm audit` auf dem Container ausführen
- [ ] Kritische Funde beheben
**Warum:** Konnte im Audit **nicht** geprüft werden — `node_modules` ist auf dem
CIFS-Share nicht installierbar (keine Symlinks). Zu bekannten Lücken in den
Abhängigkeiten liegt daher **keine Aussage** vor. Muss nachgeholt werden.
---
## Block F — Zurückgestellt
### F1. Datenbank-Passwort rotieren *(bewusst nach hinten geschoben)*
- [ ] Prüfen, ob eines der geleakten Passwörter noch aktiv ist
- [ ] Falls ja: rotieren nach [`docs/secret-rotation.md`](docs/secret-rotation.md), Schritt 2
- [ ] Falls nein: nichts zu tun, Haken dran
**Warum zurückgestellt:** Von den drei Zugangsdaten ist dieses das am wenigsten dringende.
Die Datenbank hört auf einer **privaten LAN-Adresse**, nicht am Internet. Wer das Passwort
aus dem Git holen will, braucht Zugriff auf das **private** Repository — und ist damit
ohnehin schon im Netz. `NEXTAUTH_SECRET` und `SMTP_PASSWORD` waren die kritischen, beide
sind erledigt.
**Und es ist riskant:** Supabase läuft selbst gehostet per Docker. Dasselbe Passwort steckt
in `supabase/docker/.env` **und** in Postgres. Ein halb durchgeführter Wechsel legt die
Datenbank und damit die App lahm. Das gehört an einen ruhigen Abend, nicht nebenbei.
**Zwei verschiedene Passwörter liegen im Git** (Fingerabdrücke, keine Klartexte):
| Fingerabdruck | Wo | Stand |
|---|---|---|
| `59fb583eaae9` | ehem. `deployment-notes.md`, `INFRASTRUCTURE_REQUIREMENTS.md` | altes Docker-Compose-Setup (`@db:5432`); Dateien bereinigt |
| `9dbb40b14c4d` | ehem. `.env.production.save` | Supabase-Zugang, **Status offen** — Datei gelöscht, Wert steht noch in der Historie |
Vom Nutzer bestätigt: das alte `NEXTAUTH_SECRET` (20 Zeichen) und das alte `SMTP_PASSWORD`
sind **nicht mehr in Verwendung**, beide wurden rotiert. Offen ist allein das
Datenbank-Passwort.
Prüfen lässt sich das auf dem Container, ohne einen Wert auszugeben:
```bash
cd /root/My-Weekly-ToDo-List
grep '^DATABASE_URL=' .env | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|' | tr -d '\n' | sha256sum | cut -c1-12
```
Ergibt der Befehl `9dbb40b14c4d`, ist das geleakte Passwort noch in Benutzung — dann lohnt
F1. Kommt etwas anderes heraus, wurde es längst getauscht und du kannst F1 abhaken.
---
## Block H — Aus dem scharfen Typecheck
### H1. Notion-Anbindung war defekt *(migriert, aber ungetestet)*
- [ ] Migration praktisch prüfen (braucht Notion-Zugang)
- [ ] Betroffene Nutzer informieren: Notion-Verbindung muss neu eingerichtet werden
**Warum:** Der Code rief `notion.databases.query()` auf. Die Methode gibt es in
`@notionhq/client` v5 nicht mehr (nur noch retrieve/create/update) — der Aufruf lief zur
Laufzeit in ein "is not a function". **Die Notion-Anbindung war damit schlicht kaputt**, und
das ist niemandem aufgefallen, weil der Build Typfehler verschluckte.
Umgestellt auf `dataSources.query()`; die Suche filtert jetzt auf `data_source` statt
`database`. Das ist in sich konsistent, aber **mangels Notion-Zugang nicht erprobt**.
⚠️ **Gespeicherte Verbindungen enthalten alte `database_id`s. Das sind keine
`data_source_id`s** — betroffene Nutzer müssen Notion einmal neu verbinden.
---
## Block K — Deploy-Sicherheit ✅
### K1. Build zerstört nicht mehr die laufende App ✅ *(v1.120.0)*
- [x] Build läuft nach `.next-build`, das laufende `.next` bleibt unberührt
- [x] Smoke-Test vor dem Umschalten — der neue Build muss einmal geantwortet haben
- [x] Umschalten erst danach, alter Stand bleibt als `.next-previous` liegen
- [x] Beide Fälle getestet: Fehlschlag lässt `.next` unangetastet, Erfolg schaltet sauber um
- [x] Build-Erkennung an den gebauten Commit gekoppelt (`.next/BUILD_COMMIT`) statt an
`HEAD` vs. `origin/main` — sonst überspringt das Skript den Build, wenn jemand vorher
von Hand `git pull` ausführt
- [x] Smoke-Test-Server wird zuverlässig beendet (`exec`, sonst überlebt `node` als Waise
und blockiert Port 3999 für alle weiteren Deploys)
**Warum:** `next build` lief direkt auf `.next` — also auf dem Verzeichnis, aus dem die
laufende App bedient wird. Scheiterte der Build, war nicht nur das Update weg, sondern die
laufende Instanz gleich mit: PM2 fand `server.js` nicht mehr
(`MODULE_NOT_FOUND`) und geriet in eine Neustartschleife mit über 790 Versuchen.
Am 07.09. war die Seite dadurch rund eine Stunde unbrauchbar.
**Zurückrollen** (falls ein Deploy doch mal Mist ausliefert):
```bash
pm2 stop my-weekly-todo
cd /root/My-Weekly-ToDo-List
rm -rf .next && mv .next-previous .next
pm2 restart my-weekly-todo
```
---
## Block J — Build-Speicher auf dem Container
### J1. Container-RAM erhöhen *(empfohlen)*
- [ ] LXC 202 von 3 GB auf 6 GB anheben (Proxmox → Container → Resources → Memory)
**Warum:** `next build` braucht **rund 2,6 GB Spitzenspeicher** (gemessen). Bei 3 GB
Container-RAM bleibt fast nichts übrig — der laufende PM2-Prozess und das System teilen
sich den Rest. Der Deploy vom 07.09. starb deshalb mit
`FATAL ERROR: Ineffective mark-compacts near heap limit`.
`scripts/update-app.sh` setzt jetzt `NODE_OPTIONS="--max-old-space-size=2048"`, weil Nodes
Standard-Heap auf diesem Container bei nur rund 1,5 GB liegt. Das ist die Sofortmaßnahme,
aber bei 2,6 GB Bedarf bleibt es knapp. **Mehr RAM ist die eigentliche Lösung** und in
Proxmox ein Klick.
**Nicht die Ursache — geprüft:** ESLint im Build. Gemessener Spitzenspeicher mit ESLint
2.590 MB, ohne 2.595 MB. Kein Unterschied.
### J2. Alternative: woanders bauen
- [ ] Erwägen, den Build auf dem Spark laufen zu lassen und nur `.next/standalone` zu
übertragen
**Warum:** Dann braucht der Produktionscontainer überhaupt keinen Build-Speicher mehr —
er würde nur noch fertige Dateien erhalten und PM2 neu starten. Grösserer Umbau des
Deploy-Wegs, aber die sauberste Trennung.
---
## Block I — UI-Wünsche
### I1. Aktuellen Tag auch im Spaltenkopf hervorheben ✅ *(vom Nutzer bestätigt)*
- [x] Hervorhebungsfarbe zusätzlich hinter Wochentagsname und Datum gelegt
**Wunsch:** Der laufende Tag soll deutlicher auffallen. Aktuell ist nur der Spaltenkörper
eingefärbt, der Kopfbereich mit "SEP 7 / MONDAY" bleibt weiß.
**Wo:** `globals.css:666` färbt `.weekly-day-column.is-today` mit
`var(--weekly-today-highlight)`. Der Kopf ist ein eigenes `<header class="weekly-day-header">`
([WeeklyView.tsx:7089](src/components/WeeklyView.tsx#L7089)) und übermalt die Farbe
offenbar mit eigenem Hintergrund. Der Wochentagsname hat bereits eine eigene Regel
(`globals.css:708`, `.weekly-day-name.is-today` setzt nur die Schriftfarbe).
**Zu klären:** Der Kopf ist vermutlich `sticky` und braucht deshalb einen deckenden
Hintergrund — die Farbe muss also gesetzt, nicht nur geerbt werden.
### I2. Symbole in Kalenderterminen nach oben rechts ✅ *(vom Nutzer bestätigt, Freiraum reicht)*
- [x] `.event-icons-row` nach oben rechts verschoben, Titel bekommt rechts Freiraum
**Wunsch:** Die kleinen Symbole (Wiederholung, Anbieter) sitzen unten rechts im Termin und
sollen nach oben rechts.
**Wo:** `globals.css:3183` — `.event-icons-row` hat `bottom: 2px; right: 4px;`. Für oben
rechts wird daraus `top: 2px; right: 4px;`.
**Achtung:** Der Titel beginnt oben links; bei kurzen Terminen könnten sich Symbole und
Titel überlappen. Vermutlich braucht die Titelzeile rechts etwas Freiraum
(`padding-right`), damit nichts verdeckt wird.
---
## Block G — Infrastruktur (Supabase-LXC 201)
### G1. Container läuft privilegiert *(Sicherheitsthema)*
- [ ] Entscheiden, ob `unprivileged: 0` so bleiben soll
- [ ] Falls nicht: Container neu aufsetzen und Daten übernehmen
**Warum:** `pct config 201` meldet `unprivileged: 0`. Wer aus diesem Container ausbricht, ist
**root auf dem NUC** — bei einem Container, der die Datenbank betreibt, die relevantere
Baustelle als ein fehlendes Root-Passwort. Meist wird das umgestellt, weil Docker im
unprivilegierten Container zickt; nachvollziehbar, aber ein bewusster Kompromiss.
### G2. Besitzverhältnisse im Dateisystem sind schief
- [ ] Kurzfix, falls SSH-Zugang zum Container gebraucht wird:
`pct exec 201 -- chown 0:0 /etc/passwd` und `pct exec 201 -- chown 0:42 /etc/shadow`
- [ ] Langfristig: Container sauber neu aufsetzen
**Warum:** Das Dateisystem stammt aus einem **unprivilegierten** Container (alles liegt mit
UID 100000 auf der Platte), läuft aber jetzt **privilegiert**, also ohne UID-Mapping. root
besitzt dadurch praktisch keine Datei im System. Zeitstempel belegen es: alles vom 26.02.2026
mit UID 100000, nur `/etc/hostname` vom 06.09. mit `0:0` — das schreibt Proxmox von außen.
Symptom war `passwd: Authentication token manipulation error`. Das System läuft trotzdem,
weil root per `CAP_DAC_OVERRIDE` die Rechteprüfung überspringt; Werkzeuge wie `passwd`, die
strenger prüfen, fallen darüber. Weitere solche Merkwürdigkeiten sind zu erwarten.
⚠️ **Kein `chown -R` über das ganze Dateisystem** — nicht am laufenden Container und schon
gar nicht mit Docker darin. Postgres und die Supabase-Container haben eigene UIDs (999, 70, …),
die dabei zerschossen würden.
**Kein Blocker:** `pct enter 201` gibt jederzeit eine Root-Shell ohne Passwort. Ein
Root-Passwort wird nur für direkten SSH-Zugang gebraucht.
### G3. Supabase Studio ist nicht erreichbar
- [ ] Auf 201 prüfen: `docker compose ps` — laufen die Container?
- [ ] Port klären: Studio hängt hinter Kong auf **8000**, nicht 8001
- [ ] `scripts/supabase-setup.sh` korrigieren: falsche Port-Ausgabe und Shebang `#!/bash`
**Warum:** `http://<IP>:8001` antwortet nicht. Die Zahl stammt aus einer `echo`-Zeile in
`scripts/supabase-setup.sh`, die nirgends eine Portfreigabe setzt — beim offiziellen
`supabase/docker`-Stack ist Studio nur über Kong auf 8000 erreichbar. Zusätzlich hat das
Skript `#!/bash` statt `#!/bin/bash` und ist so gar nicht direkt ausführbar.
---
## Kleinkram (ohne Eile)
- [ ] 114 `console.log` aus dem Produktionscode entfernen (die 275 `console.error` sind in Ordnung)
- [ ] `NEXTAUTH_SECRET` auf 32 Byte verlängern (erledigt sich mit B3)
- [ ] API-Routen: JSON-401 statt HTML-Redirect für nicht angemeldete Aufrufe
- [ ] `deploy.sh` und `docker-compose.yml` löschen oder als veraltet markieren — sie
beschreiben ein Portainer-Setup, das nicht mehr verwendet wird
- [ ] Eigener Auth-Check in `apple/sync` und `weather/geocode` (aktuell nur durch die
Middleware geschützt — zusätzliche Absicherung schadet nicht)

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.120.1",
"version": "1.121.3",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -1,8 +1,18 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { getUpcomingEvents } from '@/lib/apple-calendar';
import { authOptions } from "@/lib/auth";
export async function POST(req: Request) {
try {
// Ohne Anmeldung war diese Route ein offener Relay: beliebige Apple-Zugangsdaten
// liessen sich hier gegen Apple durchprobieren. Gleiche Pruefung wie in
// /api/calendar/apple/connect.
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { email, password, calendarId, timeMin, timeMax } = body;

View File

@ -3111,6 +3111,18 @@ h3 {
box-sizing: border-box;
}
/* Termine ueber Mitternacht: die Schnittkante bleibt offen, damit sichtbar ist,
dass der Termin am Nachbartag weiterlaeuft. */
.time-slot-event.event-continues {
border-bottom-right-radius: 0;
border-bottom: 2px dashed currentColor;
}
.time-slot-event.event-continued {
border-top-right-radius: 0;
border-top: 2px dashed currentColor;
}
.time-slot-event:has(.event-icons-row) .event-title-row {
padding-right: 34px;
}

View File

@ -14,13 +14,9 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('📝 AuthForm handleSubmit called with:', { email, password });
console.log('📝 onSubmit function is:', typeof onSubmit);
// Kein Logging der Formulardaten: hier stand das Passwort im Klartext in der Browser-Konsole.
if (typeof onSubmit === 'function') {
console.log('🚀 Calling onSubmit function...');
onSubmit({ email, password });
} else {
console.error('❌ onSubmit is not a function!', onSubmit);
}
};

View File

@ -108,6 +108,7 @@ import {
stripHtml, DEFAULT_SOMEDAY_SLOT_COUNT, getSomedaySlotCount, invertColor,
} from "@/lib/weekly/format";
import { isAllDayEvent } from "@/lib/weekly/date-utils";
import { computeEventOverlapLayout } from "@/lib/weekly/event-layout";
// Types
import UserMenu from "./UserMenu";
@ -127,7 +128,7 @@ import { getPriorityBadge } from "./weekly/priority-badge";
export { getPriorityBadge };
import type {
RecurringTaskException, ViewStyle, KanbanStage, Task,
CalendarEvent, SomedayList, CellDuration,
CalendarEvent, CalendarEventSegment, SomedayList, CellDuration,
} from "@/lib/weekly/types";
// Re-Export: SettingsSidebar und GridTaskBlock importieren diese Typen historisch von hier
export type { RecurringTaskException, ViewStyle, KanbanStage, Task, CalendarEvent, SomedayList, CellDuration };
@ -179,6 +180,7 @@ import {
getStartOfWeek, formatDateHeader, getDayName, isSameDay, formatDateToISO,
parseCalendarDate, formatHour, getTimeSlots, getHourFromSlot, getWeekNumber,
getCWReferenceDate, formatCustomHeader, getSelectedDay,
groupEventSegmentsByDay, localDayKey, segmentStartMinutes, segmentEndMinutes,
} from "@/lib/weekly/date-utils";
@ -2618,123 +2620,47 @@ export default function WeeklyView() {
[tasks, effectiveCellDuration, effectiveShowCompletedTasks, activeProjectFilter],
);
// Get calendar events for a specific date
// Termine ueber Mitternacht werden je Kalendertag in Abschnitte geteilt:
// 22:00-24:00 am Starttag, 00:00-02:00 am Folgetag (siehe splitEventByDay).
// Waehrend eines Drags zaehlen die Zeiten aus dem Drag-Zustand, damit sich
// die Abschnitte live mitbewegen.
const eventSegmentsByDay = useMemo(() => {
const dragged = eventDragState?.hasMoved ? eventDragState : null;
const events = dragged
? calendarEvents.map((event) =>
event.id === dragged.eventId
? { ...event, startTime: dragged.currentStartTime, endTime: dragged.currentEndTime }
: event,
)
: calendarEvents;
return groupEventSegmentsByDay(events);
}, [calendarEvents, eventDragState]);
// Get calendar event segments for a specific date (all-day events handled separately)
const getEventsForDate = useCallback(
(date: Date): CalendarEvent[] => {
return calendarEvents.filter((event) => {
// Skip all-day events (handled separately)
if (isAllDayEvent(event)) return false;
const eventDate = new Date(event.startTime);
return isSameDay(eventDate, date);
});
},
[calendarEvents],
(date: Date): CalendarEventSegment[] => eventSegmentsByDay.get(localDayKey(date)) ?? [],
[eventSegmentsByDay],
);
// Get calendar events for a specific time slot
// Get calendar event segments starting within a specific time slot
const getEventsForSlot = useCallback(
(date: Date, slot: string): CalendarEvent[] => {
return calendarEvents.filter((event) => {
// Skip all-day events (handled separately)
const isAllDay = isAllDayEvent(event);
if (isAllDay) return false;
// During drag, use the drag state's current start time for slot assignment
const isDraggedEvent = eventDragState?.eventId === event.id && eventDragState?.hasMoved && eventDragState?.mode === 'move';
const startTime = isDraggedEvent ? eventDragState!.currentStartTime : event.startTime;
const eventDate = new Date(startTime);
if (!isSameDay(eventDate, date)) return false;
// Extract hour:minute from event start time and compare with slot
const eventHour = eventDate.getHours();
const eventMinute = eventDate.getMinutes();
// Match if event starts within this slot
const [slotHour, slotMinute] = slot.split(":").map(Number);
const slotStart = slotHour * 60 + slotMinute;
const slotEnd = slotStart + effectiveCellDuration;
const eventStart = eventHour * 60 + eventMinute;
return eventStart >= slotStart && eventStart < slotEnd;
(date: Date, slot: string): CalendarEventSegment[] => {
const [slotHour, slotMinute] = slot.split(":").map(Number);
const slotStart = slotHour * 60 + slotMinute;
const slotEnd = slotStart + effectiveCellDuration;
return getEventsForDate(date).filter((segment) => {
const start = segmentStartMinutes(segment);
return start >= slotStart && start < slotEnd;
});
},
[calendarEvents, effectiveCellDuration, eventDragState],
[getEventsForDate, effectiveCellDuration],
);
// Precompute overlap layout: { [eventId]: { column, totalColumns } }
const eventOverlapLayout = useMemo(() => {
const layout: Record<string, { column: number; totalColumns: number }> = {};
// Group events by day
const dayMap = new Map<string, CalendarEvent[]>();
for (const event of calendarEvents) {
if (isAllDayEvent(event)) continue;
const d = new Date(event.startTime);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
if (!dayMap.has(key)) dayMap.set(key, []);
dayMap.get(key)!.push(event);
}
for (const events of dayMap.values()) {
// Sort by start time, then by duration (longer first)
events.sort((a, b) => {
const diff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
if (diff !== 0) return diff;
return (new Date(b.endTime).getTime() - new Date(b.startTime).getTime()) -
(new Date(a.endTime).getTime() - new Date(a.startTime).getTime());
});
// Build overlap groups using a greedy column assignment
const columns: { end: number; eventId: string }[][] = [];
for (const event of events) {
const start = new Date(event.startTime).getTime();
const end = new Date(event.endTime).getTime();
// Find first column where this event doesn't overlap
let placed = false;
for (let col = 0; col < columns.length; col++) {
const lastInCol = columns[col][columns[col].length - 1];
if (lastInCol.end <= start) {
columns[col].push({ end, eventId: event.id });
placed = true;
break;
}
}
if (!placed) {
columns.push([{ end, eventId: event.id }]);
}
}
// Now find the max columns each event actually shares with
// For each event, find all events that overlap it and determine the group width
for (const event of events) {
const start = new Date(event.startTime).getTime();
const end = new Date(event.endTime).getTime();
// Count how many columns have events overlapping this time range
let overlappingCols = 0;
for (const col of columns) {
for (const item of col) {
const itemStart = calendarEvents.find(e => e.id === item.eventId);
if (itemStart) {
const iStart = new Date(itemStart.startTime).getTime();
const iEnd = new Date(itemStart.endTime).getTime();
if (iStart < end && iEnd > start) {
overlappingCols++;
break;
}
}
}
}
// Find which column this event is in
let eventCol = 0;
for (let col = 0; col < columns.length; col++) {
if (columns[col].some(item => item.eventId === event.id)) {
eventCol = col;
break;
}
}
layout[event.id] = { column: eventCol, totalColumns: Math.max(overlappingCols, 1) };
}
}
return layout;
}, [calendarEvents]);
// Spaltenaufteilung ueberlappender Termine je Tag (siehe lib/weekly/event-layout).
const eventOverlapLayout = useMemo(
() => computeEventOverlapLayout(eventSegmentsByDay),
[eventSegmentsByDay],
);
// Calculate event duration in pixels for proper height display
const getEventDuration = (event: CalendarEvent): number => {
@ -3368,25 +3294,19 @@ export default function WeeklyView() {
const [slotHour, slotMinute] = slot.split(":").map(Number);
const slotStart = slotHour * 60 + slotMinute;
return calendarEvents.some((event) => {
if (isAllDayEvent(event)) return false;
return getEventsForDate(date).some((segment) => {
// Skip events that have been unlocked by the user
if (unlockedEvents.has(event.id)) return false;
if (unlockedEvents.has(segment.id)) return false;
const eventDate = new Date(event.startTime);
if (!isSameDay(eventDate, date)) return false;
const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes();
const eventEndDate = new Date(event.endTime);
const eventEndMinutes =
eventEndDate.getHours() * 60 + eventEndDate.getMinutes();
// Only protect if the slot start time falls within the event's actual duration
// This ensures protection matches exactly what the event covers
return slotStart >= eventStart && slotStart < eventEndMinutes;
// Only protect if the slot start time falls within the segment's
// duration on this day; a segment ending at midnight covers up to 24:00.
return (
slotStart >= segmentStartMinutes(segment) &&
slotStart < segmentEndMinutes(segment)
);
});
},
[protectEventTimes, calendarEvents, unlockedEvents],
[protectEventTimes, getEventsForDate, unlockedEvents],
);
// Check if a slot is occupied by any task (to prevent stacking)
@ -7208,7 +7128,6 @@ export default function WeeklyView() {
{/* Protection overlays - render at exact event positions */}
{protectEventTimes &&
getEventsForDate(date)
.filter((e) => !isAllDayEvent(e))
.map((event) => {
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
@ -7243,7 +7162,7 @@ export default function WeeklyView() {
return (
<div
key={`protection-${event.id}`}
key={`protection-${event.segmentKey}`}
className="event-protection-overlay"
style={{
position: "absolute",
@ -7450,15 +7369,27 @@ export default function WeeklyView() {
)}
{/* Calendar Events in time slot */}
{slotEvents.map((event) => {
// During drag, use the drag state's current times for position/height
// event ist ein Tagesabschnitt: bei Terminen ueber Mitternacht sind
// startTime/endTime auf diesen Tag beschnitten. Die Drag-Zeiten sind
// bereits in eventSegmentsByDay eingerechnet.
const isDragging = eventDragState?.eventId === event.id && eventDragState?.hasMoved;
const displayStartTime = isDragging ? new Date(eventDragState!.currentStartTime) : new Date(event.startTime);
const displayEndTime = isDragging ? new Date(eventDragState!.currentEndTime) : new Date(event.endTime);
const displayStartTime = new Date(event.startTime);
const displayEndTime = new Date(event.endTime);
const durationMs = displayEndTime.getTime() - displayStartTime.getTime();
const durationMinutes = durationMs / (1000 * 60);
const pxPerMin = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
const eventHeight = Math.max(durationMinutes * pxPerMin, 15);
const timeStr = `${displayStartTime.getHours().toString().padStart(2, "0")}:${displayStartTime.getMinutes().toString().padStart(2, "0")} - ${displayEndTime.getHours().toString().padStart(2, "0")}:${displayEndTime.getMinutes().toString().padStart(2, "0")}`;
const pad = (value: number) => value.toString().padStart(2, "0");
const startLabel = event.continuesFromPreviousDay
? "00:00"
: `${pad(displayStartTime.getHours())}:${pad(displayStartTime.getMinutes())}`;
const endLabel = event.continuesOnNextDay
? "24:00"
: `${pad(displayEndTime.getHours())}:${pad(displayEndTime.getMinutes())}`;
const timeStr = `${startLabel} - ${endLabel}`;
const fullStart = new Date(event.sourceEvent.startTime);
const fullEnd = new Date(event.sourceEvent.endTime);
const fullTimeStr = `${pad(fullStart.getHours())}:${pad(fullStart.getMinutes())} - ${pad(fullEnd.getHours())}:${pad(fullEnd.getMinutes())}`;
// Calculate offset within the slot based on event start time
const [slotHour, slotMinute] = slot
@ -7484,15 +7415,15 @@ export default function WeeklyView() {
? `color-mix(in srgb, ${eventColor} 30%, #0f172a)`
: borderColor;
const overlap = eventOverlapLayout[event.id] || { column: 0, totalColumns: 1 };
const overlap = eventOverlapLayout[event.segmentKey] || { column: 0, totalColumns: 1 };
const colWidth = 100 / overlap.totalColumns;
const colLeft = overlap.column * colWidth;
return (
<div
key={event.id}
className={`time-slot-event${isDragging ? ' event-dragging' : ''}`}
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
key={event.segmentKey}
className={`time-slot-event${isDragging ? ' event-dragging' : ''}${event.continuesFromPreviousDay ? ' event-continued' : ''}${event.continuesOnNextDay ? ' event-continues' : ''}`}
title={`${event.calendarTitle}: ${event.title}\n${event.segmentCount > 1 ? fullTimeStr : timeStr}`}
style={{
height: `${eventHeight}px`,
minHeight: `15px`,
@ -7520,23 +7451,23 @@ export default function WeeklyView() {
if (event.editable) {
setCalendarEventModal({
isOpen: true,
event: event,
event: event.sourceEvent,
});
}
}}
onMouseDown={(e) => {
if (event.editable && e.button === 0) {
handleEventDragStart(e, event, 'move');
handleEventDragStart(e, event.sourceEvent, 'move');
}
}}
>
{/* Top resize handle */}
{event.editable && (
{/* Top resize handle - nur am tatsaechlichen Terminbeginn */}
{event.editable && !event.continuesFromPreviousDay && (
<div
className="event-resize-handle event-resize-handle-top"
onMouseDown={(e) => {
e.stopPropagation();
handleEventDragStart(e, event, 'resize-top');
handleEventDragStart(e, event.sourceEvent, 'resize-top');
}}
/>
)}
@ -7562,13 +7493,13 @@ export default function WeeklyView() {
style={{ fontSize: '0.6rem' }}
/>
</div>
{/* Bottom resize handle */}
{event.editable && (
{/* Bottom resize handle - nur am tatsaechlichen Terminende */}
{event.editable && !event.continuesOnNextDay && (
<div
className="event-resize-handle event-resize-handle-bottom"
onMouseDown={(e) => {
e.stopPropagation();
handleEventDragStart(e, event, 'resize-bottom');
handleEventDragStart(e, event.sourceEvent, 'resize-bottom');
}}
/>
)}
@ -7651,14 +7582,14 @@ export default function WeeklyView() {
return (
<div
key={event.id}
key={event.segmentKey}
className="weekly-calendar-event"
onClick={(e) => {
e.stopPropagation();
if (event.editable) {
setCalendarEventModal({
isOpen: true,
event: event,
event: event.sourceEvent,
});
}
}}

View File

@ -1,4 +1,4 @@
import type { CellDuration, CalendarEvent } from "@/lib/weekly/types";
import type { CellDuration, CalendarEvent, CalendarEventSegment } from "@/lib/weekly/types";
/**
* Datums-, Wochen- und Zeitraster-Helfer der Wochenansicht.
@ -220,3 +220,125 @@ export const isAllDayEvent = (event: CalendarEvent): boolean => {
// If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day
return durationHours >= 24 && (isLocalMidnight || isUTCMidnight);
};
/**
* Aufteilung von Terminen an der Tagesgrenze.
*
* Ein Termin von 22:00 bis 02:00 gehoert in der Wochenansicht nicht als ein
* ueberlanger Block in den Starttag, sondern als zwei Abschnitte: 22:00-24:00
* am Starttag und 00:00-02:00 am Folgetag. Die Regeln stehen hier als reine
* Funktionen, damit sie ohne Browser testbar bleiben.
*/
const MS_PER_MINUTE = 60 * 1000;
/** Lokale Mitternacht des Tages, in dem `date` liegt. */
function startOfLocalDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
/** Lokale Mitternacht des Folgetages; ueber Kalenderarithmetik, damit DST passt. */
function startOfNextLocalDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0);
}
/** Stabiler Tagesschluessel in lokaler Zeit (YYYY-MM-DD). */
export function localDayKey(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
function makeSegment(
event: CalendarEvent,
segmentStart: Date,
segmentEnd: Date,
index: number,
count: number,
): CalendarEventSegment {
return {
...event,
startTime: segmentStart.toISOString(),
endTime: segmentEnd.toISOString(),
segmentKey: count > 1 ? `${event.id}#${localDayKey(segmentStart)}` : event.id,
segmentIndex: index,
segmentCount: count,
continuesFromPreviousDay: index > 0,
continuesOnNextDay: index < count - 1,
sourceEvent: event,
};
}
/**
* Zerlegt einen Termin in Abschnitte je Kalendertag.
*
* Ganztagstermine, ungueltige Zeiten und Termine ohne positive Dauer ergeben
* genau einen Abschnitt mit den Originalzeiten. Ein Termin, der punktgenau um
* Mitternacht endet, erzeugt keinen leeren Abschnitt am Folgetag.
*/
export function splitEventByDay(event: CalendarEvent): CalendarEventSegment[] {
const start = parseCalendarDate(event.startTime);
const end = event.endTime ? parseCalendarDate(event.endTime) : start;
const unsplittable =
isAllDayEvent(event) ||
Number.isNaN(start.getTime()) ||
Number.isNaN(end.getTime()) ||
end.getTime() <= start.getTime();
if (unsplittable) {
return [makeSegment(event, start, end, 0, 1)];
}
// Grenzen einsammeln: Start, jede dazwischenliegende Mitternacht, Ende.
const boundaries: Date[] = [start];
let cursor = startOfNextLocalDay(start);
while (cursor.getTime() < end.getTime()) {
boundaries.push(cursor);
cursor = startOfNextLocalDay(cursor);
}
boundaries.push(end);
const count = boundaries.length - 1;
const segments: CalendarEventSegment[] = [];
for (let i = 0; i < count; i++) {
segments.push(makeSegment(event, boundaries[i], boundaries[i + 1], i, count));
}
return segments;
}
/** Abschnitte aller Termine ohne Ganztagstermine, gruppiert nach lokalem Tag. */
export function groupEventSegmentsByDay(
events: CalendarEvent[],
): Map<string, CalendarEventSegment[]> {
const byDay = new Map<string, CalendarEventSegment[]>();
for (const event of events) {
if (isAllDayEvent(event)) continue;
for (const segment of splitEventByDay(event)) {
const key = localDayKey(new Date(segment.startTime));
const bucket = byDay.get(key);
if (bucket) {
bucket.push(segment);
} else {
byDay.set(key, [segment]);
}
}
}
return byDay;
}
/** Minuten seit lokaler Mitternacht des Abschnittstages. */
export function segmentStartMinutes(segment: CalendarEventSegment): number {
const start = new Date(segment.startTime);
return start.getHours() * 60 + start.getMinutes();
}
/**
* Ende des Abschnitts in Minuten ab Mitternacht des Abschnittstages.
* Ein bis Mitternacht laufender Abschnitt liefert 1440 statt 0.
*/
export function segmentEndMinutes(segment: CalendarEventSegment): number {
const start = new Date(segment.startTime);
const end = new Date(segment.endTime);
return Math.round((end.getTime() - startOfLocalDay(start).getTime()) / MS_PER_MINUTE);
}

View File

@ -0,0 +1,79 @@
import type { CalendarEventSegment } from "@/lib/weekly/types";
/**
* Spaltenaufteilung sich ueberlappender Termine im Tagesraster.
*
* Reine Funktionen ohne Komponenten-State und ohne Laufzeit-Importe, damit die
* Regeln mit node:test pruefbar bleiben — lagen bis v1.121.2 in WeeklyView.tsx.
*/
export interface EventColumnPlacement {
/** 0-basierte Spalte innerhalb der Ueberlappungsgruppe. */
column: number;
/** Breite der Gruppe in Spalten; mindestens 1. */
totalColumns: number;
}
const startMs = (segment: CalendarEventSegment): number =>
new Date(segment.startTime).getTime();
const endMs = (segment: CalendarEventSegment): number =>
new Date(segment.endTime).getTime();
/**
* Ordnet die Abschnitte eines Tages in Spalten an.
*
* Frueher Start zuerst, bei gleichem Start der laengere Termin. Jeder Abschnitt
* kommt in die erste Spalte, deren letzter Eintrag bereits beendet ist. Die
* Gruppenbreite ist die Anzahl der Spalten, die den Abschnitt zeitlich schneiden.
*/
export function layoutDaySegments(
daySegments: CalendarEventSegment[],
): Record<string, EventColumnPlacement> {
const layout: Record<string, EventColumnPlacement> = {};
const segments = [...daySegments].sort((a, b) => {
const byStart = startMs(a) - startMs(b);
if (byStart !== 0) return byStart;
return endMs(b) - startMs(b) - (endMs(a) - startMs(a));
});
const columns: CalendarEventSegment[][] = [];
for (const segment of segments) {
const free = columns.find((col) => endMs(col[col.length - 1]) <= startMs(segment));
if (free) {
free.push(segment);
} else {
columns.push([segment]);
}
}
for (const segment of segments) {
const start = startMs(segment);
const end = endMs(segment);
let overlappingCols = 0;
let segmentCol = 0;
columns.forEach((col, index) => {
if (col.some((other) => startMs(other) < end && endMs(other) > start)) {
overlappingCols++;
}
if (col.includes(segment)) segmentCol = index;
});
layout[segment.segmentKey] = {
column: segmentCol,
totalColumns: Math.max(overlappingCols, 1),
};
}
return layout;
}
/** Spaltenaufteilung fuer alle Tage, gebuendelt nach segmentKey. */
export function computeEventOverlapLayout(
segmentsByDay: Map<string, CalendarEventSegment[]>,
): Record<string, EventColumnPlacement> {
const layout: Record<string, EventColumnPlacement> = {};
for (const daySegments of segmentsByDay.values()) {
Object.assign(layout, layoutDaySegments(daySegments));
}
return layout;
}

View File

@ -95,3 +95,26 @@ export interface SomedayList {
// Time grid configuration options
export type CellDuration = 15 | 20 | 30 | 60;
/**
* Ein auf genau einen Kalendertag zugeschnittener Abschnitt eines Termins.
*
* Termine ueber Mitternacht werden in der Wochenansicht je Tag dargestellt:
* 22:00-24:00 am Starttag, 00:00-02:00 am Folgetag. startTime/endTime sind die
* beschnittenen Zeiten des Abschnitts; sourceEvent traegt den unveraenderten
* Termin fuer Klick, Drag und Speichern.
*/
export interface CalendarEventSegment extends CalendarEvent {
/** Eindeutig pro Abschnitt; als React-Key und fuer das Overlap-Layout. */
segmentKey: string;
/** 0-basierter Index dieses Abschnitts innerhalb des Termins. */
segmentIndex: number;
/** Anzahl der Abschnitte des Termins insgesamt. */
segmentCount: number;
/** Termin hat bereits an einem frueheren Tag begonnen. */
continuesFromPreviousDay: boolean;
/** Termin laeuft am Folgetag weiter. */
continuesOnNextDay: boolean;
/** Der ungeteilte Termin mit den Originalzeiten. */
sourceEvent: CalendarEvent;
}

View File

@ -3,7 +3,8 @@ import assert from "node:assert/strict";
import {
getStartOfWeek, getDayName, isSameDay, formatDateToISO, parseCalendarDate,
formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, getCWReferenceDate,
getSelectedDay,
getSelectedDay, splitEventByDay, groupEventSegmentsByDay,
segmentStartMinutes, segmentEndMinutes,
} from "../../src/lib/weekly/date-utils.ts";
/**
@ -228,3 +229,121 @@ describe("getDayName", () => {
assert.equal(getDayName(new Date(2026, 8, 7), "de-DE", undefined, undefined, 0, "uppercase"), "MONTAG");
});
});
/**
* Termine über Mitternacht.
*
* Erwartetes Nutzerverhalten: Ein Termin von 22:00 bis 02:00 verlängert nicht den
* Starttag, sondern wird an der Tagesgrenze geteilt und läuft am Folgetag weiter —
* so, wie es Kalender-Apps zeigen.
*/
describe("splitEventByDay", () => {
const iso = (y: number, m: number, d: number, h: number, min = 0) =>
new Date(y, m, d, h, min, 0, 0).toISOString();
const event = (startTime: string, endTime: string, id = "e1") => ({
id,
title: "Termin",
startTime,
endTime,
source: "google" as const,
});
test("Termin innerhalb eines Tages bleibt ein Abschnitt", () => {
const segments = splitEventByDay(event(iso(2026, 8, 10, 9), iso(2026, 8, 10, 10, 30)));
assert.equal(segments.length, 1);
assert.equal(segments[0].continuesFromPreviousDay, false);
assert.equal(segments[0].continuesOnNextDay, false);
assert.equal(segments[0].segmentKey, "e1");
});
test("22:00 bis 02:00 ergibt zwei Abschnitte an der Tagesgrenze", () => {
const segments = splitEventByDay(event(iso(2026, 8, 10, 22), iso(2026, 8, 11, 2)));
assert.equal(segments.length, 2);
const first = new Date(segments[0].startTime);
const firstEnd = new Date(segments[0].endTime);
assert.equal(first.getHours(), 22);
assert.equal(firstEnd.getDate(), 11);
assert.equal(firstEnd.getHours(), 0);
assert.equal(segments[0].continuesOnNextDay, true);
const second = new Date(segments[1].startTime);
const secondEnd = new Date(segments[1].endTime);
assert.equal(second.getDate(), 11);
assert.equal(second.getHours(), 0);
assert.equal(second.getMinutes(), 0);
assert.equal(secondEnd.getHours(), 2);
assert.equal(segments[1].continuesFromPreviousDay, true);
assert.equal(segments[1].continuesOnNextDay, false);
});
test("kein Abschnitt verliert Zeit: Summe der Abschnitte ist die Termindauer", () => {
const start = iso(2026, 8, 10, 22);
const end = iso(2026, 8, 13, 3, 15);
const segments = splitEventByDay(event(start, end));
const total = segments.reduce(
(sum, s) => sum + (new Date(s.endTime).getTime() - new Date(s.startTime).getTime()),
0,
);
assert.equal(total, new Date(end).getTime() - new Date(start).getTime());
});
test("Termin über drei Tage ergibt drei Abschnitte mit vollem Mitteltag", () => {
const segments = splitEventByDay(event(iso(2026, 8, 10, 23), iso(2026, 8, 12, 1)));
assert.equal(segments.length, 3);
const middle = segments[1];
assert.equal(new Date(middle.startTime).getHours(), 0);
assert.equal(new Date(middle.endTime).getDate(), 12);
assert.equal(middle.continuesFromPreviousDay, true);
assert.equal(middle.continuesOnNextDay, true);
});
test("Ende exakt um Mitternacht erzeugt keinen leeren Abschnitt am Folgetag", () => {
const segments = splitEventByDay(event(iso(2026, 8, 10, 22), iso(2026, 8, 11, 0)));
assert.equal(segments.length, 1);
assert.equal(segments[0].continuesOnNextDay, false);
});
test("Abschnitte eines Termins haben unterschiedliche Keys, aber dieselbe Event-Id", () => {
const segments = splitEventByDay(event(iso(2026, 8, 10, 22), iso(2026, 8, 11, 2)));
assert.notEqual(segments[0].segmentKey, segments[1].segmentKey);
assert.equal(segments[0].id, "e1");
assert.equal(segments[1].id, "e1");
assert.equal(segments[1].sourceEvent.startTime, iso(2026, 8, 10, 22));
assert.equal(segments[1].sourceEvent.endTime, iso(2026, 8, 11, 2));
});
test("Ganztagstermin wird nicht geteilt", () => {
const segments = splitEventByDay(event("2026-09-10", "2026-09-12"));
assert.equal(segments.length, 1);
assert.equal(segments[0].segmentCount, 1);
});
test("Ende vor Start bleibt ein Abschnitt und wirft nicht", () => {
const segments = splitEventByDay(event(iso(2026, 8, 10, 12), iso(2026, 8, 10, 11)));
assert.equal(segments.length, 1);
});
});
describe("Tagesgruppierung und Minutenumrechnung der Abschnitte", () => {
const iso = (y: number, m: number, d: number, h: number, min = 0) =>
new Date(y, m, d, h, min, 0, 0).toISOString();
test("Abschnitte landen im jeweiligen Tag", () => {
const byDay = groupEventSegmentsByDay([
{ id: "a", title: "Nacht", startTime: iso(2026, 8, 10, 22), endTime: iso(2026, 8, 11, 2), source: "google" },
{ id: "b", title: "Tag", startTime: iso(2026, 8, 11, 9), endTime: iso(2026, 8, 11, 10), source: "google" },
]);
assert.equal(byDay.get("2026-09-10")?.length, 1);
assert.equal(byDay.get("2026-09-11")?.length, 2);
});
test("bis Mitternacht laufender Abschnitt endet bei Minute 1440, nicht bei 0", () => {
const [first] = splitEventByDay({
id: "a", title: "Nacht", startTime: iso(2026, 8, 10, 22), endTime: iso(2026, 8, 11, 2), source: "google",
});
assert.equal(segmentStartMinutes(first), 22 * 60);
assert.equal(segmentEndMinutes(first), 24 * 60);
});
});

View File

@ -0,0 +1,74 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { layoutDaySegments, computeEventOverlapLayout } from "../../src/lib/weekly/event-layout.ts";
import type { CalendarEventSegment } from "../../src/lib/weekly/types.ts";
/**
* Charakterisierungstests der Spaltenaufteilung im Tagesraster.
*
* Beobachtbares Verhalten: Termine, die sich zeitlich überschneiden, stehen
* nebeneinander statt übereinander; Termine, die sich nicht berühren, bekommen
* die volle Breite.
*/
const at = (h: number, min = 0) => new Date(2026, 8, 10, h, min, 0, 0).toISOString();
const segment = (key: string, startHour: number, endHour: number): CalendarEventSegment => ({
id: key,
title: key,
startTime: at(startHour),
endTime: at(endHour),
source: "google",
segmentKey: key,
segmentIndex: 0,
segmentCount: 1,
continuesFromPreviousDay: false,
continuesOnNextDay: false,
sourceEvent: { id: key, title: key, startTime: at(startHour), endTime: at(endHour), source: "google" },
});
describe("layoutDaySegments", () => {
test("ein einzelner Termin nimmt die volle Breite ein", () => {
const layout = layoutDaySegments([segment("a", 9, 10)]);
assert.deepEqual(layout.a, { column: 0, totalColumns: 1 });
});
test("zwei überschneidende Termine teilen sich zwei Spalten", () => {
const layout = layoutDaySegments([segment("a", 9, 11), segment("b", 10, 12)]);
assert.equal(layout.a.totalColumns, 2);
assert.equal(layout.b.totalColumns, 2);
assert.notEqual(layout.a.column, layout.b.column);
});
test("aufeinanderfolgende Termine bekommen dieselbe Spalte und volle Breite", () => {
const layout = layoutDaySegments([segment("a", 9, 10), segment("b", 10, 11)]);
assert.deepEqual(layout.a, { column: 0, totalColumns: 1 });
assert.deepEqual(layout.b, { column: 0, totalColumns: 1 });
});
test("drei gleichzeitige Termine ergeben drei Spalten", () => {
const layout = layoutDaySegments([segment("a", 9, 12), segment("b", 9, 12), segment("c", 9, 12)]);
const columns = ["a", "b", "c"].map((k) => layout[k].column).sort();
assert.deepEqual(columns, [0, 1, 2]);
assert.equal(layout.a.totalColumns, 3);
});
test("die Reihenfolge der Eingabe ändert das Ergebnis nicht", () => {
const forward = layoutDaySegments([segment("a", 9, 11), segment("b", 10, 12)]);
const backward = layoutDaySegments([segment("b", 10, 12), segment("a", 9, 11)]);
assert.deepEqual(forward, backward);
});
});
describe("computeEventOverlapLayout", () => {
test("fasst mehrere Tage in einer Zuordnung zusammen", () => {
const layout = computeEventOverlapLayout(
new Map([
["2026-09-10", [segment("a", 9, 11), segment("b", 10, 12)]],
["2026-09-11", [segment("c", 9, 10)]],
]),
);
assert.equal(layout.a.totalColumns, 2);
assert.equal(layout.c.totalColumns, 1);
});
});