A clean, dark-themed digital clock with a countdown timer and alarms. Built with TypeScript and Vite.
One directory per buildable artifact:
| Directory | Contents |
|---|---|
web/ |
npm project, Vite app, and both app images |
nginx/ |
Edge reverse proxy image and config |
webhook/ |
Deploy webhook image, hooks, and entrypoint |
scripts/ |
Host-side helpers (deploy, tunnel bridge, bootstrap) |
docs/ |
Architecture decision records and runbooks |
tasks/ |
Backlog and sprint tracking |
The built app is served by two containers. nginx/ is the edge: it owns the
security headers, the real client IP, and /healthz. web/ is a plain static
file server that knows nothing about the outside world. In production a
Cloudflare Tunnel is the only ingress — no inbound ports are opened. See
ADR 0001 and
ADR 0002.
- TypeScript — Application logic and DOM manipulation
- Vite — Build tool and dev server
- Vitest — Unit testing with jsdom
web/ # Application workspace
├── src/
│ ├── main.ts # Entry point (bootstrap only)
│ ├── index.html # HTML shell
│ ├── vite-env.d.ts # Vite client types
│ ├── app/
│ │ ├── app.ts # Composition root — wires features into the page
│ │ └── config.ts # Build-time config (mode, storage keys)
│ ├── features/
│ │ ├── clock/
│ │ │ ├── clock.core.ts # Pure tick logic — computeClockTick(), msToNextSecond()
│ │ │ ├── clock.ui.ts # Clock DOM wiring — initClock()
│ │ │ ├── clock-registry.ts # Multi-clock scaffold (not wired up yet)
│ │ │ └── index.ts # Feature public surface
│ │ ├── timer/
│ │ │ ├── timer.core.ts # Pure state machine (no DOM)
│ │ │ ├── timer.presets.ts # Preset helpers (pure)
│ │ │ ├── timer.storage.ts # localStorage adapter
│ │ │ ├── timer.ui.ts # Timer DOM wiring — initTimer()
│ │ │ └── index.ts # Feature public surface
│ │ └── alarm/
│ │ ├── alarm.core.ts # Pure alarm state machine (no DOM)
│ │ ├── alarm.storage.ts # localStorage adapter
│ │ ├── alarm.ui.ts # Alarm DOM wiring — initAlarm()
│ │ └── index.ts # Feature public surface
│ ├── shared/
│ │ ├── time/format.ts # Time formatting (pure functions)
│ │ ├── audio/beep.ts # Web Audio beep (injectable AudioContext)
│ │ └── dom/query.ts # Typed querySelector helpers
│ ├── public/ # Copied verbatim to the build root
│ │ ├── favicon.svg
│ │ ├── 404.html # Served by nginx with a real 404 status
│ │ └── 404.css # Separate file — the CSP forbids inline styles
│ └── styles/
│ ├── main.css # Import hub
│ ├── variables.css # Design tokens
│ ├── base.css # Reset / body
│ ├── clock.css # Clock styles
│ ├── timer.css # Timer styles
│ ├── alarm.css # Alarm styles
│ ├── animations.css # Keyframes
│ └── responsive.css # Media queries
├── tests/
│ └── unit/ # Mirrors src/ one-to-one
│ ├── features/alarm/alarm.core.test.ts
│ ├── features/alarm/alarm.storage.test.ts
│ ├── features/clock/clock.core.test.ts
│ ├── features/timer/timer.core.test.ts
│ └── shared/time/format.test.ts
├── package.json
└── vite.config.ts
robots.txt and sitemap.xml are generated at build time from
VITE_SITE_URL (see Deployment) — never hand-add them to
src/public/.
The codebase is organized by feature, with a shared primitives layer:
- Pure cores (
clock.core.ts,timer.core.ts,shared/time/format.ts) hold all business logic with zero DOM dependencies — fully unit-testable. - UI layers (
clock.ui.ts,timer.ui.ts) query the DOM, bind events, and render from the pure core. - Adapters (
timer.storage.ts) isolate side-effecting platform APIs (localStorage). - Composition root (
app/app.ts) is the only place that knows about page element IDs;main.tsjust callsbootstrap(). - Dependency injection —
playBeep()accepts an optionalAudioContext, enabling test mocking. - Features are consumed through their barrel (
@/features/clock,@/features/timer), never by reaching into internal modules.
Run all commands from the web/ workspace:
cd web
npm install # Install dependencies
npm run dev # Start dev server (auto-opens browser)
npm run build # Production build → web/dist/
npm test # Run tests once
npm run test:watch # Run tests in watch mode
npm run typecheck # Type-check without emitting
npm run lint # Lint with ESLint
npm run format # Format with Prettiercd web
npm testTests cover:
- Time formatting (12h, AM/PM, midnight, noon, duration)
- Clock tick logic (title updates, timezone display, dev marker, timer-active guard)
- Timer state machine (start/pause/reset/tick transitions, input validation, presets)
- Alarm state machine (create/enable/delete, due-tick window, snooze, one-shot firing, persistence parsing, input validation)
Both stacks are defined by compose files and wrapped by make targets:
# Development — source-mounted app behind the edge at http://localhost:8100
make dev-up # build and start web + nginx
make dev-health # check the edge and the app
make dev-logs
make dev-down
# Opt-in development tunnel (separate token from production)
make dev-tunnel-up
# Production — pre-built GHCR images, Cloudflare Tunnel, deploy webhook
make env-check # fail fast on an unset secret
make prod-deploy
make prod-verify # check the loopback port the tunnel usesProduction is the clock-prod stack. .github/workflows/release.yml builds and
publishes the web and nginx images to GHCR — tagged both latest and
sha-<commit>, with provenance and SBOM attestations — then POSTs to the deploy
webhook on the host. deploy.sh performs the swap and rolls back automatically if the post-deploy
health check fails.
The site origin is a build-time value. release.yml passes the SITE_URL
repository variable into the image build as VITE_SITE_URL, and
vite.config.ts derives <link rel="canonical">, og:url, sitemap.xml, and
the Sitemap: line in robots.txt from it. Set the variable before the first
release; if it is unset the build falls back to http://localhost:8100.
See:
- Deployment runbook — setup, deploy, verify, rollback
- Cloudflare Tunnel runbook — ingress, token rotation, edge cache
- Architecture Decision Records — why the layout, ingress, and deploy model are what they are
- Contributing — setup, checks, commit conventions
- License — ISC