diff --git a/docs/superpowers/specs/2026-04-20-ics-subscription-design.md b/docs/superpowers/specs/2026-04-20-ics-subscription-design.md new file mode 100644 index 0000000..4452d23 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-ics-subscription-design.md @@ -0,0 +1,150 @@ +# ICS-URL Subscription — Design Spec + +**Date:** 2026-04-20 +**Status:** Approved + +## Overview + +Allow all family members to subscribe to external calendars via ICS-URL (e.g. public Google, Outlook, or any webcal-compatible feed). Events are fetched periodically and stored locally. Users can choose whether a subscription is private or shared with the whole family. + +## Requirements + +- Any user (not just admins) can add subscriptions +- Per-subscription visibility: **private** (only creator) or **shared** (all family members) +- Custom color per subscription, chosen by the user +- Sync interval: shared with existing `SYNC_INTERVAL_MINUTES` setting +- Manual "Sync now" button per subscription +- Events from subscriptions are editable; user-modified events are not overwritten on re-sync +- Events are deleted when their subscription is deleted + +--- + +## 1. Database + +### New table: `ics_subscriptions` + +```sql +CREATE TABLE ics_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6366f1', + shared INTEGER NOT NULL DEFAULT 0, -- 0 = private, 1 = shared with all + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + last_sync TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); +``` + +### Migrations to `calendar_events` + +Two new columns added via append-only migration entries: + +- `subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE` — links event to its subscription +- `user_modified INTEGER NOT NULL DEFAULT 0` — set to 1 when user edits the event; prevents sync from overwriting + +The `external_source` CHECK constraint (`'local'`, `'google'`, `'apple'`) must be extended to include `'ics'`. Since SQLite does not support `ALTER COLUMN`, this is done by recreating the table in the migration. + +### Visibility filter + +The calendar events API filters ICS events as follows: + +```sql +WHERE external_source != 'ics' + OR subscription_id IN ( + SELECT id FROM ics_subscriptions + WHERE shared = 1 OR created_by = :userId + ) +``` + +--- + +## 2. Backend + +### New file: `server/services/ics-parser.js` + +Extract `parseICS`, `unfoldLines`, `formatICSDate`, `tzLocalToUTC`, `applyDuration` from `apple-calendar.js` into a shared module. Both `apple-calendar.js` and the new `ics-subscription.js` import from here. No logic changes. + +### New file: `server/services/ics-subscription.js` + +| Export | Description | +|--------|-------------| +| `fetchAndParse(url)` | HTTP GET the ICS URL, pass response text to `parseICS()` | +| `sync(subscriptionId?)` | Sync one subscription (by id) or all; skip events with `user_modified = 1`; upsert via `external_calendar_id = UID`; update `last_sync` | +| `getAll(userId)` | Return all subscriptions visible to userId (own + shared) | +| `create(userId, { name, url, color, shared })` | Insert new subscription, trigger initial sync | +| `update(userId, id, fields)` | Update name/color/shared; only creator or admin | +| `remove(userId, id)` | Delete subscription + cascade-delete events; only creator or admin | + +### New routes: `/api/v1/calendar/subscriptions` + +| Method | Path | Action | Auth | +|--------|------|--------|------| +| `GET` | `/` | List visible subscriptions | any user | +| `POST` | `/` | Create subscription | any user | +| `PATCH` | `/:id` | Update name/color/shared | creator or admin | +| `DELETE` | `/:id` | Delete subscription + events | creator or admin | +| `POST` | `/:id/sync` | Manual sync | creator or admin | + +All handlers wrapped in `try/catch`. Responses follow `{ data: ... }` / `{ error, code }` convention. + +### Sync integration + +`server/index.js` `syncAll()` function calls `icsSubscription.sync()` alongside the existing Google/Apple sync calls. + +### Setting `user_modified` + +When a calendar event with `external_source = 'ics'` is updated via `PATCH /api/v1/calendar/events/:id`, the route sets `user_modified = 1` automatically. + +--- + +## 3. Frontend + +### Settings page (`public/pages/settings.js`) + +New card "ICS-Abonnements" in the existing "Kalender" tab, below Apple Calendar: + +- List of all visible subscriptions: color dot, name, visibility badge, last sync timestamp +- "Abonnement hinzufügen" button reveals an inline form: + - URL input (required) + - Name input (required) + - Color picker (``) + - Toggle "Für alle sichtbar" (default: off) + - Submit / Cancel buttons +- Per-subscription actions: "Jetzt synchronisieren", "Bearbeiten" (inline), "Löschen" (with confirmation) +- Only creator or admin sees edit/delete actions + +No new Web Component — rendered inline, consistent with the Apple Calendar form pattern. + +### Calendar page (`public/pages/calendar.js`) + +- Events with `external_source = 'ics'` use their subscription's color for rendering +- No special UI indicator for `user_modified` status (keeps UX clean) + +### i18n + +All new strings in `public/locales/de.json` under: +- `settings.ics.*` — subscription list, form labels, actions, status messages +- `calendar.ics.*` — any calendar-side strings (if needed) + +`de` is the reference locale; other locales fall back gracefully. + +--- + +## 4. Error Handling + +| Scenario | Behavior | +|----------|----------| +| ICS URL unreachable | Log warning, keep existing events, update `last_sync` with error note | +| Invalid ICS content | Log warning, skip malformed VEVENTs, continue with valid ones | +| URL returns non-ICS content | Log error, abort sync for this subscription | +| Unauthorized edit/delete | 403 response | +| Duplicate URL | Allowed (user may want same feed with different color/name) | + +--- + +## 5. Out of Scope + +- CalDAV authentication (Basic/OAuth) — ICS-URL only (public or pre-authenticated URLs) +- Per-event sync conflict resolution UI +- Subscription import/export