Files
oikos/docs/superpowers/specs/2026-04-20-ics-subscription-design.md
T
2026-04-20 22:53:34 +02:00

5.7 KiB

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

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:

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) Normalize webcal://https://, 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 (<input type="color">)
    • 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, leave last_sync unchanged
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