Architecture
Ingle is a Go server using the standard library’s net/http and html/template, with htmx in the browser. It stores data in SQLite through the pure-Go modernc.org/sqlite driver, so there’s no CGO and the binary is fully static. Migrations use goose, and queries are type-checked Go generated by sqlc from internal/store/queries. Templates, static assets and migrations are all embedded in the binary.
The code is on GitHub at lukeskelhorn/ingle.
Layout
Section titled “Layout”cmd/ingle/ entry point (also `ingle healthcheck`, `ingle backup` and `ingle version`)internal/config/ env config + UK defaultsinternal/locale/ date, week and money formattinginternal/server/ HTTP routes, middleware and handlers (board + live events, setup, sign-in, admin)internal/auth/ PINs and passwords (argon2id), household displays, sessions, rate limitsinternal/settings/ per-household settings (timezone, day reset, idle timeouts)internal/household/ people, credentials and rewards admininternal/avatar/ people's pictures: the emoji picker and photo uploadsinternal/emoji/ the emoji pickers, and suggesting an emoji from a titleinternal/picture/ safe image decoding and JPEG writing, shared by avatars and photosinternal/lock/ locking the displays: state, schedule and idle lockinternal/photos/ screensaver slideshow: uploaded photos plus the read-only photos folderinternal/calendar/ ICS feeds: fetch, parse and expand recurring eventsinternal/chores/ chore lifecycle: recurrence, admin edits, board, claim, complete, undo, approve, reverse, rewards, goals, the calendar viewinternal/backup/ nightly backups: database snapshot plus photos and avatarsinternal/valid/ per-field validation errors, shown next to form fieldsinternal/store/ SQLite, embedded goose migrations, sqlc queriesinternal/store/dbq/ sqlc-generated code (don't edit; run task generate)web/templates/ html/template layout, partials and pagesweb/static/ htmx, CSS, PWA manifest, service worker, iconsdeploy/ Docker Compose + Caddy, and systemd units, for a home serverHow a request flows
Section titled “How a request flows”All routes are registered in Server.Handler in internal/server/server.go. Every request passes through the same outer layers, in order:
- Cross-origin protection (
http.CrossOriginProtection): state-changing requests from another site are refused, so forms need no CSRF tokens. identifyreads the device and session cookies and works out who’s asking: a registered household display, a signed-in adult (by PIN on a display, or password elsewhere), both, or nobody. The result goes in the request context.requireSetupsends every page to/setupuntil the first adult exists.
Then each route is one of three kinds:
- Open:
/healthz, static files, the manifest and service worker, setup, and sign-in. They show no household data. - Viewer (
requireViewerthenlockGate): the board, its sheets, the calendar, the screensaver and the live event stream.requireViewerlets in household displays and signed-in adults, and sends anyone else to sign-in.lockGatehandles locked displays: only the clock, photos and the unlock screens get through, so a locked display is never sent names, chores or events. It also notes when a display is used, for the idle lock. - Admin (
requireAdminthenpublishAfterWrite): everything under/admin.requireAdminneeds a signed-in adult. After any admin write,publishAfterWritetells every open board to refresh.
Handlers call the domain packages (chores, household, lock and so on), which use store for the database. Board actions publish an event to the household’s hub in events.go, and every open board’s /board/events stream hears it and re-fetches what changed.
Data model
Section titled “Data model”Every table has a household_id, so one database can hold several households. A self-hosted install has just one.
- households and household_settings: name, timezone, when the board’s day rolls over, the idle timeouts, the lock schedule and idle lock, and the unlock code (hashed).
- people: adults and children, each with a colour, an avatar (an emoji or an uploaded photo) and a picture cards setting. Adults can have a PIN and an email + password, all stored as argon2id hashes. goal_reward_id is the reward someone is saving for, and goal_set_at is when they picked it.
- devices are registered household displays, and sessions are adult sign-ins. Only sha256 hashes of their tokens are stored.
- chores are assigned to one person, shared between a few (listed in chore_people), or sit in the pool. Recurrence is an iCal
RRULEevaluated in the household timezone, with aday_modeofeachoranyfor flexible-day chores and anon_the_dayflag. A chore can award points, pence or both, and can require an adult’s approval. - chore_occurrences: the concrete instances that appear on the board. They’re materialised 14 days ahead on startup, then refreshed every hour. A pool occurrence can be claimed by one person. A flexible one covers its week, from
available_fromtodue_on. - completions are pending, approved or rejected. Only approved completions credit ledger_entries, and a balance is the sum of a person’s entries.
- rewards are claimed through redemptions. A pending claim holds its cost (what someone can spend is their balance less their pending claims) but writes nothing to the ledger. Approving it adds one negative
redemptionentry, and declining or cancelling (stored as rejected) lets the hold go. - household_lock says whether the displays are locked, why, and which schedule window was last applied.
- photos are the screensaver photos uploaded in admin. Folder photos are indexed in memory and never stored.
- calendar_feeds are the ICS addresses. Events are fetched into memory and not stored.
- activity is an append-only feed and audit log.
- app_meta holds
schema_owner, which is stilldomusfrom before the rename.
Timestamps are stored as unix seconds (UTC), local dates as YYYY-MM-DD, and money as integer pence. A display remembers in its browser storage which goals it has already celebrated.