diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52236f8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# Oikos + +Self-hosted family planner PWA. Node.js/Express, Vanilla JS (ES modules, no build step), SQLite/SQLCipher, Docker. + +## Quick Reference + +```bash +npm start # Production server (PORT from .env, default 3000) +npm run dev # Development with --watch +npm test # All test suites (requires Node ≥22 for --experimental-sqlite) +docker compose up -d # Production deployment +``` + +## Architecture + +``` +server/ + index.js # Express entry, middleware chain, static serving + db.js # SQLite/SQLCipher connection, migrations + auth.js # Session auth middleware + login/logout/user-mgmt routes + routes/ # One file per module: tasks, shopping, meals, calendar, notes, contacts, budget, weather + services/ # google-calendar.js, apple-calendar.js, recurrence.js (RRULE) +public/ + index.html # SPA shell — single entry point + router.js # History API router (~50 lines, no library) + api.js # Fetch wrapper: auth, CSRF, error handling + styles/ # tokens.css (design tokens), reset.css, layout.css, [module].css + components/ # Web Components: oikos-[module]-[name].js + pages/ # Page modules loaded by router + sw.js # Service worker (app-shell caching) + manifest.json +docs/ + SPEC.md # Full product spec — module definitions, data model, design system +``` + +**Request flow:** Client → Express static (public/) or `/api/v1/*` → auth middleware (session check) → route handler → better-sqlite3 (sync) → JSON response. + +**No SPA framework.** Client-side routing via History API. Pages are ES modules that export a `render()` function. Web Components for reusable UI. No React, Vue, Svelte, or build tooling. + +## Code Conventions + +- ES modules everywhere (`type: "module"` in package.json, `import`/`export` in all JS) +- Semicolons: yes +- Web Component prefix: `oikos-` (not `fb-`), one component per file +- All UI text in German. Dates: `DD.MM.YYYY`. Times: `HH:MM` (24h) +- API responses: `{ data: ... }` on success, `{ error: string, code: number }` on failure +- Every route handler: `try/catch` wrapping, no unhandled promise rejections +- No `eval()`, no `innerHTML` with user input — use `textContent` or DOM API +- No external frontend dependencies except Lucide Icons (SVG sprite, self-hosted — no CDN at runtime) +- Backend deps minimal: express, better-sqlite3, bcrypt, express-session, express-rate-limit, helmet, dotenv +- Header comment in every file: purpose, module, dependencies + +## Testing + +Tests use Node.js built-in test runner with `--experimental-sqlite` for in-memory SQLite (no SQLCipher dep in tests). Each module gets a test file in `tests/`. Run: `npm test`. Add new tests: create `tests/[module].test.js`, it auto-discovers via glob pattern. + +## Security Model + +- **Auth:** Session-based. `express-session` with SQLite store, `httpOnly + secure + sameSite: strict` cookies. 7-day TTL. No public registration — admin creates users. +- **CSRF:** Double Submit Cookie pattern. Backend sets `csrfToken` cookie; frontend sends it as `X-CSRF-Token` header. Validate on all state-changing requests. +- **Rate limiting:** 5 login attempts/min/IP, 15-min lockout via `express-rate-limit`. +- **Passwords:** bcrypt, cost factor 12. +- **Headers:** `helmet()` defaults + strict CSP allowing only `'self'`. + +## Database + +SQLite via `better-sqlite3`. Optional SQLCipher encryption (AES-256) — enabled when `DB_ENCRYPTION_KEY` is set in `.env`. + +**Migrations:** `server/db.js` runs migrations sequentially on startup. Each migration is an idempotent SQL block in a `migrations` array. Add new tables/columns by appending to that array — never modify existing entries. + +**Schema conventions:** Every table has `id INTEGER PRIMARY KEY`, `created_at TEXT DEFAULT (datetime('now'))`, `updated_at TEXT DEFAULT (datetime('now'))`. Foreign keys enforced via `PRAGMA foreign_keys = ON`. + +## Deployment + +```dockerfile +# Base: node:20-slim + SQLCipher build deps (libsqlcipher-dev) +# Volume: /app/data (SQLite DB file) +# Expose: 3000 +``` + +Required env vars: `SESSION_SECRET`, `DB_ENCRYPTION_KEY` (optional), `PORT` (default 3000). +Optional: `OPENWEATHERMAP_API_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. + +Runs behind Nginx reverse proxy with SSL. Example config in `nginx.conf.example`. + +## Bootstrap Sequence + +When starting from scratch, follow this order: + +1. `npm init` + install deps + `.env.example` + `.gitignore` + `Dockerfile` + `docker-compose.yml` +2. Express server + SQLite connection + migration runner + auth system +3. Frontend app shell: SPA router, nav, layout, CSS design tokens +4. Modules one by one (see `docs/SPEC.md` for detailed specs per module) +5. Cross-module integrations (meal→shopping, dashboard widgets) +6. PWA (service worker, manifest, offline shell) +7. Security hardening (CSRF, rate limiting, CSP, input validation) + +Read `docs/SPEC.md` before implementing any module — it contains the data model, UI specs, and design system. diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..53fa8b0 --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,267 @@ +# Oikos — Produktspezifikation + +Selbstgehostete Familienplaner-Web-App für eine einzelne Familie (2–6 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 | +| created_by | INTEGER | FK → Users, 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 3–5, farbcodiert nach Person +- Dringende Aufgaben: priority urgent/high + due_date ≤48h +- Heutiges Essen: Mahlzeiten des Tages +- Pinnwand-Vorschau: 2–3 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) + +### Essensplan (`/meals`) + +Wochenansicht (Mo–So), 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) + +### Kontakte (`/contacts`) + +- CRUD mit Kategorie-Filter +- Telefon: `tel:`-Link, E-Mail: `mailto:`-Link +- Adresse: Maps-Link (Google/Apple via User-Agent) +- Echtzeit-Suchfilter + +### 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 600–700 +- 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)` +- **Buttons:** Primär = Accent + weiß. Sekundär = Outline. Min-Höhe 44px +- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px +- **Navigation:** Bottom Tab Bar mobil (Dashboard, Aufgaben, Kalender, Essen, Mehr). Sidebar Desktop +- **Transitions:** `all 0.2s ease`. Seiten: Slide-Animation +- **Empty States:** Illustration + CTA in jeder Liste + +### Breakpoints +- Mobil: < 768px (1 Spalte, Bottom Nav) +- Tablet: 768–1024px (2 Spalten, Bottom Nav) +- Desktop: > 1024px (Sidebar + Content)