Skip to content

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.

cmd/ingle/ entry point (also `ingle healthcheck`, `ingle backup` and `ingle version`)
internal/config/ env config + UK defaults
internal/locale/ date, week and money formatting
internal/server/ HTTP routes, middleware and handlers (board + live events, setup, sign-in, admin)
internal/auth/ PINs and passwords (argon2id), household displays, sessions, rate limits
internal/settings/ per-household settings (timezone, day reset, idle timeouts)
internal/household/ people, credentials and rewards admin
internal/avatar/ people's pictures: the emoji picker and photo uploads
internal/emoji/ the emoji pickers, and suggesting an emoji from a title
internal/picture/ safe image decoding and JPEG writing, shared by avatars and photos
internal/lock/ locking the displays: state, schedule and idle lock
internal/photos/ screensaver slideshow: uploaded photos plus the read-only photos folder
internal/calendar/ ICS feeds: fetch, parse and expand recurring events
internal/chores/ chore lifecycle: recurrence, admin edits, board, claim, complete, undo,
approve, reverse, rewards, goals, the calendar view
internal/backup/ nightly backups: database snapshot plus photos and avatars
internal/valid/ per-field validation errors, shown next to form fields
internal/store/ SQLite, embedded goose migrations, sqlc queries
internal/store/dbq/ sqlc-generated code (don't edit; run task generate)
web/templates/ html/template layout, partials and pages
web/static/ htmx, CSS, PWA manifest, service worker, icons
deploy/ Docker Compose + Caddy, and systemd units, for a home server

All routes are registered in Server.Handler in internal/server/server.go. Every request passes through the same outer layers, in order:

  1. Cross-origin protection (http.CrossOriginProtection): state-changing requests from another site are refused, so forms need no CSRF tokens.
  2. identify reads 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.
  3. requireSetup sends every page to /setup until 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 (requireViewer then lockGate): the board, its sheets, the calendar, the screensaver and the live event stream. requireViewer lets in household displays and signed-in adults, and sends anyone else to sign-in. lockGate handles 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 (requireAdmin then publishAfterWrite): everything under /admin. requireAdmin needs a signed-in adult. After any admin write, publishAfterWrite tells 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.

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 RRULE evaluated in the household timezone, with a day_mode of each or any for flexible-day chores and an on_the_day flag. 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_from to due_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 redemption entry, 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 still domus from 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.