Files
oikos/docs/SPEC.md
T

347 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Oikos — Produktspezifikation
Selbstgehostete Familienplaner-Web-App für eine einzelne Familie (26 Personen). Kein App-Store, kein öffentlicher Zugang. Deployment via Docker auf privatem Linux-Server hinter Nginx Reverse Proxy mit SSL.
---
## Datenmodell
Jede Tabelle: `id INTEGER PRIMARY KEY`, `created_at TEXT`, `updated_at TEXT` (ISO 8601).
### Users
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| username | TEXT | UNIQUE NOT NULL |
| display_name | TEXT | |
| password_hash | TEXT | bcrypt |
| avatar_color | TEXT | HEX-Farbcode |
| role | TEXT | 'admin' oder 'member' |
### Tasks
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| title | TEXT | NOT NULL |
| description | TEXT | |
| category | TEXT | Haushalt, Schule, Einkauf, Reparatur, Sonstiges |
| priority | TEXT | low, medium, high, urgent |
| status | TEXT | open, in_progress, done |
| due_date | TEXT | DATE, nullable |
| due_time | TEXT | TIME, nullable |
| assigned_to | INTEGER | FK → Users |
| created_by | INTEGER | FK → Users, NOT NULL |
| is_recurring | INTEGER | 0/1 |
| recurrence_rule | TEXT | iCal RRULE |
| parent_task_id | INTEGER | FK → Tasks (max 2 Ebenen) |
### Shopping Lists
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| name | TEXT | NOT NULL (z.B. "REWE", "Baumarkt") |
### Shopping Items
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| list_id | INTEGER | FK → Shopping Lists, NOT NULL |
| name | TEXT | NOT NULL |
| quantity | TEXT | z.B. "500g", "2 Stück" |
| category | TEXT | Obst & Gemüse, Milchprodukte, Fleisch & Fisch, Backwaren, Getränke, Tiefkühl, Haushalt, Drogerie, Sonstiges |
| is_checked | INTEGER | 0/1 |
| added_from_meal | INTEGER | FK → Meals, nullable |
### Meals
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| date | TEXT | DATE, NOT NULL |
| meal_type | TEXT | breakfast, lunch, dinner, snack |
| title | TEXT | NOT NULL |
| notes | TEXT | |
| created_by | INTEGER | FK → Users, NOT NULL |
### Meal Ingredients
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| meal_id | INTEGER | FK → Meals, NOT NULL |
| name | TEXT | NOT NULL |
| quantity | TEXT | |
| on_shopping_list | INTEGER | 0/1 |
### Calendar Events
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| title | TEXT | NOT NULL |
| description | TEXT | |
| start_datetime | TEXT | DATETIME, NOT NULL |
| end_datetime | TEXT | DATETIME |
| all_day | INTEGER | 0/1 |
| location | TEXT | |
| color | TEXT | HEX |
| assigned_to | INTEGER | FK → Users |
| created_by | INTEGER | FK → Users, NOT NULL |
| external_calendar_id | TEXT | ID aus externem Kalender |
| external_source | TEXT | local, google, apple |
| recurrence_rule | TEXT | iCal RRULE |
### Notes
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| title | TEXT | nullable |
| content | TEXT | NOT NULL |
| color | TEXT | HEX |
| pinned | INTEGER | 0/1 |
| created_by | INTEGER | FK → Users, NOT NULL |
### Contacts
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| name | TEXT | NOT NULL |
| category | TEXT | Arzt, Schule/Kita, Behörde, Versicherung, Handwerker, Notfall, Sonstiges |
| phone | TEXT | |
| email | TEXT | |
| address | TEXT | |
| notes | TEXT | |
### Budget Entries
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| title | TEXT | NOT NULL |
| amount | REAL | NOT NULL (positiv=Einnahme, negativ=Ausgabe) |
| category | TEXT | Lebensmittel, Miete, Versicherung, Mobilität, Freizeit, Kleidung, Gesundheit, Bildung, Sonstiges |
| date | TEXT | DATE, NOT NULL |
| is_recurring | INTEGER | 0/1 |
| recurrence_rule | TEXT | iCal RRULE |
| recurrence_parent_id | INTEGER | FK → Budget Entries (generierte Instanz zeigt auf Original) |
| created_by | INTEGER | FK → Users, NOT NULL |
### Budget Recurrence Skipped
Speichert vom Nutzer gelöschte Instanzen eines wiederkehrenden Eintrags, damit sie nicht erneut generiert werden.
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| parent_id | INTEGER | FK → Budget Entries, NOT NULL |
| month | TEXT | YYYY-MM, NOT NULL |
| PRIMARY KEY | | (parent_id, month) |
### Sync Config
Schlüssel-Wert-Tabelle für OAuth-Tokens und CalDAV-Credentials.
| Spalte | Typ | Constraint |
|--------|-----|-----------|
| key | TEXT | PRIMARY KEY |
| value | TEXT | NOT NULL |
---
## Module
### Dashboard (`/`)
Responsive Grid: 1 Spalte mobil, 2 Tablet, 3 Desktop.
**Widgets:**
- Begrüßung: "Guten [Morgen/Tag/Abend], [Name]" + Datum
- Wetter: OpenWeatherMap-Proxy, 3-Tage-Vorschau, Refresh 30min, bei API-Fehler Widget ausblenden
- Anstehende Termine: nächste 35, farbcodiert nach Person
- Dringende Aufgaben: priority urgent/high + due_date ≤48h
- Heutiges Essen: Mahlzeiten des Tages
- Pinnwand-Vorschau: 23 angepinnte Notizen
- FAB (Schnellaktionen): + Aufgabe, + Termin, + Einkaufslisteneintrag, + Notiz
Skeleton-Loading statt Spinner. Klick auf jedes Widget navigiert zum Modul.
### Aufgaben (`/tasks`)
**Ansichten:**
- Listenansicht (Standard): gruppiert nach Kategorie oder Fälligkeit (umschaltbar), Filter: Person, Priorität, Status
- Kanban: Spalten Offen → In Bearbeitung → Erledigt, Drag & Drop
**Features:**
- CRUD + Teilaufgaben (max 2 Ebenen, Checkbox-Liste, Fortschrittsbalken)
- Zuweisung an User (Avatar-Farbe als Indikator)
- Prioritäten visuell durch Farbe/Icon
- Wiederkehrend: bei Erledigung nächste Instanz automatisch erstellen
- Swipe mobil: links = erledigt, rechts = bearbeiten
- Badge bei überfälligen Aufgaben
### Einkaufslisten (`/shopping`)
- Mehrere Listen parallel
- Artikel: Name, Kategorie, Menge, Checkbox
- Gruppierung nach Kategorie (Gang-Logik)
- Integration mit Essensplan: "Zutaten auf Einkaufsliste" überträgt mit Quell-Referenz
- Erledigte Artikel durchgestrichen + nach unten
- "Liste leeren" = nur abgehakte entfernen
- Autocomplete aus bisherigen Einträgen (lokal)
- Swipe mobil: links = abhaken/zurück, rechts = löschen; × Löschen-Button auf Mobile ausgeblendet (Swipe übernimmt)
### Essensplan (`/meals`)
Wochenansicht (MoSo), Slots: Frühstück/Mittag/Abend/Snack.
- Mahlzeit: Titel + Notizen + Zutatenliste
- Button "→ Einkaufsliste": nicht-abgehakte Zutaten der Woche auf wählbare Liste übertragen
- Wochennavigation vor/zurück
- Drag & Drop zwischen Tagen/Slots
- Autocomplete aus Mahlzeiten-Historie
### Kalender (`/calendar`)
**Ansichten:** Monat (Standard, Punkt-Indikatoren), Woche (Stundenraster), Tag (Timeline), Agenda (Liste).
- CRUD: Titel, Beschreibung, Start/Ende, Ganztägig, Ort, Farbe, Zuweisung
- Farbcodierung pro Person
- Wiederkehrend via iCal RRULE
- **Google Calendar:** OAuth 2.0, Calendar API v3, Zwei-Wege-Sync
- **Apple Calendar:** CalDAV (tsdav), Zwei-Wege-Sync
- Sync-Intervall konfigurierbar (Standard 15min)
- Externe Termine visuell unterscheidbar
- Konflikte: externes Event gewinnt, lokale Ergänzungen bleiben
### Pinnwand (`/notes`)
Masonry-Grid mit farbigen Sticky Notes.
- CRUD: Titel (optional), Inhalt, Farbe
- Anpinnen → erscheint oben + Dashboard
- Ersteller angezeigt (Avatar-Farbe)
- Markdown-Light: fett, kursiv, Listen (regex-basiert)
- Volltextsuche: client-seitige Filterleiste, filtert sofort nach Titel + Inhalt
### Kontakte (`/contacts`)
- CRUD mit Kategorie-Filter
- Telefon: `tel:`-Link, E-Mail: `mailto:`-Link
- Adresse: Maps-Link (Google/Apple via User-Agent)
- Echtzeit-Suchfilter
- vCard-Export: jeder Kontakt als `.vcf` herunterladbar (`GET /api/v1/contacts/:id/vcard`)
- vCard-Import: Datei hochladen → client-seitiger Parser (FN, TEL, EMAIL, ADR, NOTE, CATEGORIES) → Kontakt anlegen
### Login (`/login`)
Nicht-authentifizierte Nutzer werden hierhin umgeleitet. Kein öffentliches Registrierungsformular — Admin erstellt Benutzer über Setup-Wizard (`setup.js`) oder Settings.
- Username + Passwort-Formular
- Fehleranzeige bei falschen Credentials
- Rate-Limiting: 5 Versuche/min/IP, 15-min Lockout
- Nach erfolgreichem Login: Redirect auf Dashboard
### Einstellungen (`/settings`)
Benutzerverwaltung und App-Konfiguration. Nur für eingeloggte Nutzer.
- **Profil:** Display-Name, Avatar-Farbe ändern, Passwort ändern
- **Benutzerverwaltung (Admin):** Neue Benutzer anlegen, bestehende Benutzer bearbeiten/löschen, Rollen zuweisen (admin/member)
- **Kalender-Integration:** Google Calendar OAuth verbinden/trennen, Apple Calendar (CalDAV) Credentials hinterlegen, Sync-Intervall konfigurieren
- **Wetter:** OpenWeatherMap Standort konfigurieren
- **Sprache:** System (folgt `navigator.language`), Deutsch, English — via `oikos-locale-picker` Web Component; Wechsel ohne Reload
- **App-Info:** Version, Lizenz
### Budget (`/budget`)
**Ansichten:**
- Monatsübersicht: Einnahmen vs. Ausgaben, Saldo, Balkendiagramm nach Kategorie (Canvas, keine Library)
- Transaktionsliste: chronologisch, filterbar
- CRUD: Titel, Betrag, Kategorie, Datum
- Wiederkehrende Buchungen
- Monatsvergleich (aktuell vs. Vormonat)
- CSV-Export
---
## Design-System
### Farben (CSS Custom Properties)
```css
:root {
--color-bg: #F5F5F7;
--color-surface: #FFFFFF;
--color-border: #E5E5EA;
--color-text-primary: #1C1C1E;
--color-text-secondary: #8E8E93;
--color-accent: #007AFF;
--color-accent-light: #E3F2FF;
--color-success: #34C759;
--color-warning: #FF9500;
--color-danger: #FF3B30;
--color-info: #5AC8FA;
--color-priority-low: #8E8E93;
--color-priority-medium: #FF9500;
--color-priority-high: #FF6B35;
--color-priority-urgent: #FF3B30;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1C1C1E;
--color-surface: #2C2C2E;
--color-border: #3A3A3C;
--color-text-primary: #F5F5F7;
--color-text-secondary: #8E8E93;
}
}
```
### Typografie
- System Font Stack, Überschriften 600700
- Body: 16px mobil, 15px Desktop, line-height 1.5
- Caption: 13px, `var(--color-text-secondary)`
### Komponenten
- **Cards:** `var(--color-surface)`, `var(--radius-md)`, `var(--shadow-sm)`. Einheitliches Padding `var(--space-4)` (16px) in allen Modulen.
- **Buttons:** Primär = Accent + weiß. Sekundär = Outline. Min-Höhe 44px. Submit-Buttons zeigen Erfolg (Checkmark, 700ms grün via `.btn--success`) und Fehler (Shake via `.btn--shaking`).
- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px. `[required]`-Felder erhalten bei Blur Validierungsstatus (`.form-field--error` / `.form-field--valid`). Enter navigiert zum nächsten Feld; Enter im letzten Feld löst Submit aus.
- **FAB (Floating Action Button):** Farbe folgt dem Modul-Akzent-Token (`--module-accent`) — jedes Modul definiert seine eigene Akzentfarbe. Wird ausgeblendet, wenn die virtuelle Tastatur geöffnet ist (`visualViewport.resize`, Schwellwert 75% der Fensterhöhe).
- **Modul-Akzentfarben:** `--module-accent` wird auf drei visuellen Ebenen angewendet — (1) aktiver Nav-Tab (Bottom Bar + Sidebar-Streifen), (2) Toolbar `border-top: 3px`, (3) Karten/Zeilen `border-left: 3px`. Der aktive Akzent wird bei jedem Navigationswechsel als `--active-module-accent` auf `:root` geschrieben. Fallback auf `--color-accent` für Seiten ohne Modul-Kontext.
- **Navigation:** Bottom Tab Bar mobil (Dashboard, Aufgaben, Kalender, Essen, Mehr). Sidebar Desktop.
- **Transitions:** Direktionale Slide-X-Animation bei Seitenwechsel (vorwärts = von rechts, rückwärts = von links, 200ms). Respektiert `prefers-reduced-motion`.
- **Empty States:** Einheitliche `.empty-state`-Klasse in allen Modulen (Icon + Titel + Beschreibung, zentriert). Kompakte Variante `.empty-state--compact` für Mahlzeiten-Slots.
- **Modals:** Auf Desktop zentriertes Panel. Auf Mobile (< 768px) Bottom Sheet — fährt von unten ein, Sheet-Handle sichtbar, Swipe-to-Close (> 80px nach unten). `focusin` scrollt Inputs bei virtueller Tastatur in den sichtbaren Bereich.
- **Listen-Animation:** Staggered Fade-In beim Laden (`stagger()` aus `public/utils/ux.js`) — max. 5 Elemente gestaffelt (30ms Abstand), Rest sofort.
- **Vibration:** `vibrate()` aus `public/utils/ux.js` — kurze Impulse bei leichten Aktionen (1040ms), Muster `[30, 50, 30]` bei destructiven Aktionen (Löschen). Respektiert `prefers-reduced-motion`.
- **PWA Install Prompt:** Erscheint erst nach 2 Nutzer-Interaktionen. Dismiss-Fenster 7 Tage; nach Dismiss wird der Interaktionszähler zurückgesetzt.
- **PWA Offline-Fallback:** Service Worker liefert `/offline.html` wenn das Netz nicht erreichbar und `index.html` nicht gecacht ist. Enthält Reload-Button.
### Breakpoints
- Mobil: < 768px (1 Spalte, Bottom Nav)
- Tablet: 7681024px (2 Spalten, Bottom Nav)
- Desktop: > 1024px (Sidebar + Content)
---
## Internationalisierung (i18n)
Alle UI-Strings werden über `public/i18n.js` verwaltet. Kein hardcodierter Text in JS-Dateien außer in Locale-Dateien.
### Architektur
- **Modul:** `public/i18n.js` — exports: `initI18n()`, `setLocale()`, `t(key, params?)`, `getLocale()`, `getSupportedLocales()`, `formatDate(date)`, `formatTime(date)`
- **Locale-Dateien:** `public/locales/de.json` (Referenz), `public/locales/en.json` — Struktur: `{ "modul.camelCaseKey": "Wert" }`
- **Variablen:** `{{variable}}`-Syntax in Übersetzungsstrings, z.B. `t('tasks.assignedTo', { name: 'Anna' })`
- **Fallback-Kette:** aktive Locale → Deutsch (`de`) → Key selbst
- **Datumsformat:** `Intl.DateTimeFormat` mit aktuellem Locale — `formatDate()` und `formatTime()` aus `i18n.js`
### Sprach-Erkennung
1. `localStorage` Eintrag `oikos-locale` (manuelle Auswahl)
2. `navigator.languages[0]` (Browser-Sprache)
3. Fallback: `de`
### Neue Sprache hinzufügen
1. `public/locales/xx.json` erstellen (Kopie von `de.json`, übersetzen)
2. `SUPPORTED_LOCALES` in `public/i18n.js` um `'xx'` erweitern
3. Label in `oikos-locale-picker` ergänzen (`LOCALE_LABELS['xx'] = 'Name'`)
### Locale-Wechsel
`setLocale(locale)` speichert die Auswahl, lädt die neue Locale-Datei und feuert das `locale-changed` Custom Event. Alle Seiten-Module und Web Components hören dieses Event und rendern sich neu — kein Seiten-Reload nötig.