Skip to content

Building and testing hyper-calendar

One page for "which command do I run, and what does it actually do".

Workspaces

WorkspaceWhat it is
shared/Types and zod schemas. No runtime behaviour.
core/The portable backend — routes, schema, domain logic. Runs in Node and in a Web Worker on Android.
server/The Node host: HTTP listener, background schedulers, and the filesystem-backed backups core can't provide (see core/src/host.ts).
web/The React app, plus src/standalone/ — the embedding that runs core on-device.

The dependency direction is shared ← core ← {server, web}. Nothing in core may import node:fs and friends; if it needs the disk, that is a capability the host passes in.

Everyday

sh
npm run dev           # server on :3001 + Vite on :5173 (proxies /api)
npm run dev:local     # the Android build in a desktop browser, no server at all
npm run typecheck     # all four workspaces
npm test              # unit tests (vitest) across workspaces
npm run test:coverage # the same run, reporting coverage (see Test layers)
npm run lint

Two commands regenerate committed artifacts rather than building anything, so they are run on demand and never by CI:

sh
npm run icons        # every app icon, splash and the store graphics, from assets/*.svg
npm run screenshots  # store and documentation screenshots, by driving the app

npm run icons needs rsvg-convert (librsvg) and magick (ImageMagick). Both are checked for before it does anything. See The Android app for what each one writes where.

npm run dev signs you in automatically as a seeded dev user (AUTH_DEV_AUTOLOGIN). npm run dev:local does not — the embedded backend runs the real session code, so you register an account the first time.

The two builds

Everything ships from the same source; a single environment variable decides which shape it takes.

npm run build            → web/dist (hosted PWA) + server/dist → Docker image
npm run build:android    → web/dist (standalone) → cap sync → android/ → APK

