style: replace em dashes with hyphens throughout codebase
Replace all — with - in all source files (JS, CSS, HTML, JSON, Markdown) for consistency and readability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Oikos — Umgebungsvariablen
|
# Oikos - Umgebungsvariablen
|
||||||
# Kopiere diese Datei nach .env und passe die Werte an.
|
# Kopiere diese Datei nach .env und passe die Werte an.
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ What problem does this solve? Describe the use case from a family member's persp
|
|||||||
|
|
||||||
## Proposed Solution
|
## Proposed Solution
|
||||||
|
|
||||||
How do you envision this working? Be as specific as you like — UI sketch, API shape, interaction flow.
|
How do you envision this working? Be as specific as you like - UI sketch, API shape, interaction flow.
|
||||||
|
|
||||||
## Alternatives Considered
|
## Alternatives Considered
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [0.5.8] - 2026-04-03
|
## [0.5.8] - 2026-04-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Add Italian (Italiano) localization — full translation of all 497 i18n keys (thanks @albanobattistella, PR #7)
|
- Add Italian (Italiano) localization - full translation of all 497 i18n keys (thanks @albanobattistella, PR #7)
|
||||||
- Add Italian as selectable language in Settings locale picker
|
- Add Italian as selectable language in Settings locale picker
|
||||||
|
|
||||||
## [0.5.7] - 2026-04-03
|
## [0.5.7] - 2026-04-03
|
||||||
|
|||||||
+1
-1
@@ -184,7 +184,7 @@ PRs are reviewed by the maintainer. Expect feedback within a few days. Once appr
|
|||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
- Web Component prefix: `oikos-` (one component per file)
|
- Web Component prefix: `oikos-` (one component per file)
|
||||||
- All UI text via i18n keys (`t('key')`) — never hardcode text in components. German (`de`) is the reference locale.
|
- All UI text via i18n keys (`t('key')`) - never hardcode text in components. German (`de`) is the reference locale.
|
||||||
- Date format: `DD.MM.YYYY` - Time format: `HH:MM` (24h)
|
- Date format: `DD.MM.YYYY` - Time format: `HH:MM` (24h)
|
||||||
- CSS uses design tokens from `public/styles/tokens.css` - never hardcode values
|
- CSS uses design tokens from `public/styles/tokens.css` - never hardcode values
|
||||||
- Pages export a `render()` function, no side effects on import
|
- Pages export a `render()` function, no side effects on import
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ Oikos uses a flat family authorization model:
|
|||||||
- **Admin** can create, edit, and delete all user accounts and all shared data.
|
- **Admin** can create, edit, and delete all user accounts and all shared data.
|
||||||
- **Member** can read and write all shared data (tasks, shopping lists, meals, calendar events, notes, contacts, budget entries) but cannot manage user accounts.
|
- **Member** can read and write all shared data (tasks, shopping lists, meals, calendar events, notes, contacts, budget entries) but cannot manage user accounts.
|
||||||
|
|
||||||
There is no per-user data isolation — all family members see and can edit all data. This is intentional: Oikos is a shared family planner, not a multi-tenant application.
|
There is no per-user data isolation - all family members see and can edit all data. This is intentional: Oikos is a shared family planner, not a multi-tenant application.
|
||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -1,4 +1,4 @@
|
|||||||
# Oikos — Product Specification
|
# Oikos - Product Specification
|
||||||
|
|
||||||
Self-hosted family planner web app for a single household (2–6 people). No app store, no public access. Deployment via Docker on a private Linux server behind an Nginx reverse proxy with SSL.
|
Self-hosted family planner web app for a single household (2–6 people). No app store, no public access. Deployment via Docker on a private Linux server behind an Nginx reverse proxy with SSL.
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ Masonry grid with colored sticky notes.
|
|||||||
|
|
||||||
### Login (`/login`)
|
### Login (`/login`)
|
||||||
|
|
||||||
Unauthenticated users are redirected here. No public registration form — admin creates users via setup wizard (`setup.js`) or Settings.
|
Unauthenticated users are redirected here. No public registration form - admin creates users via setup wizard (`setup.js`) or Settings.
|
||||||
|
|
||||||
- Username + password form
|
- Username + password form
|
||||||
- Error display for wrong credentials
|
- Error display for wrong credentials
|
||||||
@@ -232,7 +232,7 @@ User management and app configuration. Logged-in users only.
|
|||||||
- **User management (admin):** create new users, edit/delete existing users, assign roles (admin/member)
|
- **User management (admin):** create new users, edit/delete existing users, assign roles (admin/member)
|
||||||
- **Calendar integration:** connect/disconnect Google Calendar OAuth, store Apple Calendar (CalDAV) credentials, configure sync interval
|
- **Calendar integration:** connect/disconnect Google Calendar OAuth, store Apple Calendar (CalDAV) credentials, configure sync interval
|
||||||
- **Weather:** configure OpenWeatherMap location
|
- **Weather:** configure OpenWeatherMap location
|
||||||
- **Language:** System (follows `navigator.language`), German, English — via `oikos-locale-picker` web component; switch without page reload
|
- **Language:** System (follows `navigator.language`), German, English - via `oikos-locale-picker` web component; switch without page reload
|
||||||
- **App info:** version, license
|
- **App info:** version, license
|
||||||
|
|
||||||
### Budget (`/budget`)
|
### Budget (`/budget`)
|
||||||
@@ -299,14 +299,14 @@ User management and app configuration. Logged-in users only.
|
|||||||
- **Cards:** `var(--color-surface)`, `var(--radius-md)`, `var(--shadow-sm)`. Consistent padding `var(--space-4)` (16px) across all modules.
|
- **Cards:** `var(--color-surface)`, `var(--radius-md)`, `var(--shadow-sm)`. Consistent padding `var(--space-4)` (16px) across all modules.
|
||||||
- **Buttons:** Primary = accent + white. Secondary = outline. Min-height 44px. Submit buttons show success (checkmark, 700ms green via `.btn--success`) and error (shake via `.btn--shaking`).
|
- **Buttons:** Primary = accent + white. Secondary = outline. Min-height 44px. Submit buttons show success (checkmark, 700ms green via `.btn--success`) and error (shake via `.btn--shaking`).
|
||||||
- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px. `[required]` fields receive validation status on blur (`.form-field--error` / `.form-field--valid`). Enter moves focus to the next field; Enter on the last field triggers submit.
|
- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px. `[required]` fields receive validation status on blur (`.form-field--error` / `.form-field--valid`). Enter moves focus to the next field; Enter on the last field triggers submit.
|
||||||
- **FAB (Floating Action Button):** Color follows the module accent token (`--module-accent`) — each module defines its own accent color. Hidden when the virtual keyboard is open (`visualViewport.resize`, threshold 75% of window height).
|
- **FAB (Floating Action Button):** Color follows the module accent token (`--module-accent`) - each module defines its own accent color. Hidden when the virtual keyboard is open (`visualViewport.resize`, threshold 75% of window height).
|
||||||
- **Module accent colors:** `--module-accent` is applied on three visual layers — (1) active nav tab (bottom bar + sidebar stripe), (2) toolbar `border-top: 3px`, (3) cards/rows `border-left: 3px`. The active accent is written to `--active-module-accent` on `:root` on every navigation change. Falls back to `--color-accent` for pages without a module context.
|
- **Module accent colors:** `--module-accent` is applied on three visual layers - (1) active nav tab (bottom bar + sidebar stripe), (2) toolbar `border-top: 3px`, (3) cards/rows `border-left: 3px`. The active accent is written to `--active-module-accent` on `:root` on every navigation change. Falls back to `--color-accent` for pages without a module context.
|
||||||
- **Navigation:** Bottom tab bar on mobile (Dashboard, Tasks, Calendar, Meals, More). Sidebar on desktop.
|
- **Navigation:** Bottom tab bar on mobile (Dashboard, Tasks, Calendar, Meals, More). Sidebar on desktop.
|
||||||
- **Transitions:** Directional slide-X animation on page change (forward = from right, back = from left, 200ms). Respects `prefers-reduced-motion`.
|
- **Transitions:** Directional slide-X animation on page change (forward = from right, back = from left, 200ms). Respects `prefers-reduced-motion`.
|
||||||
- **Empty states:** Consistent `.empty-state` class across all modules (icon + title + description, centered). Compact variant `.empty-state--compact` for meal slots.
|
- **Empty states:** Consistent `.empty-state` class across all modules (icon + title + description, centered). Compact variant `.empty-state--compact` for meal slots.
|
||||||
- **Modals:** Centered panel on desktop. On mobile (< 768px) bottom sheet — slides in from below, sheet handle visible, swipe-to-close (> 80px downward). `focusin` scrolls inputs into view when the virtual keyboard is open.
|
- **Modals:** Centered panel on desktop. On mobile (< 768px) bottom sheet - slides in from below, sheet handle visible, swipe-to-close (> 80px downward). `focusin` scrolls inputs into view when the virtual keyboard is open.
|
||||||
- **List animation:** Staggered fade-in on load (`stagger()` from `public/utils/ux.js`) — max 5 elements staggered (30ms gap), rest appear immediately.
|
- **List animation:** Staggered fade-in on load (`stagger()` from `public/utils/ux.js`) - max 5 elements staggered (30ms gap), rest appear immediately.
|
||||||
- **Vibration:** `vibrate()` from `public/utils/ux.js` — short pulses for light actions (10–40ms), pattern `[30, 50, 30]` for destructive actions (delete). Respects `prefers-reduced-motion`.
|
- **Vibration:** `vibrate()` from `public/utils/ux.js` - short pulses for light actions (10–40ms), pattern `[30, 50, 30]` for destructive actions (delete). Respects `prefers-reduced-motion`.
|
||||||
- **PWA install prompt:** Appears only after 2 user interactions. Dismiss window 7 days; interaction counter resets after dismiss.
|
- **PWA install prompt:** Appears only after 2 user interactions. Dismiss window 7 days; interaction counter resets after dismiss.
|
||||||
- **PWA offline fallback:** Service worker serves `/offline.html` when the network is unreachable and `index.html` is not cached. Includes a reload button.
|
- **PWA offline fallback:** Service worker serves `/offline.html` when the network is unreachable and `index.html` is not cached. Includes a reload button.
|
||||||
|
|
||||||
@@ -323,11 +323,11 @@ All UI strings are managed via `public/i18n.js`. No hardcoded text in JS files o
|
|||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
- **Module:** `public/i18n.js` — exports: `initI18n()`, `setLocale()`, `t(key, params?)`, `getLocale()`, `getSupportedLocales()`, `formatDate(date)`, `formatTime(date)`
|
- **Module:** `public/i18n.js` - exports: `initI18n()`, `setLocale()`, `t(key, params?)`, `getLocale()`, `getSupportedLocales()`, `formatDate(date)`, `formatTime(date)`
|
||||||
- **Locale files:** `public/locales/de.json` (reference), `public/locales/en.json` — structure: `{ "module.camelCaseKey": "Value" }`
|
- **Locale files:** `public/locales/de.json` (reference), `public/locales/en.json` - structure: `{ "module.camelCaseKey": "Value" }`
|
||||||
- **Variables:** `{{variable}}` syntax in translation strings, e.g. `t('tasks.assignedTo', { name: 'Anna' })`
|
- **Variables:** `{{variable}}` syntax in translation strings, e.g. `t('tasks.assignedTo', { name: 'Anna' })`
|
||||||
- **Fallback chain:** active locale → German (`de`) → key itself
|
- **Fallback chain:** active locale → German (`de`) → key itself
|
||||||
- **Date format:** `Intl.DateTimeFormat` with current locale — use `formatDate()` and `formatTime()` from `i18n.js`
|
- **Date format:** `Intl.DateTimeFormat` with current locale - use `formatDate()` and `formatTime()` from `i18n.js`
|
||||||
|
|
||||||
### Language Detection
|
### Language Detection
|
||||||
|
|
||||||
@@ -343,4 +343,4 @@ All UI strings are managed via `public/i18n.js`. No hardcoded text in JS files o
|
|||||||
|
|
||||||
### Locale Switching
|
### Locale Switching
|
||||||
|
|
||||||
`setLocale(locale)` saves the selection, loads the new locale file, and fires the `locale-changed` custom event. All page modules and web components listen to this event and re-render — no page reload required.
|
`setLocale(locale)` saves the selection, loads the new locale file, and fires the `locale-changed` custom event. All page modules and web components listen to this event and re-render - no page reload required.
|
||||||
|
|||||||
+12
-12
@@ -1,4 +1,4 @@
|
|||||||
# CLAUDE.md Audit — Oikos
|
# CLAUDE.md Audit - Oikos
|
||||||
|
|
||||||
**Datum:** 2026-04-02
|
**Datum:** 2026-04-02
|
||||||
**Aktuelle Länge:** 109 Zeilen (exkl. Leerzeilen)
|
**Aktuelle Länge:** 109 Zeilen (exkl. Leerzeilen)
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
**Fehlend:**
|
**Fehlend:**
|
||||||
- Kein expliziter Hard-Constraints-Block. "No SPA framework" ist in einen Fließtext-Absatz eingebettet statt als nicht-verhandelbare Regel markiert.
|
- Kein expliziter Hard-Constraints-Block. "No SPA framework" ist in einen Fließtext-Absatz eingebettet statt als nicht-verhandelbare Regel markiert.
|
||||||
- `public/utils/ux.js` wird von 7 Seiten importiert — kein Wort davon.
|
- `public/utils/ux.js` wird von 7 Seiten importiert - kein Wort davon.
|
||||||
- `public/i18n.js` wird als System erwähnt, aber nicht als kanonische Datei referenziert.
|
- `public/i18n.js` wird als System erwähnt, aber nicht als kanonische Datei referenziert.
|
||||||
- `offline.html` fehlt im Architecture-Tree.
|
- `offline.html` fehlt im Architecture-Tree.
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
**Redundanzen mit CONTRIBUTING.md:**
|
**Redundanzen mit CONTRIBUTING.md:**
|
||||||
- Project Structure: fast identischer Tree in beiden Dateien
|
- Project Structure: fast identischer Tree in beiden Dateien
|
||||||
- Code Conventions: ES modules, Semicolons, Header comments, try/catch — alles doppelt
|
- Code Conventions: ES modules, Semicolons, Header comments, try/catch - alles doppelt
|
||||||
- Testing: identische Beschreibung in beiden
|
- Testing: identische Beschreibung in beiden
|
||||||
- Changelog: gleiche Anweisung in beiden
|
- Changelog: gleiche Anweisung in beiden
|
||||||
- Migration append-only: in beiden Dateien
|
- Migration append-only: in beiden Dateien
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
### 1.3 Signalqualität
|
### 1.3 Signalqualität
|
||||||
|
|
||||||
**Probleme:**
|
**Probleme:**
|
||||||
- "No SPA framework" ist deskriptiv formuliert statt imperativ — ein LLM könnte es als Kontextinfo statt als harte Regel lesen.
|
- "No SPA framework" ist deskriptiv formuliert statt imperativ - ein LLM könnte es als Kontextinfo statt als harte Regel lesen.
|
||||||
- Security Model ist eine Faktenbeschreibung. Kein Satz sagt "tu X" oder "tu niemals Y".
|
- Security Model ist eine Faktenbeschreibung. Kein Satz sagt "tu X" oder "tu niemals Y".
|
||||||
- Backend-Dependency-Liste ist reines Inventar — Claude kann `package.json` lesen.
|
- Backend-Dependency-Liste ist reines Inventar - Claude kann `package.json` lesen.
|
||||||
- Deployment-Dockerfile-Snippet: rein informativ, kein Handlungsbezug.
|
- Deployment-Dockerfile-Snippet: rein informativ, kein Handlungsbezug.
|
||||||
- `node:20-slim` im CLAUDE.md-Deployment-Block, aber Dockerfile verwendet tatsächlich `node:22-slim` → **falsche Information**.
|
- `node:20-slim` im CLAUDE.md-Deployment-Block, aber Dockerfile verwendet tatsächlich `node:22-slim` → **falsche Information**.
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
| Erwähnt in CLAUDE.md | Tatsächlicher Zustand | Problem |
|
| Erwähnt in CLAUDE.md | Tatsächlicher Zustand | Problem |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `public/assets/` (apple-touch-icon, favicons) | Verzeichnis existiert nicht — Icons in `public/icons/` | **Falscher Pfad** |
|
| `public/assets/` (apple-touch-icon, favicons) | Verzeichnis existiert nicht - Icons in `public/icons/` | **Falscher Pfad** |
|
||||||
| `scripts/generate-icons.js` | Existiert ✅, aber `scripts/seed-demo.js` fehlt im Tree | Unvollständig |
|
| `scripts/generate-icons.js` | Existiert ✅, aber `scripts/seed-demo.js` fehlt im Tree | Unvollständig |
|
||||||
| Dockerfile `node:20-slim` | Dockerfile nutzt `node:22-slim` | **Falsche Version** |
|
| Dockerfile `node:20-slim` | Dockerfile nutzt `node:22-slim` | **Falsche Version** |
|
||||||
| Components: `modal.js`, `oikos-install-prompt.js` | Auch `oikos-locale-picker.js` existiert | Unvollständig |
|
| Components: `modal.js`, `oikos-install-prompt.js` | Auch `oikos-locale-picker.js` existiert | Unvollständig |
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| Nur verhaltenssteuernd, keine Spec-Duplikation | ⚠️ | Security Model, Deployment, Dependency-Liste sind rein informativ |
|
| Nur verhaltenssteuernd, keine Spec-Duplikation | ⚠️ | Security Model, Deployment, Dependency-Liste sind rein informativ |
|
||||||
| Harte Constraints klar abgegrenzt | ❌ | Kein separater Block; "no framework" in Fließtext versteckt |
|
| Harte Constraints klar abgegrenzt | ❌ | Kein separater Block; "no framework" in Fließtext versteckt |
|
||||||
| Referenztabelle vollständig & aktuell | ❌ | Keine Referenztabelle vorhanden — nur ein Einzeiler zu SPEC.md |
|
| Referenztabelle vollständig & aktuell | ❌ | Keine Referenztabelle vorhanden - nur ein Einzeiler zu SPEC.md |
|
||||||
| Pfade/Module korrekt | ❌ | `public/assets/` existiert nicht, Dockerfile-Version falsch |
|
| Pfade/Module korrekt | ❌ | `public/assets/` existiert nicht, Dockerfile-Version falsch |
|
||||||
| Keine Redundanz mit CONTRIBUTING.md | ❌ | ~40% Überlappung (Structure, Conventions, Testing, Changelog) |
|
| Keine Redundanz mit CONTRIBUTING.md | ❌ | ~40% Überlappung (Structure, Conventions, Testing, Changelog) |
|
||||||
| Keine Redundanz mit docs/ | ✅ | SPEC.md wird referenziert, nicht dupliziert |
|
| Keine Redundanz mit docs/ | ✅ | SPEC.md wird referenziert, nicht dupliziert |
|
||||||
@@ -96,12 +96,12 @@ Siehe `CLAUDE.md.proposed` im Repo-Root.
|
|||||||
|
|
||||||
| Was | Warum verhaltensrelevant |
|
| Was | Warum verhaltensrelevant |
|
||||||
|-----|--------------------------|
|
|-----|--------------------------|
|
||||||
| Expliziter Hard Constraints Block | Claude muss sofort wissen, was nicht verhandelbar ist — nicht erst aus Fließtext erschließen. |
|
| Expliziter Hard Constraints Block | Claude muss sofort wissen, was nicht verhandelbar ist - nicht erst aus Fließtext erschließen. |
|
||||||
| `public/utils/ux.js` als kanonische Utility | Wird von 7 Pages importiert. Ohne dieses Wissen würde Claude Utility-Funktionen duplizieren. |
|
| `public/utils/ux.js` als kanonische Utility | Wird von 7 Pages importiert. Ohne dieses Wissen würde Claude Utility-Funktionen duplizieren. |
|
||||||
| `public/i18n.js` + `public/locales/` in Structure | Kanonische Orte für i18n — Claude muss wissen, wo Locale-Keys definiert werden. |
|
| `public/i18n.js` + `public/locales/` in Structure | Kanonische Orte für i18n - Claude muss wissen, wo Locale-Keys definiert werden. |
|
||||||
| `offline.html` im Tree | Existiert, wird vom Service Worker referenziert, fehlte komplett. |
|
| `offline.html` im Tree | Existiert, wird vom Service Worker referenziert, fehlte komplett. |
|
||||||
| Reference Documents Tabelle | Claude muss wissen, wo welche kanonische Wahrheit liegt, statt zu raten. |
|
| Reference Documents Tabelle | Claude muss wissen, wo welche kanonische Wahrheit liegt, statt zu raten. |
|
||||||
| `oikos-locale-picker.js` in Components | Existierende Komponente fehlte — Claude könnte sie unwissentlich neu bauen. |
|
| `oikos-locale-picker.js` in Components | Existierende Komponente fehlte - Claude könnte sie unwissentlich neu bauen. |
|
||||||
|
|
||||||
### Umformuliert
|
### Umformuliert
|
||||||
|
|
||||||
@@ -115,6 +115,6 @@ Siehe `CLAUDE.md.proposed` im Repo-Root.
|
|||||||
|
|
||||||
| Was | Von | Nach | Grund |
|
| Was | Von | Nach | Grund |
|
||||||
|-----|-----|------|-------|
|
|-----|-----|------|-------|
|
||||||
| Security Model Details | CLAUDE.md | Bereits in Code (middleware, auth.js) | Kein eigenes Dokument nötig — Code ist die Wahrheit |
|
| Security Model Details | CLAUDE.md | Bereits in Code (middleware, auth.js) | Kein eigenes Dokument nötig - Code ist die Wahrheit |
|
||||||
| Deployment env vars, Dockerfile | CLAUDE.md | README.md (bereits dort vorhanden) | Ops-Information, nicht Entwicklungsverhalten |
|
| Deployment env vars, Dockerfile | CLAUDE.md | README.md (bereits dort vorhanden) | Ops-Information, nicht Entwicklungsverhalten |
|
||||||
| Detaillierter Project Structure Tree | CLAUDE.md | CONTRIBUTING.md (bereits dort vorhanden) | Duplikation eliminieren — CONTRIBUTING.md hat den vollständigen Tree |
|
| Detaillierter Project Structure Tree | CLAUDE.md | CONTRIBUTING.md (bereits dort vorhanden) | Duplikation eliminieren - CONTRIBUTING.md hat den vollständigen Tree |
|
||||||
|
|||||||
+6
-6
@@ -3,21 +3,21 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Oikos — The Self-Hosted Family Planner</title>
|
<title>Oikos - The Self-Hosted Family Planner</title>
|
||||||
<meta name="description" content="A privacy-first, self-hosted family planner with tasks, shopping lists, meal planning, calendar sync, budget tracking, and more. No cloud, no tracking, your data.">
|
<meta name="description" content="A privacy-first, self-hosted family planner with tasks, shopping lists, meal planning, calendar sync, budget tracking, and more. No cloud, no tracking, your data.">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:title" content="Oikos — The Self-Hosted Family Planner">
|
<meta property="og:title" content="Oikos - The Self-Hosted Family Planner">
|
||||||
<meta property="og:description" content="A privacy-first, self-hosted family planner. Tasks, shopping, meals, calendar sync, budget — all self-hosted, no cloud dependency.">
|
<meta property="og:description" content="A privacy-first, self-hosted family planner. Tasks, shopping, meals, calendar sync, budget - all self-hosted, no cloud dependency.">
|
||||||
<meta property="og:image" content="https://ulsklyc.github.io/oikos/og-image.png">
|
<meta property="og:image" content="https://ulsklyc.github.io/oikos/og-image.png">
|
||||||
<meta property="og:url" content="https://ulsklyc.github.io/oikos/">
|
<meta property="og:url" content="https://ulsklyc.github.io/oikos/">
|
||||||
<meta property="og:site_name" content="Oikos">
|
<meta property="og:site_name" content="Oikos">
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Oikos — The Self-Hosted Family Planner">
|
<meta name="twitter:title" content="Oikos - The Self-Hosted Family Planner">
|
||||||
<meta name="twitter:description" content="A privacy-first, self-hosted family planner. Tasks, shopping, meals, calendar sync, budget — all self-hosted.">
|
<meta name="twitter:description" content="A privacy-first, self-hosted family planner. Tasks, shopping, meals, calendar sync, budget - all self-hosted.">
|
||||||
<meta name="twitter:image" content="https://ulsklyc.github.io/oikos/twitter-image.png">
|
<meta name="twitter:image" content="https://ulsklyc.github.io/oikos/twitter-image.png">
|
||||||
|
|
||||||
<link rel="canonical" href="https://ulsklyc.github.io/oikos/">
|
<link rel="canonical" href="https://ulsklyc.github.io/oikos/">
|
||||||
@@ -568,7 +568,7 @@
|
|||||||
<img src="logo.svg" alt="Oikos Logo" class="hero-logo" width="80" height="80">
|
<img src="logo.svg" alt="Oikos Logo" class="hero-logo" width="80" height="80">
|
||||||
<h1 data-i18n="hero_title">Oikos</h1>
|
<h1 data-i18n="hero_title">Oikos</h1>
|
||||||
<p class="hero-tagline" data-i18n="hero_tagline">The self-hosted family planner</p>
|
<p class="hero-tagline" data-i18n="hero_tagline">The self-hosted family planner</p>
|
||||||
<p class="hero-desc" data-i18n="hero_desc">Manage your household together. Tasks, shopping, meals, calendar, budget — all in one place. Self-hosted, private, yours.</p>
|
<p class="hero-desc" data-i18n="hero_desc">Manage your household together. Tasks, shopping, meals, calendar, budget - all in one place. Self-hosted, private, yours.</p>
|
||||||
<a href="https://github.com/ulsklyc/oikos" class="hero-cta" target="_blank" rel="noopener">
|
<a href="https://github.com/ulsklyc/oikos" class="hero-cta" target="_blank" rel="noopener">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||||
<span data-i18n="hero_cta">View on GitHub</span>
|
<span data-i18n="hero_cta">View on GitHub</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Repository Hygiene Audit — Oikos
|
# Repository Hygiene Audit - Oikos
|
||||||
|
|
||||||
**Datum:** 2026-04-02
|
**Datum:** 2026-04-02
|
||||||
**Version:** 0.5.2 (Tag `v0.5.2`)
|
**Version:** 0.5.2 (Tag `v0.5.2`)
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| # | Datei | Status | Begründung | Empfehlung |
|
| # | Datei | Status | Begründung | Empfehlung |
|
||||||
|---|-------|--------|------------|------------|
|
|---|-------|--------|------------|------------|
|
||||||
| 1 | `.worktrees/feature/` | Verwaist | Leeres Verzeichnis — Überrest eines abgeschlossenen Git-Worktree. Kein Inhalt. | **Löschen** (`rm -rf .worktrees/feature/`) |
|
| 1 | `.worktrees/feature/` | Verwaist | Leeres Verzeichnis - Überrest eines abgeschlossenen Git-Worktree. Kein Inhalt. | **Löschen** (`rm -rf .worktrees/feature/`) |
|
||||||
|
|
||||||
**Keine verwaisten JS/CSS/HTML-Dateien gefunden.** Alle Module in `public/pages/`, `public/components/`, `public/styles/` und `server/routes/` sind korrekt referenziert:
|
**Keine verwaisten JS/CSS/HTML-Dateien gefunden.** Alle Module in `public/pages/`, `public/components/`, `public/styles/` und `server/routes/` sind korrekt referenziert:
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
| # | Datei | Status | Begründung | Empfehlung |
|
| # | Datei | Status | Begründung | Empfehlung |
|
||||||
|---|-------|--------|------------|------------|
|
|---|-------|--------|------------|------------|
|
||||||
| — | — | — | Keine gefunden. | — |
|
| - | - | - | Keine gefunden. | - |
|
||||||
|
|
||||||
Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
| 1 | `.env` | OK | Existiert lokal, **nicht** von Git getrackt, in `.gitignore` gelistet. | Kein Handlungsbedarf |
|
| 1 | `.env` | OK | Existiert lokal, **nicht** von Git getrackt, in `.gitignore` gelistet. | Kein Handlungsbedarf |
|
||||||
| 2 | `.claude/` | OK | In `.gitignore`, nicht getrackt. | Kein Handlungsbedarf |
|
| 2 | `.claude/` | OK | In `.gitignore`, nicht getrackt. | Kein Handlungsbedarf |
|
||||||
| 3 | `.worktrees/` | OK | In `.gitignore`, nicht getrackt. Leeres `feature/`-Verzeichnis (s. 1.1). | Leeres Subdir löschen |
|
| 3 | `.worktrees/` | OK | In `.gitignore`, nicht getrackt. Leeres `feature/`-Verzeichnis (s. 1.1). | Leeres Subdir löschen |
|
||||||
| 4 | `docs/superpowers/` | OK | In `.gitignore`, nicht getrackt. Enthält 7 Plandokumente (3076 Zeilen) lokal. | Kein Handlungsbedarf — `.gitignore` greift |
|
| 4 | `docs/superpowers/` | OK | In `.gitignore`, nicht getrackt. Enthält 7 Plandokumente (3076 Zeilen) lokal. | Kein Handlungsbedarf - `.gitignore` greift |
|
||||||
|
|
||||||
### 1.4 Prompt-Dateien
|
### 1.4 Prompt-Dateien
|
||||||
|
|
||||||
@@ -50,15 +50,15 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
|
|
||||||
| # | Datei | Status | Begründung | Empfehlung |
|
| # | Datei | Status | Begründung | Empfehlung |
|
||||||
|---|-------|--------|------------|------------|
|
|---|-------|--------|------------|------------|
|
||||||
| — | — | — | Keine leeren oder Stub-Dateien gefunden. | — |
|
| - | - | - | Keine leeren oder Stub-Dateien gefunden. | - |
|
||||||
|
|
||||||
### 1.6 Sonderfall: Hilfs-/Script-Dateien
|
### 1.6 Sonderfall: Hilfs-/Script-Dateien
|
||||||
|
|
||||||
| # | Datei | Zeilen | Genutzt von | Empfehlung |
|
| # | Datei | Zeilen | Genutzt von | Empfehlung |
|
||||||
|---|-------|--------|-------------|------------|
|
|---|-------|--------|-------------|------------|
|
||||||
| 1 | `server/db-schema-test.js` | 185 | Allen 7 Test-Dateien (`test-*.js`) | **Behalten** — aktiv genutzte Test-Infrastruktur |
|
| 1 | `server/db-schema-test.js` | 185 | Allen 7 Test-Dateien (`test-*.js`) | **Behalten** - aktiv genutzte Test-Infrastruktur |
|
||||||
| 2 | `scripts/seed-demo.js` | 343 | Manuell (CLI-Script für Screenshots) | **Behalten** — nützliches Dev-Tool |
|
| 2 | `scripts/seed-demo.js` | 343 | Manuell (CLI-Script für Screenshots) | **Behalten** - nützliches Dev-Tool |
|
||||||
| 3 | `test-browser-loader.mjs` | — | `package.json` test:modal-utils | **Behalten** — aktiv genutzt für Browser-API-Mocks |
|
| 3 | `test-browser-loader.mjs` | - | `package.json` test:modal-utils | **Behalten** - aktiv genutzt für Browser-API-Mocks |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,9 +75,9 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
| `SECURITY.md` | ✅ Vorhanden & vollständig | Vulnerability Reporting via GitHub Private Advisories, Scope-Definition, Security-Features-Übersicht, Supported-Versions-Policy. |
|
| `SECURITY.md` | ✅ Vorhanden & vollständig | Vulnerability Reporting via GitHub Private Advisories, Scope-Definition, Security-Features-Übersicht, Supported-Versions-Policy. |
|
||||||
| `CHANGELOG.md` | ⚠️ Vorhanden, kleiner Fehler | Keep a Changelog Format, retroaktive Versionierung ab 0.1.0 ✅. **Problem:** Compare-Links am Dateiende: `[Unreleased]` verweist auf `v0.5.1...HEAD` statt `v0.5.2...HEAD`. Außerdem fehlt der `[0.5.2]`-Compare-Link. |
|
| `CHANGELOG.md` | ⚠️ Vorhanden, kleiner Fehler | Keep a Changelog Format, retroaktive Versionierung ab 0.1.0 ✅. **Problem:** Compare-Links am Dateiende: `[Unreleased]` verweist auf `v0.5.1...HEAD` statt `v0.5.2...HEAD`. Außerdem fehlt der `[0.5.2]`-Compare-Link. |
|
||||||
| `CLAUDE.md` | ✅ Vorhanden & aktuell | Behavioral guide (109 Zeilen), nicht von Git getrackt (in .gitignore). Beschreibt Architektur, Konventionen, Security, DB, Deployment, Implementation Status. Keine Duplikation von SPEC.md. |
|
| `CLAUDE.md` | ✅ Vorhanden & aktuell | Behavioral guide (109 Zeilen), nicht von Git getrackt (in .gitignore). Beschreibt Architektur, Konventionen, Security, DB, Deployment, Implementation Status. Keine Duplikation von SPEC.md. |
|
||||||
| `BACKLOG.md` | ✅ Vorhanden & aktuell | Enthält erledigte Features als Referenztabelle (BL-01 bis BL-10 mit Versionszuordnung). Aktiv keine offenen Einträge — korrekt für aktuellen Projektstand. |
|
| `BACKLOG.md` | ✅ Vorhanden & aktuell | Enthält erledigte Features als Referenztabelle (BL-01 bis BL-10 mit Versionszuordnung). Aktiv keine offenen Einträge - korrekt für aktuellen Projektstand. |
|
||||||
| `docs/SPEC.md` | ✅ Vorhanden | Nicht im Detail geprüft (Umfang), aber von CLAUDE.md und README.md referenziert und zentral für alle Module. |
|
| `docs/SPEC.md` | ✅ Vorhanden | Nicht im Detail geprüft (Umfang), aber von CLAUDE.md und README.md referenziert und zentral für alle Module. |
|
||||||
| `.dockerignore` | ⚠️ Vorhanden, kleiner Fehler | Sinnvolle Ausschlüsse. **Problem:** Referenziert `docs/social-preview.html` — diese Datei existiert nicht (nur `docs/social-preview.png` existiert). Harmlos (Ausschluss einer nicht-existenten Datei), aber irreführend. |
|
| `.dockerignore` | ⚠️ Vorhanden, kleiner Fehler | Sinnvolle Ausschlüsse. **Problem:** Referenziert `docs/social-preview.html` - diese Datei existiert nicht (nur `docs/social-preview.png` existiert). Harmlos (Ausschluss einer nicht-existenten Datei), aber irreführend. |
|
||||||
| `docker-compose.yml` | ✅ Vorhanden | Nicht im Detail geprüft, aber vorhanden und funktional laut README/CHANGELOG. |
|
| `docker-compose.yml` | ✅ Vorhanden | Nicht im Detail geprüft, aber vorhanden und funktional laut README/CHANGELOG. |
|
||||||
| `Dockerfile` | ✅ Vorhanden | Nicht im Detail geprüft, vorhanden. |
|
| `Dockerfile` | ✅ Vorhanden | Nicht im Detail geprüft, vorhanden. |
|
||||||
| `.github/ISSUE_TEMPLATE/bug_report.md` | ✅ Vorhanden & vollständig | Gutes Template mit Description, Steps to Reproduce, Expected/Actual Behavior, Environment, Logs (collapsible), Additional Context. |
|
| `.github/ISSUE_TEMPLATE/bug_report.md` | ✅ Vorhanden & vollständig | Gutes Template mit Description, Steps to Reproduce, Expected/Actual Behavior, Environment, Logs (collapsible), Additional Context. |
|
||||||
@@ -89,7 +89,7 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
| Datei | Status | Bewertung |
|
| Datei | Status | Bewertung |
|
||||||
|-------|--------|-----------|
|
|-------|--------|-----------|
|
||||||
| `docs/` Verzeichnis | ✅ Vorhanden | Enthält SPEC.md, logo.svg, social-preview.png, Screenshots (4 Varianten × 9 Module = 36 Screenshots). |
|
| `docs/` Verzeichnis | ✅ Vorhanden | Enthält SPEC.md, logo.svg, social-preview.png, Screenshots (4 Varianten × 9 Module = 36 Screenshots). |
|
||||||
| `manifest.json` | ✅ Vorhanden | `public/manifest.json` — PWA-Manifest. |
|
| `manifest.json` | ✅ Vorhanden | `public/manifest.json` - PWA-Manifest. |
|
||||||
| Service Worker | ✅ Vorhanden | `public/sw.js` + `public/sw-register.js`. |
|
| Service Worker | ✅ Vorhanden | `public/sw.js` + `public/sw-register.js`. |
|
||||||
| `.nvmrc` | ✅ Vorhanden | Inhalt: `22`. Konsistent mit `engines.node: ">=22.0.0"` in package.json. |
|
| `.nvmrc` | ✅ Vorhanden | Inhalt: `22`. Konsistent mit `engines.node: ">=22.0.0"` in package.json. |
|
||||||
| `.env.example` | ✅ Vorhanden & vollständig | 39 Zeilen. Alle dokumentierten Env-Vars abgedeckt: PORT, NODE_ENV, SESSION_SECRET, SESSION_SECURE, DB_PATH, DB_ENCRYPTION_KEY, OpenWeather, Google Calendar, Apple CalDAV, Sync-Intervall, Rate-Limiting. |
|
| `.env.example` | ✅ Vorhanden & vollständig | 39 Zeilen. Alle dokumentierten Env-Vars abgedeckt: PORT, NODE_ENV, SESSION_SECRET, SESSION_SECURE, DB_PATH, DB_ENCRYPTION_KEY, OpenWeather, Google Calendar, Apple CalDAV, Sync-Intervall, Rate-Limiting. |
|
||||||
@@ -100,7 +100,7 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
|
|
||||||
| Element | Status | Bewertung |
|
| Element | Status | Bewertung |
|
||||||
|---------|--------|-----------|
|
|---------|--------|-----------|
|
||||||
| Git Tags | ✅ Konsistent | v0.1.0 bis v0.5.2 — alle CHANGELOG-Versionen haben entsprechende Tags. |
|
| Git Tags | ✅ Konsistent | v0.1.0 bis v0.5.2 - alle CHANGELOG-Versionen haben entsprechende Tags. |
|
||||||
| `scripts/` | ✅ Sinnvoll | `generate-icons.js` (Dev-Tool) und `seed-demo.js` (Screenshot-Generierung). Beide in `.dockerignore` exkludiert. |
|
| `scripts/` | ✅ Sinnvoll | `generate-icons.js` (Dev-Tool) und `seed-demo.js` (Screenshot-Generierung). Beide in `.dockerignore` exkludiert. |
|
||||||
| `entrypoint.sh` | ✅ Vorhanden | Docker-Entrypoint. |
|
| `entrypoint.sh` | ✅ Vorhanden | Docker-Entrypoint. |
|
||||||
| `setup.js` | ✅ Vorhanden | Admin-Setup-Wizard, in README dokumentiert. |
|
| `setup.js` | ✅ Vorhanden | Admin-Setup-Wizard, in README dokumentiert. |
|
||||||
@@ -111,7 +111,7 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
|
|
||||||
| Kriterium | Status | Bewertung |
|
| Kriterium | Status | Bewertung |
|
||||||
|-----------|--------|-----------|
|
|-----------|--------|-----------|
|
||||||
| Projektname und Kurzbeschreibung | ✅ Vorhanden | Logo + "Oikos" + "The self-hosted family planner that respects your privacy." — klar und prägnant. |
|
| Projektname und Kurzbeschreibung | ✅ Vorhanden | Logo + "Oikos" + "The self-hosted family planner that respects your privacy." - klar und prägnant. |
|
||||||
| Badges | ✅ Vorhanden | 7 Badges: Release, License, Node.js, Docker, SQLCipher, PWA, i18n. flat-square Style. |
|
| Badges | ✅ Vorhanden | 7 Badges: Release, License, Node.js, Docker, SQLCipher, PWA, i18n. flat-square Style. |
|
||||||
| Screenshots/Demo | ✅ Vorhanden & hervorragend | 9 Mobile-Screens (dark/light auto-switch via `<picture>`), 8 Tablet-Screens in collapsible Section. Alle 4 Varianten (mobile-dark, mobile-light, tablet-dark, tablet-light). |
|
| Screenshots/Demo | ✅ Vorhanden & hervorragend | 9 Mobile-Screens (dark/light auto-switch via `<picture>`), 8 Tablet-Screens in collapsible Section. Alle 4 Varianten (mobile-dark, mobile-light, tablet-dark, tablet-light). |
|
||||||
| Features-Übersicht | ✅ Vorhanden | 8 Module in 2-Spalten-Tabelle + "And also"-Absatz mit Querschnittsfunktionen. |
|
| Features-Übersicht | ✅ Vorhanden | 8 Module in 2-Spalten-Tabelle + "And also"-Absatz mit Querschnittsfunktionen. |
|
||||||
@@ -145,7 +145,7 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
- **Aufwand**: 5 Minuten
|
- **Aufwand**: 5 Minuten
|
||||||
- **Dateien**: `CHANGELOG.md` (Zeilen 139–147)
|
- **Dateien**: `CHANGELOG.md` (Zeilen 139–147)
|
||||||
|
|
||||||
### [P2] .dockerignore — Phantomreferenz entfernen
|
### [P2] .dockerignore - Phantomreferenz entfernen
|
||||||
|
|
||||||
- **Was**: `.dockerignore` exkludiert `docs/social-preview.html`, aber diese Datei existiert nicht (nur `docs/social-preview.png` existiert).
|
- **Was**: `.dockerignore` exkludiert `docs/social-preview.html`, aber diese Datei existiert nicht (nur `docs/social-preview.png` existiert).
|
||||||
- **Warum**: Irreführend bei Code-Review oder Wartung. Kein funktionaler Impact.
|
- **Warum**: Irreführend bei Code-Review oder Wartung. Kein funktionaler Impact.
|
||||||
@@ -165,12 +165,12 @@ Keine `.bak`, `.old`, `.tmp`-Dateien vorhanden. Sauber.
|
|||||||
|
|
||||||
| Kategorie | Ergebnis |
|
| Kategorie | Ergebnis |
|
||||||
|-----------|----------|
|
|-----------|----------|
|
||||||
| **Verwaiste Dateien** | 1 Fund (leeres Worktree-Dir) — P3 |
|
| **Verwaiste Dateien** | 1 Fund (leeres Worktree-Dir) - P3 |
|
||||||
| **Superseded Files** | 0 Funde |
|
| **Superseded Files** | 0 Funde |
|
||||||
| **Dev Artifacts** | Korrekt durch .gitignore abgedeckt |
|
| **Dev Artifacts** | Korrekt durch .gitignore abgedeckt |
|
||||||
| **Prompt-Dateien** | Keine im Repo |
|
| **Prompt-Dateien** | Keine im Repo |
|
||||||
| **Leere Dateien** | 0 Funde |
|
| **Leere Dateien** | 0 Funde |
|
||||||
| **Projektdateien** | 2 Minor-Issues (CHANGELOG-Links, .dockerignore Phantom) — P2 |
|
| **Projektdateien** | 2 Minor-Issues (CHANGELOG-Links, .dockerignore Phantom) - P2 |
|
||||||
| **README** | Vollständig, keine Lücken |
|
| **README** | Vollständig, keine Lücken |
|
||||||
| **P0-Findings** | 0 |
|
| **P0-Findings** | 0 |
|
||||||
| **P1-Findings** | 0 |
|
| **P1-Findings** | 0 |
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.5.8",
|
"version": "0.5.8",
|
||||||
"description": "Selbstgehosteter Familienplaner — Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.",
|
"description": "Selbstgehosteter Familienplaner - Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function _wireSheetSwipe(panel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// _doClose — gemeinsame Cleanup-Logik
|
// _doClose - gemeinsame Cleanup-Logik
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function _doClose() {
|
function _doClose() {
|
||||||
@@ -169,12 +169,12 @@ function _doClose() {
|
|||||||
* Öffnet ein Modal mit dem Shared-System.
|
* Öffnet ein Modal mit dem Shared-System.
|
||||||
*
|
*
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {string} opts.title — Titel im Modal-Header
|
* @param {string} opts.title - Titel im Modal-Header
|
||||||
* @param {string} opts.content — HTML-String für den Modal-Body
|
* @param {string} opts.content - HTML-String für den Modal-Body
|
||||||
* @param {Function} [opts.onSave] — Callback, wird nach Einfügen in DOM aufgerufen
|
* @param {Function} [opts.onSave] - Callback, wird nach Einfügen in DOM aufgerufen
|
||||||
* (zum Binden von Form-Events)
|
* (zum Binden von Form-Events)
|
||||||
* @param {Function} [opts.onDelete] — Falls vorhanden, wird ein Löschen-Button eingebaut
|
* @param {Function} [opts.onDelete] - Falls vorhanden, wird ein Löschen-Button eingebaut
|
||||||
* @param {string} [opts.size='md'] — 'sm' | 'md' | 'lg'
|
* @param {string} [opts.size='md'] - 'sm' | 'md' | 'lg'
|
||||||
*/
|
*/
|
||||||
export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}) {
|
export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}) {
|
||||||
// Vorheriges Modal schließen (kein Stacking)
|
// Vorheriges Modal schließen (kein Stacking)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Bereits im Standalone-Modus — nichts anzeigen
|
// Bereits im Standalone-Modus - nichts anzeigen
|
||||||
if (
|
if (
|
||||||
window.matchMedia('(display-mode: standalone)').matches ||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
navigator.standalone === true
|
navigator.standalone === true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* oikos-locale-picker — Sprachauswahl-Web-Component
|
* oikos-locale-picker - Sprachauswahl-Web-Component
|
||||||
* Zeigt ein <select>-Dropdown für System/Deutsch/English.
|
* Zeigt ein <select>-Dropdown für System/Deutsch/English.
|
||||||
* Bei Auswahl: setLocale() oder localStorage-Eintrag löschen (System).
|
* Bei Auswahl: setLocale() oder localStorage-Eintrag löschen (System).
|
||||||
* Dependencies: i18n.js
|
* Dependencies: i18n.js
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* i18n — Internationalisierung / Übersetzungsmodul
|
* i18n - Internationalisierung / Übersetzungsmodul
|
||||||
* Bietet t(), initI18n(), setLocale(), getLocale(), getSupportedLocales(),
|
* Bietet t(), initI18n(), setLocale(), getLocale(), getSupportedLocales(),
|
||||||
* formatDate(), formatTime() für die gesamte App.
|
* formatDate(), formatTime() für die gesamte App.
|
||||||
* Dependencies: none (vanilla JS, Fetch API, Intl API)
|
* Dependencies: none (vanilla JS, Fetch API, Intl API)
|
||||||
@@ -33,7 +33,7 @@ async function loadLocale(locale) {
|
|||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initialisierung — einmal beim App-Start aufrufen */
|
/** Initialisierung - einmal beim App-Start aufrufen */
|
||||||
export async function initI18n() {
|
export async function initI18n() {
|
||||||
currentLocale = resolveLocale();
|
currentLocale = resolveLocale();
|
||||||
fallbackTranslations = await loadLocale(DEFAULT_LOCALE);
|
fallbackTranslations = await loadLocale(DEFAULT_LOCALE);
|
||||||
@@ -50,7 +50,7 @@ export async function initI18n() {
|
|||||||
document.documentElement.lang = currentLocale;
|
document.documentElement.lang = currentLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sprache wechseln — löst 'locale-changed' Event aus */
|
/** Sprache wechseln - löst 'locale-changed' Event aus */
|
||||||
export async function setLocale(locale) {
|
export async function setLocale(locale) {
|
||||||
if (!SUPPORTED_LOCALES.includes(locale)) return;
|
if (!SUPPORTED_LOCALES.includes(locale)) return;
|
||||||
localStorage.setItem(STORAGE_KEY, locale);
|
localStorage.setItem(STORAGE_KEY, locale);
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Oikos" />
|
<meta name="apple-mobile-web-app-title" content="Oikos" />
|
||||||
<meta name="description" content="Oikos — Familienplaner" />
|
<meta name="description" content="Oikos - Familienplaner" />
|
||||||
<title>Oikos</title>
|
<title>Oikos</title>
|
||||||
|
|
||||||
<!-- PWA -->
|
<!-- PWA -->
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<script src="/lucide.min.js"></script>
|
<script src="/lucide.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- App-Shell — wird durch JavaScript gefüllt -->
|
<!-- App-Shell - wird durch JavaScript gefüllt -->
|
||||||
<div id="app" class="app-shell">
|
<div id="app" class="app-shell">
|
||||||
<!-- Skeleton-Loading während Initialisierung -->
|
<!-- Skeleton-Loading während Initialisierung -->
|
||||||
<div id="app-loading" class="app-loading" aria-live="polite" aria-label="Lade Oikos…">
|
<div id="app-loading" class="app-loading" aria-live="polite" aria-label="Lade Oikos…">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"errorOccurred": "Etwas ist schiefgelaufen.",
|
"errorOccurred": "Etwas ist schiefgelaufen.",
|
||||||
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten.",
|
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||||
"errorGeneric": "Ein Fehler ist aufgetreten.",
|
"errorGeneric": "Ein Fehler ist aufgetreten.",
|
||||||
"updateAvailable": "Update verfügbar — Seite neu laden für die neueste Version.",
|
"updateAvailable": "Update verfügbar - Seite neu laden für die neueste Version.",
|
||||||
"titleRequired": "Titel ist erforderlich",
|
"titleRequired": "Titel ist erforderlich",
|
||||||
"nameRequired": "Name ist erforderlich",
|
"nameRequired": "Name ist erforderlich",
|
||||||
"contentRequired": "Inhalt ist erforderlich",
|
"contentRequired": "Inhalt ist erforderlich",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"title": "Aufgaben",
|
"title": "Aufgaben",
|
||||||
"newTask": "Neue Aufgabe",
|
"newTask": "Neue Aufgabe",
|
||||||
"editTask": "Aufgabe bearbeiten",
|
"editTask": "Aufgabe bearbeiten",
|
||||||
"emptyTitle": "Keine Aufgaben — alles erledigt?",
|
"emptyTitle": "Keine Aufgaben - alles erledigt?",
|
||||||
"emptyDescription": "Neue Aufgaben über den + Button erstellen.",
|
"emptyDescription": "Neue Aufgaben über den + Button erstellen.",
|
||||||
"titleLabel": "Titel *",
|
"titleLabel": "Titel *",
|
||||||
"titlePlaceholder": "Was muss erledigt werden?",
|
"titlePlaceholder": "Was muss erledigt werden?",
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"dueDateLabel": "Fälligkeit",
|
"dueDateLabel": "Fälligkeit",
|
||||||
"dueTimeLabel": "Uhrzeit",
|
"dueTimeLabel": "Uhrzeit",
|
||||||
"assignedLabel": "Zugewiesen an",
|
"assignedLabel": "Zugewiesen an",
|
||||||
"assignedNobody": "— Niemand —",
|
"assignedNobody": "- Niemand -",
|
||||||
"statusLabel": "Status",
|
"statusLabel": "Status",
|
||||||
"priorityUrgent": "Dringend",
|
"priorityUrgent": "Dringend",
|
||||||
"priorityHigh": "Hoch",
|
"priorityHigh": "Hoch",
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
"locationLabel": "Ort",
|
"locationLabel": "Ort",
|
||||||
"locationPlaceholder": "Optional",
|
"locationPlaceholder": "Optional",
|
||||||
"assignedLabel": "Zugewiesen an",
|
"assignedLabel": "Zugewiesen an",
|
||||||
"assignedNobody": "— Niemand —",
|
"assignedNobody": "- Niemand -",
|
||||||
"colorLabel": "Farbe",
|
"colorLabel": "Farbe",
|
||||||
"descriptionLabel": "Beschreibung",
|
"descriptionLabel": "Beschreibung",
|
||||||
"descriptionPlaceholder": "Optional…",
|
"descriptionPlaceholder": "Optional…",
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
"savedToast": "Eintrag gespeichert",
|
"savedToast": "Eintrag gespeichert",
|
||||||
"deletedToast": "Eintrag gelöscht",
|
"deletedToast": "Eintrag gelöscht",
|
||||||
"loadError": "Budget konnte nicht geladen werden.",
|
"loadError": "Budget konnte nicht geladen werden.",
|
||||||
"trendNeutral": "— wie {{month}}",
|
"trendNeutral": "- wie {{month}}",
|
||||||
"validAmountRequired": "Gültigen Betrag eingeben",
|
"validAmountRequired": "Gültigen Betrag eingeben",
|
||||||
"dateRequired": "Datum ist erforderlich",
|
"dateRequired": "Datum ist erforderlich",
|
||||||
"catFood": "Lebensmittel",
|
"catFood": "Lebensmittel",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"errorOccurred": "Something went wrong.",
|
"errorOccurred": "Something went wrong.",
|
||||||
"unexpectedError": "An unexpected error occurred.",
|
"unexpectedError": "An unexpected error occurred.",
|
||||||
"errorGeneric": "An error occurred.",
|
"errorGeneric": "An error occurred.",
|
||||||
"updateAvailable": "Update available — reload the page to get the latest version.",
|
"updateAvailable": "Update available - reload the page to get the latest version.",
|
||||||
"titleRequired": "Title is required",
|
"titleRequired": "Title is required",
|
||||||
"nameRequired": "Name is required",
|
"nameRequired": "Name is required",
|
||||||
"contentRequired": "Content is required",
|
"contentRequired": "Content is required",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"title": "Tasks",
|
"title": "Tasks",
|
||||||
"newTask": "New Task",
|
"newTask": "New Task",
|
||||||
"editTask": "Edit Task",
|
"editTask": "Edit Task",
|
||||||
"emptyTitle": "No tasks — all done?",
|
"emptyTitle": "No tasks - all done?",
|
||||||
"emptyDescription": "Create new tasks with the + button.",
|
"emptyDescription": "Create new tasks with the + button.",
|
||||||
"titleLabel": "Title *",
|
"titleLabel": "Title *",
|
||||||
"titlePlaceholder": "What needs to be done?",
|
"titlePlaceholder": "What needs to be done?",
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"dueDateLabel": "Due date",
|
"dueDateLabel": "Due date",
|
||||||
"dueTimeLabel": "Time",
|
"dueTimeLabel": "Time",
|
||||||
"assignedLabel": "Assigned to",
|
"assignedLabel": "Assigned to",
|
||||||
"assignedNobody": "— Nobody —",
|
"assignedNobody": "- Nobody -",
|
||||||
"statusLabel": "Status",
|
"statusLabel": "Status",
|
||||||
"priorityUrgent": "Urgent",
|
"priorityUrgent": "Urgent",
|
||||||
"priorityHigh": "High",
|
"priorityHigh": "High",
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
"locationLabel": "Location",
|
"locationLabel": "Location",
|
||||||
"locationPlaceholder": "Optional",
|
"locationPlaceholder": "Optional",
|
||||||
"assignedLabel": "Assigned to",
|
"assignedLabel": "Assigned to",
|
||||||
"assignedNobody": "— Nobody —",
|
"assignedNobody": "- Nobody -",
|
||||||
"colorLabel": "Color",
|
"colorLabel": "Color",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"descriptionPlaceholder": "Optional…",
|
"descriptionPlaceholder": "Optional…",
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
"savedToast": "Entry saved",
|
"savedToast": "Entry saved",
|
||||||
"deletedToast": "Entry deleted",
|
"deletedToast": "Entry deleted",
|
||||||
"loadError": "Budget could not be loaded.",
|
"loadError": "Budget could not be loaded.",
|
||||||
"trendNeutral": "— same as {{month}}",
|
"trendNeutral": "- same as {{month}}",
|
||||||
"validAmountRequired": "Please enter a valid amount",
|
"validAmountRequired": "Please enter a valid amount",
|
||||||
"dateRequired": "Date is required",
|
"dateRequired": "Date is required",
|
||||||
"catFood": "Groceries",
|
"catFood": "Groceries",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"errorOccurred": "Si è verificato un errore.",
|
"errorOccurred": "Si è verificato un errore.",
|
||||||
"unexpectedError": "Si è verificato un errore imprevisto.",
|
"unexpectedError": "Si è verificato un errore imprevisto.",
|
||||||
"errorGeneric": "Si è verificato un errore.",
|
"errorGeneric": "Si è verificato un errore.",
|
||||||
"updateAvailable": "Aggiornamento disponibile — ricarica la pagina per ottenere l'ultima versione.",
|
"updateAvailable": "Aggiornamento disponibile - ricarica la pagina per ottenere l'ultima versione.",
|
||||||
"titleRequired": "Il titolo è obbligatorio",
|
"titleRequired": "Il titolo è obbligatorio",
|
||||||
"nameRequired": "Il nome è obbligatorio",
|
"nameRequired": "Il nome è obbligatorio",
|
||||||
"contentRequired": "Il contenuto è obbligatorio",
|
"contentRequired": "Il contenuto è obbligatorio",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"title": "Compiti",
|
"title": "Compiti",
|
||||||
"newTask": "Nuovo compito",
|
"newTask": "Nuovo compito",
|
||||||
"editTask": "Modifica compito",
|
"editTask": "Modifica compito",
|
||||||
"emptyTitle": "Nessun compito — tutto fatto?",
|
"emptyTitle": "Nessun compito - tutto fatto?",
|
||||||
"emptyDescription": "Crea nuovi compiti con il pulsante +.",
|
"emptyDescription": "Crea nuovi compiti con il pulsante +.",
|
||||||
"titleLabel": "Titolo *",
|
"titleLabel": "Titolo *",
|
||||||
"titlePlaceholder": "Cosa bisogna fare?",
|
"titlePlaceholder": "Cosa bisogna fare?",
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"dueDateLabel": "Data di scadenza",
|
"dueDateLabel": "Data di scadenza",
|
||||||
"dueTimeLabel": "Ora",
|
"dueTimeLabel": "Ora",
|
||||||
"assignedLabel": "Assegnato a",
|
"assignedLabel": "Assegnato a",
|
||||||
"assignedNobody": "— Nessuno —",
|
"assignedNobody": "- Nessuno -",
|
||||||
"statusLabel": "Stato",
|
"statusLabel": "Stato",
|
||||||
"priorityUrgent": "Urgente",
|
"priorityUrgent": "Urgente",
|
||||||
"priorityHigh": "Alta",
|
"priorityHigh": "Alta",
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
"locationLabel": "Luogo",
|
"locationLabel": "Luogo",
|
||||||
"locationPlaceholder": "Opzionale",
|
"locationPlaceholder": "Opzionale",
|
||||||
"assignedLabel": "Assegnato a",
|
"assignedLabel": "Assegnato a",
|
||||||
"assignedNobody": "— Nessuno —",
|
"assignedNobody": "- Nessuno -",
|
||||||
"colorLabel": "Colore",
|
"colorLabel": "Colore",
|
||||||
"descriptionLabel": "Descrizione",
|
"descriptionLabel": "Descrizione",
|
||||||
"descriptionPlaceholder": "Opzionale…",
|
"descriptionPlaceholder": "Opzionale…",
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
"savedToast": "Voce salvata",
|
"savedToast": "Voce salvata",
|
||||||
"deletedToast": "Voce eliminata",
|
"deletedToast": "Voce eliminata",
|
||||||
"loadError": "Impossibile caricare il bilancio.",
|
"loadError": "Impossibile caricare il bilancio.",
|
||||||
"trendNeutral": "— come {{month}}",
|
"trendNeutral": "- come {{month}}",
|
||||||
"validAmountRequired": "Inserisci un importo valido",
|
"validAmountRequired": "Inserisci un importo valido",
|
||||||
"dateRequired": "La data è obbligatoria",
|
"dateRequired": "La data è obbligatoria",
|
||||||
"catFood": "Spesa alimentare",
|
"catFood": "Spesa alimentare",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { t, formatDate, formatTime, getLocale } from '/i18n.js';
|
import { t, formatDate, formatTime, getLocale } from '/i18n.js';
|
||||||
|
|
||||||
// Hält den AbortController des aktuellen FAB-Listeners — wird bei jedem render() erneuert.
|
// Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert.
|
||||||
let _fabController = null;
|
let _fabController = null;
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -225,7 +225,7 @@ function renderTodayMeals(meals) {
|
|||||||
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
|
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
|
||||||
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
|
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
|
||||||
<div class="meal-slot__type">${mealLabels[type]}</div>
|
<div class="meal-slot__type">${mealLabels[type]}</div>
|
||||||
<div class="meal-slot__title">${meal ? meal.title : '—'}</div>
|
<div class="meal-slot__title">${meal ? meal.title : '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -487,7 +487,7 @@ export async function render(container, { user }) {
|
|||||||
|
|
||||||
refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal });
|
refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal });
|
||||||
|
|
||||||
// 30-Minuten Auto-Refresh — abortiert wenn Seite verlassen wird
|
// 30-Minuten Auto-Refresh - abortiert wenn Seite verlassen wird
|
||||||
const timerId = setInterval(doWeatherRefresh, 30 * 60 * 1000);
|
const timerId = setInterval(doWeatherRefresh, 30 * 60 * 1000);
|
||||||
_fabController.signal.addEventListener('abort', () => clearInterval(timerId));
|
_fabController.signal.addEventListener('abort', () => clearInterval(timerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ function wireDragDrop(grid) {
|
|||||||
async function onUp(ev) {
|
async function onUp(ev) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const { mealId, sourceDate, sourceType, slot: sourceSlot } = dragging;
|
const { mealId, sourceDate, sourceType, slot: sourceSlot } = dragging;
|
||||||
cleanup(); // setzt dragging = null — Werte daher vorher destrukturieren
|
cleanup(); // setzt dragging = null - Werte daher vorher destrukturieren
|
||||||
|
|
||||||
if (ghost) ghost.style.display = 'none';
|
if (ghost) ghost.style.display = 'none';
|
||||||
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { t } from '/i18n.js';
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
// Swipe-Gesten Konstanten (identisch zu tasks.js)
|
// Swipe-Gesten Konstanten (identisch zu tasks.js)
|
||||||
const SWIPE_THRESHOLD = 80; // px — Mindestweg für Aktion
|
const SWIPE_THRESHOLD = 80; // px - Mindestweg für Aktion
|
||||||
const SWIPE_MAX_VERT = 12; // px — vertikaler Toleranzbereich
|
const SWIPE_MAX_VERT = 12; // px - vertikaler Toleranzbereich
|
||||||
const SWIPE_LOCK_VERT = 30; // px — ab diesem Weg gilt es als Scroll
|
const SWIPE_LOCK_VERT = 30; // px - ab diesem Weg gilt es als Scroll
|
||||||
|
|
||||||
const ITEM_CATEGORIES = [
|
const ITEM_CATEGORIES = [
|
||||||
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
||||||
|
|||||||
@@ -717,9 +717,9 @@ function updateOverdueBadge() {
|
|||||||
// Swipe-Gesten (Mobil: links = erledigt, rechts = bearbeiten)
|
// Swipe-Gesten (Mobil: links = erledigt, rechts = bearbeiten)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 80; // px — Mindestweg für Aktion
|
const SWIPE_THRESHOLD = 80; // px - Mindestweg für Aktion
|
||||||
const SWIPE_MAX_VERT = 12; // px — vertikaler Bewegungs-Toleranzbereich (darunter: kein Scroll-Abbruch)
|
const SWIPE_MAX_VERT = 12; // px - vertikaler Bewegungs-Toleranzbereich (darunter: kein Scroll-Abbruch)
|
||||||
const SWIPE_LOCK_VERT = 30; // px — ab diesem Weg gilt es als Scroll (Swipe abgebrochen)
|
const SWIPE_LOCK_VERT = 30; // px - ab diesem Weg gilt es als Scroll (Swipe abgebrochen)
|
||||||
|
|
||||||
function wireSwipeGestures(container) {
|
function wireSwipeGestures(container) {
|
||||||
const listEl = container.querySelector('#task-list');
|
const listEl = container.querySelector('#task-list');
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Oikos Familienplaner — self-hosted, private
|
# Oikos Familienplaner - self-hosted, private
|
||||||
# Keine öffentlichen Inhalte zum Indexieren
|
# Keine öffentlichen Inhalte zum Indexieren
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
|
|||||||
+4
-4
@@ -34,7 +34,7 @@ const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
|||||||
/**
|
/**
|
||||||
* Setzt die theme-color Meta-Tags (Light + Dark Variante).
|
* Setzt die theme-color Meta-Tags (Light + Dark Variante).
|
||||||
* @param {string} lightColor
|
* @param {string} lightColor
|
||||||
* @param {string} [darkColor] — Falls nicht angegeben, wird lightColor für beide gesetzt
|
* @param {string} [darkColor] - Falls nicht angegeben, wird lightColor für beide gesetzt
|
||||||
*/
|
*/
|
||||||
function setThemeColor(lightColor, darkColor) {
|
function setThemeColor(lightColor, darkColor) {
|
||||||
if (!isStandalone) return;
|
if (!isStandalone) return;
|
||||||
@@ -116,7 +116,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
pushState = userOrPushState;
|
pushState = userOrPushState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alten Pfad merken, bevor currentPath aktualisiert wird — für Richtungsberechnung
|
// Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung
|
||||||
const previousPath = currentPath;
|
const previousPath = currentPath;
|
||||||
currentPath = path;
|
currentPath = path;
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ async function renderPage(route, previousPath = null) {
|
|||||||
throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`);
|
throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// App-Shell einmalig aufbauen BEVOR render() aufgerufen wird —
|
// App-Shell einmalig aufbauen BEVOR render() aufgerufen wird -
|
||||||
// page-content muss im DOM existieren damit document.getElementById()
|
// page-content muss im DOM existieren damit document.getElementById()
|
||||||
// in Seiten-Modulen funktioniert.
|
// in Seiten-Modulen funktioniert.
|
||||||
if (!document.querySelector('.nav-bottom') && currentUser) {
|
if (!document.querySelector('.nav-bottom') && currentUser) {
|
||||||
@@ -446,7 +446,7 @@ window.addEventListener('locale-changed', () => {
|
|||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Virtuelle Tastatur: FAB ausblenden wenn Keyboard offen
|
// Virtuelle Tastatur: FAB ausblenden wenn Keyboard offen
|
||||||
// Erkennung via visualViewport — Höhe < 75% des Fensters = Keyboard aktiv.
|
// Erkennung via visualViewport - Höhe < 75% des Fensters = Keyboard aktiv.
|
||||||
// Nur auf Mobilgeräten relevant (< 1024px), Desktop hat keine virtuelle Tastatur.
|
// Nur auf Mobilgeräten relevant (< 1024px), Desktop hat keine virtuelle Tastatur.
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
if (window.visualViewport) {
|
if (window.visualViewport) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Dashboard
|
* Modul: Dashboard
|
||||||
* Zweck: Styles für das Dashboard — Begrüßung, Widget-Grid, alle Widget-Typen, FAB-Speed-Dial
|
* Zweck: Styles für das Dashboard - Begrüßung, Widget-Grid, alle Widget-Typen, FAB-Speed-Dial
|
||||||
* Abhängigkeiten: tokens.css, layout.css
|
* Abhängigkeiten: tokens.css, layout.css
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Widget hover lift (desktop) — dezent, max 1px */
|
/* Widget hover lift (desktop) - dezent, max 1px */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.widget {
|
.widget {
|
||||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
|||||||
+15
-15
@@ -208,7 +208,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Page FAB — Schwebender Erstellen-Button (alle Breakpoints)
|
* Page FAB - Schwebender Erstellen-Button (alle Breakpoints)
|
||||||
*
|
*
|
||||||
* Einheitlicher runder Plus-Button auf allen Unterseiten.
|
* Einheitlicher runder Plus-Button auf allen Unterseiten.
|
||||||
* Mobile: über der Bottom-Nav. Desktop: unten rechts im Content.
|
* Mobile: über der Bottom-Nav. Desktop: unten rechts im Content.
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar-"Neu"-Buttons überall verstecken — FAB übernimmt */
|
/* Toolbar-"Neu"-Buttons überall verstecken - FAB übernimmt */
|
||||||
#btn-new-task,
|
#btn-new-task,
|
||||||
#notes-add-btn,
|
#notes-add-btn,
|
||||||
#contacts-add-btn,
|
#contacts-add-btn,
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Sidebar Navigation — Desktop (≥ 1024px)
|
* Sidebar Navigation - Desktop (≥ 1024px)
|
||||||
*
|
*
|
||||||
* Design: Flach, kein Neumorphismus. Dezenter Seitenrand.
|
* Design: Flach, kein Neumorphismus. Dezenter Seitenrand.
|
||||||
* Aktiver State: Hintergrund-Highlight + Akzentstreifen links.
|
* Aktiver State: Hintergrund-Highlight + Akzentstreifen links.
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Sidebar Expanded (≥ 1280px) — Labels sichtbar
|
* Sidebar Expanded (≥ 1280px) - Labels sichtbar
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
:root {
|
:root {
|
||||||
@@ -515,11 +515,11 @@
|
|||||||
* - Hover auf Desktop: leichter Lift (1px)
|
* - Hover auf Desktop: leichter Lift (1px)
|
||||||
*
|
*
|
||||||
* Varianten:
|
* Varianten:
|
||||||
* .card — Basis (kein Padding)
|
* .card - Basis (kein Padding)
|
||||||
* .card--padded — Mit Padding
|
* .card--padded - Mit Padding
|
||||||
* .card--compact — Enges Padding (12px)
|
* .card--compact - Enges Padding (12px)
|
||||||
* .card--flat — Kein Shadow, nur Border
|
* .card--flat - Kein Shadow, nur Border
|
||||||
* .card--interactive — Hover-Lift + Cursor
|
* .card--interactive - Hover-Lift + Cursor
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
.card {
|
.card {
|
||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
@@ -1048,15 +1048,15 @@
|
|||||||
* Wiederverwendbare Content-Area-Patterns für Desktop.
|
* Wiederverwendbare Content-Area-Patterns für Desktop.
|
||||||
* Mobile: immer single-column (Stacking).
|
* Mobile: immer single-column (Stacking).
|
||||||
*
|
*
|
||||||
* .layout-master-detail — Liste links, Detail rechts (Aufgaben, Einkauf)
|
* .layout-master-detail - Liste links, Detail rechts (Aufgaben, Einkauf)
|
||||||
* .layout-content-aside — Hauptinhalt + schmale Seitenleiste (Kalender)
|
* .layout-content-aside - Hauptinhalt + schmale Seitenleiste (Kalender)
|
||||||
* .layout-center — Zentrierter schmaler Content (Settings, Login)
|
* .layout-center - Zentrierter schmaler Content (Settings, Login)
|
||||||
* .layout-wide — Volle Breite mit max-width (Dashboard)
|
* .layout-wide - Volle Breite mit max-width (Dashboard)
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|
||||||
/* ── Master-Detail ──
|
/* ── Master-Detail ──
|
||||||
* Mobile: gestapelt (Detail wird programmatisch ein-/ausgeblendet).
|
* Mobile: gestapelt (Detail wird programmatisch ein-/ausgeblendet).
|
||||||
* Desktop: 2 Spalten — Liste ~40%, Detail ~60%.
|
* Desktop: 2 Spalten - Liste ~40%, Detail ~60%.
|
||||||
*/
|
*/
|
||||||
.layout-master-detail {
|
.layout-master-detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1344,7 +1344,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Swipe-Wrapper — Gemeinsame Basis (Tasks + Shopping)
|
* Swipe-Wrapper - Gemeinsame Basis (Tasks + Shopping)
|
||||||
* Modul-spezifische Styles (.swipe-reveal--edit, .swipe-reveal--delete,
|
* Modul-spezifische Styles (.swipe-reveal--edit, .swipe-reveal--delete,
|
||||||
* .swipe-row .task-card, .swipe-row .shopping-item) liegen in den Modul-CSS.
|
* .swipe-row .task-card, .swipe-row .shopping-item) liegen in den Modul-CSS.
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slot-Typ-Farben — zentrale Tokens aus tokens.css */
|
/* Slot-Typ-Farben - zentrale Tokens aus tokens.css */
|
||||||
.meal-slot[data-type="breakfast"] .meal-slot__type-label { color: var(--meal-breakfast); }
|
.meal-slot[data-type="breakfast"] .meal-slot__type-label { color: var(--meal-breakfast); }
|
||||||
.meal-slot[data-type="lunch"] .meal-slot__type-label { color: var(--meal-lunch); }
|
.meal-slot[data-type="lunch"] .meal-slot__type-label { color: var(--meal-lunch); }
|
||||||
.meal-slot[data-type="dinner"] .meal-slot__type-label { color: var(--meal-dinner); }
|
.meal-slot[data-type="dinner"] .meal-slot__type-label { color: var(--meal-dinner); }
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Touch-Targets werden über tokens.css (--target-sm/md/lg) und
|
/* Touch-Targets werden über tokens.css (--target-sm/md/lg) und
|
||||||
* komponentenspezifische Styles gehandhabt — siehe Redesign Phase E.
|
* komponentenspezifische Styles gehandhabt - siehe Redesign Phase E.
|
||||||
* Keine globale min-size-Regel hier, da sie mit dem bestehenden
|
* Keine globale min-size-Regel hier, da sie mit dem bestehenden
|
||||||
* Touch-Target-System kollidiert (::before-Expansion auf kleinen Elementen). */
|
* Touch-Target-System kollidiert (::before-Expansion auf kleinen Elementen). */
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
.settings-page { --module-accent: var(--module-settings); }
|
.settings-page { --module-accent: var(--module-settings); }
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
Seiten-Layout — nutzt layout-center (max 720px)
|
Seiten-Layout - nutzt layout-center (max 720px)
|
||||||
-------------------------------------------------------- */
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
.settings-page {
|
.settings-page {
|
||||||
|
|||||||
@@ -419,7 +419,7 @@
|
|||||||
.item-delete:hover { color: var(--color-danger); }
|
.item-delete:hover { color: var(--color-danger); }
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Swipe-Wrapper — Shopping-spezifische Styles
|
* Swipe-Wrapper - Shopping-spezifische Styles
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|
||||||
/* Kein Margin mehr am shopping-item selbst (übernimmt swipe-row) */
|
/* Kein Margin mehr am shopping-item selbst (übernimmt swipe-row) */
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* × Löschen-Button auf Mobile ausblenden — Swipe übernimmt */
|
/* × Löschen-Button auf Mobile ausblenden - Swipe übernimmt */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.item-delete {
|
.item-delete {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -169,7 +169,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Swipe-Wrapper — Task-spezifische Styles
|
* Swipe-Wrapper - Task-spezifische Styles
|
||||||
* Basis-Styles (.swipe-row, .swipe-reveal, .swipe-reveal--done)
|
* Basis-Styles (.swipe-row, .swipe-reveal, .swipe-reveal--done)
|
||||||
* liegen in layout.css
|
* liegen in layout.css
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
+34
-34
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 1. Farben — Neutral-Skala
|
* 1. Farben - Neutral-Skala
|
||||||
* Leicht warmgetönt (kein reines Grau) für einladende Atmosphäre.
|
* Leicht warmgetönt (kein reines Grau) für einladende Atmosphäre.
|
||||||
* Benannt als --neutral-{stufe} für direkte Nutzung,
|
* Benannt als --neutral-{stufe} für direkte Nutzung,
|
||||||
* plus semantische Aliase (--color-bg, --color-surface etc.)
|
* plus semantische Aliase (--color-bg, --color-surface etc.)
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
--color-text-disabled: var(--neutral-300);
|
--color-text-disabled: var(--neutral-300);
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 2. Farben — Akzent (konfigurierbar)
|
* 2. Farben - Akzent (konfigurierbar)
|
||||||
* Wärmerer Blauton statt reinem Corporate-Blau.
|
* Wärmerer Blauton statt reinem Corporate-Blau.
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
--color-accent: #2563EB;
|
--color-accent: #2563EB;
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
--color-btn-primary-hover: #1E429A;
|
--color-btn-primary-hover: #1E429A;
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 3. Farben — Semantisch
|
* 3. Farben - Semantisch
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
--color-success: #15803D;
|
--color-success: #15803D;
|
||||||
--color-success-hover: #166534;
|
--color-success-hover: #166534;
|
||||||
@@ -84,22 +84,22 @@
|
|||||||
--color-info-light: #DDF4FF;
|
--color-info-light: #DDF4FF;
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 4. Farben — Modul-Akzente
|
* 4. Farben - Modul-Akzente
|
||||||
* Jedes Modul hat eine eigene dezente Akzentfarbe.
|
* Jedes Modul hat eine eigene dezente Akzentfarbe.
|
||||||
* Einsatz in Modul-Headern, Icons, aktiven States.
|
* Einsatz in Modul-Headern, Icons, aktiven States.
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
--module-dashboard: #2563EB; /* Blau — Übersicht, neutral */
|
--module-dashboard: #2563EB; /* Blau - Übersicht, neutral */
|
||||||
--module-tasks: #15803D; /* Grün — Erledigung, Fortschritt */
|
--module-tasks: #15803D; /* Grün - Erledigung, Fortschritt */
|
||||||
--module-calendar: #8250DF; /* Violett — Termine, Zeit */
|
--module-calendar: #8250DF; /* Violett - Termine, Zeit */
|
||||||
--module-meals: #B45309; /* Orange — Essen, Wärme */
|
--module-meals: #B45309; /* Orange - Essen, Wärme */
|
||||||
--module-shopping: #D4511E; /* Rot-Orange — Einkaufen, Aktion */
|
--module-shopping: #D4511E; /* Rot-Orange - Einkaufen, Aktion */
|
||||||
--module-notes: #BF8700; /* Gold — Notizen, Pinnwand */
|
--module-notes: #BF8700; /* Gold - Notizen, Pinnwand */
|
||||||
--module-contacts: #0969DA; /* Kräftiges Blau — Kontakte */
|
--module-contacts: #0969DA; /* Kräftiges Blau - Kontakte */
|
||||||
--module-budget: #1A7F5A; /* Teal — Finanzen, Stabilität */
|
--module-budget: #1A7F5A; /* Teal - Finanzen, Stabilität */
|
||||||
--module-settings: #6E7781; /* Grau — Konfiguration */
|
--module-settings: #6E7781; /* Grau - Konfiguration */
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 5. Farben — Mahlzeit-Typen
|
* 5. Farben - Mahlzeit-Typen
|
||||||
* Zentrale Tokens statt Hardcoding in meals.css
|
* Zentrale Tokens statt Hardcoding in meals.css
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
--meal-breakfast: #B45309;
|
--meal-breakfast: #B45309;
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
--meal-snack-light: #FFECE3;
|
--meal-snack-light: #FFECE3;
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 6. Farben — Prioritäten
|
* 6. Farben - Prioritäten
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
--color-priority-low: var(--neutral-500);
|
--color-priority-low: var(--neutral-500);
|
||||||
--color-priority-medium: #B45309;
|
--color-priority-medium: #B45309;
|
||||||
@@ -159,15 +159,15 @@
|
|||||||
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
|
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
|
||||||
|
|
||||||
/* Size-Skala */
|
/* Size-Skala */
|
||||||
--text-xs: 0.75rem; /* 12px — Minimum, Captions, Badges, Nav-Labels */
|
--text-xs: 0.75rem; /* 12px - Minimum, Captions, Badges, Nav-Labels */
|
||||||
--text-sm: 0.8125rem; /* 13px — Small/Secondary */
|
--text-sm: 0.8125rem; /* 13px - Small/Secondary */
|
||||||
--text-base: 0.875rem; /* 14px — Body (Desktop), kompakter */
|
--text-base: 0.875rem; /* 14px - Body (Desktop), kompakter */
|
||||||
--text-md: 1rem; /* 16px — Body (Mobile), Inputs */
|
--text-md: 1rem; /* 16px - Body (Mobile), Inputs */
|
||||||
--text-lg: 1.125rem; /* 18px — Section-Title */
|
--text-lg: 1.125rem; /* 18px - Section-Title */
|
||||||
--text-xl: 1.25rem; /* 20px — Subtitle */
|
--text-xl: 1.25rem; /* 20px - Subtitle */
|
||||||
--text-2xl: 1.5rem; /* 24px — Page-Title */
|
--text-2xl: 1.5rem; /* 24px - Page-Title */
|
||||||
--text-3xl: 1.875rem; /* 30px — Page-Title Desktop */
|
--text-3xl: 1.875rem; /* 30px - Page-Title Desktop */
|
||||||
--text-4xl: 2.25rem; /* 36px — Hero/Greeting */
|
--text-4xl: 2.25rem; /* 36px - Hero/Greeting */
|
||||||
|
|
||||||
/* Line-Heights */
|
/* Line-Heights */
|
||||||
--line-height-tight: 1.25;
|
--line-height-tight: 1.25;
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 11. Abstände — 4px-Raster
|
* 11. Abstände - 4px-Raster
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
--space-0: 0px;
|
--space-0: 0px;
|
||||||
--space-px: 1px;
|
--space-px: 1px;
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* 13. Sidebar
|
* 13. Sidebar
|
||||||
* Reduzierter Neumorphismus — subtilere Schatten.
|
* Reduzierter Neumorphismus - subtilere Schatten.
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
--sidebar-bg: var(--neutral-100);
|
--sidebar-bg: var(--neutral-100);
|
||||||
--sidebar-shadow-light: rgba(255, 255, 255, 0.6);
|
--sidebar-shadow-light: rgba(255, 255, 255, 0.6);
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
--sidebar-shadow-light: rgba(255, 255, 255, 0.04);
|
--sidebar-shadow-light: rgba(255, 255, 255, 0.04);
|
||||||
--sidebar-shadow-dark: rgba(0, 0, 0, 0.4);
|
--sidebar-shadow-dark: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
/* Akzent — Dark Mode */
|
/* Akzent - Dark Mode */
|
||||||
--color-accent: #60A5FA;
|
--color-accent: #60A5FA;
|
||||||
--color-accent-hover: #3B82F6;
|
--color-accent-hover: #3B82F6;
|
||||||
--color-accent-active: #2563EB;
|
--color-accent-active: #2563EB;
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
--color-btn-primary: #3B82F6;
|
--color-btn-primary: #3B82F6;
|
||||||
--color-btn-primary-hover: #2563EB;
|
--color-btn-primary-hover: #2563EB;
|
||||||
|
|
||||||
/* Semantische Farben — Dark Mode */
|
/* Semantische Farben - Dark Mode */
|
||||||
--color-success: #4ADE80;
|
--color-success: #4ADE80;
|
||||||
--color-warning: #F59E0B;
|
--color-warning: #F59E0B;
|
||||||
--color-danger: #FCA5A5;
|
--color-danger: #FCA5A5;
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
--color-danger-light: #3D1C1A;
|
--color-danger-light: #3D1C1A;
|
||||||
--color-info-light: #1A2D40;
|
--color-info-light: #1A2D40;
|
||||||
|
|
||||||
/* Modul-Akzente — Dark Mode */
|
/* Modul-Akzente - Dark Mode */
|
||||||
--module-dashboard: #60A5FA;
|
--module-dashboard: #60A5FA;
|
||||||
--module-tasks: #4ADE80;
|
--module-tasks: #4ADE80;
|
||||||
--module-calendar: #A78BFA;
|
--module-calendar: #A78BFA;
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
--module-budget: #34D399;
|
--module-budget: #34D399;
|
||||||
--module-settings: #94A3B8;
|
--module-settings: #94A3B8;
|
||||||
|
|
||||||
/* Mahlzeit-Typ — Dark Mode */
|
/* Mahlzeit-Typ - Dark Mode */
|
||||||
--meal-breakfast: #F59E0B;
|
--meal-breakfast: #F59E0B;
|
||||||
--meal-dinner: #60A5FA;
|
--meal-dinner: #60A5FA;
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
--sidebar-shadow-light: rgba(255, 255, 255, 0.04);
|
--sidebar-shadow-light: rgba(255, 255, 255, 0.04);
|
||||||
--sidebar-shadow-dark: rgba(0, 0, 0, 0.4);
|
--sidebar-shadow-dark: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
/* Akzent — Dark Mode */
|
/* Akzent - Dark Mode */
|
||||||
--color-accent: #60A5FA;
|
--color-accent: #60A5FA;
|
||||||
--color-accent-hover: #3B82F6;
|
--color-accent-hover: #3B82F6;
|
||||||
--color-accent-active: #2563EB;
|
--color-accent-active: #2563EB;
|
||||||
@@ -371,7 +371,7 @@
|
|||||||
--color-btn-primary: #3B82F6;
|
--color-btn-primary: #3B82F6;
|
||||||
--color-btn-primary-hover: #2563EB;
|
--color-btn-primary-hover: #2563EB;
|
||||||
|
|
||||||
/* Semantische Farben — Dark Mode */
|
/* Semantische Farben - Dark Mode */
|
||||||
--color-success: #4ADE80;
|
--color-success: #4ADE80;
|
||||||
--color-warning: #F59E0B;
|
--color-warning: #F59E0B;
|
||||||
--color-danger: #FCA5A5;
|
--color-danger: #FCA5A5;
|
||||||
@@ -382,7 +382,7 @@
|
|||||||
--color-danger-light: #3D1C1A;
|
--color-danger-light: #3D1C1A;
|
||||||
--color-info-light: #1A2D40;
|
--color-info-light: #1A2D40;
|
||||||
|
|
||||||
/* Modul-Akzente — Dark Mode */
|
/* Modul-Akzente - Dark Mode */
|
||||||
--module-dashboard: #60A5FA;
|
--module-dashboard: #60A5FA;
|
||||||
--module-tasks: #4ADE80;
|
--module-tasks: #4ADE80;
|
||||||
--module-calendar: #A78BFA;
|
--module-calendar: #A78BFA;
|
||||||
@@ -393,7 +393,7 @@
|
|||||||
--module-budget: #34D399;
|
--module-budget: #34D399;
|
||||||
--module-settings: #94A3B8;
|
--module-settings: #94A3B8;
|
||||||
|
|
||||||
/* Mahlzeit-Typ — Dark Mode */
|
/* Mahlzeit-Typ - Dark Mode */
|
||||||
--meal-breakfast: #F59E0B;
|
--meal-breakfast: #F59E0B;
|
||||||
--meal-dinner: #60A5FA;
|
--meal-dinner: #60A5FA;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -111,7 +111,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const { request } = event;
|
const { request } = event;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// API: immer Netzwerk — niemals Nutzerdaten cachen
|
// API: immer Netzwerk - niemals Nutzerdaten cachen
|
||||||
if (url.pathname.startsWith('/api/')) return;
|
if (url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
// Nur GET cachen
|
// Nur GET cachen
|
||||||
@@ -123,7 +123,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bilder + Fonts: Cache-First, langer TTL — nur Same-Origin
|
// Bilder + Fonts: Cache-First, langer TTL - nur Same-Origin
|
||||||
// Cross-Origin-Assets (z.B. Wetter-Icons von openweathermap.org) nicht
|
// Cross-Origin-Assets (z.B. Wetter-Icons von openweathermap.org) nicht
|
||||||
// abfangen: opaque Responses führen im PWA-Modus zu Darstellungsfehlern.
|
// abfangen: opaque Responses führen im PWA-Modus zu Darstellungsfehlern.
|
||||||
if (isAsset(url.pathname) && url.origin === self.location.origin) {
|
if (isAsset(url.pathname) && url.origin === self.location.origin) {
|
||||||
|
|||||||
+4
-4
@@ -10,9 +10,9 @@
|
|||||||
*
|
*
|
||||||
* @param {NodeList|Element[]} elements
|
* @param {NodeList|Element[]} elements
|
||||||
* @param {Object} [opts]
|
* @param {Object} [opts]
|
||||||
* @param {number} [opts.delay=30] — ms zwischen jedem Element
|
* @param {number} [opts.delay=30] - ms zwischen jedem Element
|
||||||
* @param {number} [opts.duration=180] — ms pro Element
|
* @param {number} [opts.duration=180] - ms pro Element
|
||||||
* @param {number} [opts.max=5] — Maximale Anzahl gestaffelter Elemente
|
* @param {number} [opts.max=5] - Maximale Anzahl gestaffelter Elemente
|
||||||
*/
|
*/
|
||||||
export function stagger(elements, { delay = 30, duration = 180, max = 5 } = {}) {
|
export function stagger(elements, { delay = 30, duration = 180, max = 5 } = {}) {
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
@@ -33,7 +33,7 @@ export function stagger(elements, { delay = 30, duration = 180, max = 5 } = {})
|
|||||||
* Vibrationsmuster abspielen, wenn die API verfügbar ist und
|
* Vibrationsmuster abspielen, wenn die API verfügbar ist und
|
||||||
* keine reduzierte Bewegung gewünscht wird.
|
* keine reduzierte Bewegung gewünscht wird.
|
||||||
*
|
*
|
||||||
* @param {number|number[]} pattern — ms oder [an, aus, an, ...]-Array
|
* @param {number|number[]} pattern - ms oder [an, aus, an, ...]-Array
|
||||||
*/
|
*/
|
||||||
export function vibrate(pattern) {
|
export function vibrate(pattern) {
|
||||||
if (!navigator.vibrate) return;
|
if (!navigator.vibrate) return;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function createAppleTouchSvg() {
|
|||||||
return createLogoSvg(180);
|
return createLogoSvg(180);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Favicon (32x32): simplified — just gradient background with house */
|
/** Favicon (32x32): simplified - just gradient background with house */
|
||||||
function createFaviconSvg() {
|
function createFaviconSvg() {
|
||||||
return createLogoSvg(32);
|
return createLogoSvg(32);
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-45
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Demo Seed Script — Oikos
|
* Demo Seed Script - Oikos
|
||||||
* Fills the database with realistic English demo content for screenshots/mockups.
|
* Fills the database with realistic English demo content for screenshots/mockups.
|
||||||
* Usage: node scripts/seed-demo.js [--db /path/to/oikos.db]
|
* Usage: node scripts/seed-demo.js [--db /path/to/oikos.db]
|
||||||
*
|
*
|
||||||
@@ -96,18 +96,18 @@ const insertTask = db.prepare(`
|
|||||||
|
|
||||||
[
|
[
|
||||||
['Book dentist appointment', 'Annual check-up for the whole family', 'health', 'high', 'open', daysFromNow(3), alexId, alexId],
|
['Book dentist appointment', 'Annual check-up for the whole family', 'health', 'high', 'open', daysFromNow(3), alexId, alexId],
|
||||||
['Pay electricity bill', 'Due end of month — online banking', 'finance', 'urgent', 'open', daysFromNow(2), alexId, alexId],
|
['Pay electricity bill', 'Due end of month - online banking', 'finance', 'urgent', 'open', daysFromNow(2), alexId, alexId],
|
||||||
['Renew car insurance', 'Compare quotes on check24.de first', 'finance', 'high', 'open', daysFromNow(10), alexId, alexId],
|
['Renew car insurance', 'Compare quotes on check24.de first', 'finance', 'high', 'open', daysFromNow(10), alexId, alexId],
|
||||||
['Fix leaking bathroom faucet', 'Replace washer, tools in basement', 'home', 'medium', 'open', daysFromNow(7), samId, alexId],
|
['Fix leaking bathroom faucet', 'Replace washer, tools in basement', 'home', 'medium', 'open', daysFromNow(7), samId, alexId],
|
||||||
['Order birthday cake', "Emma's 8th birthday — chocolate cake", 'family', 'high', 'open', daysFromNow(5), samId, samId ],
|
['Order birthday cake', "Emma's 8th birthday - chocolate cake", 'family', 'high', 'open', daysFromNow(5), samId, samId ],
|
||||||
['Clean out garage', 'Donate old stuff to charity', 'home', 'low', 'open', daysFromNow(14), alexId, alexId],
|
['Clean out garage', 'Donate old stuff to charity', 'home', 'low', 'open', daysFromNow(14), alexId, alexId],
|
||||||
['Sign school permission slip', 'Field trip to the science museum', 'school', 'urgent', 'open', daysFromNow(1), samId, samId ],
|
['Sign school permission slip', 'Field trip to the science museum', 'school', 'urgent', 'open', daysFromNow(1), samId, samId ],
|
||||||
['Renew library cards', 'All three cards expired last month', 'admin', 'low', 'open', daysFromNow(20), alexId, alexId],
|
['Renew library cards', 'All three cards expired last month', 'admin', 'low', 'open', daysFromNow(20), alexId, alexId],
|
||||||
['Plan summer holiday', 'Italy or Croatia — check flights', 'family', 'medium', 'open', daysFromNow(30), alexId, alexId],
|
['Plan summer holiday', 'Italy or Croatia - check flights', 'family', 'medium', 'open', daysFromNow(30), alexId, alexId],
|
||||||
['Tax return 2025', 'Documents ready in the folder', 'finance', 'high', 'open', daysFromNow(18), alexId, alexId],
|
['Tax return 2025', 'Documents ready in the folder', 'finance', 'high', 'open', daysFromNow(18), alexId, alexId],
|
||||||
['Grocery run', 'See shopping list for details', 'home', 'medium', 'done', daysFromNow(-1), samId, samId ],
|
['Grocery run', 'See shopping list for details', 'home', 'medium', 'done', daysFromNow(-1), samId, samId ],
|
||||||
['Call insurance about claim', 'Reference: CLM-2025-0492', 'finance', 'high', 'done', daysFromNow(-3), alexId, alexId],
|
['Call insurance about claim', 'Reference: CLM-2025-0492', 'finance', 'high', 'done', daysFromNow(-3), alexId, alexId],
|
||||||
['Oil change — VW Golf', 'Every 15 000 km / 12 months', 'home', 'medium', 'open', daysFromNow(6), alexId, alexId],
|
['Oil change - VW Golf', 'Every 15 000 km / 12 months', 'home', 'medium', 'open', daysFromNow(6), alexId, alexId],
|
||||||
['Buy birthday gift for Mum', 'Amazon wishlist or book voucher', 'family', 'medium', 'open', daysFromNow(8), samId, samId ],
|
['Buy birthday gift for Mum', 'Amazon wishlist or book voucher', 'family', 'medium', 'open', daysFromNow(8), samId, samId ],
|
||||||
['Update home inventory', 'For insurance purposes', 'admin', 'low', 'open', daysFromNow(25), alexId, alexId],
|
['Update home inventory', 'For insurance purposes', 'admin', 'low', 'open', daysFromNow(25), alexId, alexId],
|
||||||
].forEach(row => insertTask.run(...row));
|
].forEach(row => insertTask.run(...row));
|
||||||
@@ -122,20 +122,20 @@ const insertEvent = db.prepare(`
|
|||||||
|
|
||||||
[
|
[
|
||||||
["Emma's Birthday Party", 'Bouncy castle & cake at home', daysFromNow(5) + 'T14:00', daysFromNow(5) + 'T17:00', 0, 'Home', '#F59E0B', samId, samId ],
|
["Emma's Birthday Party", 'Bouncy castle & cake at home', daysFromNow(5) + 'T14:00', daysFromNow(5) + 'T17:00', 0, 'Home', '#F59E0B', samId, samId ],
|
||||||
['Dentist — Family', 'Dr. Müller, bring insurance cards', daysFromNow(3) + 'T10:00', daysFromNow(3) + 'T11:30', 0, 'Dental Practice Müller', '#EF4444', alexId, alexId],
|
['Dentist - Family', 'Dr. Müller, bring insurance cards', daysFromNow(3) + 'T10:00', daysFromNow(3) + 'T11:30', 0, 'Dental Practice Müller', '#EF4444', alexId, alexId],
|
||||||
['Parent-Teacher Evening', 'Room 12, bring report card', daysFromNow(9) + 'T18:30', daysFromNow(9) + 'T20:00', 0, 'Westpark Primary School', '#8B5CF6', samId, samId ],
|
['Parent-Teacher Evening', 'Room 12, bring report card', daysFromNow(9) + 'T18:30', daysFromNow(9) + 'T20:00', 0, 'Westpark Primary School', '#8B5CF6', samId, samId ],
|
||||||
['Science Museum Field Trip', 'Emma — permission slip signed', daysFromNow(1) + 'T08:30', daysFromNow(1) + 'T15:00', 0, 'Natural History Museum', '#06B6D4', samId, samId ],
|
['Science Museum Field Trip', 'Emma - permission slip signed', daysFromNow(1) + 'T08:30', daysFromNow(1) + 'T15:00', 0, 'Natural History Museum', '#06B6D4', samId, samId ],
|
||||||
['Family BBQ — Mum & Dad', 'Bring potato salad', daysFromNow(12) + 'T13:00', daysFromNow(12) + 'T19:00', 0, "Grandma's Garden", '#F59E0B', alexId, alexId],
|
['Family BBQ - Mum & Dad', 'Bring potato salad', daysFromNow(12) + 'T13:00', daysFromNow(12) + 'T19:00', 0, "Grandma's Garden", '#F59E0B', alexId, alexId],
|
||||||
['Car Service Appointment', 'VW Golf, oil change + tyre check', daysFromNow(6) + 'T09:00', daysFromNow(6) + 'T10:30', 0, 'AutoHaus König', '#6B7280', alexId, alexId],
|
['Car Service Appointment', 'VW Golf, oil change + tyre check', daysFromNow(6) + 'T09:00', daysFromNow(6) + 'T10:30', 0, 'AutoHaus König', '#6B7280', alexId, alexId],
|
||||||
['Yoga Class', 'Weekly — bring mat', daysFromNow(2) + 'T19:00', daysFromNow(2) + 'T20:00', 0, 'FitLife Studio', '#10B981', samId, samId ],
|
['Yoga Class', 'Weekly - bring mat', daysFromNow(2) + 'T19:00', daysFromNow(2) + 'T20:00', 0, 'FitLife Studio', '#10B981', samId, samId ],
|
||||||
['Yoga Class', 'Weekly — bring mat', daysFromNow(9) + 'T19:00', daysFromNow(9) + 'T20:00', 0, 'FitLife Studio', '#10B981', samId, samId ],
|
['Yoga Class', 'Weekly - bring mat', daysFromNow(9) + 'T19:00', daysFromNow(9) + 'T20:00', 0, 'FitLife Studio', '#10B981', samId, samId ],
|
||||||
['Mum\'s Birthday', '', daysFromNow(8) + 'T00:00', daysFromNow(8) + 'T00:00', 1, '', '#EC4899', alexId, alexId],
|
['Mum\'s Birthday', '', daysFromNow(8) + 'T00:00', daysFromNow(8) + 'T00:00', 1, '', '#EC4899', alexId, alexId],
|
||||||
['Company All-Hands', 'Q2 results + roadmap presentation', daysFromNow(4) + 'T10:00', daysFromNow(4) + 'T12:00', 0, 'Office — Conference Room B','#2563EB', alexId, alexId],
|
['Company All-Hands', 'Q2 results + roadmap presentation', daysFromNow(4) + 'T10:00', daysFromNow(4) + 'T12:00', 0, 'Office - Conference Room B','#2563EB', alexId, alexId],
|
||||||
['Football Training — Leo', 'Boots & water bottle', daysFromNow(2) + 'T17:00', daysFromNow(2) + 'T18:30', 0, 'Sports Ground West', '#F97316', samId, samId ],
|
['Football Training - Leo', 'Boots & water bottle', daysFromNow(2) + 'T17:00', daysFromNow(2) + 'T18:30', 0, 'Sports Ground West', '#F97316', samId, samId ],
|
||||||
['Football Training — Leo', 'Boots & water bottle', daysFromNow(7) + 'T17:00', daysFromNow(7) + 'T18:30', 0, 'Sports Ground West', '#F97316', samId, samId ],
|
['Football Training - Leo', 'Boots & water bottle', daysFromNow(7) + 'T17:00', daysFromNow(7) + 'T18:30', 0, 'Sports Ground West', '#F97316', samId, samId ],
|
||||||
['Holiday Planning Evening', 'Italy vs Croatia — laptops out', daysFromNow(3) + 'T21:00', daysFromNow(3) + 'T22:00', 0, 'Home', '#14B8A6', alexId, samId ],
|
['Holiday Planning Evening', 'Italy vs Croatia - laptops out', daysFromNow(3) + 'T21:00', daysFromNow(3) + 'T22:00', 0, 'Home', '#14B8A6', alexId, samId ],
|
||||||
['GP Appointment — Alex', 'Annual health check', daysFromNow(15) + 'T11:00', daysFromNow(15) + 'T11:30', 0, 'Dr. Weber — City Practice', '#EF4444', alexId, alexId],
|
['GP Appointment - Alex', 'Annual health check', daysFromNow(15) + 'T11:00', daysFromNow(15) + 'T11:30', 0, 'Dr. Weber - City Practice', '#EF4444', alexId, alexId],
|
||||||
['Weekend City Break', 'Hotel booked — just pack bags!', daysFromNow(20) + 'T00:00', daysFromNow(22) + 'T00:00', 1, 'Amsterdam', '#0EA5E9', alexId, alexId],
|
['Weekend City Break', 'Hotel booked - just pack bags!', daysFromNow(20) + 'T00:00', daysFromNow(22) + 'T00:00', 1, 'Amsterdam', '#0EA5E9', alexId, alexId],
|
||||||
].forEach(row => insertEvent.run(...row));
|
].forEach(row => insertEvent.run(...row));
|
||||||
|
|
||||||
// ── Meals ────────────────────────────────────────────────────────────────────
|
// ── Meals ────────────────────────────────────────────────────────────────────
|
||||||
@@ -191,18 +191,18 @@ const insertContact = db.prepare(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
[
|
[
|
||||||
['Dr. Anna Weber', 'medical', '+49 231 445 2210', 'praxis@dr-weber.de', 'Bürgerstraße 12, Dortmund', 'GP — appointments Mon–Thu'],
|
['Dr. Anna Weber', 'medical', '+49 231 445 2210', 'praxis@dr-weber.de', 'Bürgerstraße 12, Dortmund', 'GP - appointments Mon–Thu'],
|
||||||
['Dr. Thomas Müller', 'medical', '+49 231 887 0034', 'info@zahnarzt-mueller.de', 'Hansastraße 55, Dortmund', 'Family dentist'],
|
['Dr. Thomas Müller', 'medical', '+49 231 887 0034', 'info@zahnarzt-mueller.de', 'Hansastraße 55, Dortmund', 'Family dentist'],
|
||||||
['Grandma & Grandpa Johnson', 'family','+49 2304 78 221', 'oma.johnson@gmail.com', 'Ahornweg 4, Castrop-Rauxel', "Emma & Leo's grandparents"],
|
['Grandma & Grandpa Johnson', 'family','+49 2304 78 221', 'oma.johnson@gmail.com', 'Ahornweg 4, Castrop-Rauxel', "Emma & Leo's grandparents"],
|
||||||
['Westpark Primary School','school', '+49 231 556 8810', 'office@westpark-grundschule.de', 'Westparkstraße 20, Dortmund', "Emma's school — Mrs Bauer is class teacher"],
|
['Westpark Primary School','school', '+49 231 556 8810', 'office@westpark-grundschule.de', 'Westparkstraße 20, Dortmund', "Emma's school - Mrs Bauer is class teacher"],
|
||||||
['AutoHaus König', 'services', '+49 231 997 1100', 'service@autohaus-koenig.de','Industriestraße 88, Dortmund', 'VW service partner — Ref: Golf TDI 2021'],
|
['AutoHaus König', 'services', '+49 231 997 1100', 'service@autohaus-koenig.de','Industriestraße 88, Dortmund', 'VW service partner - Ref: Golf TDI 2021'],
|
||||||
['FitLife Studio', 'services', '+49 231 340 5060', 'hello@fitlife-dortmund.de', 'Rheinlanddamm 14, Dortmund', "Sam's yoga — Tuesdays 19:00"],
|
['FitLife Studio', 'services', '+49 231 340 5060', 'hello@fitlife-dortmund.de', 'Rheinlanddamm 14, Dortmund', "Sam's yoga - Tuesdays 19:00"],
|
||||||
['Uncle Mike Johnson', 'family', '+49 172 3340 551', 'mike.j@outlook.com', '', 'Alex\'s brother — lives in Hamburg'],
|
['Uncle Mike Johnson', 'family', '+49 172 3340 551', 'mike.j@outlook.com', '', 'Alex\'s brother - lives in Hamburg'],
|
||||||
['Aunt Claire Becker', 'family', '+49 151 2234 8876','claire.becker@web.de', 'Fichtenweg 7, Bochum', 'Sam\'s sister'],
|
['Aunt Claire Becker', 'family', '+49 151 2234 8876','claire.becker@web.de', 'Fichtenweg 7, Bochum', 'Sam\'s sister'],
|
||||||
['Leo\'s Football Coach', 'school', '+49 176 5512 4490','trainer@svwest-dortmund.de','Sportplatz West, Dortmund', 'Training Tues & Sat 17:00'],
|
['Leo\'s Football Coach', 'school', '+49 176 5512 4490','trainer@svwest-dortmund.de','Sportplatz West, Dortmund', 'Training Tues & Sat 17:00'],
|
||||||
['City Library', 'services', '+49 231 502 6600', 'stadtbibliothek@dortmund.de','Königswall 18, Dortmund', 'Family cards — renew every 2 years'],
|
['City Library', 'services', '+49 231 502 6600', 'stadtbibliothek@dortmund.de','Königswall 18, Dortmund', 'Family cards - renew every 2 years'],
|
||||||
['Landlord — Mr Groß', 'services', '+49 231 112 7743', 'vermieter.gross@gmail.com', '', 'Emergency maintenance: same number'],
|
['Landlord - Mr Groß', 'services', '+49 231 112 7743', 'vermieter.gross@gmail.com', '', 'Emergency maintenance: same number'],
|
||||||
['Emma\'s Best Friend Lena','family', '+49 231 774 3309', '', '', "Lena Braun — mum is Katrin +49 231 774 3308"],
|
['Emma\'s Best Friend Lena','family', '+49 231 774 3309', '', '', "Lena Braun - mum is Katrin +49 231 774 3308"],
|
||||||
].forEach(row => insertContact.run(...row));
|
].forEach(row => insertContact.run(...row));
|
||||||
|
|
||||||
// ── Budget ───────────────────────────────────────────────────────────────────
|
// ── Budget ───────────────────────────────────────────────────────────────────
|
||||||
@@ -215,41 +215,41 @@ const insertBudget = db.prepare(`
|
|||||||
|
|
||||||
[
|
[
|
||||||
// Income
|
// Income
|
||||||
['Alex — Monthly Salary', 3850.00, 'income', thisMonthDate(1), 1, alexId],
|
['Alex - Monthly Salary', 3850.00, 'income', thisMonthDate(1), 1, alexId],
|
||||||
['Sam — Part-time Work', 1200.00, 'income', thisMonthDate(1), 1, alexId],
|
['Sam - Part-time Work', 1200.00, 'income', thisMonthDate(1), 1, alexId],
|
||||||
['Child Benefit (Kindergeld)', 250.00, 'income', thisMonthDate(5), 1, alexId],
|
['Child Benefit (Kindergeld)', 250.00, 'income', thisMonthDate(5), 1, alexId],
|
||||||
|
|
||||||
// Fixed expenses
|
// Fixed expenses
|
||||||
['Rent', -1450.00, 'housing', thisMonthDate(1), 1, alexId],
|
['Rent', -1450.00, 'housing', thisMonthDate(1), 1, alexId],
|
||||||
['Car Insurance — VW Golf', -89.50, 'transport', thisMonthDate(1), 1, alexId],
|
['Car Insurance - VW Golf', -89.50, 'transport', thisMonthDate(1), 1, alexId],
|
||||||
['Health Insurance', -310.00, 'insurance', thisMonthDate(1), 1, alexId],
|
['Health Insurance', -310.00, 'insurance', thisMonthDate(1), 1, alexId],
|
||||||
['Internet & Phone Bundle', -49.99, 'utilities', thisMonthDate(5), 1, alexId],
|
['Internet & Phone Bundle', -49.99, 'utilities', thisMonthDate(5), 1, alexId],
|
||||||
['Electricity Bill', -78.00, 'utilities', thisMonthDate(15), 1, alexId],
|
['Electricity Bill', -78.00, 'utilities', thisMonthDate(15), 1, alexId],
|
||||||
['Netflix', -17.99, 'leisure', thisMonthDate(10), 1, alexId],
|
['Netflix', -17.99, 'leisure', thisMonthDate(10), 1, alexId],
|
||||||
['Spotify Family', -16.99, 'leisure', thisMonthDate(10), 1, alexId],
|
['Spotify Family', -16.99, 'leisure', thisMonthDate(10), 1, alexId],
|
||||||
['Gym — FitLife Monthly', -39.00, 'health', thisMonthDate(1), 1, alexId],
|
['Gym - FitLife Monthly', -39.00, 'health', thisMonthDate(1), 1, alexId],
|
||||||
|
|
||||||
// Variable this month
|
// Variable this month
|
||||||
['Weekly Groceries — Wk 1', -142.30, 'food', thisMonthDate(4), 0, samId ],
|
['Weekly Groceries - Wk 1', -142.30, 'food', thisMonthDate(4), 0, samId ],
|
||||||
['Weekly Groceries — Wk 2', -118.75, 'food', thisMonthDate(11), 0, samId ],
|
['Weekly Groceries - Wk 2', -118.75, 'food', thisMonthDate(11), 0, samId ],
|
||||||
['Weekly Groceries — Wk 3', -134.20, 'food', thisMonthDate(18), 0, samId ],
|
['Weekly Groceries - Wk 3', -134.20, 'food', thisMonthDate(18), 0, samId ],
|
||||||
['School Trip Payment', -25.00, 'school', thisMonthDate(3), 0, samId ],
|
['School Trip Payment', -25.00, 'school', thisMonthDate(3), 0, samId ],
|
||||||
['Birthday Gift — Mum', -60.00, 'family', thisMonthDate(7), 0, alexId],
|
['Birthday Gift - Mum', -60.00, 'family', thisMonthDate(7), 0, alexId],
|
||||||
['Restaurant — Date Night', -87.50, 'leisure', thisMonthDate(9), 0, alexId],
|
['Restaurant - Date Night', -87.50, 'leisure', thisMonthDate(9), 0, alexId],
|
||||||
['Fuel — VW Golf', -68.00, 'transport', thisMonthDate(6), 0, alexId],
|
['Fuel - VW Golf', -68.00, 'transport', thisMonthDate(6), 0, alexId],
|
||||||
['Pharmacy', -22.40, 'health', thisMonthDate(8), 0, samId ],
|
['Pharmacy', -22.40, 'health', thisMonthDate(8), 0, samId ],
|
||||||
['Leo\'s Football Boots', -54.99, 'school', thisMonthDate(12), 0, samId ],
|
['Leo\'s Football Boots', -54.99, 'school', thisMonthDate(12), 0, samId ],
|
||||||
['Home Improvement — Tools', -43.00, 'home', thisMonthDate(14), 0, alexId],
|
['Home Improvement - Tools', -43.00, 'home', thisMonthDate(14), 0, alexId],
|
||||||
['Clothing — Emma', -38.50, 'clothing', thisMonthDate(16), 0, samId ],
|
['Clothing - Emma', -38.50, 'clothing', thisMonthDate(16), 0, samId ],
|
||||||
['Weekend Trip Deposit', -200.00, 'leisure', thisMonthDate(19), 0, alexId],
|
['Weekend Trip Deposit', -200.00, 'leisure', thisMonthDate(19), 0, alexId],
|
||||||
|
|
||||||
// Last month (for trend comparison)
|
// Last month (for trend comparison)
|
||||||
['Alex — Monthly Salary', 3850.00, 'income', lastMonthDate(1), 0, alexId],
|
['Alex - Monthly Salary', 3850.00, 'income', lastMonthDate(1), 0, alexId],
|
||||||
['Sam — Part-time Work', 1200.00, 'income', lastMonthDate(1), 0, alexId],
|
['Sam - Part-time Work', 1200.00, 'income', lastMonthDate(1), 0, alexId],
|
||||||
['Rent', -1450.00, 'housing', lastMonthDate(1), 0, alexId],
|
['Rent', -1450.00, 'housing', lastMonthDate(1), 0, alexId],
|
||||||
['Weekly Groceries', -489.00, 'food', lastMonthDate(10), 0, samId ],
|
['Weekly Groceries', -489.00, 'food', lastMonthDate(10), 0, samId ],
|
||||||
['Electricity Bill', -82.00, 'utilities', lastMonthDate(15), 0, alexId],
|
['Electricity Bill', -82.00, 'utilities', lastMonthDate(15), 0, alexId],
|
||||||
['Fuel — VW Golf', -71.00, 'transport', lastMonthDate(8), 0, alexId],
|
['Fuel - VW Golf', -71.00, 'transport', lastMonthDate(8), 0, alexId],
|
||||||
].forEach(row => insertBudget.run(...row));
|
].forEach(row => insertBudget.run(...row));
|
||||||
|
|
||||||
// ── Notes ────────────────────────────────────────────────────────────────────
|
// ── Notes ────────────────────────────────────────────────────────────────────
|
||||||
@@ -262,7 +262,7 @@ const insertNote = db.prepare(`
|
|||||||
|
|
||||||
[
|
[
|
||||||
['Holiday Checklist 🌍',
|
['Holiday Checklist 🌍',
|
||||||
'Passports (exp. 2028)\nTravel insurance — check!\nEuro cash — €300\nBook airport parking\nAsk Mike to water plants\nPack sunscreen SPF 50',
|
'Passports (exp. 2028)\nTravel insurance - check!\nEuro cash - €300\nBook airport parking\nAsk Mike to water plants\nPack sunscreen SPF 50',
|
||||||
'#0EA5E9', 1, alexId],
|
'#0EA5E9', 1, alexId],
|
||||||
|
|
||||||
['WiFi & Smart Home',
|
['WiFi & Smart Home',
|
||||||
@@ -270,23 +270,23 @@ const insertNote = db.prepare(`
|
|||||||
'#F59E0B', 1, alexId],
|
'#F59E0B', 1, alexId],
|
||||||
|
|
||||||
["Emma's School Info",
|
["Emma's School Info",
|
||||||
"Class: 3b — Mrs Bauer\nSchool starts: 08:10\nCollection: 13:30 (Tue/Thu 15:00)\nAllergy: mild lactose intolerance\nBest friends: Lena, Sophie, Tim",
|
"Class: 3b - Mrs Bauer\nSchool starts: 08:10\nCollection: 13:30 (Tue/Thu 15:00)\nAllergy: mild lactose intolerance\nBest friends: Lena, Sophie, Tim",
|
||||||
'#EC4899', 1, samId],
|
'#EC4899', 1, samId],
|
||||||
|
|
||||||
['Leo\'s Activities',
|
['Leo\'s Activities',
|
||||||
'Football: Tues & Sat 17:00 — SV West\nSwimming: Fri 16:00 — Westbad\nNeeds: boots size 35, goggles\nCoach: Herr Krüger +49 176 5512 4490',
|
'Football: Tues & Sat 17:00 - SV West\nSwimming: Fri 16:00 - Westbad\nNeeds: boots size 35, goggles\nCoach: Herr Krüger +49 176 5512 4490',
|
||||||
'#F97316', 1, samId],
|
'#F97316', 1, samId],
|
||||||
|
|
||||||
['Emergency Numbers',
|
['Emergency Numbers',
|
||||||
'Police: 110\nFire / Ambulance: 112\nPoison Control: 0800 192 11 10\nLocal GP out-of-hours: 116 117\nNearest A&E: Klinikum Dortmund',
|
'Police: 110\nFire / Ambulance: 112\nPoison Control: 0800 192 11 10\nLocal GP out-of-hours: 116 117\nNearest A&E: Klinikum Dortmund',
|
||||||
'#EF4444', 1, alexId],
|
'#EF4444', 1, alexId],
|
||||||
|
|
||||||
['Car — Important Dates',
|
['Car - Important Dates',
|
||||||
'Next service: June 2025 (60,000 km)\nTÜV due: September 2025\nWinter tyres: stored at AutoHaus König\nInsurance renewal: October 2025',
|
'Next service: June 2025 (60,000 km)\nTÜV due: September 2025\nWinter tyres: stored at AutoHaus König\nInsurance renewal: October 2025',
|
||||||
'#6B7280', 0, alexId],
|
'#6B7280', 0, alexId],
|
||||||
|
|
||||||
['Book Recommendations',
|
['Book Recommendations',
|
||||||
'Currently reading: "Atomic Habits" — James Clear\nWishlist:\n• The Thursday Murder Club\n• Lessons in Chemistry\n• Tomorrow, and Tomorrow, and Tomorrow',
|
'Currently reading: "Atomic Habits" - James Clear\nWishlist:\n• The Thursday Murder Club\n• Lessons in Chemistry\n• Tomorrow, and Tomorrow, and Tomorrow',
|
||||||
'#8B5CF6', 0, samId],
|
'#8B5CF6', 0, samId],
|
||||||
|
|
||||||
['Garden To-Do',
|
['Garden To-Do',
|
||||||
@@ -325,7 +325,7 @@ const insertItem = db.prepare(`
|
|||||||
['Bananas', '6', 'fruit', 0],
|
['Bananas', '6', 'fruit', 0],
|
||||||
['Blueberries', '125 g', 'fruit', 0],
|
['Blueberries', '125 g', 'fruit', 0],
|
||||||
['Lemons', '4', 'fruit', 0],
|
['Lemons', '4', 'fruit', 0],
|
||||||
['Pasta — spaghetti', '500 g', 'pantry', 0],
|
['Pasta - spaghetti', '500 g', 'pantry', 0],
|
||||||
['Basmati rice', '1 kg', 'pantry', 0],
|
['Basmati rice', '1 kg', 'pantry', 0],
|
||||||
['Olive oil', '500 ml', 'pantry', 0],
|
['Olive oil', '500 ml', 'pantry', 0],
|
||||||
['Tomato passata', '2 × 500 g','pantry', 0],
|
['Tomato passata', '2 × 500 g','pantry', 0],
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ const router = express.Router();
|
|||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
|
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
|
||||||
// Eigene Implementierung — kein connect-sqlite3 (nutzt sqlite3-Bindings,
|
// Eigene Implementierung - kein connect-sqlite3 (nutzt sqlite3-Bindings,
|
||||||
// die separat kompiliert werden müssten und die Fehlerquelle waren).
|
// die separat kompiliert werden müssten und die Fehlerquelle waren).
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
class BetterSQLiteStore extends session.Store {
|
class BetterSQLiteStore extends session.Store {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: DB-Schema-Export für Tests
|
* Modul: DB-Schema-Export für Tests
|
||||||
* Zweck: SQL-Strings aus MIGRATIONS für node:sqlite-Tests exportieren.
|
* Zweck: SQL-Strings aus MIGRATIONS für node:sqlite-Tests exportieren.
|
||||||
* Nur für Testzwecke — db.js nutzt die MIGRATIONS direkt intern.
|
* Nur für Testzwecke - db.js nutzt die MIGRATIONS direkt intern.
|
||||||
* Abhängigkeiten: keine
|
* Abhängigkeiten: keine
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -60,7 +60,7 @@ function init() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Migrationen in aufsteigender Reihenfolge.
|
* Alle Migrationen in aufsteigender Reihenfolge.
|
||||||
* Neue Migrations am Ende anhängen — niemals bestehende ändern.
|
* Neue Migrations am Ende anhängen - niemals bestehende ändern.
|
||||||
*/
|
*/
|
||||||
const MIGRATIONS = [
|
const MIGRATIONS = [
|
||||||
{
|
{
|
||||||
@@ -352,7 +352,7 @@ function currentVersion() {
|
|||||||
* @returns {import('better-sqlite3').Database}
|
* @returns {import('better-sqlite3').Database}
|
||||||
*/
|
*/
|
||||||
function get() {
|
function get() {
|
||||||
if (!db) throw new Error('[DB] Nicht initialisiert — init() zuerst aufrufen.');
|
if (!db) throw new Error('[DB] Nicht initialisiert - init() zuerst aufrufen.');
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -94,7 +94,7 @@ app.use('/api/', (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Statische Dateien (Frontend) — differenzierte Caching-Strategie
|
// Statische Dateien (Frontend) - differenzierte Caching-Strategie
|
||||||
//
|
//
|
||||||
// HTML + JS + CSS: no-cache (Browser revalidiert via ETag/304, kein stale Content
|
// HTML + JS + CSS: no-cache (Browser revalidiert via ETag/304, kein stale Content
|
||||||
// nach Deployment). Bei unverändertem File → 304 Not Modified ohne Übertragung.
|
// nach Deployment). Bei unverändertem File → 304 Not Modified ohne Übertragung.
|
||||||
@@ -113,7 +113,7 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
|
|||||||
} else if (['.png', '.jpg', '.jpeg', '.ico', '.svg', '.webp', '.woff2', '.woff'].includes(ext)) {
|
} else if (['.png', '.jpg', '.jpeg', '.ico', '.svg', '.webp', '.woff2', '.woff'].includes(ext)) {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 Tage
|
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 Tage
|
||||||
} else {
|
} else {
|
||||||
// HTML, JS, CSS, JSON, manifest, sw — immer revalidieren
|
// HTML, JS, CSS, JSON, manifest, sw - immer revalidieren
|
||||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||||
}
|
}
|
||||||
// manifest.json: korrekter MIME-Type für PWA-Erkennung durch Chrome/Android
|
// manifest.json: korrekter MIME-Type für PWA-Erkennung durch Chrome/Android
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const { str, oneOf, date, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } = req
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt fehlende Instanzen wiederkehrender Budget-Einträge für den angefragten Monat.
|
* Erstellt fehlende Instanzen wiederkehrender Budget-Einträge für den angefragten Monat.
|
||||||
* Läuft idempotent — bereits vorhandene oder explizit übersprungene Instanzen werden ignoriert.
|
* Läuft idempotent - bereits vorhandene oder explizit übersprungene Instanzen werden ignoriert.
|
||||||
* @param {import('better-sqlite3').Database} database
|
* @param {import('better-sqlite3').Database} database
|
||||||
* @param {string} month YYYY-MM
|
* @param {string} month YYYY-MM
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ router.get('/google/callback', async (req, res) => {
|
|||||||
|
|
||||||
await googleCalendar.handleCallback(code);
|
await googleCalendar.handleCallback(code);
|
||||||
|
|
||||||
// Initialen Sync im Hintergrund starten (kein await — Redirect soll sofort erfolgen)
|
// Initialen Sync im Hintergrund starten (kein await - Redirect soll sofort erfolgen)
|
||||||
googleCalendar.sync().catch((e) => console.error('[Google] Initialer Sync fehlgeschlagen:', e.message));
|
googleCalendar.sync().catch((e) => console.error('[Google] Initialer Sync fehlgeschlagen:', e.message));
|
||||||
|
|
||||||
res.redirect('/settings?sync_ok=google');
|
res.redirect('/settings?sync_ok=google');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Dashboard
|
* Modul: Dashboard
|
||||||
* Zweck: Aggregierter Endpoint — liefert Daten aller Dashboard-Widgets in einem Request
|
* Zweck: Aggregierter Endpoint - liefert Daten aller Dashboard-Widgets in einem Request
|
||||||
* Abhängigkeiten: express, server/db.js
|
* Abhängigkeiten: express, server/db.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const db = require('../db');
|
|||||||
/**
|
/**
|
||||||
* GET /api/v1/dashboard
|
* GET /api/v1/dashboard
|
||||||
* Liefert aggregierte Daten für alle Dashboard-Widgets.
|
* Liefert aggregierte Daten für alle Dashboard-Widgets.
|
||||||
* Jedes Widget-Objekt hat ein eigenes `error`-Feld falls die Abfrage fehlschlägt —
|
* Jedes Widget-Objekt hat ein eigenes `error`-Feld falls die Abfrage fehlschlägt -
|
||||||
* so bricht ein fehlerhaftes Widget nicht das gesamte Dashboard.
|
* so bricht ein fehlerhaftes Widget nicht das gesamte Dashboard.
|
||||||
*
|
*
|
||||||
* Response: {
|
* Response: {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function weekEnd(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Routen — Mahlzeiten-Vorschläge (vor dynamischen Routen!)
|
// Routen - Mahlzeiten-Vorschläge (vor dynamischen Routen!)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +70,7 @@ router.get('/suggestions', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Routen — Wochenübersicht
|
// Routen - Wochenübersicht
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,7 +137,7 @@ router.get('/', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// CRUD — Mahlzeiten
|
// CRUD - Mahlzeiten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,7 +266,7 @@ router.delete('/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// CRUD — Zutaten
|
// CRUD - Zutaten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// GET /api/v1/weather/icon/:code
|
// GET /api/v1/weather/icon/:code
|
||||||
// Proxy für OpenWeatherMap-Icons — vermeidet externe Bild-Requests
|
// Proxy für OpenWeatherMap-Icons - vermeidet externe Bild-Requests
|
||||||
// im PWA-Standalone-Modus (CORS/CSP-Probleme auf Android Chrome).
|
// im PWA-Standalone-Modus (CORS/CSP-Probleme auf Android Chrome).
|
||||||
// Erlaubte Codes: 2–4 alphanumerische Zeichen (z.B. "01d", "10n").
|
// Erlaubte Codes: 2–4 alphanumerische Zeichen (z.B. "01d", "10n").
|
||||||
// Response: PNG-Bild mit 24h-Cache
|
// Response: PNG-Bild mit 24h-Cache
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Apple Calendar Sync (CalDAV)
|
* Modul: Apple Calendar Sync (CalDAV)
|
||||||
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
|
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
|
||||||
* Abhängigkeiten: tsdav (ESM — dynamisch importiert), server/db.js
|
* Abhängigkeiten: tsdav (ESM - dynamisch importiert), server/db.js
|
||||||
*
|
*
|
||||||
* Konfiguration (.env):
|
* Konfiguration (.env):
|
||||||
* APPLE_CALDAV_URL — z.B. https://caldav.icloud.com
|
* APPLE_CALDAV_URL - z.B. https://caldav.icloud.com
|
||||||
* APPLE_USERNAME — Apple-ID E-Mail
|
* APPLE_USERNAME - Apple-ID E-Mail
|
||||||
* APPLE_APP_SPECIFIC_PASSWORD — App-spezifisches Passwort aus appleid.apple.com
|
* APPLE_APP_SPECIFIC_PASSWORD - App-spezifisches Passwort aus appleid.apple.com
|
||||||
*
|
*
|
||||||
* sync_config-Schlüssel:
|
* sync_config-Schlüssel:
|
||||||
* apple_last_sync — ISO-8601-Timestamp des letzten Syncs
|
* apple_last_sync - ISO-8601-Timestamp des letzten Syncs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -134,7 +134,7 @@ function parseICS(ics) {
|
|||||||
const location = get('LOCATION') || null;
|
const location = get('LOCATION') || null;
|
||||||
const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null;
|
const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null;
|
||||||
|
|
||||||
// DTSTART — mit optionalem TZID oder VALUE=DATE
|
// DTSTART - mit optionalem TZID oder VALUE=DATE
|
||||||
const dtStartRaw = (() => {
|
const dtStartRaw = (() => {
|
||||||
const m = /^DTSTART(?:;[^:]*)?:(.*)$/im.exec(block);
|
const m = /^DTSTART(?:;[^:]*)?:(.*)$/im.exec(block);
|
||||||
return m ? m[1].trim() : null;
|
return m ? m[1].trim() : null;
|
||||||
@@ -148,7 +148,7 @@ function parseICS(ics) {
|
|||||||
const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null;
|
const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null;
|
||||||
let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null;
|
let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null;
|
||||||
|
|
||||||
// RFC 5545: DTEND for VALUE=DATE is exclusive — subtract one day
|
// RFC 5545: DTEND for VALUE=DATE is exclusive - subtract one day
|
||||||
if (allDay && dtend) {
|
if (allDay && dtend) {
|
||||||
const d = new Date(dtend + 'T00:00:00');
|
const d = new Date(dtend + 'T00:00:00');
|
||||||
d.setDate(d.getDate() - 1);
|
d.setDate(d.getDate() - 1);
|
||||||
@@ -215,7 +215,7 @@ function applyDuration(dtstart, dur, allDay) {
|
|||||||
base.setHours(base.getHours() + hours, base.getMinutes() + mins, base.getSeconds() + secs);
|
base.setHours(base.getHours() + hours, base.getMinutes() + mins, base.getSeconds() + secs);
|
||||||
|
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
// Duration end is exclusive for DATE values — subtract one day for inclusive storage
|
// Duration end is exclusive for DATE values - subtract one day for inclusive storage
|
||||||
base.setDate(base.getDate() - 1);
|
base.setDate(base.getDate() - 1);
|
||||||
return `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`;
|
return `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ function buildICS(event) {
|
|||||||
|
|
||||||
if (event.all_day) {
|
if (event.all_day) {
|
||||||
const startDate = event.start_datetime.slice(0, 10).replace(/-/g, '');
|
const startDate = event.start_datetime.slice(0, 10).replace(/-/g, '');
|
||||||
// RFC 5545: DTEND for VALUE=DATE is exclusive — add one day
|
// RFC 5545: DTEND for VALUE=DATE is exclusive - add one day
|
||||||
const endSrc = (event.end_datetime || event.start_datetime).slice(0, 10);
|
const endSrc = (event.end_datetime || event.start_datetime).slice(0, 10);
|
||||||
const endD = new Date(endSrc + 'T00:00:00');
|
const endD = new Date(endSrc + 'T00:00:00');
|
||||||
endD.setDate(endD.getDate() + 1);
|
endD.setDate(endD.getDate() + 1);
|
||||||
@@ -288,7 +288,7 @@ async function sync() {
|
|||||||
throw new Error('[Apple] Keine Credentials konfiguriert (weder in DB noch in .env).');
|
throw new Error('[Apple] Keine Credentials konfiguriert (weder in DB noch in .env).');
|
||||||
}
|
}
|
||||||
|
|
||||||
// tsdav ist ESM-only — dynamischer Import aus CommonJS
|
// tsdav ist ESM-only - dynamischer Import aus CommonJS
|
||||||
const { createDAVClient } = await import('tsdav');
|
const { createDAVClient } = await import('tsdav');
|
||||||
|
|
||||||
const client = await createDAVClient({
|
const client = await createDAVClient({
|
||||||
@@ -307,7 +307,7 @@ async function sync() {
|
|||||||
// created_by: ersten existierenden User verwenden (nicht hardcoded ID 1)
|
// created_by: ersten existierenden User verwenden (nicht hardcoded ID 1)
|
||||||
const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
|
const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
console.warn('[Apple] Kein User in der Datenbank — Sync übersprungen.');
|
console.warn('[Apple] Kein User in der Datenbank - Sync übersprungen.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const createdBy = owner.id;
|
const createdBy = owner.id;
|
||||||
@@ -397,7 +397,7 @@ async function sync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfgSet('apple_last_sync', new Date().toISOString());
|
cfgSet('apple_last_sync', new Date().toISOString());
|
||||||
console.log(`[Apple] Sync abgeschlossen — ${totalObjects} Objekte aus ${syncCalendars.length} Kalendern inbound, ${localEvents.length} lokal → iCloud.`);
|
console.log(`[Apple] Sync abgeschlossen - ${totalObjects} Objekte aus ${syncCalendars.length} Kalendern inbound, ${localEvents.length} lokal → iCloud.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { sync, getStatus, saveCredentials, clearCredentials, testConnection };
|
module.exports = { sync, getStatus, saveCredentials, clearCredentials, testConnection };
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
* Abhängigkeiten: googleapis, server/db.js
|
* Abhängigkeiten: googleapis, server/db.js
|
||||||
*
|
*
|
||||||
* sync_config-Schlüssel:
|
* sync_config-Schlüssel:
|
||||||
* google_access_token — OAuth Access Token
|
* google_access_token - OAuth Access Token
|
||||||
* google_refresh_token — OAuth Refresh Token (langlebig)
|
* google_refresh_token - OAuth Refresh Token (langlebig)
|
||||||
* google_token_expiry — ISO-8601-Timestamp bis wann Access Token gültig ist
|
* google_token_expiry - ISO-8601-Timestamp bis wann Access Token gültig ist
|
||||||
* google_sync_token — Inkrementeller Sync-Token von Google (events.list)
|
* google_sync_token - Inkrementeller Sync-Token von Google (events.list)
|
||||||
* google_last_sync — ISO-8601-Timestamp des letzten erfolgreichen Syncs
|
* google_last_sync - ISO-8601-Timestamp des letzten erfolgreichen Syncs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -65,7 +65,7 @@ function loadAuthorizedClient() {
|
|||||||
const refreshToken = cfgGet('google_refresh_token');
|
const refreshToken = cfgGet('google_refresh_token');
|
||||||
|
|
||||||
if (!accessToken || !refreshToken) {
|
if (!accessToken || !refreshToken) {
|
||||||
throw new Error('[Google] Nicht konfiguriert — zuerst OAuth durchführen.');
|
throw new Error('[Google] Nicht konfiguriert - zuerst OAuth durchführen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
@@ -103,7 +103,7 @@ function getAuthUrl() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth-Callback: tauscht Code gegen Tokens, speichert in sync_config.
|
* OAuth-Callback: tauscht Code gegen Tokens, speichert in sync_config.
|
||||||
* @param {string} code — Code aus dem OAuth-Callback-Query-Parameter
|
* @param {string} code - Code aus dem OAuth-Callback-Query-Parameter
|
||||||
*/
|
*/
|
||||||
async function handleCallback(code) {
|
async function handleCallback(code) {
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
@@ -117,7 +117,7 @@ async function handleCallback(code) {
|
|||||||
cfgSet('google_refresh_token', tokens.refresh_token);
|
cfgSet('google_refresh_token', tokens.refresh_token);
|
||||||
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
|
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
|
||||||
|
|
||||||
console.log('[Google] OAuth erfolgreich — Tokens gespeichert.');
|
console.log('[Google] OAuth erfolgreich - Tokens gespeichert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,7 +179,7 @@ async function sync() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 410) {
|
if (err.code === 410) {
|
||||||
// syncToken abgelaufen → vollständiger Resync
|
// syncToken abgelaufen → vollständiger Resync
|
||||||
console.warn('[Google] syncToken ungültig — vollständiger Resync.');
|
console.warn('[Google] syncToken ungültig - vollständiger Resync.');
|
||||||
cfgDel('google_sync_token');
|
cfgDel('google_sync_token');
|
||||||
syncToken = null;
|
syncToken = null;
|
||||||
continue;
|
continue;
|
||||||
@@ -220,7 +220,7 @@ async function sync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfgSet('google_last_sync', new Date().toISOString());
|
cfgSet('google_last_sync', new Date().toISOString());
|
||||||
console.log(`[Google] Sync abgeschlossen — ${localEvents.length} lokal → Google, Inbound via syncToken.`);
|
console.log(`[Google] Sync abgeschlossen - ${localEvents.length} lokal → Google, Inbound via syncToken.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Setup-Script
|
* Modul: Setup-Script
|
||||||
* Zweck: Erstmalige Einrichtung — ersten Admin-User anlegen.
|
* Zweck: Erstmalige Einrichtung - ersten Admin-User anlegen.
|
||||||
* Wird einmalig nach dem ersten Start ausgeführt: `node setup.js`
|
* Wird einmalig nach dem ersten Start ausgeführt: `node setup.js`
|
||||||
* Abhängigkeiten: server/db.js, bcrypt, dotenv
|
* Abhängigkeiten: server/db.js, bcrypt, dotenv
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* test-browser-loader.mjs — Node.js Custom Loader für Tests
|
* test-browser-loader.mjs - Node.js Custom Loader für Tests
|
||||||
* Zweck: Browser-absolute Pfade (/foo.js) auf Stubs umleiten, damit
|
* Zweck: Browser-absolute Pfade (/foo.js) auf Stubs umleiten, damit
|
||||||
* Frontend-Module im Node-Test-Kontext importierbar sind.
|
* Frontend-Module im Node-Test-Kontext importierbar sind.
|
||||||
* Verwendung: node --loader ./test-browser-loader.mjs test-xxx.js
|
* Verwendung: node --loader ./test-browser-loader.mjs test-xxx.js
|
||||||
|
|||||||
+1
-1
@@ -169,7 +169,7 @@ test('Kommende Termine (upcoming)', () => {
|
|||||||
// Sortierung
|
// Sortierung
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
test('Sortierung: ganztägig nach uhrzeit-basierten Terminen', () => {
|
test('Sortierung: ganztägig nach uhrzeit-basierten Terminen', () => {
|
||||||
// Gleicher Tag: Ganztägig sollte nach hinten oder flexibel — hier: all_day DESC in der Abfrage
|
// Gleicher Tag: Ganztägig sollte nach hinten oder flexibel - hier: all_day DESC in der Abfrage
|
||||||
const events = db.prepare(`
|
const events = db.prepare(`
|
||||||
SELECT * FROM calendar_events
|
SELECT * FROM calendar_events
|
||||||
WHERE DATE(start_datetime) = '2026-03-24'
|
WHERE DATE(start_datetime) = '2026-03-24'
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Datenbank-Test
|
* Modul: Datenbank-Test
|
||||||
* Zweck: Schema-Migration mit node:sqlite (built-in) validieren.
|
* Zweck: Schema-Migration mit node:sqlite (built-in) validieren.
|
||||||
* Kein Kompilieren nötig — läuft direkt mit Node 22+.
|
* Kein Kompilieren nötig - läuft direkt mit Node 22+.
|
||||||
* Testet SQL-Korrektheit, FK-Reihenfolge, Triggers, Indizes.
|
* Testet SQL-Korrektheit, FK-Reihenfolge, Triggers, Indizes.
|
||||||
*
|
*
|
||||||
* Ausführen: node test-db.js
|
* Ausführen: node test-db.js
|
||||||
@@ -13,7 +13,7 @@ const { DatabaseSync } = require('node:sqlite');
|
|||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Migrations-SQL direkt aus db.js extrahieren
|
// Migrations-SQL direkt aus db.js extrahieren
|
||||||
// (Nur für Tests — in Produktion läuft db.js mit better-sqlite3)
|
// (Nur für Tests - in Produktion läuft db.js mit better-sqlite3)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
|
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -251,7 +251,7 @@ test('Zutaten → Einkaufsliste übertragen (INSERT + Flag setzen)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Zweiter Transfer überträgt nichts (alle bereits markiert)', () => {
|
test('Zweiter Transfer überträgt nichts (alle bereits markiert)', () => {
|
||||||
// Mahlzeit aus vorherigem Test — alle on_shopping_list = 1
|
// Mahlzeit aus vorherigem Test - alle on_shopping_list = 1
|
||||||
const suppe = db.prepare(`SELECT id FROM meals WHERE title = 'Suppe'`).get();
|
const suppe = db.prepare(`SELECT id FROM meals WHERE title = 'Suppe'`).get();
|
||||||
const open = db.prepare(`
|
const open = db.prepare(`
|
||||||
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Tests: Modal Utilities (wireBlurValidation, btnSuccess, btnError)
|
* Tests: Modal Utilities (wireBlurValidation, btnSuccess, btnError)
|
||||||
* Modul: /public/components/modal.js
|
* Modul: /public/components/modal.js
|
||||||
* Läuft im Node-Kontext — die Utility-Funktionen greifen ausschließlich
|
* Läuft im Node-Kontext - die Utility-Funktionen greifen ausschließlich
|
||||||
* über ihre Parameter auf DOM-Objekte zu, daher kein DOM-Polyfill nötig.
|
* über ihre Parameter auf DOM-Objekte zu, daher kein DOM-Polyfill nötig.
|
||||||
*/
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Notes / Contacts / Budget — Tests
|
* Modul: Notes / Contacts / Budget - Tests
|
||||||
* Zweck: Validiert CRUD, Constraints, Filterabfragen, Aggregation für alle drei Module
|
* Zweck: Validiert CRUD, Constraints, Filterabfragen, Aggregation für alle drei Module
|
||||||
* Ausführen: node --experimental-sqlite test-notes-contacts-budget.js
|
* Ausführen: node --experimental-sqlite test-notes-contacts-budget.js
|
||||||
*/
|
*/
|
||||||
|
|||||||
+4
-4
@@ -72,21 +72,21 @@ test('Liste umbenennen', () => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Artikel-CRUD
|
// Artikel-CRUD
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
test('Artikel hinzufügen — Obst & Gemüse', () => {
|
test('Artikel hinzufügen - Obst & Gemüse', () => {
|
||||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||||
VALUES (?, 'Äpfel', '1 kg', 'Obst & Gemüse')`).run(listId);
|
VALUES (?, 'Äpfel', '1 kg', 'Obst & Gemüse')`).run(listId);
|
||||||
itemId1 = r.lastInsertRowid;
|
itemId1 = r.lastInsertRowid;
|
||||||
assert(itemId1 > 0);
|
assert(itemId1 > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Artikel hinzufügen — Milchprodukte', () => {
|
test('Artikel hinzufügen - Milchprodukte', () => {
|
||||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||||
VALUES (?, 'Milch', '1 Liter', 'Milchprodukte')`).run(listId);
|
VALUES (?, 'Milch', '1 Liter', 'Milchprodukte')`).run(listId);
|
||||||
itemId2 = r.lastInsertRowid;
|
itemId2 = r.lastInsertRowid;
|
||||||
assert(itemId2 > 0);
|
assert(itemId2 > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Artikel hinzufügen — Backwaren', () => {
|
test('Artikel hinzufügen - Backwaren', () => {
|
||||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, category)
|
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, category)
|
||||||
VALUES (?, 'Brot', 'Backwaren')`).run(listId);
|
VALUES (?, 'Brot', 'Backwaren')`).run(listId);
|
||||||
itemId3 = r.lastInsertRowid;
|
itemId3 = r.lastInsertRowid;
|
||||||
@@ -184,7 +184,7 @@ test('Autocomplete-Suggestions nach Prefix', () => {
|
|||||||
assert(results[0].name === 'Joghurt', `Erwartet Joghurt, erhalten: ${results[0].name}`);
|
assert(results[0].name === 'Joghurt', `Erwartet Joghurt, erhalten: ${results[0].name}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Autocomplete — kein Match gibt leeres Array', () => {
|
test('Autocomplete - kein Match gibt leeres Array', () => {
|
||||||
const results = db.prepare(`
|
const results = db.prepare(`
|
||||||
SELECT DISTINCT name FROM shopping_items WHERE name LIKE ? COLLATE NOCASE
|
SELECT DISTINCT name FROM shopping_items WHERE name LIKE ? COLLATE NOCASE
|
||||||
`).all('XXXXXXXX%');
|
`).all('XXXXXXXX%');
|
||||||
|
|||||||
+3
-3
@@ -1,17 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Tests: UX Utilities (stagger, vibrate)
|
* Tests: UX Utilities (stagger, vibrate)
|
||||||
* Läuft im Node-Kontext — kein DOM verfügbar, daher nur Pure-Logic-Tests.
|
* Läuft im Node-Kontext - kein DOM verfügbar, daher nur Pure-Logic-Tests.
|
||||||
*/
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
// Minimales Window/Navigator-Mock für Node
|
// Minimales Window/Navigator-Mock für Node
|
||||||
const { stagger, vibrate } = await (async () => {
|
const { stagger, vibrate } = await (async () => {
|
||||||
// stagger braucht window.matchMedia — wir mocken es
|
// stagger braucht window.matchMedia - wir mocken es
|
||||||
global.window = {
|
global.window = {
|
||||||
matchMedia: () => ({ matches: false }),
|
matchMedia: () => ({ matches: false }),
|
||||||
};
|
};
|
||||||
// navigator ist in Node ein getter-only property — über defineProperty überschreiben
|
// navigator ist in Node ein getter-only property - über defineProperty überschreiben
|
||||||
Object.defineProperty(global, 'navigator', {
|
Object.defineProperty(global, 'navigator', {
|
||||||
value: { vibrate: null },
|
value: { vibrate: null },
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user