Project · Personal OS

Personal OS

production modular Svelte 5

A private dashboard over my Obsidian vault. It reads a SQLite mirror of the vault, renders whatever modules I've enabled today, and stays entirely inside a single-user perimeter. Phase 3 of a personal OS build — the part where notes, projects, biometrics, and system health all render on one page without shipping any of it to a third party.

What it is

The Personal OS dashboard is a SvelteKit 2 + Svelte 5 app deployed to Fly.io, gated behind Cloudflare Access. There is no login form and no password field — the edge authenticates me before the app boots. The vault lives in ~/reasons_brain/. A post-commit hook plus a launchd job keep a SQLite file at _data/os.db current with every change. Litestream streams that file to the Fly volume; the app opens it read-only through better-sqlite3.

It is not a SaaS. It is not for other people. It is a viewer, not a source of truth — the vault remains canonical, the database is a derived index, the dashboard is a rendering surface. Four live modules today: projects, daily notes, Whoop biometrics, system health. The whole point is that the fifth one is cheap.

Architecture

The interesting part is how modules compose. Every module is a folder at src/lib/modules/<id>/ with exactly two files — index.ts exporting a typed load(db), and Module.svelte rendering the data shape that load returned. A central registry.ts imports each module once and dispatches by id. A separate dashboard.config.ts decides which modules are enabled and in what order.

Plumbing cost per new module: roughly fifteen lines. One import in the registry, one entry in the config, one folder with two files. Nothing in the layout, nothing in the router, nothing in the data plumbing moves. The home route renders whatever the registry hands back; reordering is config, not code; toggling off a module is config, not code.

Module loads are wrapped individually. If the Whoop module throws because the whoop_daily table is empty or the OAuth token has not refreshed yet, the projects module still renders. The dashboard logs the failure, pins the tombstone card in place of the dead module, and keeps the shell up. A single slot crashing cannot dark the whole page — the lesson from every monolithic internal tool I've ever touched, installed at the contract boundary.

Hard-won bits

Three things worth calling out.

Litestream as the mirror

The vault is Markdown; the dashboard wants structured queries. The Python sync package at packages/sync/ parses frontmatter, resolves wikilinks, and writes typed rows into os.db — a projects table, a daily_notes table, a whoop_daily table, a health_checks table, a roadmap_items table. Litestream handles the rest: stream the SQLite file to Fly, the app opens the replica read-only. The mirror turns a hard problem (how do you serve structured views over a folder of Markdown to a deployed web app?) into an easy one (ship the SQLite file). A rebuild in a different framework tomorrow does not move the data contract.

Modules over routes

I did not want a dashboard where every new data source meant a new route file, a new nav entry, and a new layout variant. The module contract — load(db) plus Module.svelte — means every module is a self-contained rectangle on the home route. The registry composes them; the config reorders them; the home route is dumb glue. Adding the system-health module after it was already running in Python was a forty-minute exercise, and most of that was deciding how many recent failures to show.

Path-qualified wikilink normalization

A bug that burned a week of sync output: Obsidian auto-renames a file, the vault starts emitting path-qualified wikilinks like [[20-projects/slug]], and sync.py's slug lookup silently failed to resolve them. focus_project on the daily_notes table went NULL for every sync afterward. The dashboard kept rendering — the data it rendered was just missing a column. Fix was resolve_project_ref normalizing path-qualified forms before lookup, plus twenty-five tests in test_resolve_project_ref.py. Worth flagging because silent data loss in a read-only mirror is the failure mode the mirror was supposed to prevent.

Status

Deployed to Fly.io on 2026-04-15. Cloudflare Access in front for auth. Litestream syncing os.db from the vault. Four modules live: projects (with per-project detail routes at /projects/[slug] rendering body_md, roadmap items, and recent git activity), daily notes, Whoop (thirty-day recovery bars plus a seven-day moving average), and system health. A weekly review agent runs via launchd. Lint is clean, types are strict, AGENTS.md is symlinked to CLAUDE.md so the Claude Agent SDK reads the same rules the IDE does.

Next piece of work is an LLMOps curriculum module — the first module with a write path. POST endpoints, a writable SQLite handle, and a parser at packages/sync/sync.py::sync_curriculum() already landing rows into curriculum_phases, curriculum_tasks, and cert_status. The module itself is what comes next.

What I'd tell another builder

If you are building internal tools for yourself, resist the instinct to flatten everything onto one route and watch it calcify. A module contract is fifteen lines of plumbing; the payoff is that every new data source drops in instead of triggering a refactor. Error isolation falls out almost free once you have committed to the shape — one try/catch around mod.load(db) and a console.error is enough to stop one broken integration from breaking the page.

The other thing: mirror the data, do not integrate with the source. A SQLite replica streamed over Litestream gave me a clean read boundary between my vault and my dashboard. The vault stays canonical; the dashboard stays disposable. That is the part worth getting right early.