# Family Calendar Web

A responsive browser client for the same CloudKit data used by the Family Calendar iOS app.

The root path (`/`, `index.html`) is a **static marketing landing page** — no
scripts, so it loads instantly. The actual app (sign-in + all logic) lives at
**`/app`** (`app/index.html`), which the landing's "Open the web app" button links
to. `/app` is served by nginx's `try_files $uri/` and by `python -m http.server`
alike, so no rewrite rules are needed.

## Features

- Month calendar and selected-day agenda
- Tap an event to see its details, then edit or soft-delete it
- Complete, edit, and soft-delete existing tasks
- Edit existing family profiles
- Shared lists: check/uncheck items, edit or soft-delete lists and items
- Owner private-zone and invited participant shared-zone discovery

> **Editing only — no creation.** The web app does not add new events, tasks,
> profiles, lists, or list items. Create those in the Family Calendar iOS app;
> the web client is for viewing and editing the records that already exist. This
> keeps the web experience focused and avoids duplicate-record edge cases across
> clients.
>
> **Lists need a Queryable index.** `FamilyList` and `ListItem` (like
> `RecurrenceException` and `TaskLog`) only appear in the web client once their
> record types are deployed with a Queryable index in CloudKit Console;
> otherwise the Lists tab loads empty and a warning is logged.
- CloudKit update saves preserve each record's `recordChangeTag`
- Local demo mode when CloudKit is not configured
- No npm packages or build step

## Run locally

CloudKit sign-in needs both **HTTPS** and a **standard port**: its API returns
`421 Misdirected Request` for origins that include a non-default port (e.g.
`:8080`), and the sign-in handshake only completes over TLS. So serve over HTTPS on
port 443 — origin `https://localhost`, no port. Port 443 is privileged, so run with
sudo (from the repo root):

```bash
sudo python3 serve-web-https.py
```

Open `https://localhost` and accept the self-signed certificate once (Safari:
**Show Details → visit this website**). Register `https://localhost` as the
sign-in callback URL for your CloudKit web API token.

For read-only/demo work without sign-in, a plain server is fine:

```bash
python3 -m http.server 8000
```

Do not open `index.html` directly with a `file://` URL. CloudKit web authentication
requires an HTTP or HTTPS origin, and sign-in specifically requires HTTPS.

## Connect CloudKit

1. Open [CloudKit Console](https://icloud.developer.apple.com/dashboard/) and select `iCloud.us.tapone.FamilySharedCalendar`.
2. Confirm the `FamilyCalendar`, `Profile`, `CalendarEvent`, and `FamilyTask` record types are deployed to the environment you plan to use.
3. In the container's API access settings, create a CloudKit web API token.
4. Add each permitted web origin, such as `http://localhost:8080` and the production HTTPS domain.
5. Update `config.js`:

```js
window.FAMILY_CALENDAR_CONFIG = {
  containerIdentifier: "iCloud.us.tapone.FamilySharedCalendar",
  apiToken: "YOUR_CLOUDKIT_WEB_API_TOKEN",
  environment: "development",
  zoneName: "FamilyCalendarZone"
};
```

Use `environment: "production"` only after deploying the CloudKit schema to production.

The API token is intended for browser use and is restricted by allowed origins. It is not a CloudKit server-to-server private key. Never put server private keys in this project.

## Sharing behavior

The iOS app stores the owner's records in the private `FamilyCalendarZone`. Invited family members access the owner's zone through CloudKit's shared database. On sign-in, this web client:

1. Checks the current user's private `FamilyCalendarZone`.
2. If no family record is found, checks shared zones named `FamilyCalendarZone`.
3. Uses the first matching zone containing a `FamilyCalendar` record.

This matches the single-family behavior in the iOS app. If the product later supports membership in multiple families, add a family picker instead of selecting the first matching shared zone.

## Data compatibility

CloudKit record names and fields mirror `CloudKitExtensions.swift` in the iOS project. Deletes are soft deletes (`isDeleted = 1`) so they sync consistently with the native app.

Recurring records retain their recurrence metadata. The web calendar generates virtual recurring **event** instances (daily, weekly, biweekly, monthly, yearly, and custom-weekday rules), hides occurrences removed via a `deleted` `RecurrenceException`, and applies `modified` ones — including those that move an occurrence to another day. Not yet ported from the native `RecurrenceService`: recurring **task** expansion in the Tasks view, and writing exceptions (the web has no "this occurrence only" edit, so editing any occurrence edits the series).

## Local cache

The last loaded records are cached in `localStorage` so opening the app paints the family's calendar immediately instead of waiting on CloudKit. Raw CloudKit records are cached rather than parsed models, because a model's `_ckRecord` carries the `recordChangeTag` every save needs — the same parsers run again on read.

The cache is **display-only**: edits stay disabled for the moment between the cached paint and the live data landing, and nothing is ever written to CloudKit from cached state. If the load fails or the browser is offline, the cached calendar stays on screen with an "Offline · last synced …" label instead of an error page.

Keys in `localStorage`:

| Key | Contents |
| --- | --- |
| `familyCalendar.dataCache.v1.<container>.<environment>` | last loaded records (see below) |
| `familyCalendar.ckSession.<container>` | CloudKit session token |
| `familyCalendar.taskColumnOrder` | task column order (profile ids) |
| `familyCalendar.analyticsDeviceId` | Amplitude device id |

The cache key includes the environment because `config.js` and `config.prod.js` share a container and differ only by `environment` — without it a development session and a production session would overwrite each other on the same origin.

It is purged on sign-out, when CloudKit reports a different iCloud user than the entry was written for, after 7 days, and whenever the stored payload can't be read. Task logs are capped at the 200 most recent and star logs at 30 days; if the payload still doesn't fit, those types are dropped before the calendar itself.

## Deployment

The app is static and can be hosted on any HTTPS static host. Upload the folder contents without a build step, configure the production origin in CloudKit Console, and set the production environment in `config.local.js`.

For a step-by-step guide to deploying on a Linode server (`familycal-app.com`) with nginx + HTTPS (low memory, high performance), see **[DEPLOY.md](./DEPLOY.md)**. The `../deploy.sh` script rsyncs `web/` to the server.
