<!-- generated-by: gsd-doc-writer -->

# FamilySync

A self-hosted family organization hub for a two-person household. One color-coded calendar view across all family members' Fastmail calendars, plus shared collaborative lists (groceries, gift ideas) — delivered as a React PWA with no app store required.

## What It Does

- **Unified calendar** — aggregates each member's Fastmail CalDAV calendars into a single color-coded view via tsdav + ical.js
- **Shared lists** — collaborative grocery and gift-idea lists with live sync via Server-Sent Events
- **PWA** — installable on iOS (Home Screen) and Android; push notifications via VAPID
- **Single sign-on** — all auth flows through your existing Authelia OIDC deployment

## Prerequisites

- Node.js 22 LTS
- pnpm 11.5.1 (`corepack enable pnpm`)
- Docker + Docker Compose (for MariaDB, Redis, and production deployment)

## Installation

```bash
git clone <repo-url> familysync
cd familysync
pnpm install
```

Copy the environment template and fill in values:

```bash
cp .env.example .env   # then fill in values — see Environment Variables below
```

Required environment variables (set in `.env` or your Docker host):

| Variable                      | Description                                                  |
| ----------------------------- | ------------------------------------------------------------ |
| `DB_PASSWORD`                 | MariaDB password for the `familysync` user                   |
| `DB_ROOT_PASSWORD`            | MariaDB root password                                        |
| `OIDC_ISSUER`                 | Authelia OIDC issuer URL                                     |
| `OIDC_CLIENT_ID`              | OIDC client ID (default: `familysync`)                       |
| `OIDC_CLIENT_SECRET`          | OIDC client secret                                           |
| `OIDC_REDIRECT_URI`           | Callback URL registered in Authelia                          |
| `OIDC_AUTH_SECRET`            | Random secret for session cookie signing                     |
| `APP_PASSWORD_ENCRYPTION_KEY` | Key used to encrypt stored Fastmail app passwords            |
| `VAPID_PUBLIC_KEY`            | VAPID public key (`npx web-push generate-vapid-keys --json`) |
| `VAPID_PRIVATE_KEY`           | VAPID private key (never commit)                             |
| `VAPID_SUBJECT`               | VAPID subject (`mailto:you@example.com`)                     |

## Quick Start

**Development (with hot-reload API and Vite HMR):**

```bash
# Start backing services
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis

# Run migrations
pnpm --filter @familysync/api db:migrate

# Start API (in one terminal)
pnpm dev:api

# Start PWA (in another terminal)
pnpm dev:pwa
```

**Production (Docker Compose):**

```bash
docker compose up --build
```

The API listens on port 3000. The PWA build is served separately (Vite `preview` or a static host in front of the API container).

## Monorepo Structure

```text
apps/
  api/        Hono backend — CalDAV sync, OIDC auth, lists API, push notifications
  pwa/        React 19 PWA — calendar view, lists UI, service worker
docker-compose.yml       Production services (API, MariaDB 11, Redis 7)
docker-compose.dev.yml   Dev overrides (bind-mount src/, expose DB/Redis ports)
```

## Commands

| Command                                     | What it does                                          |
| ------------------------------------------- | ----------------------------------------------------- |
| `pnpm dev:api`                              | Start API in watch mode (`dist/` must be built first) |
| `pnpm dev:pwa`                              | Start Vite dev server with HMR                        |
| `pnpm build`                                | Build both api and pwa                                |
| `pnpm test`                                 | Run API test suite (vitest + real MariaDB)            |
| `pnpm test:e2e`                             | Run Playwright harness (iPhone + Pixel profiles)      |
| `pnpm lint`                                 | ESLint across all workspaces (flat config, TS-aware)  |
| `pnpm typecheck`                            | Type-check all workspaces                             |
| `pnpm format`                               | Reformat all files with Prettier                      |
| `pnpm format:check`                         | Check formatting without writing (used in CI)         |
| `pnpm --filter @familysync/api db:generate` | Generate Drizzle migration from schema changes        |
| `pnpm --filter @familysync/api db:migrate`  | Apply pending migrations to MariaDB                   |

## Tech Stack

| Layer           | Technology                                                           |
| --------------- | -------------------------------------------------------------------- |
| Backend runtime | Node.js 22 + TypeScript, Hono 4.12.23                                |
| Database ORM    | Drizzle ORM 0.45.2 on MariaDB 11 (via mysql2)                        |
| Auth            | `@hono/oidc-auth` 1.8.3 — authorization code + PKCE against Authelia |
| Calendar        | tsdav 2.2.2 (CalDAV) + ical.js 2.2.1 against Fastmail                |
| Push            | web-push 3.6.7 (VAPID)                                               |
| Live sync       | Server-Sent Events + Redis 7 pub/sub                                 |
| Frontend        | React 19, Vite 8, vite-plugin-pwa 1.3, TanStack Query 5, Zustand 5   |
| Calendar UI     | Schedule-X 4.6                                                       |

## Calendar Integration

FamilySync reads and writes calendars via CalDAV against Fastmail — not JMAP (not available for Fastmail calendars). Configure your Fastmail app password under the "Mail, Contacts & Calendars" scope. The principal URL follows the pattern:

```text
https://caldav.fastmail.com/dav/principals/user/<your-fastmail-address>/
```

Store the app password in the database via the `/me` endpoint after first login.

## Deployment

See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deployment notes including the Pangolin/Newt tunnel configuration.

## CI

Every PR to `main` must pass three required checks before it can merge:

| Job                | What it runs                                                               |
| ------------------ | -------------------------------------------------------------------------- |
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm typecheck`, PWA unit tests         |
| `CI / api`         | DB migrations + API test suite against a real MariaDB 11 service container |
| `CI / harness`     | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel)             |

`fast-checks` and `api`/`harness` run in parallel. Defined in `.gitea/workflows/ci.yml`.

## Publishing / Releases

Publishing happens automatically on every push to `main` — i.e. when a PR merges. The `.gitea/workflows/publish.yml` workflow runs and builds + pushes the API image to the Gitea container registry.

**Image:** `git.bergerhouse.net/luckberg/familysync-api`

**Tags (two per release):**

- `:latest` — moving pointer for easy pulls
- `:<MILESTONE>-<shortsha>` — immutable, rollback-traceable (e.g. `v1.1-98acff8`)

**Required secret:** `REGISTRY_PAT` — a Gitea Actions secret holding a PAT with `write:package` scope. Named `REGISTRY_PAT` (not `GITEA_*`): Gitea reserves the `GITEA_` prefix for secret names, so `GITEA_`-prefixed names cannot be created. `GITEA_TOKEN` / `GITHUB_TOKEN` cannot push packages.

**Safety gate:** Branch protection on `main`, not a `needs:` dependency in `publish.yml`. The PR test jobs (`fast-checks`, `api`, `harness` in `ci.yml`) run on `pull_request` — they never run in the same workflow invocation as `publish.yml`. Tests gate the PR; `main` is trusted to be green because direct push and force push are blocked and the three required checks (`CI / fast-checks (pull_request)`, `CI / api (pull_request)`, `CI / harness (pull_request)`) must pass before merge.

**To bump the milestone tag** at a milestone boundary: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`.

## License

Private — not open source.