HYPERCAL_TARGET=android is what build:android sets. In that mode web/vite.config.ts:

  • bundles core into a Web Worker and aliases Node built-ins to the shims in web/src/standalone/backend/shims/;
  • drops the service worker (a packaged app already has its assets locally, and a second update path would fight the store's);
  • drops voice control, because its ~41 MB speech model can't be in an F-Droid build (see android.md);
  • prunes public/ assets a packaged app can't use — without this the first APK was 44 MB, 40 of them a speech model.

Docker

sh
docker build -t hyper-calendar .

Multi-stage: builds every workspace, then copies only web/dist, server/dist, shared/, and core/drizzle into a slim image. The migrations are read from disk at boot — server/src/index.ts resolves them as ../core/drizzle, so that COPY and that path have to stay in step.

What actually ships

Not the same split as npm's dependencies vs devDependencies. The runtime stage copies the pre-built artifacts and then runs npm install --omit=devwithout web/package.json, so the only packages present at runtime are the server's and core's runtime deps (kept external by esbuild --packages=external) plus what shared pulls in. Every web dependency is build-time only — it is bundled into web/dist and never installed in the running image.

Runtime, in the production container:

PackageWhy it's needed
hono + @hono/node-serverHTTP framework and its Node adapter (routing, static files, conn info)
better-sqlite3SQLite driver (native module; the image compiles it with python/make/g++)
drizzle-ormthe ORM behind every query
date-fns + @date-fns/tzall day/week/timezone math — raw Date arithmetic is banned
zod (via shared)request/response validation against the shared schemas
rruleexpanding recurring events into instances
nanoidshort unique ids for sessions, tasks, reminders, subscriptions
pinothe structured request/error log (core/src/lib/logger.ts)
@simplewebauthn/serverpasskey (WebAuthn) registration & authentication
@noble/ciphersAES-GCM for the CalDAV secret box
web-pushsending Web Push reminder notifications
node-icalparsing imported / subscribed ICS calendars
ical-generatorproducing ICS export feeds
@touch4it/ical-timezonesVTIMEZONE definitions so exported events survive in other clients
xmlbuilder2building and parsing CalDAV multistatus XML

Build and dev only, never in the runtime image:

  • Frontend libraries, bundled into web/dist: react + react-dom, idb (offline IndexedDB queue), @simplewebauthn/browser, @noble/hashes (the blind search index), qrcode (TOTP enrolment), @sqlite.org/sqlite-wasm (the on-device database in the Android build), vosk-browser (offline voice — the heaviest dep, lazy-loaded so it costs nothing until the mic is used), plus date-fns/@date-fns/tz/rrule for the UI.
  • Build & tooling: vite + @vitejs/plugin-react, tailwindcss + @tailwindcss/vite, vite-plugin-pwa and the workbox-* modules it uses, esbuild (server bundle), tsx (dev watch + scripts), typescript, drizzle-kit (migrations), @capacitor/cli, and concurrently.
  • Testing: vitest (+ fake-indexeddb), @playwright/test, @types/*.

Capacitor's runtime packages (@capacitor/core, app, filesystem, local-notifications, preferences) sit in the root package.json, not web/, and ship inside the APK rather than the container.

APK

sh
npm run build:android
cd android && ./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk

Release signing comes from android/keystore.properties (gitignored, pointing at a keystore outside the tree). The file is optional on purpose: F-Droid builds from source and signs with its own key, so the release build must stay valid without it.

Test layers

Five, each proving something the others can't.

sh
npm test                 # unit — pure logic: dates, recurrence, merge, protocol parsing
npm run test:gui         # gestures with /api/* mocked in-page; no backend
npm run test:e2e         # a gesture's write survives a round trip through the real server
npm run test:enc         # the server's SQLite file contains only ciphertext
npm run test:local       # the Android build with no server anywhere
npm run test:local:dist  # the same, against the *built* bundle, plus bundle assertions

All five are one playwright.config.ts, chosen by HYPERCAL_SUITE — use the scripts. npm test is the root vitest.config.ts, which runs all four workspaces as projects in a single pass; npm test -w core still runs one workspace on its own.

Run test:local:dist before cutting an APK. It is the only layer that exercises vite build output, where a broken alias or an over-eager tree-shake would otherwise first show up on a phone. It also asserts on what shipped: no speech model, no service worker, a total size budget.

Coverage

npm run test:coverage is npm test plus --coverage: the same suite, with a table in the terminal and a Cobertura report in coverage/ (gitignored). CI runs it in the unit-tests job, which publishes that report — merge requests then annotate the diff line by line with what is and isn't exercised, and the percentage shows in the MR widget. There is no badge; see the note at the top of the README for why.

It measures core/src/lib/** and shared/src/** only — recurrence expansion, event merging, free/busy, ICS, and the schemas both sides validate against. That is where a covered line means a checked behaviour; a repo-wide number would be dominated by React components and would move for reasons that have nothing to do with this logic.

Nothing is gated on the number. A threshold on a codebase this size buys tests written to clear the bar rather than to check anything, so coverage here is a thing to read during review — especially the diff annotations — not a wall to get over.

Environment

Validated at boot by core/src/lib/env.ts; a bad value fails fast rather than surfacing later.

VariableEffect
DATABASE_PATHSQLite file (default ./data/hyper-calendar.db)
PORT, TRUST_PROXY, TZListener, X-Forwarded-For trust, timezone
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYWeb Push reminders; absent = disabled
WEBAUTHN_RP_ID / WEBAUTHN_ORIGINPasskeys behind a proxy that rewrites Origin
CALDAV_SECRET32 bytes encrypting stored CalDAV passwords; absent = linking disabled
AUTH_DEV_AUTOLOGINDev/test only — treats session-less requests as a seeded user

The pattern for optional features is deliberate: a missing key disables the feature with an explanation, it does not crash the server and does not silently degrade to something insecure.

Database migrations

sh
npm run db:generate      # after editing core/src/db/schema.ts
npm run db:migrate       # apply (the server also migrates on boot)

Migrations live in core/drizzle/ beside the schema. The Android build can't read them off disk, so web/src/standalone/backend/migrate.ts inlines the same SQL at build time and applies it in journal order — never hand-write a migration, or the two paths diverge.