Merge branch 'main' of github.com:rafaelfoster/oikos
This commit is contained in:
+17
@@ -86,3 +86,20 @@ New suggestion? → [Open an issue](https://github.com/ulsklyc/oikos/issues/new?
|
|||||||
| - | Calendar: custom event icons (102 Lucide options via visual picker); birthday events auto-assigned `cake` icon | v0.29.0 |
|
| - | Calendar: custom event icons (102 Lucide options via visual picker); birthday events auto-assigned `cake` icon | v0.29.0 |
|
||||||
| - | Calendar: extended reminder presets (2 days, 1 week, 2 weeks) + fully custom reminder (number + unit) | v0.29.0 |
|
| - | Calendar: extended reminder presets (2 days, 1 week, 2 weeks) + fully custom reminder (number + unit) | v0.29.0 |
|
||||||
| - | Calendar: locale-aware date text inputs (MDY / DMY / YMD) in all date fields across Calendar, Tasks, Meals, Birthdays, Budget | v0.29.0 |
|
| - | Calendar: locale-aware date text inputs (MDY / DMY / YMD) in all date fields across Calendar, Tasks, Meals, Birthdays, Budget | v0.29.0 |
|
||||||
|
| - | i18n: recipe strings contributed by @baragoon for 13 locales (ar, el, es, fr, hi, it, ja, pt, ru, sv, tr, zh, uk) | v0.30.0 |
|
||||||
|
| - | Family: phone, email, and birthday fields on family member records, auto-synced to Contacts and Birthdays | v0.31.0 |
|
||||||
|
| - | Settings: dedicated Family Management tab and API Tokens tab (admin-only) | v0.31.0 |
|
||||||
|
| - | Settings: ICS subscription edit modal — update name, color, and shared visibility inline | v0.31.2 |
|
||||||
|
| - | Documents module: upload and manage family files with grid/list view, category tags, visibility ACL (family / restricted / private), archive, download | v0.32.0 |
|
||||||
|
| - | Tasks: archive status — completed tasks can be archived; visible in dedicated Archived filter | v0.32.0 |
|
||||||
|
| - | Tasks: inline reminder presets (15 min–2 weeks or custom offset from due date/time) | v0.32.0 |
|
||||||
|
| - | Typography: Plus Jakarta Sans variable font self-hosted under `public/fonts/` — no CDN dependency at runtime | v0.32.3 |
|
||||||
|
| - | Module toolbars sticky while scrolling (Tasks, Notes, Calendar, Contacts, Shopping) | v0.32.3 |
|
||||||
|
| - | Calendar: overlapping timed events render side-by-side in week and day views | v0.33.0 |
|
||||||
|
| - | Calendar: event file attachments (images, PDFs, Office documents ≤ 5 MB); drag-and-drop upload; inline image preview | v0.33.0 |
|
||||||
|
| - | Navigation: Kitchen (Meals / Recipes / Shopping) grouped behind a single bottom-bar entry with a persistent tab bar | v0.34.0 |
|
||||||
|
| - | Settings: Backup Management tab (admin-only) — database download and restore via file upload with pre-restore rollback copy | v0.35.0 |
|
||||||
|
| - | UX: empty states in all modules include a primary CTA button that triggers the page FAB | v0.36.0 |
|
||||||
|
| - | UX: `friendlyError()` helper — unhandled promise rejections show status-code-aware messages instead of raw error text | v0.36.0 |
|
||||||
|
| - | Date input: default format changed to DMY with dot separator; dot-separated dates accepted everywhere | v0.36.1 |
|
||||||
|
| - | Microinteraction long loops: FAB entry animation stops after 5 views; keyboard shortcut hint hides after first use; success toasts suppressed after 50 saves; empty-state CTA delayed fade-in | v0.38.0 |
|
||||||
|
|||||||
@@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.38.4] - 2026-04-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Dashboard portrait mode on mobile: layout no longer overflows to landscape width; `overflow: visible` override in the Admin Dashboard Layout CSS block has been removed so the correct `overflow: clip` takes effect, and `.app-content` now uses `overflow-x: hidden` (instead of `clip`) to properly contain layout overflow at the scroll container level
|
||||||
|
|
||||||
|
## [0.38.3] - 2026-04-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Dashboard portrait mode on Android: horizontal scrollbar no longer appears due to subpixel overflow in the main scroll container (`overflow-x: clip` added to `.app-content`)
|
||||||
|
|
||||||
|
## [0.38.2] - 2026-04-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recurring calendar events with `FREQ=WEEKLY;INTERVAL=N;BYDAY=...` (N > 1) now correctly skip N−1 weeks between occurrences instead of repeating every week
|
||||||
|
|
||||||
|
## [0.38.1] - 2026-04-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Docs: SPEC.md — `family_documents` and `family_document_access` tables added; `calendar_events` extended with `icon` and four attachment columns; `contacts` and `birthdays` extended with `family_user_id`; Tasks `status` includes `archived`; Documents module section added; Calendar section updated with icons, file attachments, and overlapping event rendering; Settings section updated with Backup Management tab and family member contact fields
|
||||||
|
- Docs: BACKLOG.md — completed features table brought up to date through v0.38.0 (v0.30.0–v0.38.0 entries added)
|
||||||
|
- Docs: README.md — Backup entry added to the feature table; Documents entry updated with exact category count
|
||||||
|
- Docs: CONTRIBUTING.md — `innerHTML` security note updated to reflect current `insertAdjacentHTML`/`replaceChildren`/`esc()` pattern; individual test-suite commands listed
|
||||||
|
|
||||||
|
## [0.38.0] - 2026-04-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- FAB entry animation now stops after 5 page views (long loop progressive reduction)
|
||||||
|
- Search keyboard shortcut hint (`/`) hides permanently after first keyboard use
|
||||||
|
- Success toasts are suppressed after 50 successful saves to reduce noise for power users
|
||||||
|
- Empty state CTA button fades in with a short delay to draw attention as the primary action
|
||||||
|
- Form fields pulse with a red glow on the second or subsequent validation failure on the same field
|
||||||
|
- Shopping quick-add input shows a brief accent-colour glow after each successful item add
|
||||||
|
|
||||||
|
## [0.37.2] - 2026-04-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Search bar in More sheet: added hover, active, and focus states with accent colour highlight and subtle scale feedback
|
||||||
|
- Search bar icon changes to accent colour on hover and press for clearer trigger affordance
|
||||||
|
- Keyboard shortcut hint (`/`) shown inside search bar on desktop as discoverability signal
|
||||||
|
|
||||||
|
## [0.37.1] - 2026-04-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Bottom navigation: Tasks replaces Search as a primary tab bar item
|
||||||
|
- More menu: layout changed from two columns to a three-column grid (two rows of three)
|
||||||
|
- Search: embedded as a narrow bar at the top of the More sheet instead of a standalone bottom-nav button
|
||||||
|
|
||||||
|
## [0.37.0] - 2026-04-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Calendar: drag-and-drop file upload dropzone for event attachments (consistent with Documents module)
|
||||||
|
- Calendar: popup positioning now fully viewport-aware (flips above anchor if insufficient space below)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Calendar: event attachments with raw base64 data (no `data:` prefix) now render correctly as images
|
||||||
|
- Calendar: "file too large" error is now shown correctly when saving an oversized attachment
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Theme init script extracted from inline `<script>` to `/theme-init.js` for a stricter Content Security Policy (`'self'` only, no SHA hash)
|
||||||
|
- Modal overlay is now vertically centered on mobile (with safe-area insets) matching desktop behavior; rounded corners on all sides
|
||||||
|
- Modal `max-height` is computed from `100dvh` minus safe-area insets for accurate sizing on notched devices
|
||||||
|
|
||||||
## [0.36.1] - 2026-04-29
|
## [0.36.1] - 2026-04-29
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+12
-3
@@ -47,7 +47,17 @@ npm run dev
|
|||||||
npm test # All suites
|
npm test # All suites
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests use the Node.js built-in test runner with in-memory SQLite. No running server or database required - tests import route handlers directly.
|
Individual suites (faster during development):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:db && npm run test:tasks && npm run test:shopping
|
||||||
|
npm run test:meals && npm run test:calendar && npm run test:ncb
|
||||||
|
npm run test:reminders && npm run test:dashboard && npm run test:api
|
||||||
|
npm run test:ics-parser && npm run test:ics-sub
|
||||||
|
npm run test:modal-utils && npm run test:ux-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use the Node.js built-in test runner with in-memory SQLite (`--experimental-sqlite`). No running server or database required — tests import route handlers directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,9 +187,8 @@ PRs are reviewed by the maintainer. Expect feedback within a few days. Once appr
|
|||||||
|
|
||||||
- ES modules everywhere (`import`/`export`, never `require`)
|
- ES modules everywhere (`import`/`export`, never `require`)
|
||||||
- Semicolons: **yes**
|
- Semicolons: **yes**
|
||||||
- Header comment in every file: purpose, module, dependencies
|
|
||||||
- `try/catch` in every route handler - no unhandled promise rejections
|
- `try/catch` in every route handler - no unhandled promise rejections
|
||||||
- No `eval()`, no `innerHTML` with user input - use `textContent` or DOM API
|
- No dynamic code execution. Never write user data directly into an HTML string — use `esc()` from `public/utils/html.js` in template literals, or DOM API (`createElement`, `textContent`). Use `insertAdjacentHTML` to append HTML fragments, `replaceChildren()` to replace content. Direct `innerHTML` writes are blocked by a pre-commit hook.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -53,13 +53,14 @@ The goal is a single, private place for everything that keeps a household runnin
|
|||||||
| **Meal Planning** | Weekly drag-and-drop planner. Export ingredient lists directly to your shopping list. |
|
| **Meal Planning** | Weekly drag-and-drop planner. Export ingredient lists directly to your shopping list. |
|
||||||
| **Recipes** | Create, duplicate, and scale reusable recipes. Pre-fill meal slots from a recipe or save any meal as a recipe. |
|
| **Recipes** | Create, duplicate, and scale reusable recipes. Pre-fill meal slots from a recipe or save any meal as a recipe. |
|
||||||
| **Calendar** | Two-way sync with Google Calendar (OAuth) and Apple iCloud (CalDAV). Subscribe to any public ICS/webcal URL with per-subscription color and visibility. Overlapping timed events render side-by-side. Events support file attachments (images, PDFs, Office documents). |
|
| **Calendar** | Two-way sync with Google Calendar (OAuth) and Apple iCloud (CalDAV). Subscribe to any public ICS/webcal URL with per-subscription color and visibility. Overlapping timed events render side-by-side. Events support file attachments (images, PDFs, Office documents). |
|
||||||
| **Documents** | Upload and manage family files (PDF, images, Office documents up to 5 MB). Grid/list view, drag-and-drop upload, category tags (medical, school, identity, finance, and more), per-document visibility (family, selected members, private), archive and download. |
|
| **Documents** | Upload and manage family files (PDF, images, Office documents up to 5 MB). Grid/list view, drag-and-drop upload, 14 category tags (medical, school, identity, finance, and more), per-document visibility (family, selected members, private), archive and download. |
|
||||||
| **Budget** | Track income and expenses with recurring entries, monthly trends, and CSV export. 35 predefined categories plus custom ones. Supports 15 currencies. |
|
| **Budget** | Track income and expenses with recurring entries, monthly trends, and CSV export. 35 predefined categories plus custom ones. Supports 15 currencies. |
|
||||||
| **Notes & Contacts** | Colored sticky notes with Markdown support. Contact directory with vCard import/export. |
|
| **Notes & Contacts** | Colored sticky notes with Markdown support. Contact directory with vCard import/export. |
|
||||||
| **Birthdays** | Birthday tracker with automatic annual calendar events, age display, profile photos, and 1-day-before reminders. |
|
| **Birthdays** | Birthday tracker with automatic annual calendar events, age display, profile photos, and 1-day-before reminders. |
|
||||||
| **Reminders** | Time-based reminders on tasks and calendar events. In-app notification badge. |
|
| **Reminders** | Time-based reminders on tasks and calendar events. In-app notification badge. |
|
||||||
| **Family** | Assign family roles, profile pictures, phone, email, and birthday per member. Family details are automatically synced to Contacts and Birthdays. |
|
| **Family** | Assign family roles, profile pictures, phone, email, and birthday per member. Family details are automatically synced to Contacts and Birthdays. |
|
||||||
| **API Tokens** | Named Bearer / X-API-Key tokens for external integrations. SHA-256-hashed at rest, with optional expiry. OpenAPI 3.0 spec at `/api/v1/openapi.json`. |
|
| **API Tokens** | Named Bearer / X-API-Key tokens for external integrations. SHA-256-hashed at rest, with optional expiry. OpenAPI 3.0 spec at `/api/v1/openapi.json`. |
|
||||||
|
| **Backup** | Admin-only database backup and restore via the Settings UI. Download a snapshot or restore from a file upload with an automatic pre-restore rollback copy. |
|
||||||
|
|
||||||
## Design & Technology
|
## Design & Technology
|
||||||
|
|
||||||
|
|||||||
+57
-4
@@ -26,7 +26,7 @@ Every table: `id INTEGER PRIMARY KEY`, `created_at TEXT`, `updated_at TEXT` (ISO
|
|||||||
| description | TEXT | |
|
| description | TEXT | |
|
||||||
| category | TEXT | Household, School, Shopping, Repairs, Other |
|
| category | TEXT | Household, School, Shopping, Repairs, Other |
|
||||||
| priority | TEXT | none (default), low, medium, high, urgent |
|
| priority | TEXT | none (default), low, medium, high, urgent |
|
||||||
| status | TEXT | open, in_progress, done |
|
| status | TEXT | open, in_progress, done, archived |
|
||||||
| due_date | TEXT | DATE, nullable |
|
| due_date | TEXT | DATE, nullable |
|
||||||
| due_time | TEXT | TIME, nullable |
|
| due_time | TEXT | TIME, nullable |
|
||||||
| assigned_to | INTEGER | FK → Users |
|
| assigned_to | INTEGER | FK → Users |
|
||||||
@@ -108,6 +108,7 @@ Reusable recipe cards that can be pre-filled into meal slots.
|
|||||||
| all_day | INTEGER | 0/1 |
|
| all_day | INTEGER | 0/1 |
|
||||||
| location | TEXT | |
|
| location | TEXT | |
|
||||||
| color | TEXT | HEX |
|
| color | TEXT | HEX |
|
||||||
|
| icon | TEXT | Lucide icon name, default 'calendar' |
|
||||||
| assigned_to | INTEGER | FK → Users |
|
| assigned_to | INTEGER | FK → Users |
|
||||||
| created_by | INTEGER | FK → Users, NOT NULL |
|
| created_by | INTEGER | FK → Users, NOT NULL |
|
||||||
| external_calendar_id | TEXT | ID from external calendar |
|
| external_calendar_id | TEXT | ID from external calendar |
|
||||||
@@ -116,6 +117,10 @@ Reusable recipe cards that can be pre-filled into meal slots.
|
|||||||
| subscription_id | INTEGER | FK → ICS Subscriptions (CASCADE delete) |
|
| subscription_id | INTEGER | FK → ICS Subscriptions (CASCADE delete) |
|
||||||
| user_modified | INTEGER | 0/1 — prevents sync overwrite when 1 |
|
| user_modified | INTEGER | 0/1 — prevents sync overwrite when 1 |
|
||||||
| calendar_ref_id | INTEGER | FK → External Calendars (ON DELETE SET NULL) |
|
| calendar_ref_id | INTEGER | FK → External Calendars (ON DELETE SET NULL) |
|
||||||
|
| attachment_name | TEXT | Original filename of attached file, nullable |
|
||||||
|
| attachment_mime | TEXT | MIME type (e.g. image/jpeg, application/pdf), nullable |
|
||||||
|
| attachment_size | INTEGER | File size in bytes, nullable |
|
||||||
|
| attachment_data | TEXT | Base64 data URL of attachment (≤ 5 MB), nullable |
|
||||||
|
|
||||||
### External Calendars
|
### External Calendars
|
||||||
Display metadata (name, color) for synced Google/Apple calendars. Populated automatically during sync.
|
Display metadata (name, color) for synced Google/Apple calendars. Populated automatically during sync.
|
||||||
@@ -146,6 +151,7 @@ Display metadata (name, color) for synced Google/Apple calendars. Populated auto
|
|||||||
| email | TEXT | |
|
| email | TEXT | |
|
||||||
| address | TEXT | |
|
| address | TEXT | |
|
||||||
| notes | TEXT | |
|
| notes | TEXT | |
|
||||||
|
| family_user_id | INTEGER | FK → Users (CASCADE delete), UNIQUE (one linked user per contact), nullable |
|
||||||
|
|
||||||
### Budget Entries
|
### Budget Entries
|
||||||
| Column | Type | Constraint |
|
| Column | Type | Constraint |
|
||||||
@@ -215,6 +221,7 @@ Birthday records with optional profile photo and automatic calendar event + remi
|
|||||||
| notes | TEXT | nullable |
|
| notes | TEXT | nullable |
|
||||||
| photo_data | TEXT | Base64 data URL (≤ 5 MB), nullable |
|
| photo_data | TEXT | Base64 data URL (≤ 5 MB), nullable |
|
||||||
| calendar_event_id | INTEGER | FK → calendar_events (SET NULL on delete), nullable |
|
| calendar_event_id | INTEGER | FK → calendar_events (SET NULL on delete), nullable |
|
||||||
|
| family_user_id | INTEGER | FK → Users (CASCADE delete), UNIQUE (one linked user per birthday), nullable |
|
||||||
| created_by | INTEGER | FK → Users (CASCADE delete), NOT NULL |
|
| created_by | INTEGER | FK → Users (CASCADE delete), NOT NULL |
|
||||||
|
|
||||||
### API Tokens
|
### API Tokens
|
||||||
@@ -247,6 +254,33 @@ External calendar feeds subscribed by users (read-only, auto-synced).
|
|||||||
| last_sync | TEXT | ISO timestamp of last successful sync |
|
| last_sync | TEXT | ISO timestamp of last successful sync |
|
||||||
| created_at | TEXT | ISO timestamp |
|
| created_at | TEXT | ISO timestamp |
|
||||||
|
|
||||||
|
### Family Documents
|
||||||
|
Upload and manage family files with per-document access control.
|
||||||
|
|
||||||
|
| Column | Type | Constraint |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| name | TEXT | NOT NULL (display name) |
|
||||||
|
| description | TEXT | nullable |
|
||||||
|
| category | TEXT | medical, school, identity, insurance, finance, home, vehicle, legal, travel, pets, warranty, taxes, work, other (default) |
|
||||||
|
| status | TEXT | active (default), archived |
|
||||||
|
| visibility | TEXT | family (default), restricted, private |
|
||||||
|
| original_name | TEXT | NOT NULL (original filename) |
|
||||||
|
| mime_type | TEXT | NOT NULL |
|
||||||
|
| file_size | INTEGER | NOT NULL (bytes) |
|
||||||
|
| content_data | TEXT | NOT NULL (Base64 data URL) |
|
||||||
|
| storage_provider | TEXT | local (default), external |
|
||||||
|
| storage_key | TEXT | nullable (external storage path) |
|
||||||
|
| created_by | INTEGER | FK → Users (CASCADE delete), NOT NULL |
|
||||||
|
|
||||||
|
### Family Document Access
|
||||||
|
Allowlist for `visibility = 'restricted'` documents — only listed users can see the document.
|
||||||
|
|
||||||
|
| Column | Type | Constraint |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| document_id | INTEGER | FK → Family Documents (CASCADE delete), NOT NULL |
|
||||||
|
| user_id | INTEGER | FK → Users (CASCADE delete), NOT NULL |
|
||||||
|
| PRIMARY KEY | | (document_id, user_id) |
|
||||||
|
|
||||||
### Sync Config
|
### Sync Config
|
||||||
Key-value table for OAuth tokens and CalDAV credentials.
|
Key-value table for OAuth tokens and CalDAV credentials.
|
||||||
|
|
||||||
@@ -286,6 +320,8 @@ Skeleton loading instead of spinners. Clicking any widget navigates to that modu
|
|||||||
- Assignment to users (avatar color as indicator)
|
- Assignment to users (avatar color as indicator)
|
||||||
- Priorities shown visually via color/icon
|
- Priorities shown visually via color/icon
|
||||||
- Recurring: automatically create next instance on completion
|
- Recurring: automatically create next instance on completion
|
||||||
|
- Archive: completed tasks can be archived (status = 'archived'); visible in a separate Archived filter
|
||||||
|
- Inline reminder presets: offset from due date/time — 15 min, 1 h, 1 d, 2 d, 1 w, 2 w, or fully custom offset
|
||||||
- Mobile swipe: left = done, right = edit
|
- Mobile swipe: left = done, right = edit
|
||||||
- Badge for overdue tasks
|
- Badge for overdue tasks
|
||||||
|
|
||||||
@@ -330,9 +366,12 @@ Reusable recipe cards linked to meal slots.
|
|||||||
- Recurring via iCal RRULE
|
- Recurring via iCal RRULE
|
||||||
- **Google Calendar:** OAuth 2.0, Calendar API v3, two-way sync
|
- **Google Calendar:** OAuth 2.0, Calendar API v3, two-way sync
|
||||||
- **Apple Calendar:** CalDAV (tsdav), two-way sync
|
- **Apple Calendar:** CalDAV (tsdav), two-way sync
|
||||||
- **ICS Subscriptions:** Subscribe to any public ICS/webcal URL (e.g. public holidays, sports schedules). Per-subscription color, private/shared visibility, manual "Sync now" and automatic sync on the shared interval. RRULE events expanded into a rolling ±6/+12 month window. SSRF-protected (DNS pre-resolution), ETag/Last-Modified conditional fetch, 10 MB limit, 15 s timeout. User-edited events are protected from being overwritten (`user_modified`); a "Reset to original" link restores them.
|
- **ICS Subscriptions:** Subscribe to any public ICS/webcal URL (e.g. public holidays, sports schedules). Per-subscription color, private/shared visibility, manual "Sync now" and automatic sync on the shared interval. Edit name, color, and visibility of any subscription inline. RRULE events expanded into a rolling ±6/+12 month window. SSRF-protected (DNS pre-resolution), ETag/Last-Modified conditional fetch, 10 MB limit, 15 s timeout. User-edited events are protected from being overwritten (`user_modified`); a "Reset to original" link restores them.
|
||||||
- **External calendar names & colors:** Google and Apple sync stores each calendar's display name and background color in the `external_calendars` table (migration v14). A colored `event-cal-label` badge appears in event popups, agenda, month, week, and day views when `cal_name` is present.
|
- **External calendar names & colors:** Google and Apple sync stores each calendar's display name and background color in the `external_calendars` table (migration v14). A colored `event-cal-label` badge appears in event popups, agenda, month, week, and day views when `cal_name` is present.
|
||||||
- **Event location:** Event popup and dashboard display the location field with RFC 5545 backslash-escape normalization (`\n`, `\,`, `\;`, `\\`) via `fmtLocation()` in `public/utils/html.js`.
|
- **Event location:** Event popup and dashboard display the location field with RFC 5545 backslash-escape normalization (`\n`, `\,`, `\;`, `\\`) via `fmtLocation()` in `public/utils/html.js`.
|
||||||
|
- **Custom event icons:** Each event can have an icon chosen from 102 validated Lucide icons via a visual picker. Birthday events are automatically assigned the `cake` icon. Icon stored in `calendar_events.icon`.
|
||||||
|
- **File attachments:** Events support a single file attachment (images, PDFs, Office documents, ≤ 5 MB). Images are displayed inline in the event popup; other files show a download link. Drag-and-drop upload supported in the event modal. Stored as Base64 in `attachment_data`.
|
||||||
|
- **Overlapping events:** In week and day views, timed events that overlap in time are rendered side-by-side using a column-layout algorithm instead of stacking.
|
||||||
- Configurable sync interval (default 15 min)
|
- Configurable sync interval (default 15 min)
|
||||||
- External events visually distinguishable
|
- External events visually distinguishable
|
||||||
- Conflicts: external event wins, local additions are preserved
|
- Conflicts: external event wins, local additions are preserved
|
||||||
@@ -356,6 +395,19 @@ Masonry grid with colored sticky notes.
|
|||||||
- vCard export: each contact downloadable as `.vcf` (`GET /api/v1/contacts/:id/vcard`)
|
- vCard export: each contact downloadable as `.vcf` (`GET /api/v1/contacts/:id/vcard`)
|
||||||
- vCard import: upload file → client-side parser (FN, TEL, EMAIL, ADR, NOTE, CATEGORIES) → create contact
|
- vCard import: upload file → client-side parser (FN, TEL, EMAIL, ADR, NOTE, CATEGORIES) → create contact
|
||||||
|
|
||||||
|
### Documents (`/documents`)
|
||||||
|
|
||||||
|
Upload and manage family files with per-document access control.
|
||||||
|
|
||||||
|
- CRUD: name, description, category, file upload (PDF, images, text, Office documents; ≤ 5 MB)
|
||||||
|
- Drag-and-drop upload in the new-document modal
|
||||||
|
- **Grid / list view** toggle; view mode persisted in localStorage
|
||||||
|
- **Category tags:** 14 predefined categories (medical, school, identity, insurance, finance, home, vehicle, legal, travel, pets, warranty, taxes, work, other)
|
||||||
|
- **Visibility:** family (all members see it), restricted (only selected members), private (only the uploader)
|
||||||
|
- **Archive / restore** — archived documents hidden from the main view, accessible via the Archive filter
|
||||||
|
- **Download** — original file downloaded with its original filename
|
||||||
|
- API: `GET /api/v1/documents`, `POST /api/v1/documents`, `GET /api/v1/documents/:id`, `PUT /api/v1/documents/:id`, `DELETE /api/v1/documents/:id`, `GET /api/v1/documents/:id/download`
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -375,8 +427,9 @@ User management and app configuration. Logged-in users only.
|
|||||||
- **Weather:** configure OpenWeatherMap location
|
- **Weather:** configure OpenWeatherMap location
|
||||||
- **Language:** System (follows `navigator.language`), German, English, Spanish, French, Italian, Swedish, Greek, Russian, Turkish, Chinese, Japanese, Arabic, Hindi, Portuguese - via `oikos-locale-picker` web component; switch without page reload
|
- **Language:** System (follows `navigator.language`), German, English, Spanish, French, Italian, Swedish, Greek, Russian, Turkish, Chinese, Japanese, Arabic, Hindi, Portuguese - via `oikos-locale-picker` web component; switch without page reload
|
||||||
- **API Tokens (admin):** create named Bearer / X-API-Key tokens for external integrations; the full token value is shown only once immediately after creation; tokens can be revoked at any time; support optional expiry and track last-used timestamp
|
- **API Tokens (admin):** create named Bearer / X-API-Key tokens for external integrations; the full token value is shown only once immediately after creation; tokens can be revoked at any time; support optional expiry and track last-used timestamp
|
||||||
- **Tab navigation:** Settings is organized in seven tabs (General, Meals, Budget, Shopping, Calendar, API Tokens, Account). Sticky tab bar, active tab persists in sessionStorage, Calendar tab auto-activates after OAuth callbacks.
|
- **Backup Management (admin):** download the current database as a file (`GET /api/v1/backup/database`) or restore from a backup file (`POST /api/v1/backup/restore`, drag-and-drop supported). Validates that the uploaded file is a valid Oikos database. A rollback copy is created automatically before restore.
|
||||||
- **Family management (admin):** assign a `family_role` (Dad, Mom, Parent, Child, Grandparent, Relative, Other) to each user. Displayed in the family member list and profile views.
|
- **Tab navigation:** Settings is organized in eight tabs (General, Meals, Budget, Shopping, Calendar, Family, API Tokens, Account). Admin-only tabs: Family, API Tokens, Backup. Sticky tab bar, active tab persists in sessionStorage, Calendar tab auto-activates after OAuth callbacks.
|
||||||
|
- **Family management (admin):** assign a `family_role` (Dad, Mom, Parent, Child, Grandparent, Relative, Other) to each user, and set per-member phone, email, and birthday — automatically synced to Contacts and Birthdays. Displayed in the family member list and profile views.
|
||||||
- **Profile picture:** users can upload a personal avatar (PNG/JPEG/WebP/GIF, ≤ 5 MB), stored as a Base64 data URL in `avatar_data`. Displayed alongside display name across the app.
|
- **Profile picture:** users can upload a personal avatar (PNG/JPEG/WebP/GIF, ≤ 5 MB), stored as a Base64 data URL in `avatar_data`. Displayed alongside display name across the app.
|
||||||
- **App info:** version, license
|
- **App info:** version, license
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.36.1",
|
"version": "0.38.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.36.1",
|
"version": "0.38.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.36.1",
|
"version": "0.38.4",
|
||||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -608,6 +608,20 @@ function _validateField(input) {
|
|||||||
group?.classList.toggle('form-field--error', !hasValue);
|
group?.classList.toggle('form-field--error', !hasValue);
|
||||||
group?.classList.toggle('form-field--valid', hasValue);
|
group?.classList.toggle('form-field--valid', hasValue);
|
||||||
input.setAttribute('aria-invalid', String(!hasValue));
|
input.setAttribute('aria-invalid', String(!hasValue));
|
||||||
|
|
||||||
|
if (!hasValue && group) {
|
||||||
|
const count = parseInt(group.dataset.errorCount ?? '0', 10) + 1;
|
||||||
|
group.dataset.errorCount = String(count);
|
||||||
|
if (count >= 2) {
|
||||||
|
group.classList.remove('form-field--error-repeat');
|
||||||
|
void group.offsetWidth;
|
||||||
|
group.classList.add('form-field--error-repeat');
|
||||||
|
group.addEventListener('animationend', () => group.classList.remove('form-field--error-repeat'), { once: true });
|
||||||
|
}
|
||||||
|
} else if (hasValue && group) {
|
||||||
|
group.dataset.errorCount = '0';
|
||||||
|
}
|
||||||
|
|
||||||
return hasValue;
|
return hasValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,8 @@ function wireQuickAdd(container) {
|
|||||||
// Erfolgs-Feedback auf dem +-Button (DOM-API, kein innerHTML)
|
// Erfolgs-Feedback auf dem +-Button (DOM-API, kein innerHTML)
|
||||||
_flashAddBtn(form.querySelector('.quick-add__btn'));
|
_flashAddBtn(form.querySelector('.quick-add__btn'));
|
||||||
nameInput.focus();
|
nameInput.focus();
|
||||||
|
nameInput.classList.add('quick-add__input--flash');
|
||||||
|
nameInput.addEventListener('animationend', () => nameInput.classList.remove('quick-add__input--flash'), { once: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(err.message, 'danger');
|
window.oikos.showToast(err.message, 'danger');
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-31
@@ -132,7 +132,7 @@ let _pendingLoginRedirect = false;
|
|||||||
const ROUTE_ORDER = ['/', '/calendar', '/tasks', '/meals', '/recipes', '/shopping',
|
const ROUTE_ORDER = ['/', '/calendar', '/tasks', '/meals', '/recipes', '/shopping',
|
||||||
'/birthdays', '/notes', '/contacts', '/budget', '/documents', '/settings'];
|
'/birthdays', '/notes', '/contacts', '/budget', '/documents', '/settings'];
|
||||||
|
|
||||||
const PRIMARY_NAV = 2;
|
const PRIMARY_NAV = 3;
|
||||||
|
|
||||||
const DEFAULT_APP_NAME = 'Oikos';
|
const DEFAULT_APP_NAME = 'Oikos';
|
||||||
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
|
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
|
||||||
@@ -366,6 +366,16 @@ async function renderPage(route, previousPath = null) {
|
|||||||
|
|
||||||
await module.render(pageWrapper, { user: currentUser });
|
await module.render(pageWrapper, { user: currentUser });
|
||||||
|
|
||||||
|
// FAB Long Loop: Einstiegsanimation nach FAB_SEEN_MAX Views deaktivieren
|
||||||
|
if (pageWrapper.querySelector('.page-fab')) {
|
||||||
|
let fabCount = parseInt(localStorage.getItem(FAB_SEEN_KEY) ?? '0', 10);
|
||||||
|
if (fabCount < FAB_SEEN_MAX) {
|
||||||
|
fabCount++;
|
||||||
|
localStorage.setItem(FAB_SEEN_KEY, String(fabCount));
|
||||||
|
}
|
||||||
|
document.documentElement.classList.toggle('fab-anim-done', fabCount >= FAB_SEEN_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
// Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt)
|
// Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt)
|
||||||
const announcer = document.getElementById('route-announcer');
|
const announcer = document.getElementById('route-announcer');
|
||||||
if (announcer) {
|
if (announcer) {
|
||||||
@@ -504,23 +514,6 @@ function renderAppShell(container) {
|
|||||||
kitchenBtn.addEventListener('click', () => navigate(getLastKitchenRoute()));
|
kitchenBtn.addEventListener('click', () => navigate(getLastKitchenRoute()));
|
||||||
bottomItems.appendChild(kitchenBtn);
|
bottomItems.appendChild(kitchenBtn);
|
||||||
|
|
||||||
const searchNavBtn = document.createElement('button');
|
|
||||||
searchNavBtn.className = 'nav-item nav-item--search';
|
|
||||||
searchNavBtn.id = 'search-btn';
|
|
||||||
searchNavBtn.type = 'button';
|
|
||||||
searchNavBtn.setAttribute('aria-label', t('nav.search'));
|
|
||||||
searchNavBtn.setAttribute('title', t('nav.search'));
|
|
||||||
const searchNavIcon = document.createElement('i');
|
|
||||||
searchNavIcon.dataset.lucide = 'search';
|
|
||||||
searchNavIcon.className = 'nav-item__icon';
|
|
||||||
searchNavIcon.setAttribute('aria-hidden', 'true');
|
|
||||||
const searchNavLabel = document.createElement('span');
|
|
||||||
searchNavLabel.className = 'nav-item__label';
|
|
||||||
searchNavLabel.textContent = t('nav.search');
|
|
||||||
searchNavBtn.appendChild(searchNavIcon);
|
|
||||||
searchNavBtn.appendChild(searchNavLabel);
|
|
||||||
bottomItems.appendChild(searchNavBtn);
|
|
||||||
|
|
||||||
const moreBtn = document.createElement('button');
|
const moreBtn = document.createElement('button');
|
||||||
moreBtn.className = 'nav-item nav-item--more';
|
moreBtn.className = 'nav-item nav-item--more';
|
||||||
moreBtn.id = 'more-btn';
|
moreBtn.id = 'more-btn';
|
||||||
@@ -553,6 +546,29 @@ function renderAppShell(container) {
|
|||||||
dragHandle.className = 'more-sheet__handle';
|
dragHandle.className = 'more-sheet__handle';
|
||||||
dragHandle.setAttribute('aria-hidden', 'true');
|
dragHandle.setAttribute('aria-hidden', 'true');
|
||||||
moreSheet.insertAdjacentElement('afterbegin', dragHandle);
|
moreSheet.insertAdjacentElement('afterbegin', dragHandle);
|
||||||
|
|
||||||
|
const moreSearchBar = document.createElement('div');
|
||||||
|
moreSearchBar.className = 'more-sheet__search';
|
||||||
|
moreSearchBar.id = 'more-sheet-search';
|
||||||
|
moreSearchBar.setAttribute('role', 'button');
|
||||||
|
moreSearchBar.setAttribute('tabindex', '0');
|
||||||
|
moreSearchBar.setAttribute('aria-label', t('search.placeholder'));
|
||||||
|
const moreSearchIcon = document.createElement('i');
|
||||||
|
moreSearchIcon.dataset.lucide = 'search';
|
||||||
|
moreSearchIcon.className = 'more-sheet__search-icon';
|
||||||
|
moreSearchIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
const moreSearchPlaceholder = document.createElement('span');
|
||||||
|
moreSearchPlaceholder.className = 'more-sheet__search-placeholder';
|
||||||
|
moreSearchPlaceholder.textContent = t('search.placeholder');
|
||||||
|
const moreSearchKbd = document.createElement('kbd');
|
||||||
|
moreSearchKbd.className = 'more-sheet__search-kbd';
|
||||||
|
moreSearchKbd.textContent = '/';
|
||||||
|
moreSearchKbd.setAttribute('aria-hidden', 'true');
|
||||||
|
moreSearchBar.appendChild(moreSearchIcon);
|
||||||
|
moreSearchBar.appendChild(moreSearchPlaceholder);
|
||||||
|
moreSearchBar.appendChild(moreSearchKbd);
|
||||||
|
moreSheet.appendChild(moreSearchBar);
|
||||||
|
|
||||||
navItems().filter((i) => !i.kitchenGroup).slice(PRIMARY_NAV).forEach((item) => moreSheet.appendChild(moreItemEl(item)));
|
navItems().filter((i) => !i.kitchenGroup).slice(PRIMARY_NAV).forEach((item) => moreSheet.appendChild(moreItemEl(item)));
|
||||||
|
|
||||||
const searchOverlay = document.createElement('div');
|
const searchOverlay = document.createElement('div');
|
||||||
@@ -606,15 +622,28 @@ function renderAppShell(container) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
initMoreSheet(container);
|
const openSearch = initSearch(container);
|
||||||
|
initMoreSheet(container, openSearch);
|
||||||
initNavHideOnScroll(container);
|
initNavHideOnScroll(container);
|
||||||
initSearch(container);
|
|
||||||
initOfflineBanner();
|
initOfflineBanner();
|
||||||
initKeyboardShortcuts();
|
initKeyboardShortcuts();
|
||||||
|
if (localStorage.getItem(SEARCH_KBD_KEY)) {
|
||||||
|
document.documentElement.classList.add('search-kbd-done');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FAB_SEEN_KEY = 'oikos:fabSeenCount';
|
||||||
|
const FAB_SEEN_MAX = 5;
|
||||||
|
const SEARCH_KBD_KEY = 'oikos:searchKbdUsed';
|
||||||
|
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
{ key: '/', description: () => t('shortcuts.search'), action: () => document.getElementById('search-btn')?.click() },
|
{ key: '/', description: () => t('shortcuts.search'), action: () => {
|
||||||
|
if (!localStorage.getItem(SEARCH_KBD_KEY)) {
|
||||||
|
localStorage.setItem(SEARCH_KBD_KEY, '1');
|
||||||
|
document.documentElement.classList.add('search-kbd-done');
|
||||||
|
}
|
||||||
|
document.getElementById('more-sheet-search')?.click();
|
||||||
|
} },
|
||||||
{ key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() },
|
{ key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() },
|
||||||
{ key: '?', description: () => t('shortcuts.help'), action: () => showShortcutsModal() },
|
{ key: '?', description: () => t('shortcuts.help'), action: () => showShortcutsModal() },
|
||||||
{ key: 'g d', description: () => t('shortcuts.goDash'), action: () => navigate('/') },
|
{ key: 'g d', description: () => t('shortcuts.goDash'), action: () => navigate('/') },
|
||||||
@@ -774,7 +803,7 @@ function initNavHideOnScroll(container) {
|
|||||||
/**
|
/**
|
||||||
* Öffnet/schließt das More-Sheet und die Backdrop.
|
* Öffnet/schließt das More-Sheet und die Backdrop.
|
||||||
*/
|
*/
|
||||||
function initMoreSheet(container) {
|
function initMoreSheet(container, openSearch) {
|
||||||
const moreBtn = container.querySelector('#more-btn');
|
const moreBtn = container.querySelector('#more-btn');
|
||||||
const backdrop = container.querySelector('#more-backdrop');
|
const backdrop = container.querySelector('#more-backdrop');
|
||||||
const sheet = container.querySelector('#more-sheet');
|
const sheet = container.querySelector('#more-sheet');
|
||||||
@@ -812,6 +841,15 @@ function initMoreSheet(container) {
|
|||||||
el.addEventListener('click', () => closeSheet());
|
el.addEventListener('click', () => closeSheet());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const moreSearchBar = sheet.querySelector('#more-sheet-search');
|
||||||
|
if (moreSearchBar && openSearch) {
|
||||||
|
const triggerSearch = () => { closeSheet(); openSearch(); };
|
||||||
|
moreSearchBar.addEventListener('click', triggerSearch);
|
||||||
|
moreSearchBar.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); triggerSearch(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
window._closeMoreSheet = closeSheet;
|
window._closeMoreSheet = closeSheet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,12 +857,11 @@ function initMoreSheet(container) {
|
|||||||
* Initialisiert die Suchfunktion (Overlay + API-Calls).
|
* Initialisiert die Suchfunktion (Overlay + API-Calls).
|
||||||
*/
|
*/
|
||||||
function initSearch(container) {
|
function initSearch(container) {
|
||||||
const searchBtn = container.querySelector('#search-btn');
|
|
||||||
const searchClose = container.querySelector('#search-close');
|
const searchClose = container.querySelector('#search-close');
|
||||||
const overlay = container.querySelector('#search-overlay');
|
const overlay = container.querySelector('#search-overlay');
|
||||||
const input = container.querySelector('#search-input');
|
const input = container.querySelector('#search-input');
|
||||||
const results = container.querySelector('#search-results');
|
const results = container.querySelector('#search-results');
|
||||||
if (!searchBtn || !overlay || !input || !results) return;
|
if (!overlay || !input || !results) return null;
|
||||||
|
|
||||||
// Leichtgewichtiger Focus Trap für das Search Overlay.
|
// Leichtgewichtiger Focus Trap für das Search Overlay.
|
||||||
// Eigenständig (kein modal.js), da modul-globale Variablen in modal.js
|
// Eigenständig (kein modal.js), da modul-globale Variablen in modal.js
|
||||||
@@ -832,7 +869,6 @@ function initSearch(container) {
|
|||||||
let _searchTrapHandler = null;
|
let _searchTrapHandler = null;
|
||||||
|
|
||||||
function openSearch() {
|
function openSearch() {
|
||||||
window._openSearch = openSearch;
|
|
||||||
if (window._closeMoreSheet) window._closeMoreSheet();
|
if (window._closeMoreSheet) window._closeMoreSheet();
|
||||||
overlay.setAttribute('aria-hidden', 'false');
|
overlay.setAttribute('aria-hidden', 'false');
|
||||||
overlay.classList.add('search-overlay--visible');
|
overlay.classList.add('search-overlay--visible');
|
||||||
@@ -866,8 +902,7 @@ function initSearch(container) {
|
|||||||
results.replaceChildren();
|
results.replaceChildren();
|
||||||
}
|
}
|
||||||
|
|
||||||
searchBtn.addEventListener('click', openSearch);
|
if (searchClose) searchClose.addEventListener('click', closeSearch);
|
||||||
searchClose.addEventListener('click', closeSearch);
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && overlay.classList.contains('search-overlay--visible')) {
|
if (e.key === 'Escape' && overlay.classList.contains('search-overlay--visible')) {
|
||||||
@@ -892,6 +927,8 @@ function initSearch(container) {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return openSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1093,6 +1130,9 @@ function renderError(container, err) {
|
|||||||
* @param {'default'|'success'|'danger'|'warning'} type
|
* @param {'default'|'success'|'danger'|'warning'} type
|
||||||
* @param {number} duration - ms
|
* @param {number} duration - ms
|
||||||
*/
|
*/
|
||||||
|
const TOAST_SUCCESS_KEY = 'oikos:toastSuccessCount';
|
||||||
|
const TOAST_SUCCESS_MAX = 50;
|
||||||
|
|
||||||
const TOAST_ICONS = {
|
const TOAST_ICONS = {
|
||||||
success: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>',
|
success: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>',
|
||||||
danger: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
|
danger: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
|
||||||
@@ -1103,6 +1143,13 @@ function showToast(message, type = 'default', duration = 3000, onUndo = null) {
|
|||||||
const container = document.getElementById('toast-container');
|
const container = document.getElementById('toast-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
// Long Loop: Success-Toasts nach TOAST_SUCCESS_MAX Aufrufen unterdrücken
|
||||||
|
if (type === 'success' && typeof onUndo !== 'function') {
|
||||||
|
const successCount = parseInt(localStorage.getItem(TOAST_SUCCESS_KEY) ?? '0', 10) + 1;
|
||||||
|
localStorage.setItem(TOAST_SUCCESS_KEY, String(successCount));
|
||||||
|
if (successCount > TOAST_SUCCESS_MAX) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht
|
// Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht
|
||||||
const existing = container.querySelectorAll('.toast');
|
const existing = container.querySelectorAll('.toast');
|
||||||
if (existing.length >= 3) existing[0].remove();
|
if (existing.length >= 3) existing[0].remove();
|
||||||
@@ -1253,17 +1300,21 @@ window.addEventListener('locale-changed', () => {
|
|||||||
}
|
}
|
||||||
if (bottomItems) {
|
if (bottomItems) {
|
||||||
const kitchenBtnEl = bottomItems.querySelector('#kitchen-btn');
|
const kitchenBtnEl = bottomItems.querySelector('#kitchen-btn');
|
||||||
const searchBtnEl = bottomItems.querySelector('#search-btn');
|
|
||||||
const moreBtn = bottomItems.querySelector('#more-btn');
|
const moreBtn = bottomItems.querySelector('#more-btn');
|
||||||
if (kitchenBtnEl) kitchenBtnEl.querySelector('.nav-item__label').textContent = t('nav.kitchen');
|
if (kitchenBtnEl) kitchenBtnEl.querySelector('.nav-item__label').textContent = t('nav.kitchen');
|
||||||
if (searchBtnEl) searchBtnEl.querySelector('.nav-item__label').textContent = t('nav.search');
|
|
||||||
const newItems = navItems().slice(0, PRIMARY_NAV).map(navItemEl);
|
const newItems = navItems().slice(0, PRIMARY_NAV).map(navItemEl);
|
||||||
bottomItems.replaceChildren(...newItems, kitchenBtnEl, searchBtnEl, moreBtn);
|
bottomItems.replaceChildren(...newItems, kitchenBtnEl, moreBtn);
|
||||||
}
|
}
|
||||||
if (moreSheet) {
|
if (moreSheet) {
|
||||||
const handle = moreSheet.querySelector('.more-sheet__handle');
|
const handle = moreSheet.querySelector('.more-sheet__handle');
|
||||||
|
const searchBar = moreSheet.querySelector('#more-sheet-search');
|
||||||
|
if (searchBar) {
|
||||||
|
const placeholder = searchBar.querySelector('.more-sheet__search-placeholder');
|
||||||
|
if (placeholder) placeholder.textContent = t('search.placeholder');
|
||||||
|
searchBar.setAttribute('aria-label', t('search.placeholder'));
|
||||||
|
}
|
||||||
const newMoreItems = navItems().filter((i) => !i.kitchenGroup).slice(PRIMARY_NAV).map(moreItemEl);
|
const newMoreItems = navItems().filter((i) => !i.kitchenGroup).slice(PRIMARY_NAV).map(moreItemEl);
|
||||||
moreSheet.replaceChildren(handle, ...newMoreItems);
|
moreSheet.replaceChildren(handle, ...(searchBar ? [searchBar] : []), ...newMoreItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('[data-route]').forEach((el) => {
|
document.querySelectorAll('[data-route]').forEach((el) => {
|
||||||
|
|||||||
@@ -1941,7 +1941,6 @@
|
|||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
.dashboard {
|
.dashboard {
|
||||||
max-width: min(1680px, 100%);
|
max-width: min(1680px, 100%);
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard::before,
|
.dashboard::before,
|
||||||
|
|||||||
+114
-1
@@ -128,6 +128,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
@@ -218,7 +219,7 @@
|
|||||||
padding: var(--space-4) var(--space-4) calc(var(--space-4) + var(--safe-area-inset-bottom));
|
padding: var(--space-4) var(--space-4) calc(var(--space-4) + var(--safe-area-inset-bottom));
|
||||||
z-index: calc(var(--z-nav) + 2);
|
z-index: calc(var(--z-nav) + 2);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
transition: transform 0.25s var(--ease-out);
|
transition: transform 0.25s var(--ease-out);
|
||||||
@@ -238,6 +239,78 @@
|
|||||||
margin: 0 auto var(--space-2);
|
margin: 0 auto var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── More-Sheet Suchleiste ── */
|
||||||
|
.more-sheet__search {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background-color: var(--color-surface-elevated);
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: text;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sheet__search:hover {
|
||||||
|
border-color: var(--color-border-strong, var(--color-border));
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sheet__search:active {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-elevated));
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sheet__search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sheet__search-icon {
|
||||||
|
width: var(--space-4);
|
||||||
|
height: var(--space-4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sheet__search:hover .more-sheet__search-icon,
|
||||||
|
.more-sheet__search:active .more-sheet__search-icon {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sheet__search-placeholder {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-sheet__search-kbd {
|
||||||
|
display: none;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-quaternary, var(--color-text-tertiary));
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
html:not(.search-kbd-done) .more-sheet__search-kbd {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── More-Item ── */
|
/* ── More-Item ── */
|
||||||
.more-item {
|
.more-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -507,6 +580,11 @@
|
|||||||
box-shadow: 0 0 0 4px var(--color-accent);
|
box-shadow: 0 0 0 4px var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Long Loop: FAB-Animation nach N Aufrufen deaktivieren */
|
||||||
|
html.fab-anim-done .page-fab {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Desktop: FAB Position anpassen (keine Bottom-Nav) und etwas kleiner */
|
/* Desktop: FAB Position anpassen (keine Bottom-Nav) und etwas kleiner */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.page-fab {
|
.page-fab {
|
||||||
@@ -1311,6 +1389,22 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pulse-Animation bei wiederholtem Fehler */
|
||||||
|
@keyframes field-error-pulse {
|
||||||
|
0%, 100% { box-shadow: none; }
|
||||||
|
40% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 35%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field--error-repeat .input,
|
||||||
|
.form-field--error-repeat .form-input {
|
||||||
|
animation: field-error-pulse 500ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.form-field--error-repeat .input,
|
||||||
|
.form-field--error-repeat .form-input { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Toggle-Switch
|
* Toggle-Switch
|
||||||
* Custom iOS-style toggle, ersetzt native Checkboxen.
|
* Custom iOS-style toggle, ersetzt native Checkboxen.
|
||||||
@@ -1433,6 +1527,7 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.empty-state { animation: none; }
|
.empty-state { animation: none; }
|
||||||
|
.empty-state__cta { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state__icon {
|
.empty-state__icon {
|
||||||
@@ -1468,6 +1563,7 @@
|
|||||||
|
|
||||||
.empty-state__cta {
|
.empty-state__cta {
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
animation: list-item-in 300ms var(--ease-out) 300ms both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state--compact {
|
.empty-state--compact {
|
||||||
@@ -2241,6 +2337,23 @@ textarea.input { resize: vertical; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Signature Moment: Eingabefeld-Glow nach erfolgreichem Item-Add
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
@keyframes input-add-flash {
|
||||||
|
0% { box-shadow: none; }
|
||||||
|
25% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--module-accent, var(--color-accent)) 45%, transparent); }
|
||||||
|
100% { box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__input--flash {
|
||||||
|
animation: input-add-flash 550ms var(--ease-out) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.quick-add__input--flash { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Windows High Contrast / Forced Colors
|
* Windows High Contrast / Forced Colors
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -78,7 +78,13 @@ function nextOccurrence(baseDateStr, rrule) {
|
|||||||
});
|
});
|
||||||
// Tage bis zum nächsten Vorkommen (mind. 1, damit nicht derselbe Tag)
|
// Tage bis zum nächsten Vorkommen (mind. 1, damit nicht derselbe Tag)
|
||||||
let daysUntil = (sorted[0] - currentDay + 7) % 7;
|
let daysUntil = (sorted[0] - currentDay + 7) % 7;
|
||||||
if (daysUntil === 0) daysUntil = 7 * interval;
|
if (daysUntil === 0) {
|
||||||
|
// Selber Wochentag → ganzes Intervall überspringen
|
||||||
|
daysUntil = 7 * interval;
|
||||||
|
} else if ((sorted[0] + 6) % 7 < (currentDay + 6) % 7) {
|
||||||
|
// Wochengrenze überschritten (ISO-Woche MO–SO) → interval-1 Wochen extra überspringen
|
||||||
|
daysUntil += 7 * (interval - 1);
|
||||||
|
}
|
||||||
next.setUTCDate(next.getUTCDate() + daysUntil);
|
next.setUTCDate(next.getUTCDate() + daysUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,33 @@ test('Monatsbereich: 42 Tage für Kalenderraster', () => {
|
|||||||
assert(to === '2026-04-11', `Erwartet 2026-04-11, erhalten ${to}`);
|
assert(to === '2026-04-11', `Erwartet 2026-04-11, erhalten ${to}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// nextOccurrence: INTERVAL-Korrektheit mit BYDAY
|
||||||
|
// --------------------------------------------------------
|
||||||
|
import { nextOccurrence } from './server/services/recurrence.js';
|
||||||
|
|
||||||
|
test('nextOccurrence: WEEKLY BYDAY=MO,TU,WE,TH,FR INTERVAL=2 — kein täglicher Übergang', () => {
|
||||||
|
const rule = 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;INTERVAL=2';
|
||||||
|
// Innerhalb der Woche: Mo→Di (1 Tag, kein Intervallsprung)
|
||||||
|
assert(nextOccurrence('2026-05-04', rule) === '2026-05-05', 'Mo→Di');
|
||||||
|
// Innerhalb der Woche: Di→Mi
|
||||||
|
assert(nextOccurrence('2026-05-05', rule) === '2026-05-06', 'Di→Mi');
|
||||||
|
// Freitag → Montag der übernächsten Woche (3 + 7 = 10 Tage)
|
||||||
|
assert(nextOccurrence('2026-05-08', rule) === '2026-05-18', 'Fr→Mo (übernächste Woche)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextOccurrence: WEEKLY BYDAY=SA,SU INTERVAL=2 — Wochenend-Pair bleibt zusammen', () => {
|
||||||
|
const rule = 'FREQ=WEEKLY;BYDAY=SA,SU;INTERVAL=2';
|
||||||
|
// Sa→So (1 Tag, gleiche Woche)
|
||||||
|
assert(nextOccurrence('2026-05-09', rule) === '2026-05-10', 'Sa→So');
|
||||||
|
// So→Sa der übernächsten Woche (13 Tage)
|
||||||
|
assert(nextOccurrence('2026-05-10', rule) === '2026-05-23', 'So→Sa (übernächste Woche)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextOccurrence: WEEKLY BYDAY=MO INTERVAL=2 — klassisch alle 2 Wochen', () => {
|
||||||
|
assert(nextOccurrence('2026-05-04', 'FREQ=WEEKLY;BYDAY=MO;INTERVAL=2') === '2026-05-18', 'Mo→Mo+14');
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Ergebnis
|
// Ergebnis
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
@@ -39,12 +39,20 @@ const _origSetTimeout = setTimeout;
|
|||||||
|
|
||||||
function makeField() {
|
function makeField() {
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
|
const listeners = {};
|
||||||
|
const dataset = {};
|
||||||
return {
|
return {
|
||||||
|
dataset,
|
||||||
|
offsetWidth: 0,
|
||||||
classList: {
|
classList: {
|
||||||
toggle(cls, force) { force ? classes.add(cls) : classes.delete(cls); },
|
toggle(cls, force) { force ? classes.add(cls) : classes.delete(cls); },
|
||||||
|
add(cls) { classes.add(cls); },
|
||||||
|
remove(cls) { classes.delete(cls); },
|
||||||
contains(cls) { return classes.has(cls); },
|
contains(cls) { return classes.has(cls); },
|
||||||
},
|
},
|
||||||
|
addEventListener(event, fn) { listeners[event] = fn; },
|
||||||
_classes: classes,
|
_classes: classes,
|
||||||
|
_listeners: listeners,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user