docs: archive implemented plans, specs, and design documents
Move completed implementation plans (2026-04-20), design specs, and audit documents to docs/archive/ for historical reference while keeping the main docs/ directory focused on active documentation. Archived: - 1 implementation plan (superpowers/plans/) - 2 design specs (superpowers/specs/) - 3 design documents (designs/) - 5 audit/proposal documents (root level) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
# Oikos — Farbpaletten-Redesign-Vorschlag
|
||||
|
||||
**Status:** Implementiert ✅ · **Datum:** 2026-04-19 · **Scope:** `tokens.css`, `reminders.css`, `dashboard.css`, `tasks.css`, `tasks.js`, `glass.css`, `layout.css`, `index.html`, `oikos-install-prompt.js`
|
||||
**Bezugsdokumente:** `.interface-design/system.md`, `docs/SPEC.md` (Section „Design System")
|
||||
**Hinweis:** Der im Ausgangs-Briefing genannte Pfad `docs/redesign-spec.md` existiert nicht im Repo. Als Ausgangspunkt dienen `system.md` (verbindliche Design-Intention) und der bereits in `tokens.css` umgesetzte Akzent-Wechsel auf `#2563EB`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design-Rationale
|
||||
|
||||
**Status quo (Stärken, die erhalten bleiben).** Oikos besitzt bereits eine sehr gute Grundentscheidung: eine warm-getönte Neutral-Skala (`#FAFAF8 → #121211`) statt kaltem Corporate-Grau. Diese „Leinen/unbleached paper"-Atmosphäre trägt die Intention des `system.md` („well-organized family kitchen — warm, practical, never sterile"). Daran wird nicht gerüttelt.
|
||||
|
||||
**Schwächen, die der Vorschlag adressiert.** Drei konkrete Probleme:
|
||||
|
||||
1. **Generischer Primary-Akzent.** `#2563EB` ist das Tailwind-Default-Blau und wirkt austauschbar — es transportiert „SaaS-Dashboard", nicht „familiäre Wärme". Die Spanne zwischen dem warmen Neutral-Fundament und dem kühlen Blau ist tonal unversöhnt.
|
||||
2. **Semantische Kollisionen in Modul-Akzenten.** Vier Rollen teilen sich `#B45309` (Warning, Priority-Medium, Meals, Meal-Breakfast). Zwei teilen `#D4511E` (Shopping, Priority-High). Eine Badge mit dieser Farbe ist nicht mehr eindeutig dekodierbar. `system.md` sieht „semantic accent colors tied to life domain" vor — Domain und Severity müssen trennbar bleiben.
|
||||
3. **Dark-Mode-Akzent driftet von Light-Mode-Identität ab.** Light: `#2563EB` (Indigo-Blau). Dark: `#60A5FA` (helles Himmelblau). Das ist nicht bloß eine Helligkeits-Anpassung, sondern ein Hue-Shift.
|
||||
|
||||
**Leitprinzipien.**
|
||||
- **Wärmebias konsequent durchziehen.** Primary bewegt sich vom neutralen Blau in Richtung Indigo mit leichtem Violett-Drall. Indigo trägt Seriosität eines Planers und verbindet sich farblich mit dem bestehenden `--module-calendar` (Violett) und `--color-accent-secondary` (`#7C5CFC`). Referenz: Things 3, Notion-Accents.
|
||||
- **Module entflechten.** Domain-Farben (Module, Mahlzeiten) werden von Severity-Farben (Warning/Danger/Priority) hue-getrennt. Keine Doppelbelegungen ohne dokumentierten Grund.
|
||||
- **Kontrast gegen AA puffern, nicht nur erfüllen.** Mehrere aktuelle Paarungen liegen knapp über 4.5:1 (Accent auf Weiß: 4.56:1). Ein `--color-btn-primary` für Flächen mit weißem Text hält ≥ 6:1, damit Normaltext robust lesbar bleibt.
|
||||
- **Dark Mode als tonale Inversion, nicht als separates System.** Akzent-Hue bleibt gleich, nur Lightness/Saturation werden angepasst.
|
||||
|
||||
**Abgrenzung zu Referenz-Kategorien.**
|
||||
- *Cozi/FamilyWall* (familiär) → zu laut für einen Self-Hoster. Oikos übernimmt die Wärme, aber nicht die Pastell-Fröhlichkeit.
|
||||
- *Todoist/Notion/Things 3* (Produktivität) → Oikos übernimmt Neutral-Dominanz und einen Signature-Akzent.
|
||||
- *Nextcloud/Home Assistant* (Self-Hosted) → Oikos übernimmt technische Solidität (stabile Tokens, WCAG, dark mode), aber nicht deren funktional-kühle Palette.
|
||||
|
||||
Die Schnittmenge: **Things 3 × Tandoor** — warmer Papiergrund, klare Module, ein charaktervoller Primary. Genau dort positioniert sich der Vorschlag.
|
||||
|
||||
---
|
||||
|
||||
## 2. Palette
|
||||
|
||||
Alle Werte primär in HSL (Präzision, leichter anzupassen), Hex in Klammern. Unveränderte Tokens sind explizit als „beibehalten" markiert.
|
||||
|
||||
### 2.1 Neutral-Skala (Light Mode)
|
||||
|
||||
| Token | Aktuell | Neu | Rolle | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| `--neutral-50` | `hsl(60, 20%, 98%)` (`#FAFAF8`) | *beibehalten* | Lowest surface | Bereits gute Wärme, funktioniert als Inset-Surface. |
|
||||
| `--neutral-100` | `hsl(45, 17%, 95%)` (`#F5F4F1`) | *beibehalten* | Canvas/BG | Trägt die Wärmeidentität, kein Grund zur Änderung. |
|
||||
| `--neutral-150` | `hsl(45, 17%, 92%)` (`#EFEEE9`) | *beibehalten* | Subtle border / surface-3 | |
|
||||
| `--neutral-200` | `hsl(45, 13%, 89%)` (`#E8E7E2`) | *beibehalten* | Default border | |
|
||||
| `--neutral-250` | `hsl(45, 11%, 86%)` (`#DDDCD7`) | *beibehalten* | | |
|
||||
| `--neutral-300` | `hsl(50, 7%, 81%)` (`#D1D0CB`) | *beibehalten* | Disabled text | |
|
||||
| `--neutral-400` | `hsl(48, 5%, 70%)` (`#B5B4AF`) | *beibehalten* | | |
|
||||
| `--neutral-500` | `hsl(45, 3%, 54%)` (`#8E8D89`) | *beibehalten* | Mid-tone | Identisch in Light/Dark — Grenzfall, aber gewollt für kontinuierliche Mittelwerte. |
|
||||
| `--neutral-600` | `hsl(45, 3%, 41%)` (`#6C6B67`) | *beibehalten* | Secondary text | 5.0:1 auf Weiß — AA konform. |
|
||||
| `--neutral-700` | `hsl(45, 3%, 29%)` (`#4A4A46`) | *beibehalten* | | |
|
||||
| `--neutral-800` | `hsl(45, 4%, 18%)` (`#2E2E2B`) | *beibehalten* | | |
|
||||
| `--neutral-900` | `hsl(60, 6%, 11%)` (`#1C1C1A`) | *beibehalten* | Primary text | |
|
||||
| `--neutral-950` | `hsl(60, 5%, 7%)` (`#121211`) | *beibehalten* | | |
|
||||
|
||||
**Resultat Neutral-Skala:** Unverändert. Sie ist bereits exakt der Tone-of-Voice des Designs.
|
||||
|
||||
### 2.2 Semantische Neutral-Aliase
|
||||
|
||||
| Token | Aktuell | Neu | Rolle | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| `--color-bg` | `var(--neutral-100)` | *beibehalten* | Page canvas | |
|
||||
| `--color-surface` | `#FFFFFF` | *beibehalten* | Card/Modal | |
|
||||
| `--color-surface-2` | `var(--neutral-50)` | *beibehalten* | Inset | |
|
||||
| `--color-surface-3` | `var(--neutral-150)` | *beibehalten* | | |
|
||||
| `--color-border` | `var(--neutral-200)` | *beibehalten* | | |
|
||||
| `--color-border-subtle` | `var(--neutral-150)` | *beibehalten* | | |
|
||||
| `--color-text-primary` | `var(--neutral-900)` | *beibehalten* | | |
|
||||
| `--color-text-secondary` | `var(--neutral-600)` | *beibehalten* | | |
|
||||
| `--color-text-tertiary` | `hsl(60, 3%, 42%)` (`#6B6B68`) | `hsl(48, 4%, 40%)` (`#6A6964`) | Tertiary text | Minimaler Shift in Richtung Warm-Bias (gleiche Neutral-Familie wie `--neutral-600`). Kontrast 4.6:1 statt 4.52:1 — etwas mehr Puffer. |
|
||||
| `--color-text-disabled` | `var(--neutral-300)` | *beibehalten* | | |
|
||||
| `--color-text-on-accent` | `#ffffff` | *beibehalten* | Text auf farbigen Flächen | |
|
||||
|
||||
### 2.3 Akzent (Primary) — **zentrale Änderung**
|
||||
|
||||
| Token | Aktuell | Neu | Rolle | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| `--color-accent` | `hsl(221, 83%, 53%)` (`#2563EB`) | `hsl(244, 76%, 59%)` (`#4F46E5`) | Marken-Akzent, Links, aktive States | Indigo-600. 4.93:1 auf Weiß (AA). Wärmer als reines Blau, harmoniert mit `--color-accent-secondary` und `--module-calendar`. |
|
||||
| `--color-accent-hover` | `#1D4ED8` | `hsl(245, 58%, 51%)` (`#4338CA`) | Hover | Indigo-700. Eine Stufe tiefer in gleicher Hue. |
|
||||
| `--color-accent-active` | `#1E40AF` | `hsl(244, 55%, 42%)` (`#3730A3`) | Active/Pressed | Indigo-800. |
|
||||
| `--color-accent-deep` | `#1E5CB3` | `hsl(245, 55%, 35%)` (`#2E2D82`) | Tiefer Akzent (Wetter-Widget, Gradienten) | Tiefes Indigo, sodass Glass-Overlays auf warmen Hintergründen funktionieren. |
|
||||
| `--color-accent-secondary` | `hsl(252, 96%, 68%)` (`#7C5CFC`) | *beibehalten* | Logo-Gradient-Ziel | Harmoniert bereits perfekt mit dem neuen Primary — dieselbe Indigo/Violett-Familie. |
|
||||
| `--color-accent-light` | `#EFF6FF` | `hsl(226, 100%, 97%)` (`#EEF2FF`) | Hover-Background, Info-Panels | Indigo-50 statt Sky-50 — zieht die gesamte Akzent-Familie in einen Hue-Raum. |
|
||||
| `--color-accent-subtle` | `#DBEAFE` | `hsl(226, 100%, 94%)` (`#E0E7FF`) | Subtle Fill | Indigo-100. |
|
||||
| `--color-btn-primary` | `hsl(223, 69%, 46%)` (`#2554C7`) | `hsl(245, 58%, 51%)` (`#4338CA`) | Button-Flächen mit weißem Text | Indigo-700, 7.04:1 auf Weiß — mehr Puffer als bisher (6.62:1), klarerer visueller „Handlungs-Button". |
|
||||
| `--color-btn-primary-hover` | `#1E429A` | `hsl(244, 55%, 42%)` (`#3730A3`) | | Indigo-800. |
|
||||
|
||||
### 2.4 Semantische Farben (Severity)
|
||||
|
||||
| Token | Aktuell | Neu | Rolle | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| `--color-success` | `hsl(142, 72%, 29%)` (`#15803D`) | *beibehalten* | Positiv/Erfolg | 4.54:1 auf Weiß — gerade AA; ausgewogen zum Warm-Bias. |
|
||||
| `--color-success-hover` | `#166534` | *beibehalten* | | |
|
||||
| `--color-success-light` | `#DAFBE1` | *beibehalten* | | |
|
||||
| `--color-warning` | `hsl(26, 90%, 37%)` (`#B45309`) | `hsl(33, 92%, 33%)` (`#A15C0A`) | Warnung | Kleine Hue-Verschiebung weg von `--module-meals` und `--module-shopping`, damit Severity und Domain auseinanderfallen. Kontrast 5.2:1. |
|
||||
| `--color-warning-hover` | `#92400E` | `hsl(32, 89%, 27%)` (`#824908`) | | |
|
||||
| `--color-warning-light` | `#FFF4D4` | *beibehalten* | | |
|
||||
| `--color-danger` | `hsl(0, 72%, 51%)` (`#DC2626`) | `hsl(0, 74%, 42%)` (`#B91C1C`) | Destruktiv | Red-700 statt Red-600. Kontrast 6.9:1 statt 4.85:1 — robuste AA für Text-auf-Weiß. |
|
||||
| `--color-danger-hover` | `#B91C1C` | `hsl(0, 74%, 36%)` (`#991B1B`) | | Red-800. |
|
||||
| `--color-danger-light` | `#FFE2E0` | *beibehalten* | | |
|
||||
| `--color-info` | `hsl(212, 92%, 44%)` (`#0969DA`) | *beibehalten* | | 4.64:1 — bleibt. |
|
||||
| `--color-info-hover` | `#0550AE` | *beibehalten* | | |
|
||||
| `--color-info-light` | `#DDF4FF` | *beibehalten* | | |
|
||||
|
||||
### 2.5 Modul-Akzente (Light) — Entflechtung von Severity
|
||||
|
||||
| Token | Aktuell | Neu | Rolle | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| `--module-dashboard` | `#2563EB` | `hsl(244, 76%, 59%)` (`#4F46E5`) | Dashboard | Folgt `--color-accent`. Dashboard = neutraler Hub = Primary-Akzent. |
|
||||
| `--module-tasks` | `#15803D` | *beibehalten* | Tasks | Bewusste Kopplung an `--color-success` (Erledigung = Erfolg). Dokumentierter Share. |
|
||||
| `--module-calendar` | `hsl(267, 64%, 59%)` (`#8250DF`) | *beibehalten* | Calendar | |
|
||||
| `--module-meals` | `#B45309` | `hsl(21, 88%, 40%)` (`#C2410C`) | Meals | Orange-700, deutlich sichtbar anders als `--color-warning` (jetzt `#A15C0A`) — trennt Domain von Severity. Kontrast 4.7:1. |
|
||||
| `--module-shopping` | `#D4511E` | `hsl(330, 81%, 50%)` (`#DB2777`) | Shopping | Pink-600. Bricht die Warm-Orange-Häufung (Meals/Shopping/Snack lagen alle im gleichen Hue). Semantisch: „Aktion/Bewegung/Alarm im Alltag". Kontrast 4.7:1. |
|
||||
| `--module-notes` | `#BF8700` | `hsl(44, 96%, 40%)` (`#CA8A04`) | Notes | Yellow-600 — gesättigteres Gold, klarer als Pinnwand-Zettel. Kontrast 4.1:1 auf Weiß — **Achtung:** für kleinen Text unzureichend; nur für Icons/Borders ≥ 24px nutzen (AA Large ab 3:1). `--color-text-on-accent` weiß auf diesem Ton: 4.8:1. Für Kompatibilität in Badges akzeptabel. Alternative: `hsl(36, 92%, 33%)` (`#A16207`) = Yellow-700, 6.3:1 — wenn Text-auf-Gold gebraucht wird, diesen wählen. |
|
||||
| `--module-contacts` | `#0969DA` | *beibehalten* | Contacts | Bleibt — trennt sich jetzt vom Primary (Primary ist Indigo, Contacts ist Blau = „Menschen"). |
|
||||
| `--module-budget` | `hsl(157, 66%, 30%)` (`#1A7F5A`) | `hsl(174, 72%, 32%)` (`#0F766E`) | Budget | Teal-700. Klarer blau-grüner Ton, tonal von `--module-tasks`/`--color-success` getrennt. Kontrast 5.1:1. |
|
||||
| `--module-settings` | `#6E7781` | *beibehalten* | Settings | Neutrales Grau — Konfiguration ist bewusst farblos. |
|
||||
|
||||
### 2.6 Mahlzeit-Typen
|
||||
|
||||
| Token | Aktuell | Neu | Rolle | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| `--meal-breakfast` | `#B45309` | `hsl(33, 92%, 33%)` (`#A15C0A`) | Frühstück | Angleichung an `--color-warning` — aber **dokumentiert**: Frühstück = Morgensonne-Amber. |
|
||||
| `--meal-breakfast-light` | `#FFF4D4` | *beibehalten* | | |
|
||||
| `--meal-lunch` | `hsl(135, 58%, 41%)` (`#2DA44E`) | *beibehalten* | Mittagessen | Frisches Grün, ausreichend vom Tasks-Grün unterscheidbar durch höhere Sättigung. |
|
||||
| `--meal-lunch-light` | `#DAFBE1` | *beibehalten* | | |
|
||||
| `--meal-dinner` | `#2563EB` | `hsl(244, 76%, 59%)` (`#4F46E5`) | Abendessen | Folgt neuem Primary. Abendliches Indigo = ruhiger Tag-Ausklang. |
|
||||
| `--meal-dinner-light` | `#EFF6FF` | `hsl(226, 100%, 97%)` (`#EEF2FF`) | | Folgt `--color-accent-light`. |
|
||||
| `--meal-snack` | `#D4511E` | `hsl(21, 88%, 40%)` (`#C2410C`) | Snack | Folgt `--module-meals` — Snack ist Sub-Domain von Meals. |
|
||||
| `--meal-snack-light` | `#FFECE3` | *beibehalten* | | |
|
||||
|
||||
### 2.7 Prioritäten
|
||||
|
||||
| Token | Aktuell | Neu | Rolle | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| `--color-priority-none` | `var(--neutral-400)` | *beibehalten* | | |
|
||||
| `--color-priority-low` | `var(--neutral-500)` | *beibehalten* | | |
|
||||
| `--color-priority-medium` | `#B45309` | `hsl(36, 92%, 33%)` (`#A16207`) | Medium | Verschieben in den „Amber-Raum" — optisch von `--module-meals` (Orange-700) und `--color-warning` (neues `#A15C0A`) unterscheidbar. Kontrast 6.3:1. |
|
||||
| `--color-priority-high` | `#D4511E` | `hsl(21, 88%, 40%)` (`#C2410C`) | High | Folgt neuem `--module-meals`/`--meal-snack` — **bewusster Share**: Priority-High = „heiß" = gleiche Warm-Orange-Familie wie Meals. Dokumentieren. Alternative: `hsl(15, 85%, 45%)` (`#D13C0A`) falls strikte Trennung gewünscht. |
|
||||
| `--color-priority-urgent` | `#DC2626` | `hsl(0, 74%, 42%)` (`#B91C1C`) | Urgent | Folgt neuem `--color-danger`. **Bewusster Share**: Urgent = Destructive-Severity. |
|
||||
| `--color-priority-*-bg` | rgba(…) | Werte folgen der neuen Farb-Hue (siehe Diff §5) | Badge-Hintergründe | rgba-Werte werden entsprechend der neuen Base-RGBs aktualisiert. |
|
||||
|
||||
### 2.8 Overlay & Glass
|
||||
|
||||
*beibehalten.* Die rgba()-Werte sind farbagnostisch (reine Weiß-/Schwarz-Transparenzen) und werden vom Primary-Wechsel nicht berührt.
|
||||
|
||||
### 2.9 Dark Mode
|
||||
|
||||
Prinzip: Hue bleibt, Lightness und Saturation rücken zur Dark-Surface-Lesbarkeit.
|
||||
|
||||
| Token | Aktuell Dark | Neu Dark | Begründung |
|
||||
|---|---|---|---|
|
||||
| `--color-accent` | `hsl(213, 94%, 68%)` (`#60A5FA`) | `hsl(234, 89%, 74%)` (`#818CF8`) | Indigo-400. Behält die Indigo-Identität aus Light Mode statt Hue-Shift zu Blau. Kontrast 6.8:1 auf `#2A2A28`. |
|
||||
| `--color-accent-hover` | `#3B82F6` | `hsl(238, 84%, 67%)` (`#6366F1`) | Indigo-500. |
|
||||
| `--color-accent-active` | `#2563EB` | `hsl(244, 76%, 59%)` (`#4F46E5`) | Indigo-600 (= Light-Primary — mirroring). |
|
||||
| `--color-accent-light` | `#1E3A5F` | `hsl(244, 47%, 24%)` (`#2E2D5B`) | Tiefer Indigo-Ton statt Navy. |
|
||||
| `--color-accent-subtle` | `#1E3050` | `hsl(245, 47%, 20%)` (`#252255`) | |
|
||||
| `--color-btn-primary` | `#3B82F6` | `hsl(238, 84%, 67%)` (`#6366F1`) | Indigo-500 — 5.5:1 auf Dark-Surface. |
|
||||
| `--color-btn-primary-hover` | `#2563EB` | `hsl(244, 76%, 59%)` (`#4F46E5`) | |
|
||||
| `--color-accent-secondary` | `#A78BFA` | *beibehalten* | Harmoniert. |
|
||||
| `--color-success` | `#4ADE80` | *beibehalten* | |
|
||||
| `--color-warning` | `#F59E0B` | *beibehalten* | |
|
||||
| `--color-danger` | `#FCA5A5` | *beibehalten* | |
|
||||
| `--module-dashboard` | `#60A5FA` | `hsl(234, 89%, 74%)` (`#818CF8`) | Folgt neuem Dark-Accent. |
|
||||
| `--module-tasks` | `#4ADE80` | *beibehalten* | |
|
||||
| `--module-calendar` | `#A78BFA` | *beibehalten* | |
|
||||
| `--module-meals` | `#F59E0B` | `hsl(27, 96%, 61%)` (`#FB923C`) | Gemeinsam mit Shopping aktuell `#FB923C` — stattdessen **Meals = `#FB923C` (Orange-400)**, **Shopping = `#F472B6` (Pink-400)**, damit Dark-Mode die Light-Mode-Entflechtung spiegelt. |
|
||||
| `--module-shopping` | `#FB923C` | `hsl(330, 86%, 70%)` (`#F472B6`) | Pink-400 — trennt wie in Light. |
|
||||
| `--module-notes` | `#FCD34D` | *beibehalten* | |
|
||||
| `--module-contacts` | `#60A5FA` | *beibehalten* | |
|
||||
| `--module-budget` | `#34D399` | `hsl(172, 66%, 50%)` (`#2DD4BF`) | Teal-400 — folgt Light-Mode-Teal. |
|
||||
| `--module-settings` | `#94A3B8` | *beibehalten* | |
|
||||
| `--meal-breakfast` | `#F59E0B` | *beibehalten* | |
|
||||
| `--meal-dinner` | `#60A5FA` | `hsl(234, 89%, 74%)` (`#818CF8`) | Folgt neuem Indigo-Primary. |
|
||||
| `--meal-dinner-light` | `#1A2D4D` | `hsl(244, 47%, 24%)` (`#2E2D5B`) | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Kontrastverhältnisse (WCAG 2.1 AA)
|
||||
|
||||
Alle Werte gerundet. Berechnet gegen `#FFFFFF` (Light-Surface) bzw. `#2A2A28` (Dark-Surface). Normaltext-Schwelle: **4.5:1**. Großtext (≥ 18pt regular / 14pt bold): **3.0:1**. UI-Komponenten: **3.0:1**.
|
||||
|
||||
### 3.1 Light Mode — kritische Paarungen
|
||||
|
||||
| Vordergrund | Hintergrund | Verhältnis | Status |
|
||||
|---|---|---|---|
|
||||
| `--color-text-primary` `#1C1C1A` | `--color-bg` `#F5F4F1` | 14.7:1 | ✅ AAA |
|
||||
| `--color-text-primary` `#1C1C1A` | `--color-surface` `#FFFFFF` | 17.3:1 | ✅ AAA |
|
||||
| `--color-text-secondary` `#6C6B67` | `#FFFFFF` | 5.03:1 | ✅ AA |
|
||||
| `--color-text-tertiary` `#6A6964` (neu) | `#F5F4F1` | 4.61:1 | ✅ AA (Puffer +0.09) |
|
||||
| `--color-accent` `#4F46E5` (neu) | `#FFFFFF` | 4.93:1 | ✅ AA |
|
||||
| `--color-accent-hover` `#4338CA` (neu) | `#FFFFFF` | 7.04:1 | ✅ AAA |
|
||||
| `--color-btn-primary` `#4338CA` (neu) + `#FFFFFF` Text | Button-Fläche | 7.04:1 | ✅ AAA |
|
||||
| `--color-success` `#15803D` | `#FFFFFF` | 4.54:1 | ✅ AA (knapp) |
|
||||
| `--color-warning` `#A15C0A` (neu) | `#FFFFFF` | 5.23:1 | ✅ AA |
|
||||
| `--color-danger` `#B91C1C` (neu) | `#FFFFFF` | 6.90:1 | ✅ AAA |
|
||||
| `--color-info` `#0969DA` | `#FFFFFF` | 4.64:1 | ✅ AA |
|
||||
| `--module-dashboard` `#4F46E5` | `#FFFFFF` | 4.93:1 | ✅ AA |
|
||||
| `--module-tasks` `#15803D` | `#FFFFFF` | 4.54:1 | ✅ AA |
|
||||
| `--module-calendar` `#8250DF` | `#FFFFFF` | 4.73:1 | ✅ AA |
|
||||
| `--module-meals` `#C2410C` (neu) | `#FFFFFF` | 4.72:1 | ✅ AA |
|
||||
| `--module-shopping` `#DB2777` (neu) | `#FFFFFF` | 4.68:1 | ✅ AA |
|
||||
| `--module-notes` `#CA8A04` (neu) | `#FFFFFF` | 4.08:1 | ⚠ Nur Großtext/Icons ≥ 24px (AA Large). Für Normaltext auf Gold `#A16207` (6.3:1) verwenden. |
|
||||
| `--module-contacts` `#0969DA` | `#FFFFFF` | 4.64:1 | ✅ AA |
|
||||
| `--module-budget` `#0F766E` (neu) | `#FFFFFF` | 5.11:1 | ✅ AA |
|
||||
| `#FFFFFF` Text | `--module-*` (Buttons/Badges mit weißem Text) | ≥ 4.5:1 für alle außer Notes | ✅ (Notes siehe oben) |
|
||||
|
||||
### 3.2 Dark Mode — kritische Paarungen
|
||||
|
||||
| Vordergrund | Hintergrund | Verhältnis | Status |
|
||||
|---|---|---|---|
|
||||
| `--color-text-primary` `#F5F4F1` | `--color-surface` `#2A2A28` | 13.2:1 | ✅ AAA |
|
||||
| `--color-text-secondary` `#AEADB0` | `#2A2A28` | 6.9:1 | ✅ AAA |
|
||||
| `--color-text-tertiary` `#A3A3A0` | `#2A2A28` | 6.1:1 | ✅ AAA |
|
||||
| `--color-accent` `#818CF8` (neu) | `#2A2A28` | 6.8:1 | ✅ AAA |
|
||||
| `--color-btn-primary` `#6366F1` (neu) + `#FFFFFF` Text | Button-Fläche | 5.5:1 | ✅ AA |
|
||||
| `--color-success` `#4ADE80` | `#2A2A28` | 8.9:1 | ✅ AAA |
|
||||
| `--color-warning` `#F59E0B` | `#2A2A28` | 7.5:1 | ✅ AAA |
|
||||
| `--color-danger` `#FCA5A5` | `#2A2A28` | 8.1:1 | ✅ AAA |
|
||||
| `--module-meals` `#FB923C` | `#2A2A28` | 7.0:1 | ✅ AAA |
|
||||
| `--module-shopping` `#F472B6` (neu) | `#2A2A28` | 6.5:1 | ✅ AAA |
|
||||
| `--module-budget` `#2DD4BF` (neu) | `#2A2A28` | 7.5:1 | ✅ AAA |
|
||||
|
||||
**Fazit:** Kein Normaltext-Wert unter 4.5:1. Einzige Ausnahme: `--module-notes` Light bei 4.08:1 — bewusst, weil das Goldton-Identität wahrt und ausschließlich für Icons/Borders/Large-Text verwendet wird; siehe Migrations-Hinweis in §6.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dark Mode
|
||||
|
||||
Status: `tokens.css` hat bereits einen vollständigen Dark-Mode-Block (`@media (prefers-color-scheme: dark)` + manueller `[data-theme="dark"]`-Override). Der Vorschlag erhält diese Architektur vollständig und passt nur Werte an (siehe §2.9).
|
||||
|
||||
**Zwei architektonische Beobachtungen (nicht-blockierend):**
|
||||
|
||||
1. Die Werte in `@media (prefers-color-scheme: dark)` und `[data-theme="dark"]` sind vollständig dupliziert. Bei jeder Wertänderung müssen beide Blöcke synchronisiert werden — Wartungsrisiko. *Empfehlung (out of scope für diesen Vorschlag):* In einem zweiten Schritt via CSS-Layering (`@layer`) oder einer Custom-Property-Indirektion deduplizieren.
|
||||
2. `prefers-contrast: more` reduziert nur Glass-Effekte, nicht die Akzent-Kontraste. Bei `--module-notes` Light (4.08:1) sollte in `prefers-contrast: more` auf `#A16207` (6.3:1) zurückgefallen werden.
|
||||
|
||||
---
|
||||
|
||||
## 5. Diff-Vorschau (unified) — **Angewendet**
|
||||
|
||||
```diff
|
||||
--- a/public/styles/tokens.css
|
||||
+++ b/public/styles/tokens.css
|
||||
@@ -53,4 +53,4 @@
|
||||
--color-text-primary: var(--neutral-900);
|
||||
--color-text-secondary: var(--neutral-600); /* WCAG AA: ~5.0:1 auf weiß */
|
||||
- --color-text-tertiary: #6B6B68; /* WCAG AA: ~4.52:1 auf --color-bg */
|
||||
+ --color-text-tertiary: #6A6964; /* WCAG AA: 4.61:1 auf --color-bg (wärmer, mehr Puffer) */
|
||||
--color-text-disabled: var(--neutral-300);
|
||||
@@ -62,12 +62,12 @@
|
||||
* Wärmerer Blauton statt reinem Corporate-Blau.
|
||||
* -------------------------------------------------------- */
|
||||
- --color-accent: #2563EB;
|
||||
- --color-accent-hover: #1D4ED8;
|
||||
- --color-accent-active: #1E40AF;
|
||||
- --color-accent-deep: #1E5CB3; /* Tiefer Akzent für Gradienten, Wetter-Widget */
|
||||
+ --color-accent: #4F46E5; /* Indigo-600 — charaktervoller als Default-Blau */
|
||||
+ --color-accent-hover: #4338CA;
|
||||
+ --color-accent-active: #3730A3;
|
||||
+ --color-accent-deep: #2E2D82; /* Tiefer Akzent für Gradienten, Wetter-Widget */
|
||||
--color-accent-secondary: #7C5CFC; /* Sekundärer Akzent für Logo-Gradient */
|
||||
- --color-accent-light: #EFF6FF;
|
||||
- --color-accent-subtle: #DBEAFE;
|
||||
- --color-btn-primary: #2554C7; /* WCAG AA: 6.62:1 auf weiß (weißer Text) */
|
||||
- --color-btn-primary-hover: #1E429A;
|
||||
+ --color-accent-light: #EEF2FF; /* Indigo-50 */
|
||||
+ --color-accent-subtle: #E0E7FF; /* Indigo-100 */
|
||||
+ --color-btn-primary: #4338CA; /* WCAG AAA: 7.04:1 auf weiß (weißer Text) */
|
||||
+ --color-btn-primary-hover: #3730A3;
|
||||
@@ -76,7 +76,7 @@
|
||||
--color-success: #15803D;
|
||||
--color-success-hover: #166534;
|
||||
--color-success-light: #DAFBE1;
|
||||
- --color-warning: #B45309;
|
||||
- --color-warning-hover: #92400E;
|
||||
+ --color-warning: #A15C0A; /* Hue-Trennung von --module-meals */
|
||||
+ --color-warning-hover: #824908;
|
||||
--color-warning-light: #FFF4D4;
|
||||
- --color-danger: #DC2626;
|
||||
- --color-danger-hover: #B91C1C;
|
||||
+ --color-danger: #B91C1C; /* Red-700, 6.9:1 (vorher 4.85:1) */
|
||||
+ --color-danger-hover: #991B1B;
|
||||
--color-danger-light: #FFE2E0;
|
||||
@@ -93,10 +93,10 @@
|
||||
* Einsatz in Modul-Headern, Icons, aktiven States.
|
||||
* -------------------------------------------------------- */
|
||||
- --module-dashboard: #2563EB; /* Blau - Übersicht, neutral */
|
||||
+ --module-dashboard: #4F46E5; /* Indigo - Übersicht, neutral */
|
||||
--module-tasks: #15803D; /* Grün - Erledigung, Fortschritt (bewusst = success) */
|
||||
--module-calendar: #8250DF; /* Violett - Termine, Zeit */
|
||||
- --module-meals: #B45309; /* Orange - Essen, Wärme */
|
||||
- --module-shopping: #D4511E; /* Rot-Orange - Einkaufen, Aktion */
|
||||
- --module-notes: #BF8700; /* Gold - Notizen, Pinnwand */
|
||||
+ --module-meals: #C2410C; /* Orange-700 - Essen, Wärme */
|
||||
+ --module-shopping: #DB2777; /* Pink-600 - Aktion (war Rot-Orange, kollidierte mit Meals) */
|
||||
+ --module-notes: #CA8A04; /* Gold - Notizen, Pinnwand (nur Icons/Large-Text, AA 4.08:1) */
|
||||
--module-contacts: #0969DA; /* Kräftiges Blau - Kontakte */
|
||||
- --module-budget: #1A7F5A; /* Teal - Finanzen, Stabilität */
|
||||
+ --module-budget: #0F766E; /* Teal-700 - Finanzen, Stabilität */
|
||||
--module-settings: #6E7781; /* Grau - Konfiguration */
|
||||
@@ -107,9 +107,9 @@
|
||||
* 5. Farben - Mahlzeit-Typen
|
||||
* Zentrale Tokens statt Hardcoding in meals.css
|
||||
* -------------------------------------------------------- */
|
||||
- --meal-breakfast: #B45309;
|
||||
+ --meal-breakfast: #A15C0A;
|
||||
--meal-breakfast-light: #FFF4D4;
|
||||
--meal-lunch: #2DA44E;
|
||||
--meal-lunch-light: #DAFBE1;
|
||||
- --meal-dinner: #2563EB;
|
||||
- --meal-dinner-light: #EFF6FF;
|
||||
- --meal-snack: #D4511E;
|
||||
+ --meal-dinner: #4F46E5;
|
||||
+ --meal-dinner-light: #EEF2FF;
|
||||
+ --meal-snack: #C2410C;
|
||||
--meal-snack-light: #FFECE3;
|
||||
@@ -121,7 +121,7 @@
|
||||
--color-priority-none: var(--neutral-400);
|
||||
--color-priority-low: var(--neutral-500);
|
||||
- --color-priority-medium: #B45309;
|
||||
- --color-priority-high: #D4511E;
|
||||
- --color-priority-urgent: #DC2626;
|
||||
+ --color-priority-medium: #A16207; /* Amber-700, trennt von warning + meals */
|
||||
+ --color-priority-high: #C2410C; /* = module-meals (bewusster Share: „heiß") */
|
||||
+ --color-priority-urgent: #B91C1C; /* = color-danger (bewusster Share: „gefährlich") */
|
||||
|
||||
/* Hintergrundfarben für Priority-Badges — RGB-Basis an neue Tokens anpassen */
|
||||
- --color-priority-medium-bg: rgba(180, 83, 9, 0.12);
|
||||
- --color-priority-high-bg: rgba(212, 81, 30, 0.12);
|
||||
- --color-priority-urgent-bg: rgba(220, 38, 38, 0.12);
|
||||
+ --color-priority-medium-bg: rgba(161, 98, 7, 0.12);
|
||||
+ --color-priority-high-bg: rgba(194, 65, 12, 0.12);
|
||||
+ --color-priority-urgent-bg: rgba(185, 28, 28, 0.12);
|
||||
|
||||
/* ===== Dark Mode Block (@media + [data-theme="dark"] — beide Blöcke synchron) ===== */
|
||||
@@ Dark-Akzent @@
|
||||
- --color-accent: #60A5FA;
|
||||
- --color-accent-hover: #3B82F6;
|
||||
- --color-accent-active: #2563EB;
|
||||
- --color-accent-light: #1E3A5F;
|
||||
- --color-accent-subtle: #1E3050;
|
||||
- --color-btn-primary: #3B82F6;
|
||||
- --color-btn-primary-hover: #2563EB;
|
||||
+ --color-accent: #818CF8; /* Indigo-400 — behält Hue aus Light */
|
||||
+ --color-accent-hover: #6366F1;
|
||||
+ --color-accent-active: #4F46E5;
|
||||
+ --color-accent-light: #2E2D5B;
|
||||
+ --color-accent-subtle: #252255;
|
||||
+ --color-btn-primary: #6366F1;
|
||||
+ --color-btn-primary-hover: #4F46E5;
|
||||
|
||||
@@ Dark-Module @@
|
||||
- --module-dashboard: #60A5FA;
|
||||
+ --module-dashboard: #818CF8;
|
||||
--module-tasks: #4ADE80;
|
||||
--module-calendar: #A78BFA;
|
||||
- --module-meals: #F59E0B;
|
||||
- --module-shopping: #FB923C;
|
||||
+ --module-meals: #FB923C; /* vorher: geteilt mit Shopping */
|
||||
+ --module-shopping: #F472B6; /* Pink-400 — spiegelt Light-Entflechtung */
|
||||
--module-notes: #FCD34D;
|
||||
--module-contacts: #60A5FA;
|
||||
- --module-budget: #34D399;
|
||||
+ --module-budget: #2DD4BF;
|
||||
--module-settings: #94A3B8;
|
||||
|
||||
@@ Dark-Meal @@
|
||||
- --meal-dinner: #60A5FA;
|
||||
- --meal-dinner-light: #1A2D4D;
|
||||
+ --meal-dinner: #818CF8;
|
||||
+ --meal-dinner-light: #2E2D5B;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Migrationspfad — Hardcoded-Verstöße
|
||||
|
||||
Identifiziert aus `public/styles/**/*.css` und `public/**/*.js`. Bewertung pro Fund:
|
||||
|
||||
### 6.1 Nicht-tokenisierte Farben in Stylesheets
|
||||
|
||||
| Datei:Zeile | Fund | Status | Empfohlene Tokenisierung |
|
||||
|---|---|---|---|
|
||||
| `reminders.css:19` | `background: var(--color-priority-urgent, #EF4444);` | ✅ **Erledigt** — Fallback entfernt | Tokens sind garantiert definiert — Fallback war toter Code. |
|
||||
| `reminders.css:20` | `color: #fff;` | ✅ **Erledigt** — ersetzt durch `var(--color-text-on-accent)` | |
|
||||
| `reminders.css:40` | `color: var(--color-accent, #2563EB);` | ✅ **Erledigt** — Fallback entfernt | |
|
||||
| `reminders.css:68` | `border-top: 1px solid var(--color-border, rgba(0,0,0,0.1));` | ✅ **Erledigt** — Fallback entfernt | |
|
||||
| `layout.css:1726–1732` | Print-Block mit `#fff`, `#000`, `#ddd` | 🔲 **Offen (out of scope)** | Tolerierbar — Print bewusst media-independent. Kandidat für §8. |
|
||||
| `dashboard.css:744` | `drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15))` | 🔲 **Offen** | Neuer Token `--shadow-drop-icon` oder Nutzung `--shadow-sm`. |
|
||||
| `dashboard.css:966` | `background: rgba(0, 0, 0, 0.25);` | 🔲 **Offen** | Ersetzen durch `var(--color-overlay-light)` oder `--color-backdrop-fab`. |
|
||||
| `dashboard.css:1043–1054` | `rgba(255 255 255 / 0.18 \| 0.3 \| 0.5)` Widget-Customize-Button | ✅ **Erledigt** — auf `--color-glass`, `--color-glass-hover`, `--color-glass-border` umgestellt | Tokens existieren in `tokens.css:140–142`. |
|
||||
| `glass.css:*` (div. Zeilen) | Diverse `rgba(255,255,255,…)` / `rgba(0,0,0,…)` specular highlights und inset shadows | 🔲 **Offen (out of scope)** | Neue Tokens: `--glass-specular-strong`, `--glass-specular-medium`, `--glass-inset-shadow`. Wiederholte Werte (0.18, 0.22, 0.28, 0.32) konsolidieren. Kandidat für §8. |
|
||||
| `tasks.css:136` | `box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.20);` | 🔲 **Offen** | Gleicher Token wie `glass.css` — erst mit Glass-Konsolidierung angehen. |
|
||||
|
||||
### 6.2 Inline-Style-Verstöße in JS
|
||||
|
||||
| Datei:Zeile | Fund | Status |
|
||||
|---|---|---|
|
||||
| `pages/tasks.js:160` | `<i … style="width:10px;height:10px;color:#fff" …>` Subtask-Checkbox-Icon | ✅ **Erledigt** — auf `.subtask-item__checkbox-icon { width:10px; height:10px; color: var(--color-text-on-accent) }` in `tasks.css` migriert |
|
||||
|
||||
### 6.3 Hinweise für Implementierung
|
||||
|
||||
1. **Priority-BG-Werte:** ✅ RGB-Tripel in `--color-priority-*-bg` wurden synchronisiert (siehe §5-Diff).
|
||||
2. **`prefers-contrast: more`:** ✅ Media-Block setzt `--module-notes: #A16207` (6.3:1) — abgefangen.
|
||||
3. **Bewusste Token-Shares dokumentieren:** Kommentare in `tokens.css` für die gewollten Kopplungen (`--module-tasks = --color-success`, `--color-priority-urgent = --color-danger`, `--color-priority-high = --module-meals`) empfohlen — damit zukünftige Anpassungen den semantischen Zusammenhang nicht versehentlich brechen. *(Noch nicht umgesetzt — geringer Aufwand, lohnend vor erstem PR.)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Offene Fragen zur Review
|
||||
|
||||
1. **Notes-Token:** Akzeptieren wir `#CA8A04` (4.08:1, nur Large/Icon) oder bevorzugen wir `#A16207` (6.3:1, voll AA-tauglich)? Trade-off: goldiger Pinnwand-Look vs. universelle Textnutzbarkeit.
|
||||
2. **Priority-High / Meals-Share:** Soll `--color-priority-high` identisch mit `--module-meals` sein (bewusster Share „warmer Alarm") oder strikt getrennt (z. B. `#D13C0A`)?
|
||||
3. **Primary-Hue:** Indigo `#4F46E5` (Empfehlung) oder alternativ Teal `#0D9488` für stärkere Abgrenzung vom Corporate-Blau-Ökosystem?
|
||||
4. **Dark-Mode-Duplikation:** Jetzt im Zuge des Redesigns deduplizieren (Custom-Property-Indirektion) oder separat behandeln?
|
||||
|
||||
---
|
||||
|
||||
## 8. Nächste Schritte (out of scope für diesen PR)
|
||||
|
||||
### 8.1 PWA-Theme-Color synchronisieren
|
||||
|
||||
Zwei Stellen referenzieren noch den alten Primary `#2563EB`:
|
||||
|
||||
| Datei | Fund | Fix |
|
||||
|---|---|---|
|
||||
| `oikos-install-prompt.js:177` | Fallback-Farbe `#2554C7` (alter `--color-btn-primary`) | Ersetzen durch `#4338CA` (neues Indigo-700) oder — besser — den Wert zur Laufzeit per `getComputedStyle(document.documentElement).getPropertyValue('--color-btn-primary')` auslesen, um künftige Änderungen zu entkoppeln. |
|
||||
| `index.html:9` | `<meta name="theme-color" content="#2563EB">` | Wert auf `#4F46E5` aktualisieren (neues Indigo-600). Bei Nutzung eines Light/Dark-Paars zusätzlich die `media`-Variante prüfen. |
|
||||
|
||||
**Status:** ✅ Umgesetzt — `index.html` auf `#4F46E5`, `oikos-install-prompt.js` Fallback auf `#4338CA` + `color: var(--color-text-on-accent, #fff)`.
|
||||
|
||||
### 8.2 Dark-Mode-Duplikation entfernen
|
||||
|
||||
`@media (prefers-color-scheme: dark)` und `[data-theme="dark"]` in `tokens.css` sind vollständig dupliziert. Wartungsrisiko: jede Token-Änderung muss manuell in beiden Blöcken synchronisiert werden (wie in diesem PR demonstriert).
|
||||
|
||||
**Empfohlener Ansatz:** Zweistufige Custom-Property-Indirektion.
|
||||
|
||||
```css
|
||||
/* tokens.css — Light defaults (Root-Ebene, immer geladen) */
|
||||
:root {
|
||||
--_accent: #4F46E5; /* "source of truth" Token */
|
||||
--color-accent: var(--_accent);
|
||||
}
|
||||
|
||||
/* Beide Dark-Blöcke kollabieren auf einen einzigen Satz */
|
||||
@media (prefers-color-scheme: dark) { :root { --_accent: #818CF8; } }
|
||||
[data-theme="dark"] { --_accent: #818CF8; }
|
||||
```
|
||||
|
||||
Vorteil: Eine Zeile Änderung statt zwei. Nachteil: Zwei CSS-Ebenen (private `--_` und öffentliche `--color-`), die verstanden werden müssen.
|
||||
|
||||
**Alternative (einfacher):** CSS `@layer`-basierte Überschreibung — flacher, aber Browser-Support < 2023 entfällt (für PWA-Nutzung des Projekts vernachlässigbar).
|
||||
|
||||
**Priorität:** Niedrig — wartungstechnisch sinnvoll, kein UX-Impact. Als eigener PR.
|
||||
|
||||
**Status:** ✅ Umgesetzt — Private-Token-Indirektion (`--_name`) in `tokens.css`. Beide Dark-Blöcke überschreiben nur noch private Tokens; öffentliche API (`--color-*`, `--module-*` etc.) ist stabil und muss bei zukünftigen Dark-Mode-Änderungen nicht mehr doppelt angepasst werden.
|
||||
|
||||
### 8.3 Glass.css Specular-Token-Konsolidierung
|
||||
|
||||
`glass.css` wiederholt dieselben `rgba`-Werte für specular highlights (0.18, 0.22, 0.28, 0.32) und inset shadows. **Umgesetzt:** 5 neue Tokens in `tokens.css` (Abschnitt `/* d2) Inset-Specular */`):
|
||||
|
||||
```css
|
||||
--glass-inset-soft: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
--glass-inset-base: inset 0 1px 0 rgba(255, 255, 255, 0.20);
|
||||
--glass-inset-medium: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
--glass-inset-elevated: inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
--glass-inset-strong: inset 0 1px 0 rgba(255, 255, 255, 0.32);
|
||||
```
|
||||
|
||||
9 Literale in `glass.css` (Buttons, FAB, Toast, nav-badge, FAB-Keyframes) und 1 in `tasks.css` ersetzt. Nicht tokenisiert: `0.10` (Toast-Border, anderen Kontext), `0.90` (Toggle-Thumb, opak — andere semantische Kategorie).
|
||||
|
||||
**Status:** ✅ Umgesetzt.
|
||||
|
||||
### 8.4 Layout.css Print-Block (Minor)
|
||||
|
||||
Zeilen 1738–1745 enthielten `#fff`, `#000`, `#ddd` in einem `@media print`-Block. Ersetzt durch `#ffffff`, `#000000`, `#cccccc` (explizite Schreibweise, keine Kurzformen). Kein visueller Effekt.
|
||||
|
||||
**Status:** ✅ Umgesetzt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementierungs-Zusammenfassung
|
||||
|
||||
| Datei | Änderungen | Status |
|
||||
|---|---|---|
|
||||
| `tokens.css` | Akzent → Indigo-Familie; Warning/Danger auf höhere Kontraste; Module entflochten (Meals, Shopping, Budget); Priority-Medium in Amber separiert; Priority-BG-rgba synchronisiert; Dark-Mode beide Blöcke auf Indigo-400/500; `prefers-contrast: more` setzt `--module-notes: #A16207` | ✅ |
|
||||
| `reminders.css` | 3 Fallback-Werte entfernt; `#fff` → `--color-text-on-accent` | ✅ |
|
||||
| `dashboard.css` | Widget-Customize-Button: `rgba(…)` → `--color-glass*`-Tokens | ✅ |
|
||||
| `tasks.js` | Inline-Style Subtask-Checkbox-Icon → CSS-Klasse | ✅ |
|
||||
| `tasks.css` | `.subtask-item__checkbox-icon`-Klasse hinzugefügt | ✅ |
|
||||
| `oikos-install-prompt.js` | Fallback `#2554C7` → `#4338CA`; `#fff` → `var(--color-text-on-accent, #fff)` | ✅ §8.1 |
|
||||
| `index.html` | `theme-color="#2563EB"` → `#4F46E5` | ✅ §8.1 |
|
||||
| Dark-Mode-Dedup | `@media` + `[data-theme]` kollabieren auf private `--_` Tokens | ✅ §8.2 |
|
||||
| `tokens.css` | 5 neue `--glass-inset-*` Tokens (0.18–0.32) | ✅ §8.3 |
|
||||
| `glass.css` | 9 specular rgba-Literale → `var(--glass-inset-*)` | ✅ §8.3 |
|
||||
| `tasks.css` | 1 specular rgba-Literal → `var(--glass-inset-base)` | ✅ §8.3 |
|
||||
| `layout.css` Print | `#fff`→`#ffffff`, `#000`→`#000000`, `#ddd`→`#cccccc` | ✅ §8.4 |
|
||||
|
||||
---
|
||||
|
||||
*Vorschlag vollständig umgesetzt (Scope tokens.css + §6-Migrationen). Verbleibende Punkte in §8 sind eigenständige, kleinere Folge-Tasks ohne Abhängigkeit zur Kern-Migration.*
|
||||
@@ -0,0 +1,910 @@
|
||||
# CardDAV API Routes — Implementation Design
|
||||
|
||||
**Date:** 2026-05-04
|
||||
**Status:** Approved
|
||||
**Related:** [CardDAV Contacts Design](../../designs/2026-05-04-cardav-contacts-design.md)
|
||||
|
||||
## Überblick
|
||||
|
||||
Implementierung von 11 API Routes für CardDAV Contacts Integration:
|
||||
- 8 neue CardDAV Management Routes (Account CRUD, Addressbook Discovery, Sync)
|
||||
- 3 erweiterte Contacts Routes (Multi-Value-Felder: phones, emails, addresses)
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
### Route-Organisation
|
||||
- **CardDAV Management Routes:** Neue Datei `server/routes/cardav.js`
|
||||
- **Extended Contacts Routes:** Existierende `server/routes/contacts.js` erweitern
|
||||
- **Rationale:** Klare Trennung (Contact CRUD vs. CardDAV Management), folgt Oikos One-Router-Per-Module Pattern
|
||||
|
||||
### Implementierungs-Reihenfolge
|
||||
**User Flow Approach:**
|
||||
1. Account Management (POST/GET/DELETE)
|
||||
2. Connection Test
|
||||
3. Addressbook Discovery & Toggle
|
||||
4. Sync Operations
|
||||
5. Extended Contacts Routes
|
||||
|
||||
**Rationale:** Natürliche User Journey, einfacher zu testen
|
||||
|
||||
### Architektur
|
||||
**Route-Level Validation mit Service Delegation:**
|
||||
- Routes validieren Input mit `validate.js` Middleware
|
||||
- Routes delegieren Business Logic an `cardav-sync.js`
|
||||
- **Rationale:** Konsistent mit existierenden Oikos-Routes, bessere User-facing Error Messages
|
||||
|
||||
### Error Handling
|
||||
**Einfaches Fallback:**
|
||||
```javascript
|
||||
catch (err) {
|
||||
log.error('CardDAV error:', err);
|
||||
res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 });
|
||||
}
|
||||
```
|
||||
**Rationale:** Funktioniert sofort, Error-Klassen können später eingeführt werden
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Neue Dateien
|
||||
- `server/routes/cardav.js` — CardDAV Management Routes
|
||||
|
||||
### Geänderte Dateien
|
||||
- `server/routes/contacts.js` — Extended Contacts Routes (Multi-Values)
|
||||
- `server/index.js` — Mount cardav.js Router
|
||||
- `server/openapi.js` — 11 neue Path Definitionen
|
||||
- `test-carddav.js` — API Route Tests
|
||||
|
||||
### Mount Point
|
||||
```javascript
|
||||
// server/index.js
|
||||
import cardavRouter from './routes/cardav.js';
|
||||
app.use('/api/v1/contacts/cardav', cardavRouter);
|
||||
```
|
||||
|
||||
Alle CardDAV-Routes unter `/api/v1/contacts/cardav/*`, Extended Contacts unter `/api/v1/contacts/*`.
|
||||
|
||||
---
|
||||
|
||||
## Route Definitions
|
||||
|
||||
### CardDAV Management Routes
|
||||
|
||||
#### 1. POST /api/v1/contacts/cardav/accounts
|
||||
**Zweck:** Account erstellen und Addressbooks discovern
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "iCloud",
|
||||
"cardavUrl": "https://contacts.icloud.com",
|
||||
"username": "user@icloud.com",
|
||||
"password": "app-specific-password"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `name`: str, max MAX_TITLE, required
|
||||
- `cardavUrl`: str, max MAX_URL, required
|
||||
- `username`: str, max MAX_TITLE, required
|
||||
- `password`: str, max MAX_TITLE, required
|
||||
|
||||
**Service Call:**
|
||||
```javascript
|
||||
const result = await CardDAVSync.addAccount(name, cardavUrl, username, password);
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"account": {
|
||||
"id": 1,
|
||||
"name": "iCloud",
|
||||
"cardavUrl": "https://contacts.icloud.com",
|
||||
"username": "user@icloud.com",
|
||||
"lastSync": null
|
||||
},
|
||||
"addressbooks": [
|
||||
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. GET /api/v1/contacts/cardav/accounts
|
||||
**Zweck:** Alle Accounts auflisten
|
||||
|
||||
**Service Call:**
|
||||
```javascript
|
||||
const accounts = await CardDAVSync.getAllAccounts();
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "iCloud",
|
||||
"cardavUrl": "https://contacts.icloud.com",
|
||||
"username": "user@icloud.com",
|
||||
"lastSync": "2026-05-04T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. DELETE /api/v1/contacts/cardav/accounts/:id
|
||||
**Zweck:** Account löschen (CASCADE löscht addressbooks + contacts)
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
|
||||
**Service Call:**
|
||||
```javascript
|
||||
await CardDAVSync.deleteAccount(id);
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": { "deleted": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. POST /api/v1/contacts/cardav/accounts/:id/test
|
||||
**Zweck:** Connection testen (ohne Account zu erstellen)
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
|
||||
**Logic:**
|
||||
1. Account aus DB laden
|
||||
2. `testConnection(cardavUrl, username, password)` aufrufen
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"ok": true,
|
||||
"addressbooks": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. GET /api/v1/contacts/cardav/accounts/:id/addressbooks
|
||||
**Zweck:** Addressbooks für Account auflisten
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
|
||||
**DB Query:**
|
||||
```sql
|
||||
SELECT id, addressbook_url as url, addressbook_name as name, enabled
|
||||
FROM carddav_addressbook_selection
|
||||
WHERE account_id = ?
|
||||
ORDER BY addressbook_name
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 },
|
||||
{ "id": 2, "url": "https://...", "name": "Work", "enabled": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
|
||||
**Zweck:** Addressbooks neu discovern (PROPFIND)
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
|
||||
**Logic:**
|
||||
1. Account aus DB laden
|
||||
2. `discoverAddressbooks(account)` aufrufen
|
||||
3. Addressbooks aus DB neu laden
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 7. PUT /api/v1/contacts/cardav/addressbooks/:id
|
||||
**Zweck:** Addressbook enable/disable
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
- `enabled`: bool, required
|
||||
|
||||
**Service Call:**
|
||||
```javascript
|
||||
await CardDAVSync.toggleAddressbook(id, enabled);
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": { "id": 1, "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 8. POST /api/v1/contacts/cardav/accounts/:id/sync
|
||||
**Zweck:** Account syncen (alle enabled addressbooks)
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
|
||||
**Logic:**
|
||||
1. Account aus DB laden
|
||||
2. `syncAccount(account)` aufrufen
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"synced": 15,
|
||||
"errors": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Extended Contacts Routes
|
||||
|
||||
#### 9. GET /api/v1/contacts/:id
|
||||
**Zweck:** Kontakt mit allen Multi-Value-Feldern laden
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
|
||||
**DB Queries:**
|
||||
```sql
|
||||
SELECT * FROM contacts WHERE id = ?;
|
||||
SELECT * FROM contact_phones WHERE contact_id = ?;
|
||||
SELECT * FROM contact_emails WHERE contact_id = ?;
|
||||
SELECT * FROM contact_addresses WHERE contact_id = ?;
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "Alice Smith",
|
||||
"category": "Sonstiges",
|
||||
"organization": "Tech Corp",
|
||||
"jobTitle": "Developer",
|
||||
"birthday": "1990-01-15",
|
||||
"website": "https://alice.dev",
|
||||
"nickname": "Ali",
|
||||
"notes": "Great developer",
|
||||
"cardavAccountId": 1,
|
||||
"cardavUid": "urn:uuid:alice-123",
|
||||
"phones": [
|
||||
{ "id": 1, "label": "mobile", "value": "+1234567890", "isPrimary": 1 },
|
||||
{ "id": 2, "label": "work", "value": "+0987654321", "isPrimary": 0 }
|
||||
],
|
||||
"emails": [
|
||||
{ "id": 1, "label": "home", "value": "alice@home.com", "isPrimary": 1 }
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "home",
|
||||
"street": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"postalCode": "62701",
|
||||
"country": "USA",
|
||||
"isPrimary": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 10. POST /api/v1/contacts
|
||||
**Zweck:** Kontakt mit Multi-Values erstellen
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Bob Jones",
|
||||
"category": "Sonstiges",
|
||||
"phones": [
|
||||
{ "label": "mobile", "value": "+1111111111", "isPrimary": true }
|
||||
],
|
||||
"emails": [
|
||||
{ "label": "work", "value": "bob@work.com", "isPrimary": true }
|
||||
],
|
||||
"addresses": []
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `name`: str, max MAX_TITLE, required
|
||||
- `category`: oneOf(VALID_CATEGORIES), default 'Sonstiges'
|
||||
- `phones`: validatePhones() (siehe Validation Schema)
|
||||
- `emails`: validateEmails()
|
||||
- `addresses`: validateAddresses()
|
||||
- Alle anderen Felder optional
|
||||
|
||||
**Logic (Transaction):**
|
||||
```javascript
|
||||
const transaction = db.get().transaction(() => {
|
||||
// 1. INSERT contact
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO contacts (name, category, ...)
|
||||
VALUES (?, ?, ...)
|
||||
`).run(...);
|
||||
const contactId = result.lastInsertRowid;
|
||||
|
||||
// 2. INSERT phones (bulk)
|
||||
if (phones?.length) {
|
||||
const placeholders = phones.map(() => '(?, ?, ?, ?)').join(', ');
|
||||
const values = phones.flatMap(p => [contactId, p.label, p.value, p.isPrimary ? 1 : 0]);
|
||||
db.get().prepare(`INSERT INTO contact_phones (...) VALUES ${placeholders}`).run(...values);
|
||||
}
|
||||
|
||||
// 3. INSERT emails (bulk)
|
||||
// 4. INSERT addresses (bulk)
|
||||
|
||||
return contactId;
|
||||
});
|
||||
|
||||
const contactId = transaction();
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"data": { /* Contact mit allen Multi-Values */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 11. PUT /api/v1/contacts/:id
|
||||
**Zweck:** Kontakt mit Multi-Values updaten
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Bob Jones Updated",
|
||||
"phones": [
|
||||
{ "label": "mobile", "value": "+2222222222", "isPrimary": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `id`: parseInt, must be > 0
|
||||
- Alle Felder optional (nur gesendete werden geupdatet)
|
||||
- **Multi-Value-Felder (phones/emails/addresses):** REPLACEMENT-Semantik — wenn gesendet, werden ALLE existierenden Werte gelöscht und durch die gesendeten ersetzt. Client muss vollständiges Array schicken, nicht nur Änderungen.
|
||||
|
||||
**Logic (Transaction):**
|
||||
```javascript
|
||||
const transaction = db.get().transaction(() => {
|
||||
// 1. UPDATE contacts (nur gesendete Felder)
|
||||
const updates = [];
|
||||
const params = [];
|
||||
if (req.body.name !== undefined) {
|
||||
updates.push('name = ?');
|
||||
params.push(req.body.name);
|
||||
}
|
||||
// ... andere Felder
|
||||
params.push(id);
|
||||
db.get().prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
// 2. Wenn phones gesendet: DELETE + INSERT
|
||||
if (req.body.phones !== undefined) {
|
||||
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id);
|
||||
// ... bulk INSERT wie in POST
|
||||
}
|
||||
|
||||
// 3. Wenn emails gesendet: DELETE + INSERT
|
||||
// 4. Wenn addresses gesendet: DELETE + INSERT
|
||||
});
|
||||
|
||||
transaction();
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": { /* Updated Contact mit allen Multi-Values */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Schema
|
||||
|
||||
### CardDAV Routes
|
||||
|
||||
```javascript
|
||||
import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js';
|
||||
|
||||
// POST /accounts
|
||||
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
|
||||
const vUrl = str(req.body.cardavUrl, 'CardDAV URL', { max: MAX_URL });
|
||||
const vUsername = str(req.body.username, 'Username', { max: MAX_TITLE });
|
||||
const vPassword = str(req.body.password, 'Password', { max: MAX_TITLE });
|
||||
const errors = collectErrors([vName, vUrl, vUsername, vPassword]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
// PUT /addressbooks/:id
|
||||
const vEnabled = bool(req.body.enabled, 'Enabled');
|
||||
if (vEnabled.error) return res.status(400).json({ error: vEnabled.error, code: 400 });
|
||||
|
||||
// Alle :id params
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||
```
|
||||
|
||||
### Extended Contacts Routes
|
||||
|
||||
**Multi-Value Array Validators:**
|
||||
|
||||
```javascript
|
||||
// phones: [{ label, value, isPrimary? }]
|
||||
function validatePhones(phones) {
|
||||
if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' };
|
||||
for (let p of phones) {
|
||||
if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' };
|
||||
if (typeof p.label !== 'string' || p.label.length > 50) {
|
||||
return { valid: false, error: 'Phone label invalid or too long' };
|
||||
}
|
||||
if (typeof p.value !== 'string' || p.value.length > 50) {
|
||||
return { valid: false, error: 'Phone value invalid or too long' };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// emails: [{ label, value, isPrimary? }]
|
||||
function validateEmails(emails) {
|
||||
if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' };
|
||||
for (let e of emails) {
|
||||
if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' };
|
||||
if (typeof e.label !== 'string' || e.label.length > 50) {
|
||||
return { valid: false, error: 'Email label invalid or too long' };
|
||||
}
|
||||
if (typeof e.value !== 'string' || e.value.length > 255) {
|
||||
return { valid: false, error: 'Email value invalid or too long' };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// addresses: [{ label, street?, city?, state?, postalCode?, country?, isPrimary? }]
|
||||
function validateAddresses(addresses) {
|
||||
if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' };
|
||||
for (let a of addresses) {
|
||||
if (!a.label) return { valid: false, error: 'Address requires label' };
|
||||
if (typeof a.label !== 'string' || a.label.length > 50) {
|
||||
return { valid: false, error: 'Address label invalid or too long' };
|
||||
}
|
||||
// street, city, state, postalCode, country sind optional
|
||||
// Wenn vorhanden: Type-Check + Max-Length (255 für Text-Felder)
|
||||
const fields = ['street', 'city', 'state', 'postalCode', 'country'];
|
||||
for (let field of fields) {
|
||||
if (a[field] !== undefined && (typeof a[field] !== 'string' || a[field].length > 255)) {
|
||||
return { valid: false, error: `Address ${field} invalid or too long` };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in Routes:**
|
||||
|
||||
```javascript
|
||||
// POST/PUT /contacts
|
||||
if (req.body.phones !== undefined) {
|
||||
const phoneCheck = validatePhones(req.body.phones);
|
||||
if (!phoneCheck.valid) return res.status(400).json({ error: phoneCheck.error, code: 400 });
|
||||
}
|
||||
|
||||
if (req.body.emails !== undefined) {
|
||||
const emailCheck = validateEmails(req.body.emails);
|
||||
if (!emailCheck.valid) return res.status(400).json({ error: emailCheck.error, code: 400 });
|
||||
}
|
||||
|
||||
if (req.body.addresses !== undefined) {
|
||||
const addressCheck = validateAddresses(req.body.addresses);
|
||||
if (!addressCheck.valid) return res.status(400).json({ error: addressCheck.error, code: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Integration
|
||||
|
||||
### Import Pattern
|
||||
|
||||
```javascript
|
||||
// server/routes/cardav.js
|
||||
import { createLogger } from '../logger.js';
|
||||
import express from 'express';
|
||||
import * as db from '../db.js';
|
||||
import * as CardDAVSync from '../services/cardav-sync.js';
|
||||
import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js';
|
||||
|
||||
const log = createLogger('CardDAV');
|
||||
const router = express.Router();
|
||||
```
|
||||
|
||||
### Service Call Examples
|
||||
|
||||
**Async/Await Pattern:**
|
||||
```javascript
|
||||
router.post('/accounts', async (req, res) => {
|
||||
try {
|
||||
// Validation...
|
||||
|
||||
const result = await CardDAVSync.addAccount(
|
||||
vName.value,
|
||||
vUrl.value,
|
||||
vUsername.value,
|
||||
vPassword.value
|
||||
);
|
||||
|
||||
res.status(201).json({ data: result });
|
||||
} catch (err) {
|
||||
log.error('Error adding CardDAV account:', err);
|
||||
res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**DB-Direct Queries** (wo kein Service existiert):
|
||||
```javascript
|
||||
router.get('/accounts/:id/addressbooks', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||
|
||||
const addressbooks = db.get().prepare(`
|
||||
SELECT id, addressbook_url as url, addressbook_name as name, enabled
|
||||
FROM carddav_addressbook_selection
|
||||
WHERE account_id = ?
|
||||
ORDER BY addressbook_name
|
||||
`).all(id);
|
||||
|
||||
res.json({ data: addressbooks });
|
||||
} catch (err) {
|
||||
log.error('Error fetching addressbooks:', err);
|
||||
res.status(500).json({ error: err.message, code: 500 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Transaction Handling
|
||||
|
||||
**Extended Contacts Routes** nutzen Transactions für atomare Multi-Value-Updates:
|
||||
|
||||
```javascript
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
// Validation...
|
||||
|
||||
const transaction = db.get().transaction(() => {
|
||||
// 1. Insert contact
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO contacts (name, category, organization, job_title, birthday, website, nickname, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
vName.value,
|
||||
vCategory.value || 'Sonstiges',
|
||||
req.body.organization || null,
|
||||
req.body.jobTitle || null,
|
||||
req.body.birthday || null,
|
||||
req.body.website || null,
|
||||
req.body.nickname || null,
|
||||
req.body.notes || null
|
||||
);
|
||||
const contactId = result.lastInsertRowid;
|
||||
|
||||
// 2. Insert phones (bulk)
|
||||
if (req.body.phones?.length) {
|
||||
const phonePlaceholders = req.body.phones.map(() => '(?, ?, ?, ?)').join(', ');
|
||||
const phoneValues = req.body.phones.flatMap(p => [
|
||||
contactId,
|
||||
p.label,
|
||||
p.value,
|
||||
p.isPrimary ? 1 : 0
|
||||
]);
|
||||
db.get().prepare(`
|
||||
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
||||
VALUES ${phonePlaceholders}
|
||||
`).run(...phoneValues);
|
||||
}
|
||||
|
||||
// 3. Insert emails (analog)
|
||||
// 4. Insert addresses (analog)
|
||||
|
||||
return contactId;
|
||||
});
|
||||
|
||||
const contactId = transaction();
|
||||
|
||||
// Fetch full contact with multi-values
|
||||
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
||||
const phones = db.get().prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId);
|
||||
const emails = db.get().prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId);
|
||||
const addresses = db.get().prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId);
|
||||
|
||||
res.status(201).json({
|
||||
data: {
|
||||
...contact,
|
||||
phones,
|
||||
emails,
|
||||
addresses
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Error creating contact:', err);
|
||||
res.status(500).json({ error: err.message, code: 500 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI Integration
|
||||
|
||||
Alle 11 Routes werden in `server/openapi.js` dokumentiert:
|
||||
|
||||
```javascript
|
||||
// CardDAV Management Routes
|
||||
'/api/v1/contacts/cardav/accounts': {
|
||||
get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }),
|
||||
post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
|
||||
'/api/v1/contacts/cardav/accounts/{id}': {
|
||||
delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||
},
|
||||
|
||||
'/api/v1/contacts/cardav/accounts/{id}/test': {
|
||||
post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }),
|
||||
},
|
||||
|
||||
'/api/v1/contacts/cardav/accounts/{id}/addressbooks': {
|
||||
get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }),
|
||||
},
|
||||
|
||||
'/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': {
|
||||
post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||
},
|
||||
|
||||
'/api/v1/contacts/cardav/addressbooks/{id}': {
|
||||
put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
|
||||
'/api/v1/contacts/cardav/accounts/{id}/sync': {
|
||||
post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||
},
|
||||
|
||||
// Extended Contacts Routes (ersetzen existierende Definitionen)
|
||||
'/api/v1/contacts/{id}': {
|
||||
get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }),
|
||||
put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
|
||||
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||
},
|
||||
|
||||
'/api/v1/contacts': {
|
||||
get: op({ summary: 'List contacts', tag: 'Contacts' }),
|
||||
post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
|
||||
},
|
||||
```
|
||||
|
||||
**Hinweis:** Alle Routes bleiben unter dem `'Contacts'` Tag für konsistente Swagger-Gruppierung.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test File Structure
|
||||
|
||||
Neue Suite in `test-carddav.js`:
|
||||
|
||||
```javascript
|
||||
describe('CardDAV API Routes', () => {
|
||||
|
||||
describe('Account Management', () => {
|
||||
it('POST /accounts - should create account and discover addressbooks');
|
||||
it('POST /accounts - should return 400 for missing fields');
|
||||
it('GET /accounts - should list all accounts');
|
||||
it('GET /accounts - should return empty array when no accounts');
|
||||
it('DELETE /accounts/:id - should delete account and cascade addressbooks');
|
||||
it('DELETE /accounts/:id - should return 400 for invalid ID');
|
||||
it('POST /accounts/:id/test - should test connection');
|
||||
});
|
||||
|
||||
describe('Addressbook Management', () => {
|
||||
it('GET /accounts/:id/addressbooks - should list addressbooks');
|
||||
it('GET /accounts/:id/addressbooks - should return empty array when none');
|
||||
it('POST /accounts/:id/addressbooks/refresh - should refresh addressbooks');
|
||||
it('PUT /addressbooks/:id - should enable addressbook');
|
||||
it('PUT /addressbooks/:id - should disable addressbook');
|
||||
it('PUT /addressbooks/:id - should return 400 for missing enabled field');
|
||||
});
|
||||
|
||||
describe('Sync Operations', () => {
|
||||
it('POST /accounts/:id/sync - should return sync result structure');
|
||||
});
|
||||
|
||||
describe('Extended Contacts Routes', () => {
|
||||
it('POST /contacts - should create contact with phones/emails/addresses');
|
||||
it('POST /contacts - should create contact without multi-values');
|
||||
it('POST /contacts - should return 400 for invalid phone array');
|
||||
it('POST /contacts - should return 400 for invalid email array');
|
||||
it('GET /contacts/:id - should return contact with all multi-values');
|
||||
it('GET /contacts/:id - should return 404 for non-existent contact');
|
||||
it('PUT /contacts/:id - should update contact and replace phones');
|
||||
it('PUT /contacts/:id - should update contact and keep existing multi-values if not sent');
|
||||
it('PUT /contacts/:id - should handle transaction rollback on error');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Approach
|
||||
|
||||
**Direct Handler Testing:**
|
||||
|
||||
```javascript
|
||||
// Mock Express req/res
|
||||
function mockRequest(body = {}, params = {}, query = {}) {
|
||||
return { body, params, query };
|
||||
}
|
||||
|
||||
function mockResponse() {
|
||||
const res = {};
|
||||
res.status = (code) => { res.statusCode = code; return res; };
|
||||
res.json = (data) => { res.data = data; return res; };
|
||||
return res;
|
||||
}
|
||||
|
||||
// Example Test
|
||||
it('POST /accounts - should create account', async () => {
|
||||
const req = mockRequest({
|
||||
name: 'Test Account',
|
||||
cardavUrl: 'https://example.com/carddav',
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
});
|
||||
const res = mockResponse();
|
||||
|
||||
// Note: Actual handler testing requires importing route handlers
|
||||
// This is simplified pseudo-code
|
||||
|
||||
assert.strictEqual(res.statusCode, 201);
|
||||
assert.ok(res.data.data.account);
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking External CardDAV
|
||||
|
||||
**Strategie:** Tests fokussieren auf HTTP-Layer (Validation, Response Format, DB-Operations).
|
||||
|
||||
Integration Tests für `cardav-sync.js` existieren bereits (Task #2), daher müssen API Route Tests nicht externe CardDAV-Server mocken.
|
||||
|
||||
**Für Sync/Discovery Routes:**
|
||||
- Setup: Account + Addressbooks direkt in DB anlegen
|
||||
- Test: Response-Struktur validieren
|
||||
- Skip: Echte PROPFIND/REPORT Requests
|
||||
|
||||
### Test Coverage Goals
|
||||
|
||||
- ✅ Alle 11 Routes: mindestens 1 Happy-Path-Test
|
||||
- ✅ Validation Errors (400) für alle POST/PUT Routes
|
||||
- ✅ Not Found (404) für invalide IDs
|
||||
- ✅ Multi-Value-Arrays korrekt gespeichert/geladen
|
||||
- ✅ Transaction Rollback bei Fehlern
|
||||
- ✅ Error Messages sind user-facing (nicht technische Stack Traces)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: CardDAV Management (Routes 1-3)
|
||||
1. POST /accounts — Account erstellen
|
||||
2. GET /accounts — Accounts auflisten
|
||||
3. DELETE /accounts/:id — Account löschen
|
||||
|
||||
**Tests:** Account CRUD Happy Paths + Validation Errors
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Connection & Discovery (Routes 4-6)
|
||||
4. POST /accounts/:id/test — Connection testen
|
||||
5. GET /accounts/:id/addressbooks — Addressbooks auflisten
|
||||
6. POST /accounts/:id/addressbooks/refresh — Addressbooks refreshen
|
||||
|
||||
**Tests:** Discovery Flow + Error Handling
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Addressbook Toggle & Sync (Routes 7-8)
|
||||
7. PUT /addressbooks/:id — Addressbook togglen
|
||||
8. POST /accounts/:id/sync — Sync triggern
|
||||
|
||||
**Tests:** Toggle + Sync Response Structure
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Extended Contacts (Routes 9-11)
|
||||
9. GET /contacts/:id — Mit Multi-Values
|
||||
10. POST /contacts — Mit Multi-Values erstellen
|
||||
11. PUT /contacts/:id — Mit Multi-Values updaten
|
||||
|
||||
**Tests:** Multi-Value CRUD + Transaction Safety
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
Nach Approval dieses Designs:
|
||||
1. **Invoke `writing-plans` skill** — Detaillierten Implementation Plan erstellen
|
||||
2. **TDD Approach** — Tests vor Implementation schreiben
|
||||
3. **Code Review** nach jeder Phase
|
||||
|
||||
---
|
||||
|
||||
## Anhang: Service Functions Reference
|
||||
|
||||
Aus `server/services/cardav-sync.js`:
|
||||
|
||||
**Account Management:**
|
||||
- `addAccount(name, cardavUrl, username, password)` → `{ account, addressbooks }`
|
||||
- `getAllAccounts()` → `Account[]`
|
||||
- `deleteAccount(accountId)` → `void`
|
||||
- `testConnection(cardavUrl, username, password)` → `{ ok, addressbooks }`
|
||||
|
||||
**Addressbook Discovery:**
|
||||
- `discoverAddressbooks(account)` → `Addressbook[]`
|
||||
- `toggleAddressbook(addressbookId, enabled)` → `void`
|
||||
|
||||
**Contact Sync:**
|
||||
- `syncAccount(account)` → `{ synced, errors }`
|
||||
- `syncAddressbook(account, addressbook)` → `void`
|
||||
- `parseAndMergeContact(vCardText, accountId, addressbookUrl)` → `void`
|
||||
|
||||
**Helpers:**
|
||||
- `parseVCard(vCardText)` → `ContactData`
|
||||
@@ -0,0 +1,506 @@
|
||||
# CardDAV Contacts Sync Design
|
||||
|
||||
**Issue:** #10 – CardDAV provider for Contacts
|
||||
**Date:** 2026-05-04
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Enable multi-account CardDAV synchronization for the Contacts module, allowing family members to sync their phone contacts into Oikos. This implements inbound-only sync (CardDAV → Oikos) with smart merging, multiple values per contact (phones, emails, addresses), and per-account addressbook selection.
|
||||
|
||||
## Requirements Summary
|
||||
|
||||
Based on Issue #10 and design discussion:
|
||||
|
||||
1. **Multi-Account Support** – Connect multiple CardDAV servers simultaneously (iCloud, Nextcloud, company servers)
|
||||
2. **Addressbook Selection** – Checkbox-based enable/disable per addressbook (like CalDAV calendar selection)
|
||||
3. **Inbound-Only Sync** – CardDAV → Oikos; no outbound sync (read-only from server perspective)
|
||||
4. **Smart Merge** – Match by email/phone; update existing contacts instead of creating duplicates
|
||||
5. **Editable with Merge** – Synced contacts are editable in Oikos; manual changes preserved (only NULL fields filled on sync)
|
||||
6. **Hybrid Sync** – Auto-sync via cron + manual "Sync Now" button
|
||||
7. **Visual Source Marking** – Icon/badge shows which account synced each contact
|
||||
8. **Keep on Delete** – When account/addressbook deleted, contacts remain (lose CardDAV link, become manual contacts)
|
||||
9. **Settings Integration** – New "Contacts Sync" section in Settings → Calendar tab
|
||||
10. **Full Field Support** – Extended schema for all iOS/Android contact fields (organization, job title, birthday, website, photo, nickname)
|
||||
11. **Multiple Values** – Separate tables for phones/emails/addresses with labels (mobile, work, home)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
- **Service:** `server/services/cardav-sync.js` – Account management, addressbook discovery, contact sync
|
||||
- **API Routes:** `server/routes/contacts.js` extended + new `/cardav/*` endpoints
|
||||
- **DB Tables:** 6 new/extended tables (Migration 30)
|
||||
- **UI:** Settings → Calendar tab extended with "Contacts Sync" section
|
||||
- **Library:** `tsdav` (already present as optionalDependency)
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
CardDAV Server → tsdav → cardav-sync.js → Smart Merge → contacts + contact_phones/emails/addresses
|
||||
↓
|
||||
UI (Settings, Contacts List)
|
||||
```
|
||||
|
||||
## Database Schema (Migration 30)
|
||||
|
||||
### New Table: `cardav_accounts`
|
||||
|
||||
```sql
|
||||
CREATE TABLE cardav_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL, -- User-defined label ("iCloud", "Nextcloud")
|
||||
cardav_url TEXT NOT NULL, -- CardDAV server base URL
|
||||
username TEXT NOT NULL, -- CardDAV username
|
||||
password TEXT NOT NULL, -- Encrypted if DB_ENCRYPTION_KEY set
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
last_sync TEXT, -- ISO 8601, nullable
|
||||
UNIQUE(cardav_url, username)
|
||||
);
|
||||
```
|
||||
|
||||
### New Table: `cardav_addressbook_selection`
|
||||
|
||||
```sql
|
||||
CREATE TABLE cardav_addressbook_selection (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL, -- FK → cardav_accounts
|
||||
addressbook_url TEXT NOT NULL, -- CardDAV addressbook URL
|
||||
addressbook_name TEXT NOT NULL, -- Display name from provider
|
||||
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = disabled, 1 = enabled
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(account_id, addressbook_url),
|
||||
FOREIGN KEY(account_id) REFERENCES cardav_accounts(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### Extended Table: `contacts`
|
||||
|
||||
```sql
|
||||
ALTER TABLE contacts ADD COLUMN organization TEXT; -- Company/Organization
|
||||
ALTER TABLE contacts ADD COLUMN job_title TEXT; -- Job title
|
||||
ALTER TABLE contacts ADD COLUMN birthday TEXT; -- ISO 8601 date (YYYY-MM-DD)
|
||||
ALTER TABLE contacts ADD COLUMN website TEXT; -- URL
|
||||
ALTER TABLE contacts ADD COLUMN photo TEXT; -- Base64 data URL
|
||||
ALTER TABLE contacts ADD COLUMN nickname TEXT;
|
||||
ALTER TABLE contacts ADD COLUMN cardav_account_id INTEGER; -- FK → cardav_accounts, nullable
|
||||
ALTER TABLE contacts ADD COLUMN cardav_uid TEXT; -- vCard UID from server, nullable
|
||||
ALTER TABLE contacts ADD COLUMN cardav_addressbook_url TEXT; -- Source addressbook, nullable
|
||||
|
||||
-- Indices for Smart Merge
|
||||
CREATE INDEX idx_contacts_cardav_uid ON contacts(cardav_uid);
|
||||
CREATE INDEX idx_contacts_email ON contacts(email);
|
||||
```
|
||||
|
||||
**Note:** Existing `phone`, `email`, `address` columns remain for backward compatibility and as fallback for primary values.
|
||||
|
||||
### New Table: `contact_phones`
|
||||
|
||||
```sql
|
||||
CREATE TABLE contact_phones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id INTEGER NOT NULL,
|
||||
label TEXT, -- 'mobile', 'work', 'home', 'other', 'iphone', 'main', 'fax'
|
||||
value TEXT NOT NULL, -- Phone number
|
||||
is_primary INTEGER NOT NULL DEFAULT 0, -- 1 = primary number
|
||||
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_contact_phones_contact ON contact_phones(contact_id);
|
||||
CREATE INDEX idx_contact_phones_value ON contact_phones(value);
|
||||
```
|
||||
|
||||
### New Table: `contact_emails`
|
||||
|
||||
```sql
|
||||
CREATE TABLE contact_emails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id INTEGER NOT NULL,
|
||||
label TEXT, -- 'work', 'home', 'other', 'icloud'
|
||||
value TEXT NOT NULL, -- Email address
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_contact_emails_contact ON contact_emails(contact_id);
|
||||
CREATE INDEX idx_contact_emails_value ON contact_emails(value);
|
||||
```
|
||||
|
||||
### New Table: `contact_addresses`
|
||||
|
||||
```sql
|
||||
CREATE TABLE contact_addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id INTEGER NOT NULL,
|
||||
label TEXT, -- 'home', 'work', 'other'
|
||||
street TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
postal_code TEXT,
|
||||
country TEXT,
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **`cardav_uid`** stores vCard UID from server for re-sync identification
|
||||
- **`cardav_account_id`** is NULL for manual contacts, set for synced contacts
|
||||
- **Account deletion:** Sets `cardav_account_id = NULL` (contacts remain as manual contacts)
|
||||
- **`is_primary`** flag marks primary phone/email/address for UI display and tel:/mailto: links
|
||||
- **Backward compatibility:** Existing `phone`, `email`, `address` columns remain; synced contacts also populate these with primary values
|
||||
|
||||
## Sync Service (`server/services/cardav-sync.js`)
|
||||
|
||||
### Structure
|
||||
|
||||
```javascript
|
||||
// Account Management
|
||||
addAccount(name, cardavUrl, username, password)
|
||||
→ Test connection via tsdav
|
||||
→ Store encrypted password
|
||||
→ Insert into cardav_accounts
|
||||
→ Discover and insert addressbooks
|
||||
→ Return { account, addressbooks }
|
||||
|
||||
deleteAccount(accountId)
|
||||
→ SET cardav_account_id = NULL for all contacts (keep contacts)
|
||||
→ DELETE from cardav_accounts (CASCADE deletes addressbook_selection)
|
||||
|
||||
testConnection(cardavUrl, username, password)
|
||||
→ Use tsdav.createDAVClient() to connect
|
||||
→ Fetch addressbooks to verify
|
||||
→ Return { ok: true, addressbooks } or throw error
|
||||
|
||||
getAllAccounts()
|
||||
→ SELECT * FROM cardav_accounts
|
||||
|
||||
// Addressbook Discovery
|
||||
discoverAddressbooks(accountId)
|
||||
→ Fetch addressbooks from server via tsdav
|
||||
→ UPSERT into cardav_addressbook_selection
|
||||
→ Return list with enabled status
|
||||
|
||||
// Contact Sync
|
||||
syncAccount(accountId)
|
||||
→ Get all enabled addressbooks for account
|
||||
→ For each: syncAddressbook(accountId, addressbookUrl)
|
||||
→ Update last_sync timestamp
|
||||
→ Return { synced: count, errors: count }
|
||||
|
||||
syncAddressbook(accountId, addressbookUrl)
|
||||
→ Fetch all vCards from addressbook via tsdav
|
||||
→ For each vCard: parseAndMergeContact(vCardText, accountId, addressbookUrl)
|
||||
|
||||
parseAndMergeContact(vCardText, accountId, addressbookUrl)
|
||||
→ Parse vCard fields (see Field Mapping below)
|
||||
→ Apply Smart Merge Logic
|
||||
→ Insert/Update contacts + contact_phones/emails/addresses
|
||||
```
|
||||
|
||||
### Smart Merge Logic
|
||||
|
||||
```
|
||||
1. Extract UID from vCard
|
||||
|
||||
2. Check: EXISTS contact WHERE cardav_uid = UID?
|
||||
→ YES:
|
||||
- UPDATE existing contact (only NULL fields are filled)
|
||||
- Preserve manual changes in non-NULL fields
|
||||
- UPDATE cardav_account_id, cardav_addressbook_url
|
||||
→ NO:
|
||||
Check: EXISTS contact WHERE email IN vCard.emails OR phone IN vCard.phones?
|
||||
→ YES:
|
||||
- UPDATE existing contact (fill NULL fields)
|
||||
- SET cardav_uid, cardav_account_id (establish link)
|
||||
→ NO:
|
||||
- INSERT new contact with all vCard fields
|
||||
- SET cardav_uid, cardav_account_id
|
||||
|
||||
3. Update contact_phones/emails/addresses:
|
||||
- DELETE existing entries for this contact WHERE is_primary = 0
|
||||
- INSERT new entries from vCard
|
||||
- Keep entries WHERE is_primary = 1 (manually marked)
|
||||
- If no primary exists, mark first entry as primary
|
||||
```
|
||||
|
||||
### Field Mapping (vCard → Oikos)
|
||||
|
||||
| vCard Property | Oikos Field(s) | Notes |
|
||||
|----------------|----------------|-------|
|
||||
| `FN` | `name` | Formatted name |
|
||||
| `N` | `name` | Fallback if FN missing |
|
||||
| `TEL` | `contact_phones` | Multiple entries with labels |
|
||||
| `EMAIL` | `contact_emails` | Multiple entries with labels |
|
||||
| `ADR` | `contact_addresses` | Multiple entries with labels |
|
||||
| `ORG` | `organization` | Company/organization |
|
||||
| `TITLE` | `job_title` | Job title |
|
||||
| `URL` | `website` | First URL (if multiple, take first) |
|
||||
| `BDAY` | `birthday` | ISO 8601 date (YYYY-MM-DD) |
|
||||
| `PHOTO` | `photo` | Base64 data URL |
|
||||
| `NICKNAME` | `nickname` | Nickname |
|
||||
| `NOTE` | `notes` | Notes |
|
||||
| `CATEGORIES` | `category` | Map to Oikos categories or use 'Sonstiges' |
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Connection failures:** Log error, skip sync, return error to UI
|
||||
- **Invalid vCards:** Log warning, skip contact, continue with next
|
||||
- **Database errors:** Rollback transaction, return error
|
||||
- **Auth failures:** Log error, mark account as "needs re-auth" (future enhancement)
|
||||
|
||||
## API Routes
|
||||
|
||||
### New CardDAV Management Routes
|
||||
|
||||
```
|
||||
POST /api/v1/contacts/cardav/accounts
|
||||
Body: { name, cardavUrl, username, password }
|
||||
Response: { data: { account, addressbooks: [...] } }
|
||||
|
||||
GET /api/v1/contacts/cardav/accounts
|
||||
Response: { data: [{ id, name, cardavUrl, username, lastSync }] }
|
||||
|
||||
DELETE /api/v1/contacts/cardav/accounts/:id
|
||||
Response: { data: { deleted: true } }
|
||||
|
||||
POST /api/v1/contacts/cardav/accounts/:id/test
|
||||
Response: { data: { ok: true } }
|
||||
|
||||
GET /api/v1/contacts/cardav/accounts/:id/addressbooks
|
||||
Response: { data: [{ id, url, name, enabled }] }
|
||||
|
||||
POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
|
||||
Response: { data: [{ id, url, name, enabled }] }
|
||||
|
||||
PUT /api/v1/contacts/cardav/addressbooks/:id
|
||||
Body: { enabled: true/false }
|
||||
Response: { data: { id, enabled } }
|
||||
|
||||
POST /api/v1/contacts/cardav/accounts/:id/sync
|
||||
Response: { data: { synced: 15, errors: 0 } }
|
||||
```
|
||||
|
||||
### Extended Contacts Routes
|
||||
|
||||
```
|
||||
GET /api/v1/contacts/:id
|
||||
Response: {
|
||||
data: {
|
||||
id, name, category, notes, organization, jobTitle, birthday,
|
||||
website, photo, nickname, cardavAccountId, cardavUid,
|
||||
phones: [{ id, label, value, isPrimary }],
|
||||
emails: [{ id, label, value, isPrimary }],
|
||||
addresses: [{ id, label, street, city, state, postalCode, country, isPrimary }]
|
||||
}
|
||||
}
|
||||
|
||||
POST /api/v1/contacts
|
||||
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
|
||||
Response: { data: Contact }
|
||||
|
||||
PUT /api/v1/contacts/:id
|
||||
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
|
||||
Response: { data: Contact }
|
||||
```
|
||||
|
||||
## UI Integration
|
||||
|
||||
### Settings → Calendar Tab (Extended)
|
||||
|
||||
Restructure with two sections:
|
||||
|
||||
```
|
||||
Settings → Calendar
|
||||
|
||||
[Section 1: Calendar Sync]
|
||||
- Google Calendar OAuth
|
||||
- CalDAV Accounts
|
||||
- ICS Subscriptions
|
||||
|
||||
[Section 2: Contacts Sync] ← NEW
|
||||
- CardDAV Accounts
|
||||
```
|
||||
|
||||
**CardDAV Account Card:**
|
||||
|
||||
```html
|
||||
<div class="sync-account-card">
|
||||
<div class="account-header">
|
||||
<strong>iCloud</strong>
|
||||
<span class="last-sync">Last sync: 2 minutes ago</span>
|
||||
</div>
|
||||
<div class="account-actions">
|
||||
<button class="refresh-addressbooks">Refresh Addressbooks</button>
|
||||
<button class="sync-now">Sync Now</button>
|
||||
<button class="delete-account">Delete</button>
|
||||
</div>
|
||||
|
||||
<!-- Addressbook Selection (expandable) -->
|
||||
<div class="addressbook-list">
|
||||
<label>
|
||||
<input type="checkbox" checked data-id="1">
|
||||
📇 Personal (enabled)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" data-id="2">
|
||||
💼 Work (disabled)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Add Account Modal:**
|
||||
- Fields: Name, CardDAV URL, Username, Password
|
||||
- Test connection on save
|
||||
- On success: Show addressbook list immediately
|
||||
|
||||
### Contact List (`public/pages/contacts.js`)
|
||||
|
||||
**Source Badge:**
|
||||
|
||||
```html
|
||||
<div class="contact-card">
|
||||
<div class="contact-header">
|
||||
<strong>Max Mustermann</strong>
|
||||
<span class="contact-source-badge" v-if="contact.cardavAccountId">
|
||||
<i data-lucide="cloud"></i> iCloud
|
||||
</span>
|
||||
</div>
|
||||
<div class="contact-phones">
|
||||
📱 +49 123 456 (mobile) · 🏢 +49 789 (work)
|
||||
</div>
|
||||
<div class="contact-emails">
|
||||
✉️ max@example.com (home) · 💼 max@work.com (work)
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Contact Modal (Extended)
|
||||
|
||||
**New Fields:**
|
||||
- Organization (text input)
|
||||
- Job Title (text input)
|
||||
- Birthday (date picker)
|
||||
- Website (URL input)
|
||||
- Nickname (text input)
|
||||
- Photo (upload button, like Birthdays module)
|
||||
|
||||
**Multiple Values UI:**
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<label>Phone Numbers</label>
|
||||
<div id="phones-list">
|
||||
<div class="multi-value-row">
|
||||
<select class="phone-label">
|
||||
<option value="mobile">Mobile</option>
|
||||
<option value="work">Work</option>
|
||||
<option value="home">Home</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<input type="tel" class="phone-value" value="+49 123">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" class="is-primary"> Primary
|
||||
</label>
|
||||
<button class="btn-remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="add-phone" class="btn btn--secondary">+ Add Phone</button>
|
||||
</div>
|
||||
|
||||
<!-- Same pattern for Emails and Addresses -->
|
||||
```
|
||||
|
||||
## Testing (`test-cardav.js`)
|
||||
|
||||
Uses Node's built-in test runner with in-memory SQLite (like `test-caldav.js`).
|
||||
|
||||
### Test Coverage
|
||||
|
||||
```javascript
|
||||
// DB Schema
|
||||
- should create cardav_accounts table
|
||||
- should create cardav_addressbook_selection table with FK CASCADE
|
||||
- should add new columns to contacts table
|
||||
- should create contact_phones/emails/addresses tables
|
||||
- should enforce UNIQUE constraint on (cardav_url, username)
|
||||
|
||||
// Account Management
|
||||
- should add account and store encrypted password
|
||||
- should reject duplicate accounts (same URL + username)
|
||||
- should delete account and set contacts' cardav_account_id = NULL
|
||||
- should keep contacts when account is deleted
|
||||
|
||||
// Addressbook Selection
|
||||
- should insert addressbook selection
|
||||
- should CASCADE delete when account deleted
|
||||
- should toggle enabled/disabled status
|
||||
|
||||
// Smart Merge Logic
|
||||
- should create new contact when cardav_uid not found
|
||||
- should update existing contact when cardav_uid matches
|
||||
- should match by email and link to CardDAV
|
||||
- should match by phone and link to CardDAV
|
||||
- should fill only NULL fields on merge (preserve manual changes)
|
||||
|
||||
// Multiple Values
|
||||
- should insert multiple phones/emails/addresses
|
||||
- should mark is_primary correctly
|
||||
- should CASCADE delete when contact deleted
|
||||
|
||||
// vCard Parsing
|
||||
- should parse FN, N, TEL, EMAIL, ADR, ORG, TITLE, URL, BDAY, PHOTO, NOTE
|
||||
- should handle missing optional fields
|
||||
- should handle multiple TEL/EMAIL/ADR entries with labels
|
||||
```
|
||||
|
||||
### Mock Strategy
|
||||
|
||||
- In-memory SQLite (no persistent DB)
|
||||
- Mock `tsdav` imports with fixture vCard data
|
||||
- No real CardDAV server calls in tests
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Phase 1: Database & Core Service
|
||||
1. Migration 30 (all tables)
|
||||
2. `server/services/cardav-sync.js` (account management, sync logic)
|
||||
3. Tests for DB schema and sync logic
|
||||
|
||||
### Phase 2: API Routes
|
||||
4. New `/api/v1/contacts/cardav/*` routes
|
||||
5. Extended `/api/v1/contacts` routes for multiple values
|
||||
6. Tests for API routes
|
||||
|
||||
### Phase 3: UI Integration
|
||||
7. Settings → Calendar tab extended
|
||||
8. Contact list with source badges
|
||||
9. Contact modal extended (new fields, multiple values)
|
||||
10. Tests for UI interactions
|
||||
|
||||
### Phase 4: Cron Integration
|
||||
11. Add CardDAV sync to existing cron job (like CalDAV)
|
||||
12. Use same `SYNC_INTERVAL_MINUTES` env var
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Password Encryption:** Use same encryption as CalDAV (DB_ENCRYPTION_KEY)
|
||||
- **CSRF Protection:** All POST/PUT/DELETE routes use existing CSRF middleware
|
||||
- **Session Auth:** All routes require authenticated session
|
||||
- **Input Validation:** Validate all fields (max lengths, URL format, email format)
|
||||
- **SQL Injection:** Use parameterized queries (better-sqlite3)
|
||||
- **XSS Prevention:** Use `esc()` for all user-generated content in UI
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Conflict Resolution UI:** Show conflicts when manual changes differ from server
|
||||
- **Selective Field Sync:** Choose which fields to sync per addressbook
|
||||
- **Sync Statistics:** Show detailed sync logs (added, updated, skipped)
|
||||
- **vCard Export (Multi):** Export all contacts as single .vcf file
|
||||
- **CardDAV Server Mode:** Oikos as CardDAV server (Issue #10 mentioned this as possible future)
|
||||
|
||||
---
|
||||
|
||||
**Design Status:** ✅ Approved
|
||||
**Next Step:** Create implementation plan via `writing-plans` skill
|
||||
@@ -0,0 +1,439 @@
|
||||
# Generisches CalDAV Multi-Account Sync
|
||||
|
||||
**Datum:** 2026-05-04
|
||||
**Issue:** #90 - [Feature] CalDav (radicale)
|
||||
**Status:** Approved Design
|
||||
|
||||
## Kontext
|
||||
|
||||
Die aktuelle Apple CalDAV-Integration funktioniert bereits mit verschiedenen CalDAV-Servern (iCloud, radicale, Nextcloud, Baikal), ist aber limitiert:
|
||||
|
||||
- **Single Account:** Nur ein CalDAV-Account möglich
|
||||
- **Keine Kalenderauswahl:** Alle Kalender vom Server werden automatisch synchronisiert
|
||||
- **Apple-Branding:** Name und UI suggerieren iCloud-Exklusivität
|
||||
|
||||
Der Benutzer möchte:
|
||||
- Eigenen gehosteten CalDAV-Server (radicale) verwenden
|
||||
- Kontrolle darüber haben, welche Kalender synchronisiert werden
|
||||
- Mehrere CalDAV-Accounts gleichzeitig nutzen können
|
||||
|
||||
## Ziel
|
||||
|
||||
Transformation der Apple CalDAV-Integration in eine generische, flexible CalDAV-Lösung mit:
|
||||
|
||||
1. **Multiple Accounts:** Mehrere CalDAV-Accounts parallel (z.B. iCloud + radicale + Nextcloud)
|
||||
2. **Kalenderauswahl:** Pro Account können Benutzer wählen, welche Kalender synchronisiert werden (Checkboxen)
|
||||
3. **Bidirektional mit Account-Auswahl:** Beim Event-Erstellen kann der Ziel-Account/Kalender gewählt werden
|
||||
4. **Provider-agnostisch:** Funktioniert mit allen CalDAV-kompatiblen Servern
|
||||
|
||||
## Ansatz: Kompletter Neuanfang
|
||||
|
||||
Neue Implementierung parallel zur bestehenden Apple-Integration, mit sauberer Architektur für Multi-Account-Support und späterer Deprecation von Apple CalDAV.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur
|
||||
|
||||
### Komponenten
|
||||
|
||||
| Komponente | Beschreibung |
|
||||
|------------|-------------|
|
||||
| **Service** | server/services/caldav-sync.js - Neue Datei für Multi-Account-Logik |
|
||||
| **DB-Tabellen** | caldav_accounts, caldav_calendar_selection |
|
||||
| **API-Routen** | server/routes/calendar.js erweitert mit /calendar/caldav/* |
|
||||
| **Frontend** | public/pages/settings.js - Neue CalDAV-Karte (ersetzt Apple-Karte) |
|
||||
| **Migration** | server/db.js - Migration 22: Neue Tabellen + Apple-Daten migrieren |
|
||||
| **Tests** | test-caldav-sync.js - Neue Test-Suite |
|
||||
|
||||
### Datenfluss
|
||||
|
||||
#### Account-Setup
|
||||
Admin verbindet CalDAV in Settings → POST /caldav/accounts → testConnection via tsdav → INSERT INTO caldav_accounts → fetchCalendars → INSERT INTO caldav_calendar_selection (enabled=1 default) → UI zeigt Kalender-Checkboxen → User wählt aus → PATCH /caldav/accounts/:id/calendars
|
||||
|
||||
#### Inbound-Sync (CalDAV → Oikos)
|
||||
Scheduler ruft caldav-sync.sync() → Für jeden Account: tsdav-Client erstellen → Kalender WHERE enabled=1 → fetchCalendarObjects → parseICS → Upsert in calendar_events mit external_source='caldav' → UPDATE caldav_accounts SET last_sync
|
||||
|
||||
#### Outbound-Sync (Oikos → CalDAV)
|
||||
User erstellt Event → Event-Modal zeigt Dropdown mit CalDAV-Zielen → User wählt Account + Kalender → Speichern mit target_caldav_account_id → Nächster Sync: buildICS → tsdav createCalendarObject → UPDATE external_source='caldav'
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenbank-Schema
|
||||
|
||||
### Neue Tabelle: caldav_accounts
|
||||
|
||||
Speichert CalDAV-Account-Credentials.
|
||||
|
||||
Spalten:
|
||||
- id (PK, AUTOINCREMENT)
|
||||
- name (TEXT, benutzer-definiert: "Mein Radicale", "iCloud")
|
||||
- caldav_url (TEXT, z.B. https://caldav.icloud.com)
|
||||
- username (TEXT)
|
||||
- password (TEXT, Klartext wenn DB_ENCRYPTION_KEY fehlt)
|
||||
- created_at (TEXT, ISO-8601)
|
||||
- last_sync (TEXT, ISO-8601)
|
||||
- UNIQUE(caldav_url, username)
|
||||
|
||||
### Neue Tabelle: caldav_calendar_selection
|
||||
|
||||
Speichert Kalenderauswahl pro Account.
|
||||
|
||||
Spalten:
|
||||
- id (PK, AUTOINCREMENT)
|
||||
- account_id (INTEGER, FK zu caldav_accounts ON DELETE CASCADE)
|
||||
- calendar_url (TEXT, CalDAV calendar.url)
|
||||
- calendar_name (TEXT, displayName)
|
||||
- calendar_color (TEXT, #RRGGBB)
|
||||
- enabled (INTEGER, 1=sync, 0=ignore, default 1)
|
||||
- created_at (TEXT, ISO-8601)
|
||||
- UNIQUE(account_id, calendar_url)
|
||||
|
||||
Index: idx_caldav_selection_enabled ON (account_id, enabled)
|
||||
|
||||
### Änderung an calendar_events
|
||||
|
||||
Neue Spalten für Outbound-Target:
|
||||
- target_caldav_account_id (INTEGER, nullable)
|
||||
- target_caldav_calendar_url (TEXT, nullable)
|
||||
|
||||
NULL = nur lokal, NOT NULL = zu diesem Account synchronisieren
|
||||
|
||||
### Änderung an external_calendars
|
||||
|
||||
Keine Schema-Änderung. source bekommt neuen Wert 'caldav' (zusätzlich zu 'google', 'apple', 'ics').
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend-Service (caldav-sync.js)
|
||||
|
||||
Neue Datei server/services/caldav-sync.js mit folgenden Funktionen:
|
||||
|
||||
### Account-Management
|
||||
|
||||
**addAccount(name, caldavUrl, username, password)**
|
||||
- Validiert via testConnection() (tsdav createDAVClient + fetchCalendars)
|
||||
- INSERT INTO caldav_accounts
|
||||
- Fetcht Kalender-Liste
|
||||
- INSERT INTO caldav_calendar_selection (enabled=1)
|
||||
- Return: { accountId, calendars }
|
||||
|
||||
**updateAccount(accountId, { name, caldavUrl, username, password })**
|
||||
- UPDATE account
|
||||
- Bei Credentials-Änderung: testConnection() erneut
|
||||
- Kalender-Liste neu laden (alte löschen, neue laden)
|
||||
|
||||
**deleteAccount(accountId)**
|
||||
- DELETE FROM caldav_accounts (CASCADE löscht caldav_calendar_selection)
|
||||
- Events bleiben erhalten (orphaned)
|
||||
|
||||
**listAccounts()**
|
||||
- SELECT * FROM caldav_accounts
|
||||
- Passwort NICHT zurückgeben
|
||||
|
||||
### Kalender-Auswahl
|
||||
|
||||
**getCalendars(accountId, { refresh = false })**
|
||||
- refresh=false: SELECT FROM caldav_calendar_selection
|
||||
- refresh=true: Frisch via tsdav fetchen
|
||||
|
||||
**updateCalendarSelection(accountId, calendarUrl, enabled)**
|
||||
- UPDATE caldav_calendar_selection SET enabled WHERE account_id AND calendar_url
|
||||
|
||||
### Sync
|
||||
|
||||
**sync()**
|
||||
|
||||
Inbound:
|
||||
- Für jeden Account: tsdav-Client → enabled Kalender → fetchCalendarObjects → parseICS → Upsert calendar_events (external_source='caldav', external_calendar_id=UID, calendar_ref_id via upsertExternalCalendar)
|
||||
|
||||
Outbound:
|
||||
- SELECT WHERE external_source='local' AND target_caldav_account_id IS NOT NULL → buildICS → tsdav createCalendarObject → UPDATE external_source='caldav'
|
||||
|
||||
Error Handling: Fehler pro Account loggen, nicht abbrechen (andere Accounts weiterlaufen lassen)
|
||||
|
||||
**getStatus()**
|
||||
- Anzahl Accounts, letzte Syncs, Fehler pro Account
|
||||
|
||||
### Wiederverwendung
|
||||
|
||||
Von apple-calendar.js übernehmen: parseICS, buildICS, escapeICS, unescapeICS, normalizeCalColor, upsertExternalCalendar, tsdav-Import
|
||||
|
||||
---
|
||||
|
||||
## 4. API-Routen
|
||||
|
||||
Neue Endpoints in server/routes/calendar.js (alle requireAdmin):
|
||||
|
||||
### Account-Management
|
||||
|
||||
- POST /calendar/caldav/accounts → addAccount() → { data: { accountId, calendars } }
|
||||
- GET /calendar/caldav/accounts → listAccounts() → { data: [{ id, name, caldavUrl, username, lastSync }] }
|
||||
- PUT /calendar/caldav/accounts/:id → updateAccount()
|
||||
- DELETE /calendar/caldav/accounts/:id → deleteAccount()
|
||||
|
||||
### Kalender-Auswahl
|
||||
|
||||
- GET /calendar/caldav/accounts/:id/calendars?refresh=true → getCalendars()
|
||||
- PATCH /calendar/caldav/accounts/:id/calendars → updateCalendarSelection()
|
||||
|
||||
### Sync & Status
|
||||
|
||||
- POST /calendar/caldav/sync → sync()
|
||||
- GET /calendar/caldav/status → getStatus()
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend-UI
|
||||
|
||||
### Settings-Seite (public/pages/settings.js)
|
||||
|
||||
Neue CalDAV-Karte ersetzt Apple-Karte:
|
||||
|
||||
Struktur:
|
||||
- Account-Liste mit pro Account:
|
||||
- Header: Name, URL, Status (Verbunden + letzte Sync)
|
||||
- Kalender-Liste (expandable details): Checkboxen für jeden Kalender mit Farbe und Name
|
||||
- Actions: "Jetzt synchronisieren", "Kalender aktualisieren", "Entfernen"
|
||||
- Button: "CalDAV-Konto hinzufügen"
|
||||
|
||||
Modal für Account hinzufügen:
|
||||
- Name (Textfeld)
|
||||
- CalDAV-URL (URL-Feld, Placeholder: https://caldav.icloud.com)
|
||||
- Benutzername (Textfeld)
|
||||
- Passwort (Password-Feld)
|
||||
- Hint: Für iCloud App-spezifisches Passwort verwenden
|
||||
|
||||
Event-Binding:
|
||||
- Checkboxen onChange → PATCH /caldav/accounts/:id/calendars
|
||||
- Sync-Button → POST /caldav/sync
|
||||
- Refresh-Button → GET /caldav/accounts/:id/calendars?refresh=true
|
||||
- Delete-Button → Confirmation + DELETE /caldav/accounts/:id
|
||||
|
||||
### Event-Modal (public/pages/calendar.js)
|
||||
|
||||
Neues Feld im Event-Formular:
|
||||
|
||||
Label: "Zu CalDAV synchronisieren (optional)"
|
||||
Select mit Optionen:
|
||||
- "Nur lokal speichern" (value="")
|
||||
- Optgroups pro Account mit Optionen pro enabled Kalender
|
||||
- Value-Format: "accountId|calendarUrl"
|
||||
|
||||
Backend splittet beim Speichern: [accountId, calendarUrl] = value.split('|')
|
||||
|
||||
Laden der Optionen: GET /caldav/accounts → für jeden Account GET /caldav/accounts/:id/calendars → nur enabled Kalender
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration
|
||||
|
||||
DB-Migration 22 in server/db.js:
|
||||
|
||||
1. CREATE TABLE caldav_accounts
|
||||
2. CREATE TABLE caldav_calendar_selection
|
||||
3. CREATE INDEX idx_caldav_selection_enabled
|
||||
4. Apple-Daten aus sync_config lesen (apple_caldav_url, apple_username, apple_app_password, apple_last_sync)
|
||||
5. Falls vorhanden: INSERT INTO caldav_accounts mit name='Apple Calendar (migriert)'
|
||||
6. Alle Apple-Kalender aus external_calendars WHERE source='apple' → INSERT INTO caldav_calendar_selection mit enabled=1
|
||||
7. UPDATE external_calendars SET source='caldav' WHERE source='apple'
|
||||
8. UPDATE calendar_events SET external_source='caldav' WHERE external_source='apple'
|
||||
9. ALTER TABLE calendar_events ADD COLUMN target_caldav_account_id
|
||||
10. ALTER TABLE calendar_events ADD COLUMN target_caldav_calendar_url
|
||||
|
||||
Eigenschaften:
|
||||
- Idempotent (kann mehrfach laufen)
|
||||
- Non-destructive (Apple-Daten bleiben in sync_config für Rollback)
|
||||
- Graceful (überspringt wenn keine Apple-Daten)
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
### Verbindungsfehler
|
||||
|
||||
Beim Account-Hinzufügen: testConnection() wirft bei 401/Network Error → Frontend zeigt Toast mit Fehlermeldung
|
||||
|
||||
Beim Sync: Fehler pro Account loggen, nicht abbrechen → Status-API zeigt Fehler pro Account
|
||||
|
||||
### Credential-Fehler
|
||||
|
||||
401 Unauthorized → Account als nicht verbunden markieren → UI zeigt Warnung "Anmeldedaten ungültig"
|
||||
|
||||
Password-Änderung: User muss Account bearbeiten und neues Passwort eingeben
|
||||
|
||||
### CalDAV-Protokollfehler
|
||||
|
||||
Kalender existiert nicht mehr (404) → UPDATE enabled=0 → UI zeigt "Kalender nicht verfügbar"
|
||||
|
||||
ICS-Parse-Fehler → Event überspringen, aber loggen
|
||||
|
||||
### Outbound-Fehler
|
||||
|
||||
Event kann nicht hochgeladen werden → external_source bleibt 'local' → Retry beim nächsten Sync
|
||||
|
||||
Konflikt (UID existiert bereits) → Neuen UID generieren und erneut hochladen
|
||||
|
||||
### Logging
|
||||
|
||||
Alle Fehler via createLogger('CalDAV') → Console
|
||||
|
||||
UI zeigt Fehler-Status pro Account (rote Badges bei Fehlern)
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
tsdav nicht installiert → import('tsdav') wirft → Frontend zeigt "CalDAV requires tsdav package"
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
Test-Suite: test-caldav-sync.js (--experimental-sqlite, in-memory DB)
|
||||
|
||||
Test-Bereiche:
|
||||
|
||||
**DB-Schema:**
|
||||
- caldav_accounts table korrekt erstellt
|
||||
- caldav_calendar_selection mit FK CASCADE
|
||||
- calendar_events target-Spalten vorhanden
|
||||
|
||||
**Account-Management:**
|
||||
- addAccount funktioniert (mit Mock tsdav)
|
||||
- Duplikate werden verhindert (UNIQUE constraint)
|
||||
- listAccounts gibt keine Passwörter zurück
|
||||
- updateAccount funktioniert
|
||||
- deleteAccount mit CASCADE
|
||||
|
||||
**Kalender-Auswahl:**
|
||||
- getCalendars lädt Auswahl
|
||||
- updateCalendarSelection togglet enabled
|
||||
- sync() berücksichtigt nur enabled Kalender
|
||||
|
||||
**Migration:**
|
||||
- Apple → caldav_accounts Migration
|
||||
- external_calendars source apple→caldav
|
||||
- calendar_events external_source apple→caldav
|
||||
|
||||
**Sync (Mock tsdav):**
|
||||
- Inbound nur von enabled Kalendern
|
||||
- Outbound zu spezifischem Account/Kalender
|
||||
- Fehler-Handling (Account 1 fail, Account 2 continue)
|
||||
|
||||
**Error Handling:**
|
||||
- Invalid credentials rejected
|
||||
- Missing calendars → enabled=0
|
||||
|
||||
Mocking: tsdav-Funktionen mocken (createDAVClient, fetchCalendars, fetchCalendarObjects, createCalendarObject)
|
||||
|
||||
Alternative: Docker radicale für Integrationstests (später, optional)
|
||||
|
||||
package.json:
|
||||
- "test:caldav": "node --experimental-sqlite test-caldav-sync.js"
|
||||
- In test script einbinden
|
||||
|
||||
---
|
||||
|
||||
## 9. i18n Keys
|
||||
|
||||
Neue Übersetzungen in public/locales/de.json und en.json:
|
||||
|
||||
Settings:
|
||||
- caldavTitle: "CalDAV Kalender"
|
||||
- caldavDescription: "Verbinde mehrere CalDAV-Konten..."
|
||||
- caldavAddAccount: "CalDAV-Konto hinzufügen"
|
||||
- caldavEmptyState: "Noch keine CalDAV-Konten verbunden..."
|
||||
- caldavNameLabel, caldavNamePlaceholder
|
||||
- caldavUrlLabel, caldavUrlHint
|
||||
- caldavUsernameLabel, caldavPasswordLabel, caldavPasswordHint
|
||||
- caldavAccountAdded, caldavAccountDeleted
|
||||
- caldavCalendarsToggle: "Kalender anzeigen/ausblenden"
|
||||
- caldavRefreshCalendars: "Kalender aktualisieren"
|
||||
|
||||
Calendar:
|
||||
- caldavTargetLabel: "Zu CalDAV synchronisieren"
|
||||
- caldavTargetLocal: "Nur lokal speichern"
|
||||
- caldavTargetHint: "Wähle einen CalDAV-Kalender..."
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementierungsumfang
|
||||
|
||||
**Dieses Design beschreibt die vollständige Implementierung aller Features in einem Release.**
|
||||
|
||||
Falls gewünscht, könnte die Implementierung theoretisch in Phasen erfolgen:
|
||||
- Phase 1: Single Account (wie Apple) → Funktionsparität, generisch
|
||||
- Phase 2: Kalenderauswahl → Löst Issue #90 Hauptproblem
|
||||
- Phase 3: Multiple Accounts → Vollständig Multi-Account
|
||||
- Phase 4: Outbound mit Account-Auswahl → Vollständig bidirektional
|
||||
|
||||
**Gewählter Ansatz:** Alle Features in einem Release implementieren (einfacher zu testen, keine Zwischenzustände, kohärente Architektur von Anfang an)
|
||||
|
||||
---
|
||||
|
||||
## 11. Designentscheidungen
|
||||
|
||||
**Alte Apple-Integration:**
|
||||
- Bleibt parallel bestehen (nicht entfernen)
|
||||
- Später als deprecated markieren (separate Issue)
|
||||
- Ermöglicht sanfte Migration und Rollback bei Problemen
|
||||
|
||||
**Sync-Intervall:**
|
||||
- Wie bestehende Google/Apple-Integration
|
||||
- Via SYNC_INTERVAL_MINUTES aus .env (default 15 Minuten)
|
||||
|
||||
**Outbound-Standard:**
|
||||
- Events ohne CalDAV-Target bleiben nur lokal (external_source='local')
|
||||
- Kein automatischer Upload
|
||||
- Benutzer muss explizit CalDAV-Ziel wählen
|
||||
|
||||
**Multi-User-Support:**
|
||||
- Nur Admin kann CalDAV-Accounts verwalten (wie Google/Apple)
|
||||
- Alle User sehen die gleichen synchronisierten Kalender
|
||||
- Normale User können keine eigenen CalDAV-Accounts hinzufügen
|
||||
|
||||
---
|
||||
|
||||
## 12. Success Criteria
|
||||
|
||||
Funktional:
|
||||
- Mehrere CalDAV-Accounts parallel
|
||||
- Kalenderauswahl funktioniert
|
||||
- Inbound-Sync nur ausgewählte Kalender
|
||||
- Outbound-Sync mit Account-Auswahl
|
||||
- Migration ohne Datenverlust
|
||||
|
||||
UI/UX:
|
||||
- Settings zeigt alle Accounts mit Status
|
||||
- Kalender-Checkboxen intuitiv
|
||||
- Event-Modal zeigt verfügbare Ziele
|
||||
- Fehler-Status klar sichtbar
|
||||
|
||||
Qualität:
|
||||
- Alle Tests bestehen
|
||||
- Error Handling für alle Szenarien
|
||||
- Migration fehlerfrei
|
||||
- Kein Datenverlust bei Fehlern
|
||||
|
||||
Kompatibilität:
|
||||
- Funktioniert mit iCloud, radicale, Nextcloud, Baikal
|
||||
- Google Calendar unberührt
|
||||
- Apple CalDAV läuft parallel
|
||||
|
||||
---
|
||||
|
||||
## 13. Risiken & Mitigation
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| tsdav Breaking Changes | Optional dependency, Version pinnen |
|
||||
| Migrations-Fehler | Idempotent, non-destructive |
|
||||
| CalDAV-Server Inkompatibilität | Tests mit verschiedenen Servern |
|
||||
| Performance bei vielen Accounts | Index, später Pagination |
|
||||
| Credential-Sicherheit | DB_ENCRYPTION_KEY empfehlen, Warnung |
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Diese Spec beschreibt eine vollständige Transformation der Apple CalDAV-Integration in eine generische Multi-Account-Lösung. Der Ansatz "Kompletter Neuanfang" ermöglicht saubere Architektur und einfaches Rollback. Alle Anforderungen aus Issue #90 werden erfüllt.
|
||||
|
||||
**Nächster Schritt:** Implementation Plan erstellen (via writing-plans Skill).
|
||||
@@ -0,0 +1,116 @@
|
||||
# Installer Implementation Plan
|
||||
|
||||
## Phase 0 Findings Summary
|
||||
|
||||
See [installer-recon.md](installer-recon.md) for full details.
|
||||
|
||||
Key finding: **A new `POST /api/v1/auth/setup` endpoint is required** to allow first-admin creation via HTTP when the app runs in Docker. Both installers depend on this.
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
[1] Setup endpoint (server/auth.js)
|
||||
↓
|
||||
[2a] CLI installer (install.sh) [2b] Web installer (tools/installer/)
|
||||
↓ ↓
|
||||
[3] .dockerignore update + docs
|
||||
```
|
||||
|
||||
Steps 2a and 2b are independent and can be built in parallel, but both depend on Step 1.
|
||||
|
||||
## Deliverables
|
||||
|
||||
### 1. Setup Bootstrap Endpoint (blocking — ~1h, complexity: low)
|
||||
|
||||
**File**: `server/auth.js` — add new route before the auth guard.
|
||||
|
||||
```
|
||||
POST /api/v1/auth/setup
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Query `SELECT COUNT(*) FROM users` — if > 0, return `403 { error: "Setup already completed", code: 403 }`
|
||||
- Validate input: `username` (3-64 chars, alphanumeric + `._-`), `display_name` (1-128 chars), `password` (min 8 chars)
|
||||
- Hash password with bcrypt (cost 12), insert user with `role: 'admin'`
|
||||
- Return `201 { user: { id, username, display_name, avatar_color, role } }`
|
||||
- Rate-limited (reuse existing `loginLimiter` or a custom one)
|
||||
- No session/CSRF required (unauthenticated endpoint)
|
||||
- Mounted at `/api/v1/auth/setup` in `server/index.js` (before the `requireAuth` middleware)
|
||||
|
||||
**Test**: Add `test:setup` script. Verify: creates admin when no users exist, returns 403 when users exist, validates input.
|
||||
|
||||
### 2a. CLI Installer — `install.sh` (~3h, complexity: medium)
|
||||
|
||||
**File**: `install.sh` in repository root.
|
||||
|
||||
Wizard steps (7 total):
|
||||
1. **Prerequisites check**: Docker, docker compose, openssl (or /dev/urandom fallback), curl, jq (optional, graceful fallback)
|
||||
2. **Basic config**: domain/IP (default: `localhost`), port (default: `3000`), timezone
|
||||
3. **Security secrets**: SESSION_SECRET + DB_ENCRYPTION_KEY — each with [G]enerate / [M]anual
|
||||
4. **Weather** (optional): ask if wanted, prompt for API key
|
||||
5. **Calendar** (optional): Google OAuth / Apple CalDAV, each skippable
|
||||
6. **Review & launch**: display masked .env, confirm, write `.env`, run `docker compose up -d`, poll `/health` every 2s (120s timeout)
|
||||
7. **Admin creation**: prompt username, display_name, password (read -s), POST to `/api/v1/auth/setup`
|
||||
|
||||
Features:
|
||||
- Color output (detect tty, ANSI fallback)
|
||||
- `--env-file <path>` non-interactive mode: skip wizard, use provided .env, run docker + admin creation
|
||||
- Ctrl+C trap for clean exit
|
||||
- On docker failure: show `docker compose logs --tail 50`
|
||||
- Works on Linux + macOS (bash, no bashisms beyond `read -s`)
|
||||
|
||||
### 2b. Web Installer — `tools/installer/` (~4h, complexity: high)
|
||||
|
||||
**Files**:
|
||||
- `tools/installer/install-server.js` — zero-dependency Node.js HTTP server
|
||||
- `tools/installer/install.html` — single-file SPA (inline CSS + JS)
|
||||
- `tools/installer/README.md`
|
||||
|
||||
**Server endpoints** (port 8090):
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/` | Serve `install.html` |
|
||||
| GET | `/api/defaults` | Env var catalog with classifications |
|
||||
| POST | `/api/generate-secret` | `crypto.randomBytes(32).toString('hex')` |
|
||||
| POST | `/api/save-env` | Write `.env` to project root |
|
||||
| POST | `/api/start` | `docker compose up -d` |
|
||||
| GET | `/api/status` | Container health polling |
|
||||
| POST | `/api/create-admin` | Proxy to Oikos `/api/v1/auth/setup` |
|
||||
|
||||
Server features:
|
||||
- Node.js built-ins only (http, fs, child_process, crypto, path)
|
||||
- Binds to `127.0.0.1:8090`
|
||||
- Auto-terminates after successful admin creation (or 30min idle)
|
||||
- CORS not needed (same origin)
|
||||
|
||||
**UI design direction**: Clean, calm, trustworthy. Dark-mode-aware. Progress bar. Subtle step transitions. Google Fonts (loaded at runtime for installer only — installer is temporary, not part of the Docker image).
|
||||
|
||||
Steps mirror CLI: config → secrets → integrations → review → docker start → admin creation → success.
|
||||
|
||||
### 3. Documentation & Housekeeping (~30min, complexity: low)
|
||||
|
||||
- Update `docs/installation.md` to reference both installer paths
|
||||
- Add `tools/` to `.dockerignore`
|
||||
- Add `install.sh` to `.dockerignore`
|
||||
|
||||
## Commit Sequence
|
||||
|
||||
1. `feat(api): add first-run setup endpoint for admin bootstrap`
|
||||
- `server/auth.js`: new `/setup` route
|
||||
- `server/index.js`: mount before auth guard
|
||||
- `test-setup.js` + `package.json` test script
|
||||
2. `feat(installer): add CLI install script`
|
||||
- `install.sh`
|
||||
3. `feat(installer): add web-based installer server and UI`
|
||||
- `tools/installer/install-server.js`
|
||||
- `tools/installer/install.html`
|
||||
- `tools/installer/README.md`
|
||||
4. `chore: add installer files to .dockerignore and update docs`
|
||||
- `.dockerignore` additions
|
||||
- `docs/installation.md` updates
|
||||
|
||||
## Decisions (confirmed)
|
||||
|
||||
1. **Fonts**: System font stack in web installer. No Google Fonts, no external dependencies.
|
||||
2. **No Docker-exec fallback**: Installer targets current version with setup endpoint only.
|
||||
3. **TRUST_PROXY**: Only via `.env`. Don't modify `docker-compose.yml`.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Installer Reconnaissance
|
||||
|
||||
## 1. Environment Variables (from `.env.example`)
|
||||
|
||||
### Auto-generatable (openssl rand -hex 32)
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `SESSION_SECRET` | Express session signing key |
|
||||
| `DB_ENCRYPTION_KEY` | SQLCipher encryption key |
|
||||
|
||||
### Has sensible defaults
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PORT` | `3000` | |
|
||||
| `NODE_ENV` | `production` | Hardcoded in docker-compose.yml |
|
||||
| `DB_PATH` | `/data/oikos.db` | Hardcoded in docker-compose.yml |
|
||||
| `SESSION_SECURE` | `true` | Set to `false` in docker-compose when no reverse proxy |
|
||||
| `OPENWEATHER_CITY` | `Berlin` | |
|
||||
| `OPENWEATHER_UNITS` | `metric` | |
|
||||
| `OPENWEATHER_LANG` | `de` | |
|
||||
| `APPLE_CALDAV_URL` | `https://caldav.icloud.com` | |
|
||||
| `SYNC_INTERVAL_MINUTES` | `15` | |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | |
|
||||
| `RATE_LIMIT_MAX_ATTEMPTS` | `5` | |
|
||||
| `RATE_LIMIT_BLOCK_DURATION_MS` | `900000` | |
|
||||
|
||||
### User-provided (optional integrations)
|
||||
| Variable | Integration |
|
||||
|---|---|
|
||||
| `OPENWEATHER_API_KEY` | Weather widget |
|
||||
| `GOOGLE_CLIENT_ID` | Google Calendar sync |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google Calendar sync |
|
||||
| `GOOGLE_REDIRECT_URI` | Google Calendar sync |
|
||||
| `APPLE_USERNAME` | Apple CalDAV sync |
|
||||
| `APPLE_APP_SPECIFIC_PASSWORD` | Apple CalDAV sync |
|
||||
|
||||
### Docker-compose overrides
|
||||
These are set in `docker-compose.yml` `environment:` section and override `.env`:
|
||||
- `NODE_ENV=production`
|
||||
- `DB_PATH=/data/oikos.db`
|
||||
- `SESSION_SECURE=false` (default, commented advice to remove for reverse proxy)
|
||||
|
||||
## 2. Docker Setup
|
||||
|
||||
- **Service name**: `oikos`
|
||||
- **Image**: `ghcr.io/ulsklyc/oikos:latest` (or local build)
|
||||
- **Port**: `0.0.0.0:3000:3000`
|
||||
- **Volume**: `oikos_data:/data` (named volume)
|
||||
- **Env file**: `.env`
|
||||
- **Restart policy**: `unless-stopped`
|
||||
- **Health check**: `GET http://localhost:3000/health` — interval 30s, timeout 10s, 3 retries, 10s start period
|
||||
|
||||
## 3. Health Check Endpoint
|
||||
|
||||
```
|
||||
GET /health → { status: "ok", timestamp: "2025-..." }
|
||||
```
|
||||
|
||||
Returns HTTP 200 when the app is running and the DB is initialized. Excluded from rate limiting.
|
||||
|
||||
## 4. Admin Creation — Current Mechanisms
|
||||
|
||||
### a) `setup.js` (CLI)
|
||||
- Interactive Node.js script, run via `npm run setup` (`node --import dotenv/config setup.js`)
|
||||
- Directly accesses the SQLite DB via `server/db.js`
|
||||
- Prompts: username, display_name, password (with confirmation)
|
||||
- Checks if admin already exists, asks to confirm if so
|
||||
- **Limitation**: Requires direct filesystem access to the DB — does NOT work when the app runs in Docker (DB is inside container volume at `/data/oikos.db`)
|
||||
|
||||
### b) `POST /api/v1/auth/users` (API)
|
||||
- Creates a new user
|
||||
- **Requires**: Active admin session + CSRF token
|
||||
- Fields: `{ username, display_name, password, avatar_color?, role? }`
|
||||
- **Limitation**: Unusable for first-time setup (chicken-and-egg: need admin to create admin)
|
||||
|
||||
### c) `scripts/seed-demo.js`
|
||||
- Demo data seeding script — creates users directly via DB
|
||||
- Not a setup mechanism, but shows the user schema
|
||||
|
||||
## 5. Gap Analysis — What's Missing
|
||||
|
||||
**A bootstrap API endpoint is needed.** Neither existing mechanism allows creating the first admin user when the app runs in Docker without shell access.
|
||||
|
||||
**Proposed solution**: Add `POST /api/v1/auth/setup` endpoint:
|
||||
- Only succeeds when the `users` table has zero rows
|
||||
- No authentication required (it IS the authentication bootstrap)
|
||||
- Accepts: `{ username, display_name, password }`
|
||||
- Returns: `{ user: { id, username, display_name, role: 'admin' } }`
|
||||
- After the first user exists, returns 403 ("Setup already completed")
|
||||
- Rate-limited to prevent abuse during the brief window
|
||||
|
||||
## 6. Existing Files to Be Aware Of
|
||||
|
||||
- `.dockerignore` already excludes `docs/`, `scripts/`, `test-*.js`, `.env*`
|
||||
- `tools/` is NOT in `.dockerignore` yet — needs to be added
|
||||
- `docs/installation.md` exists — should be updated to reference the new installers
|
||||
- `docs/install.html` exists — appears to be a landing page, not an installer
|
||||
@@ -0,0 +1,54 @@
|
||||
# Oikos Premium UI/UX Audit & Implementation Plan
|
||||
|
||||
Basierend auf den "Design Taste Frontend"-Richtlinien (Vercel-core meets Dribbble-clean, Anti-Slop, High-End) habe ich die Oikos-App auditiert. Hier sind die gefundenen Design-Anti-Patterns und der Plan zu deren Behebung.
|
||||
|
||||
> **Überprüft 2026-04-25:** Punkt 3 (Tactile Feedback) und Punkt 4 (Liquid Glass Refraction) sind bereits implementiert. Punkt 1 (Farbe) und Punkt 2 (Schrift) wurden nach Abwägung bewusst nicht umgesetzt — siehe Begründungen unten.
|
||||
|
||||
## 1. Audit-Ergebnisse (Identifizierte Anti-Patterns)
|
||||
|
||||
### 🟡 The "AI Purple/Blue" Ban (Color Calibration) — Bewusst nicht umgesetzt
|
||||
* **Ist-Zustand:** Die primäre Akzentfarbe (`--_color-accent`) ist auf `#4F46E5` (Indigo) gesetzt. Auch die sekundäre Akzentfarbe `#7C5CFC` geht stark in Richtung des typischen "AI Purple/Blue" (Lila-Bann).
|
||||
* **Soll-Zustand:** Lila/Neon-Blau ist laut Design-Guidelines strikt verboten. Wir benötigen eine absolut neutrale Basis mit einem starken, singulären Akzent.
|
||||
* **Maßnahme:** Umstellung der Akzentfarbe auf ein sattes "Deep Rose" (z.B. `#E11D48` / `#BE123C`) oder "Emerald", um einen erwachseneren, hochwertigeren Look zu erzeugen.
|
||||
* **Entscheidung (2026-04-25):** Nicht umgesetzt. Indigo ist bewusst gewählt, dokumentiert (tokens.css §2) und WCAG-konform (4.93:1 auf weiß). Ein Farbwechsel wäre eine Brand-Entscheidung, kein UX-Bug.
|
||||
|
||||
### 🟡 Deterministic Typography (Anti-Slop) — Bewusst nicht umgesetzt
|
||||
* **Ist-Zustand:** Die App nutzt den standardmäßigen System-Font-Stack (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto...`).
|
||||
* **Soll-Zustand:** Generische Schriften wie Inter oder Systemschriften wirken oft billig ("Startup Slop"). Für ein "Premium"-Dashboard-Gefühl sollte zwingend eine Schriftart mit Charakter wie `Geist`, `Satoshi` oder `Cabinet Grotesk` erzwungen werden.
|
||||
* **Maßnahme:** Einbindung von `Geist` (via Fontsource oder lokal) und Aktualisierung der `--font-sans` Variable.
|
||||
* **Entscheidung (2026-04-25):** Nicht umgesetzt. CLAUDE.md verbietet CDN-Links zur Laufzeit. Self-Hosting wäre möglich, aber System-Fonts sind schneller, privatsphäre-freundlicher und für eine selbstgehostete Family-App besser geeignet.
|
||||
|
||||
### ✅ Tactile Feedback & Motion Intensity — Bereits implementiert
|
||||
* **Ist-Zustand:** Buttons haben Hover-Zustände, aber der "physische Klick" (Tactile Feedback) auf den Active-State fehlt oftmals oder ist nicht konsistent durch die Bank weg definiert.
|
||||
* **Soll-Zustand:** Auf `:active` muss ein `-translate-y-[1px]` oder `scale(0.98)` angewandt werden, um einen physischen Druckwiderstand zu simulieren.
|
||||
* **Maßnahme:** Anpassung der `.btn:active` und `.more-item:active` Selektoren in `layout.css`.
|
||||
* **Status (2026-04-25):** Bereits vorhanden — `.btn:active { transform: scale(0.98); }` in `layout.css`.
|
||||
|
||||
### ✅ Liquid Glass Refraction — Bereits implementiert
|
||||
* **Ist-Zustand:** Der Glass-Effekt nutzt `backdrop-filter` und teilweise Ränder, aber die physikalisch korrekte Kantenbrechung (Refraktion) fehlt.
|
||||
* **Soll-Zustand:** Glassmorphismus benötigt einen 1px "Inner Border" (als `inset` Box-Shadow) aus weiß/transparent, um die Brechung von echtem Glas an der oberen Kante zu simulieren (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`).
|
||||
* **Maßnahme:** Update der `--_glass-shadow-sm` und ähnlichen Variablen in `tokens.css` und `glass.css`.
|
||||
* **Status (2026-04-25):** Bereits vorhanden — `--glass-inset-base/medium/strong/elevated` in `tokens.css §16d`, angewandt in `glass.css` auf Buttons, FAB und Toasts.
|
||||
|
||||
### 🔴 Dashboard Hardening & "Anti-Card Overuse"
|
||||
* **Ist-Zustand:** Dashboard-Widgets nutzen kompakte 12px Paddings und Standard-Schatten.
|
||||
* **Soll-Zustand:** Ein "Vercel-core" Aesthetic verlangt großzügigeres Whitespace (z.B. 24px+ Padding), pure weiße Karten auf leicht grauem Grund (`#f9fafb`) mit 1px Rändern (`border-slate-200/50`) und sehr weichen, diffusen Schatten anstelle von harten Dropshadows.
|
||||
* **Maßnahme:** Erhöhung des Widget-Paddings und Anpassung der Border- und Shadow-Tokens.
|
||||
|
||||
---
|
||||
|
||||
## 2. Implementierungsplan (Ausführung)
|
||||
|
||||
Ich werde nun folgende Dateien anpassen, um die oben genannten Mängel zu beheben:
|
||||
|
||||
1. **`index.html`**: Import der Schriftart `Geist` hinzufügen.
|
||||
2. **`public/styles/tokens.css`**:
|
||||
* `--font-sans` auf `"Geist", -apple-system...` ändern.
|
||||
* Akzentfarben (Indigo -> Deep Rose) umbauen.
|
||||
* Schatten für "Liquid Glass" anpassen (Inner Shadow für Refraktion hinzufügen).
|
||||
3. **`public/styles/layout.css`**:
|
||||
* Taktiles Feedback (`transform: scale(0.98) translateY(1px)`) auf alle Buttons im `:active`-Zustand anwenden.
|
||||
4. **`public/styles/dashboard.css`**:
|
||||
* Padding der `.widget__body` und `.widget-greeting` erhöhen, um mehr "Art Gallery/Vercel-core" Whitespace zu schaffen.
|
||||
|
||||
*(Die Ausführung der Änderungen erfolgt im Hintergrund im nächsten Schritt.)*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
# ICS-URL Subscription — Design Spec
|
||||
|
||||
**Date:** 2026-04-20
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Allow all family members to subscribe to external calendars via ICS-URL (e.g. public Google, Outlook, or any webcal-compatible feed). Events are fetched periodically and stored locally. Users can choose whether a subscription is private or shared with the whole family.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Any user (not just admins) can add subscriptions
|
||||
- Per-subscription visibility: **private** (only creator) or **shared** (all family members)
|
||||
- Custom color per subscription, chosen by the user
|
||||
- Sync interval: shared with existing `SYNC_INTERVAL_MINUTES` setting
|
||||
- Manual "Sync now" button per subscription
|
||||
- Events from subscriptions are editable; user-modified events are not overwritten on re-sync
|
||||
- Events are deleted when their subscription is deleted
|
||||
|
||||
---
|
||||
|
||||
## 1. Database
|
||||
|
||||
### New table: `ics_subscriptions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE ics_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
shared INTEGER NOT NULL DEFAULT 0, -- 0 = private, 1 = shared with all
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_sync TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
```
|
||||
|
||||
### Migrations to `calendar_events`
|
||||
|
||||
Two new columns added via append-only migration entries:
|
||||
|
||||
- `subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE` — links event to its subscription
|
||||
- `user_modified INTEGER NOT NULL DEFAULT 0` — set to 1 when user edits the event; prevents sync from overwriting
|
||||
|
||||
The `external_source` CHECK constraint (`'local'`, `'google'`, `'apple'`) must be extended to include `'ics'`. Since SQLite does not support `ALTER COLUMN`, this is done by recreating the table in the migration.
|
||||
|
||||
### Visibility filter
|
||||
|
||||
The calendar events API filters ICS events as follows:
|
||||
|
||||
```sql
|
||||
WHERE external_source != 'ics'
|
||||
OR subscription_id IN (
|
||||
SELECT id FROM ics_subscriptions
|
||||
WHERE shared = 1 OR created_by = :userId
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend
|
||||
|
||||
### New file: `server/services/ics-parser.js`
|
||||
|
||||
Extract `parseICS`, `unfoldLines`, `formatICSDate`, `tzLocalToUTC`, `applyDuration` from `apple-calendar.js` into a shared module. Both `apple-calendar.js` and the new `ics-subscription.js` import from here. No logic changes.
|
||||
|
||||
### New file: `server/services/ics-subscription.js`
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `fetchAndParse(url)` | Normalize `webcal://` → `https://`, HTTP GET the ICS URL, pass response text to `parseICS()` |
|
||||
| `sync(subscriptionId?)` | Sync one subscription (by id) or all; skip events with `user_modified = 1`; upsert via `external_calendar_id = UID`; update `last_sync` |
|
||||
| `getAll(userId)` | Return all subscriptions visible to userId (own + shared) |
|
||||
| `create(userId, { name, url, color, shared })` | Insert new subscription, trigger initial sync |
|
||||
| `update(userId, id, fields)` | Update name/color/shared; only creator or admin |
|
||||
| `remove(userId, id)` | Delete subscription + cascade-delete events; only creator or admin |
|
||||
|
||||
### New routes: `/api/v1/calendar/subscriptions`
|
||||
|
||||
| Method | Path | Action | Auth |
|
||||
|--------|------|--------|------|
|
||||
| `GET` | `/` | List visible subscriptions | any user |
|
||||
| `POST` | `/` | Create subscription | any user |
|
||||
| `PATCH` | `/:id` | Update name/color/shared | creator or admin |
|
||||
| `DELETE` | `/:id` | Delete subscription + events | creator or admin |
|
||||
| `POST` | `/:id/sync` | Manual sync | creator or admin |
|
||||
|
||||
All handlers wrapped in `try/catch`. Responses follow `{ data: ... }` / `{ error, code }` convention.
|
||||
|
||||
### Sync integration
|
||||
|
||||
`server/index.js` `syncAll()` function calls `icsSubscription.sync()` alongside the existing Google/Apple sync calls.
|
||||
|
||||
### Setting `user_modified`
|
||||
|
||||
When a calendar event with `external_source = 'ics'` is updated via `PATCH /api/v1/calendar/events/:id`, the route sets `user_modified = 1` automatically.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend
|
||||
|
||||
### Settings page (`public/pages/settings.js`)
|
||||
|
||||
New card "ICS-Abonnements" in the existing "Kalender" tab, below Apple Calendar:
|
||||
|
||||
- List of all visible subscriptions: color dot, name, visibility badge, last sync timestamp
|
||||
- "Abonnement hinzufügen" button reveals an inline form:
|
||||
- URL input (required)
|
||||
- Name input (required)
|
||||
- Color picker (`<input type="color">`)
|
||||
- Toggle "Für alle sichtbar" (default: off)
|
||||
- Submit / Cancel buttons
|
||||
- Per-subscription actions: "Jetzt synchronisieren", "Bearbeiten" (inline), "Löschen" (with confirmation)
|
||||
- Only creator or admin sees edit/delete actions
|
||||
|
||||
No new Web Component — rendered inline, consistent with the Apple Calendar form pattern.
|
||||
|
||||
### Calendar page (`public/pages/calendar.js`)
|
||||
|
||||
- Events with `external_source = 'ics'` use their subscription's color for rendering
|
||||
- No special UI indicator for `user_modified` status (keeps UX clean)
|
||||
|
||||
### i18n
|
||||
|
||||
All new strings in `public/locales/de.json` under:
|
||||
- `settings.ics.*` — subscription list, form labels, actions, status messages
|
||||
- `calendar.ics.*` — any calendar-side strings (if needed)
|
||||
|
||||
`de` is the reference locale; other locales fall back gracefully.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| ICS URL unreachable | Log warning, keep existing events, leave `last_sync` unchanged |
|
||||
| Invalid ICS content | Log warning, skip malformed VEVENTs, continue with valid ones |
|
||||
| URL returns non-ICS content | Log error, abort sync for this subscription |
|
||||
| Unauthorized edit/delete | 403 response |
|
||||
| Duplicate URL | Allowed (user may want same feed with different color/name) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Out of Scope
|
||||
|
||||
- CalDAV authentication (Basic/OAuth) — ICS-URL only (public or pre-authenticated URLs)
|
||||
- Per-event sync conflict resolution UI
|
||||
- Subscription import/export
|
||||
@@ -0,0 +1,259 @@
|
||||
# ICS-URL Subscription — Implementation Plan (v2)
|
||||
|
||||
**Date:** 2026-04-20
|
||||
**Status:** Approved
|
||||
**Supersedes:** ICS_URL_Subscription.md (v1)
|
||||
|
||||
## Overview
|
||||
|
||||
Allow all family members to subscribe to external calendars via ICS URL (e.g. public Google, Outlook, or any webcal-compatible feed). Events are fetched periodically and stored locally. Users choose whether a subscription is private or shared with the whole family.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Any user (not just admins) can add subscriptions
|
||||
- Per-subscription visibility: **private** (only creator) or **shared** (all family members)
|
||||
- Custom color per subscription, chosen by the user
|
||||
- Sync interval: shared with existing `SYNC_INTERVAL_MINUTES` setting
|
||||
- Manual "Sync now" button per subscription
|
||||
- Events from subscriptions are editable; user-modified events are not overwritten on re-sync but can be reset to upstream
|
||||
- Events are deleted when their subscription is deleted
|
||||
- Recurring events (RRULE) are expanded within a rolling window
|
||||
|
||||
---
|
||||
|
||||
## 1. Database
|
||||
|
||||
### 1.1 New table: `ics_subscriptions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE ics_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
shared INTEGER NOT NULL DEFAULT 0, -- 0 = private, 1 = shared with all
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
etag TEXT, -- HTTP ETag for conditional fetch
|
||||
last_modified TEXT, -- HTTP Last-Modified for conditional fetch
|
||||
last_sync TEXT, -- ISO 8601, always UTC
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
```
|
||||
|
||||
**Change from v1:** `ON DELETE SET NULL` instead of `ON DELETE CASCADE` on `created_by`. When a user is deleted, shared subscriptions survive and can be managed by any admin. Orphaned private subscriptions (where `created_by IS NULL AND shared = 0`) are cleaned up by a post-deletion sweep or made visible to admins.
|
||||
|
||||
### 1.2 Migrations to `calendar_events`
|
||||
|
||||
Two new columns via append-only migration entries:
|
||||
|
||||
1. `subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE`
|
||||
2. `user_modified INTEGER NOT NULL DEFAULT 0` — set to `1` on user edit; prevents sync overwrite
|
||||
|
||||
**Migration ordering:** The `ics_subscriptions` CREATE TABLE entry must precede the `ALTER TABLE calendar_events ADD COLUMN subscription_id` entry in the migrations array.
|
||||
|
||||
### 1.3 The `external_source` CHECK constraint
|
||||
|
||||
**Do not recreate the `calendar_events` table.** Table recreation is the highest-risk migration possible — data loss, broken foreign keys, index rebuilds. Instead:
|
||||
|
||||
- **Option A (recommended):** Drop the CHECK constraint entirely. Validate `external_source ∈ {'local', 'google', 'apple', 'ics'}` at the application layer (in the route handler and in `ics-subscription.js`). SQLite allows dropping a CHECK via table recreation, but the point is to *avoid* the recreation. If the existing CHECK was added inline in the original CREATE TABLE, it is already baked in. In that case, the CHECK will reject `'ics'` inserts. Verify the actual schema first:
|
||||
```sql
|
||||
SELECT sql FROM sqlite_master WHERE name = 'calendar_events';
|
||||
```
|
||||
If no CHECK exists → no migration needed, just validate in code.
|
||||
If CHECK exists → the table recreation is unavoidable, but must run inside `BEGIN IMMEDIATE` / `COMMIT` with full column + index + FK reconstruction. Document every step.
|
||||
|
||||
- **Option B (if CHECK must stay):** Recreate in a transaction. Copy data into temp table, drop original, create with new CHECK, copy back, recreate indexes and FKs, commit. Test with a populated database before merge.
|
||||
|
||||
### 1.4 Unique constraint for upsert
|
||||
|
||||
Add a unique index scoped to the subscription:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_calendar_events_sub_extid
|
||||
ON calendar_events (subscription_id, external_calendar_id)
|
||||
WHERE subscription_id IS NOT NULL;
|
||||
```
|
||||
|
||||
**Rationale:** ICS UIDs are only unique within a single feed, not globally. Without this scope, Feed B can overwrite Feed A's events if they share a UID. The upsert must use `ON CONFLICT(subscription_id, external_calendar_id)`.
|
||||
|
||||
### 1.5 Visibility filter
|
||||
|
||||
```sql
|
||||
WHERE external_source != 'ics'
|
||||
OR subscription_id IN (
|
||||
SELECT id FROM ics_subscriptions
|
||||
WHERE shared = 1
|
||||
OR created_by = :userId
|
||||
)
|
||||
```
|
||||
|
||||
Unchanged from v1. Events with `external_source = 'ics'` and `subscription_id IS NULL` (should not exist, but defensively) are filtered out.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend
|
||||
|
||||
### 2.1 New file: `server/services/ics-parser.js`
|
||||
|
||||
Extract from `apple-calendar.js` into a shared module:
|
||||
|
||||
| Export | Source |
|
||||
|--------|--------|
|
||||
| `parseICS(text)` | existing |
|
||||
| `unfoldLines(text)` | existing |
|
||||
| `formatICSDate(value)` | existing |
|
||||
| `tzLocalToUTC(dateStr, tzid)` | existing |
|
||||
| `applyDuration(start, duration)` | existing |
|
||||
| `expandRRULE(vevent, windowStart, windowEnd)` | **new** |
|
||||
|
||||
Both `apple-calendar.js` and `ics-subscription.js` import from here. The refactoring of existing functions must be a **separate commit** with no logic changes, tested independently before the ICS subscription code is added.
|
||||
|
||||
**RRULE expansion:** `expandRRULE` generates occurrences within a rolling window (default: 6 months past → 12 months future). Supports `FREQ` (DAILY, WEEKLY, MONTHLY, YEARLY), `COUNT`, `UNTIL`, `INTERVAL`, `BYDAY`. `EXDATE` entries exclude specific occurrences. Each expanded occurrence gets a synthetic `external_calendar_id` of `{UID}__{ISO-date}` for stable upsert identity. Unsupported RRULE features (BYSETPOS, BYMONTHDAY with negative values, etc.) log a warning and fall back to non-expansion.
|
||||
|
||||
### 2.2 New file: `server/services/ics-subscription.js`
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `fetchAndParse(url, etag?, lastModified?)` | Validate + fetch + parse (see §2.3) |
|
||||
| `sync(subscriptionId?)` | Sync one or all subscriptions (see §2.4) |
|
||||
| `getAll(userId)` | Return all subscriptions visible to userId (own + shared) |
|
||||
| `create(userId, { name, url, color, shared })` | Validate, insert, trigger initial sync. Return subscription + sync result (success or error message) |
|
||||
| `update(userId, id, fields)` | Update name/color/shared; only creator or admin |
|
||||
| `remove(userId, id)` | Delete subscription (events cascade); only creator or admin |
|
||||
|
||||
### 2.3 `fetchAndParse` — security hardening
|
||||
|
||||
1. **Scheme whitelist:** Only `https://` and `webcal://` (normalized to `https://`). Reject `http://`, `file://`, `ftp://`, `data://`.
|
||||
2. **DNS rebinding / SSRF protection:** After URL parsing, resolve the hostname. Reject if the resolved IP falls in private ranges: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `fc00::/7`, `fe80::/10`. Use `dns.resolve4` / `dns.resolve6` before passing to `fetch`.
|
||||
3. **Timeout:** 15 seconds per request (`AbortController` + `setTimeout`).
|
||||
4. **Response size limit:** Abort if `Content-Length > 10 MB` or if streamed body exceeds 10 MB.
|
||||
5. **Content-Type hint check:** Warn (but don't block) if response Content-Type is not `text/calendar`. Some servers serve ICS as `text/plain`.
|
||||
6. **Conditional fetch:** Send `If-None-Match: {etag}` and `If-Modified-Since: {lastModified}` headers. On `304 Not Modified`, skip parsing entirely and return early. On `200`, store new `etag` and `last-modified` response headers back to `ics_subscriptions`.
|
||||
|
||||
### 2.4 `sync` — operational details
|
||||
|
||||
1. Wrap the entire sync for one subscription in `BEGIN IMMEDIATE` / `COMMIT`. This turns N individual upserts into a single disk write.
|
||||
2. **Per-subscription mutex:** Maintain an in-memory `Set<subscriptionId>` of currently-syncing subscriptions. If a sync is already running for a given subscription (e.g. manual sync while periodic sync is active), skip it and return early. The Set is process-local — sufficient for single-process Oikos.
|
||||
3. **Upsert logic:** `INSERT ... ON CONFLICT(subscription_id, external_calendar_id) DO UPDATE SET ... WHERE user_modified = 0`. Events where `user_modified = 1` are untouched in a single statement — no per-row branching needed.
|
||||
4. **Stale event cleanup:** After upsert, delete events belonging to this subscription whose `external_calendar_id` is not in the current feed's UID set AND whose `user_modified = 0`. User-modified events whose upstream counterpart disappeared are kept (the user explicitly edited them).
|
||||
5. On fetch error: log warning, leave existing events and `last_sync` unchanged, continue to next subscription.
|
||||
|
||||
### 2.5 New routes: `/api/v1/calendar/subscriptions`
|
||||
|
||||
| Method | Path | Action | Auth |
|
||||
|--------|------|--------|------|
|
||||
| `GET` | `/` | List visible subscriptions | any user |
|
||||
| `POST` | `/` | Create subscription | any user |
|
||||
| `PATCH` | `/:id` | Update name/color/shared | creator or admin |
|
||||
| `DELETE` | `/:id` | Delete subscription + events | creator or admin |
|
||||
| `POST` | `/:id/sync` | Manual sync now | creator or admin |
|
||||
|
||||
All handlers in `try/catch`. Responses follow `{ data: ... }` / `{ error, code }`.
|
||||
|
||||
**Input validation on POST/PATCH:**
|
||||
- `url`: required, must parse as valid URL, scheme must be `https` or `webcal`
|
||||
- `name`: required, non-empty, max 100 chars
|
||||
- `color`: required on POST, must match `/^#[0-9a-fA-F]{6}$/`
|
||||
- `shared`: boolean-coercible integer (0 or 1)
|
||||
|
||||
### 2.6 Setting `user_modified`
|
||||
|
||||
When `PATCH /api/v1/calendar/events/:id` updates an event with `external_source = 'ics'`, the handler sets `user_modified = 1` automatically.
|
||||
|
||||
**New:** `PATCH /api/v1/calendar/events/:id/reset` sets `user_modified = 0` on an ICS event. The next sync cycle will overwrite it with upstream data. Returns `{ data: { reset: true } }`. Only the event creator, subscription creator, or admin can call this.
|
||||
|
||||
### 2.7 Sync integration
|
||||
|
||||
`server/index.js` `syncAll()` calls `icsSubscription.sync()` alongside existing Google/Apple sync. ICS sync runs last (lowest priority — Google/Apple are authenticated and more critical).
|
||||
|
||||
### 2.8 Orphan cleanup
|
||||
|
||||
After a user is deleted (`ON DELETE SET NULL` on `created_by`), run a sweep:
|
||||
|
||||
```sql
|
||||
DELETE FROM ics_subscriptions WHERE created_by IS NULL AND shared = 0;
|
||||
```
|
||||
|
||||
This removes private subscriptions that no one can see or manage. Shared orphans remain visible and editable by admins.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend
|
||||
|
||||
### 3.1 Settings page (`public/pages/settings.js`)
|
||||
|
||||
New card **"ICS-Abonnements"** in the existing "Kalender" tab, below Apple Calendar:
|
||||
|
||||
- List of visible subscriptions: color dot, name, visibility badge (`Privat` / `Geteilt`), last sync timestamp (via `formatDate()` + `formatTime()`), sync error indicator if last sync failed
|
||||
- **"Abonnement hinzufügen"** button reveals inline form:
|
||||
- URL input (required, `type="url"`)
|
||||
- Name input (required)
|
||||
- Color picker (`<input type="color">`)
|
||||
- Toggle "Für alle sichtbar" (default: off)
|
||||
- Submit / Cancel buttons
|
||||
- Per-subscription actions: "Jetzt synchronisieren" (shows spinner during sync), "Bearbeiten" (inline), "Löschen" (confirmation via existing confirm pattern)
|
||||
- Only creator or admin sees edit/delete/sync actions
|
||||
- Initial sync error on create: show inline warning with error message, subscription is still created
|
||||
|
||||
Rendered inline — no new Web Component. Consistent with Apple Calendar form pattern in the same tab.
|
||||
|
||||
### 3.2 Calendar page (`public/pages/calendar.js`)
|
||||
|
||||
- Events with `external_source = 'ics'` render with their subscription's color
|
||||
- No special UI indicator for `user_modified` — keeps UX clean
|
||||
- Event detail view for `user_modified = 1` events shows a subtle "Auf Original zurücksetzen" link that calls `PATCH .../reset`
|
||||
|
||||
### 3.3 i18n
|
||||
|
||||
All new strings in `public/locales/de.json`:
|
||||
|
||||
- `settings.ics.title` — "ICS-Abonnements"
|
||||
- `settings.ics.add` — "Abonnement hinzufügen"
|
||||
- `settings.ics.form.*` — URL, Name, Color, Shared toggle labels
|
||||
- `settings.ics.actions.*` — Sync, Edit, Delete labels
|
||||
- `settings.ics.status.*` — last sync, sync error, syncing states
|
||||
- `settings.ics.confirm_delete` — deletion confirmation
|
||||
- `settings.ics.badges.*` — "Privat", "Geteilt"
|
||||
- `calendar.ics.reset` — "Auf Original zurücksetzen"
|
||||
|
||||
`de` is the reference locale. Other locales fall back gracefully via `t()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| ICS URL unreachable / timeout | Log warning, keep existing events, leave `last_sync` unchanged |
|
||||
| 304 Not Modified | Skip parse, update `last_sync` timestamp only |
|
||||
| Invalid ICS content | Log warning, skip malformed VEVENTs, continue with valid ones |
|
||||
| URL returns non-ICS content (no `BEGIN:VCALENDAR`) | Log error, abort sync for this subscription |
|
||||
| Response > 10 MB | Abort fetch, log error |
|
||||
| SSRF attempt (private IP) | Reject with 400: "URL resolves to a private address" |
|
||||
| Unsupported URL scheme | Reject with 400: "Only https and webcal URLs are supported" |
|
||||
| RRULE with unsupported features | Log warning per event, fall back to single occurrence |
|
||||
| Unauthorized edit/delete | 403 response |
|
||||
| Duplicate URL across subscriptions | Allowed (user may want same feed with different color/name) |
|
||||
| Initial sync fails on create | Subscription created, error message returned in response body |
|
||||
| Concurrent sync on same subscription | Second sync skipped (in-memory mutex) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Commit Strategy
|
||||
|
||||
| # | Scope | Description |
|
||||
|---|-------|-------------|
|
||||
| 1 | `refactor(calendar)` | Extract ICS parser from `apple-calendar.js` into `server/services/ics-parser.js`. No logic changes. Existing Apple Calendar tests must still pass. |
|
||||
| 2 | `feat(calendar)` | Add `ics_subscriptions` table, `calendar_events` columns, unique index. Add RRULE expansion to parser. Migrations in correct order. |
|
||||
| 3 | `feat(calendar)` | Add `ics-subscription.js` service, routes, sync integration, security hardening. Backend tests. |
|
||||
| 4 | `feat(calendar)` | Frontend: settings card, calendar color rendering, reset flow, i18n keys. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Out of Scope
|
||||
|
||||
- CalDAV authentication (Basic/OAuth) — ICS-URL only (public or pre-authenticated URLs)
|
||||
- Per-event sync conflict resolution UI (beyond the reset button)
|
||||
- Subscription import/export
|
||||
- VTIMEZONE definitions beyond offset-based conversion (use system timezone as fallback)
|
||||
- RRULE features beyond FREQ/COUNT/UNTIL/INTERVAL/BYDAY/EXDATE
|
||||
@@ -0,0 +1,51 @@
|
||||
# Oikos UX/UI Audit & Implementation Plan
|
||||
|
||||
## 1. Design-System & Styling
|
||||
|
||||
### Issues Identified
|
||||
1. **Touch Targets Too Small**: `--target-sm: 32px` and `.btn--icon-sm` (36x36px) violate minimum touch target guidelines (44x44px or 48x48px). This causes usability issues on mobile devices (fat-finger syndrome).
|
||||
2. **Duplicated Dark Mode Tokens**: Dark mode CSS variables in `tokens.css` are duplicated across `@media (prefers-color-scheme: dark)` and `[data-theme="dark"]`, creating a maintenance nightmare.
|
||||
|
||||
### Implementation Steps
|
||||
- [x] **Fix Touch Targets**: `--target-base: 44px` Token ergänzt; `.btn--icon-sm` auf `min-height/min-width: var(--target-base)` korrigiert; `--target-sm` bleibt 32px als visuelle Größe (kein Touch-Target).
|
||||
- [ ] **Refactor Theme Tokens**: Bewusst übersprungen — der CSS-native `@media (prefers-color-scheme: dark)`-Block ist eine Stärke (Dark Mode ohne JS). Entfernen würde Nutzer ohne JS ohne Dark Mode lassen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components & Interaction
|
||||
|
||||
### Issues Identified
|
||||
1. **Mobile Modal Swipe-to-Close Bug**: If a user drags the modal down (`dy > 0`) and then back up (`dy < 0`) without lifting their finger, `dragging` is set to `false`. The `touchend` event is then ignored, leaving the modal stuck out of position.
|
||||
2. **Accessibility (A11y) Violation**: The `.modal-overlay` element uses `role="presentation"` alongside an `aria-label`. `role="presentation"` hides the element from screen readers, conflicting with the label and its function as a clickable close area.
|
||||
3. **Misplaced Utility Functions**: Generic UI helpers (`wireBlurValidation`, `validateAll`, `btnSuccess`, `btnLoading`, `btnError`) are hardcoded in `pages/dashboard.js` instead of a shared utility file.
|
||||
|
||||
### Implementation Steps
|
||||
- [x] **Fix Swipe Bug (`modal.js`)**: `touchmove`-Handler korrigiert — bei `dy < 0` wird Panel auf `translateY(0)` zurückgesetzt, `dragging` bleibt `true`.
|
||||
- [x] **Fix Modal A11y (`modal.js`)**: `role="presentation"` aus `.modal-overlay` entfernt.
|
||||
- [x] **Relocate Utilities**: Bereits erledigt — `wireBlurValidation`, `validateAll`, `btnSuccess`, `btnLoading`, `btnError` sind in `utils/ux.js` (Zeilen 538–620).
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout, Navigation & Routing
|
||||
|
||||
### Issues Identified
|
||||
1. **FOUC (Flash of Unstyled Content) on Navigation**: In `router.js`, `loadPageStyle` removes the old stylesheet before the new page transition animation (`page-transition--in-right`) completes, causing layout jumps.
|
||||
2. **Missing Focus Trap in Global Search**: The `#search-overlay` does not use the focus trap logic from `modal.js`. Users can tab out of the search overlay into the hidden page below.
|
||||
3. **SVG ID Collision Risk**: The logo generated in `router.js` uses a hardcoded ID (`id="oikos-logo-bg"`) for its gradient. If reused, this will break rendering.
|
||||
|
||||
### Implementation Steps
|
||||
- [ ] **Fix Routing FOUC (`router.js`)**: Kein echter Bug — `style.cleanup()` wird vor `module.render()` aufgerufen, aber die neue Seite startet `opacity: 0`. Kein sichtbares FOUC in der aktuellen Implementierung.
|
||||
- [x] **Add Search Focus Trap (`router.js`)**: Eigenständiger Focus Trap in `openSearch`/`closeSearch` implementiert (ohne modal.js-Kopplung).
|
||||
- [x] **Fix SVG IDs (`router.js`)**: Gradient-ID wird nun mit `Math.random().toString(36)`-Suffix generiert.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dashboard
|
||||
|
||||
### Issues Identified
|
||||
1. **Wasted Space from Large Empty States**: Empty widgets (e.g., no tasks) render a large "Empty State" UI. On mobile, this pushes populated widgets below the fold.
|
||||
2. **Lack of Visual Feedback in Customization**: Reordering widgets in the customize modal (`rebuildList()`) happens instantly without transition, feeling jarring.
|
||||
|
||||
### Implementation Steps
|
||||
- [x] **Compact Empty States (`dashboard.css`)**: `.widget__empty` auf horizontales Row-Layout umgestellt, Icon 28→20px, Padding reduziert — spart ~40px vertikalen Platz pro leerem Widget.
|
||||
- [x] **Animate Widget Reordering (`dashboard.js`)**: `rebuildList()` nutzt nun `document.startViewTransition()` mit `prefers-reduced-motion`-Guard und `view-transition-name` je Row.
|
||||
Reference in New Issue
Block a user