Architecture
Workspaces
hyper-calendar is an npm-workspaces monorepo with four packages:
| Workspace | Stack | Role |
|---|---|---|
shared/ | TypeScript + zod | Types and validation schemas imported by everything else |
core/ | Hono + Drizzle ORM + SQLite | The portable backend — all routes, all business logic |
server/ | Node 22 | Host process: HTTP listener, schedulers, filesystem-backed backups |
web/ | React 18 + Vite + Tailwind v4 | The PWA |
Dependency direction is strictly shared ← core ← {server, web}. Imports resolve through TS path aliases (@hyper-calendar/shared, …).
Why core is separate from server
core contains the entire backend but imports nothing from node:fs and friends. Anything that needs the outside world is a capability the host passes in through core/src/host.ts:
createApp({ db, host, devAutologin });host is optional. The Node server supplies it (that's what backs the filesystem .ics and database snapshots). The Android app omits it, and the routes that need it report the feature as unavailable instead of crashing.
That one indirection is what lets the identical backend run in a Node process on a server and inside a Web Worker on a phone. It is the main constraint to respect when adding backend code: if you reach for fs, path, or process inside core, you have broken the Android build.
See docs/building.md for how the two builds diverge.
The event/segment model
The single most important data decision in the project:
- An event is stored once, as a UTC instant pair (
start_utc,end_utc) plus an IANAtimezonestring. - Rendering splits an event into per-day segments at local midnight.
- Segments are never persisted. They are a pure frontend derivation, recomputed from the stored instants.
This is why raw Date arithmetic is banned for day and week boundaries: DST transitions make "add 24 hours" and "the next day" different things, and the week view is where that difference becomes visible. All boundary math goes through date-fns + @date-fns/tz.
Recurring events are stored as a master row with an rrule, expanded on demand via the rrule package (core/src/lib/recurrence.ts). Per-occurrence edits live in a separate event_overrides table keyed by occurrence_start_utc, so "change just this one" and "change the series" stay distinguishable.
Request pipeline
Every request through core/src/app.ts passes, in order:
requestLog— tags the request with a correlation id and emits one structured JSON log line (method, path, status, latency, user).secureHeaders— conservative CSP (default-src 'self', no framing,object-src 'none'),nosniff,no-referrer, HSTS in production only.style-srcallows'unsafe-inline'because Vite/Tailwind inject<style>tags.bodyLimiton/api/*— hard ceiling of the attachment cap + 1 MB, so a single oversized payload can't exhaust memory. The attachment route enforces the finer admin-configured per-file limit on top.- Rate limits on
/api/auth/login,/api/auth/login/totp, and/api/auth/register(fixed window, keyed by client IP). requireAuthon/api/*— resolves the session cookie to ausers.idand setsc.get("user"). Every query must be scoped by that id.requireAdminadditionally on/api/admin/*.app.onErrorlogs one JSON line and returns a generic 500 — the request id correlates it with the request-log line.
The router is created with strict: false so a trailing slash is optional; CalDAV collection URLs are conventionally slash-terminated and clients vary.
In production the same app also serves the built SPA from ../web/dist, with a catch-all falling back to index.html (the login screen is handled client-side).
Gestures
Drag, resize, and create gestures are hand-rolled on Pointer Events — pointerdown / pointermove / pointerup with setPointerCapture. No drag-and-drop library. The hooks live in web/src/hooks/: useGridDrag, useCreateDrag, useWeekSwipe, useLongPress, useHourZoom, useTimeZoom, and useGestureArbiter (which decides who wins when two gestures could claim the same pointer).
Because every one of these must work with mouse and touch, they are covered by a dedicated Playwright suite driving real pointer input — see Development.
Offline and sync
- The service worker (
web/src/sw.ts, built byvite-plugin-pwa+ Workbox) precaches the app shell.GET /api/eventsuses network-first with a short timeout so recently viewed weeks render offline. - Offline mutations are queued in IndexedDB (
idb) and replayed on reconnect throughPOST /api/events/sync. - Conflicts merge field by field (
core/src/lib/mergeEvent.ts): edits to different fields both survive; where both sides changed the same field, the user is prompted. Rows carry a monotonicversioncolumn for this.
CalDAV, both directions
Easy to confuse, so keep them straight:
| Direction | Entry point | |
|---|---|---|
core/src/caldav/server/ | Other apps read/write hyper-calendar | /dav, HTTP Basic with an app password |
core/src/caldav/client/ | hyper-calendar syncs out to Nextcloud, Radicale, … | the poller in client/sync.ts |
core/src/caldav/shared/ | Event ⇆ iCalendar, used by both | — |
core/src/caldav/links.ts | The /api/caldav link-management routes | — |
Both directions must agree byte-for-byte on serialization, which is why shared/serialize.ts (event → .ics + ETag) and shared/object.ts (.ics → database, reconciling RRULE with EXDATEs and RECURRENCE-ID instances) are shared rather than duplicated. Two implementations of override reconciliation would drift and recurring events would quietly lose their exceptions.
The server also publishes the user's task list as a synthetic VTODO collection at /dav/calendars/:user/tasks/ (shared/vtodo.ts), read-write, so a task ticked off in DAVx5 or Thunderbird lands in the app. It's the one collection that doesn't advertise sync-collection: tasks are hard-deleted, so a delta report could never name a removed member, and its CTag is the change signal instead.
ICS subscriptions are a third, unrelated thing: read-only external feeds, refreshed on an interval. They ingest through the same parseIcsObjects → applyCaldavObject pair as import and PUT, so a feed's recurring events are stored as series rather than expanded into a row per occurrence.