A Go web app that displays a shared family calendar (pulled from Fastmail and iCloud via CalDAV) plus a kids' chore tracker with parent approval, designed to run as a wall-mounted kiosk display in Kubernetes behind Traefik, with CloudNativePG as the database.
cmd/server/main.go Entry point - wires everything together, HTTP routes
internal/
config/ Env var loading/validation
db/ Postgres connection + goose migrations (embedded)
models/ SQL queries, one file per table group
caldav/ Fastmail/iCloud CalDAV sync (event detail: description,
organizer, attendees, attachments)
weather/ Open-Meteo forecast fetch, geocoding, in-memory cache
email/ SMTP sending + HTML email templates
auth/ Password hashing, session cookies, signed approval links
report/ Weekly PDF report generation
handlers/ HTTP handlers (kiosk, parent dashboard, approval, auth)
scheduler/ Background jobs: calendar sync, chore generation, weekly
email, weather refresh
util/ AES-GCM credential encryption
testutil/ testcontainers-backed Postgres helper shared by tests
web/
templates/kiosk/ Kiosk (no-login) page + fragments for seamless refresh,
including the event-detail and weather-detail modals
templates/parent/ Parent dashboard (htmx fragment swaps) + login
static/css, static/js Kiosk + parent styling; htmx.min.js vendored (no CDN)
embed.go Embeds templates/ and static/ into the compiled binary
(via go:embed) - the app does not depend on being run
from any particular working directory.
deploy/
Dockerfile Multi-stage build -> distroless runtime image
k8s/ namespace, configmap, secret examples, deployment, service, IngressRoute, kustomization
The app, module, and every k8s resource are named hhq (short for
"HappyHome Quest", the display name).
/- the kiosk view. No login. Read-only calendar + today's agenda, plus chores kids can tap to mark complete. This is what you point the wall-mounted monitor's browser at./parent- the settings/management dashboard. Requires a parent login (redirects to/loginif unauthenticated). Manage children, parents, calendar accounts, chores, approve/reject pending chores, and download the weekly report.
This matches your "separate endpoint for kiosk vs. password-prompting endpoint" request - there's no shared code path where an unauthenticated visitor can reach anything mutating besides the single narrow "mark my chore complete" action.
openssl rand -hex 32 # -> ENCRYPTION_KEYPut this (plus your SMTP credentials) into deploy/k8s/secret-example.yaml,
save it somewhere not committed to git, and apply it.
SESSION_SECRET, APPROVAL_SECRET, INVITE_SECRET, and
PASSWORD_RESET_SECRET are optional - only ENCRYPTION_KEY needs to be
generated by hand. If you leave the other four unset, the app generates a
random one for each on first startup and stores it encrypted (using
ENCRYPTION_KEY) in the settings table, so it's stable across restarts
without you having to manage it. Set one explicitly only if you have a
specific reason to control its value (e.g. rotating it yourself, or keeping
it stable across a database restore that predates the app's own generated
value).
There are two ways to create the first parent account - use whichever's convenient:
BOOTSTRAP_PARENT_NAME/_EMAIL/_PASSWORDenv vars: if set, the app creates that parent automatically on startup, as long as no parent exists yet. Remove those keys afterward (it's a no-op once any parent exists). An optionalBOOTSTRAP_PARENT_AVATAR_FILEenv var (a path to a PNG/JPEG/GIF, same 2 MB/4096x4096px limits aschildren.json'savatar_file, resolved relative toCONFIG_DIRunless absolute) sets that parent's avatar at the same time - unlikeavatar_file, this only applies once, at the moment the initial parent is created, since there's no ongoing bootstrap file for parents to reconcile against on every restart; a photo uploaded later from the dashboard is never overwritten.- The
/setuppage: if you'd rather not put a password in an env var, leave the bootstrap vars unset and visit/setupin a browser instead - it shows a "create the initial account" form, and self-disables (redirects to/login) as soon as a parent exists.
Every parent added after the first is invited by email instead (see the
"Parents" section of /parent): enter their name and email, they get a
link to set their own password. INVITE_SECRET signs those links, kept
separate from APPROVAL_SECRET (which signs chore-approval email links) so
a leak of one doesn't affect the other.
Parents who forget their password can use the "Forgot password?" link on
the login page, which emails them a link (signed with the separate
PASSWORD_RESET_SECRET, 1-hour expiry) to set a new one.
go.sum is committed, so a fresh clone should build as-is:
cd hhq
go build ./... # sanity check everything compiles
go test ./... # full suite; DB-backed tests need Docker (testcontainers), others still run without itIf your Go version differs enough to need dependency updates, go mod tidy
will refresh go.sum - commit the result.
docker build -t ghcr.io/mscreations/hhq:latest -f deploy/Dockerfile .
docker push ghcr.io/mscreations/hhq:latestUpdate deploy/k8s/deployment.yaml's image: field to match.
kubectl apply -k deploy/k8sThis assumes:
- A CloudNativePG
Clusteralready exists and has generated a Secret namedhhq-postgres-app(adjust the name indeployment.yamlif yours differs - check withkubectl get secret hhq-postgres-app -o jsonpath='{.data}' | jq keys). - Traefik is already running as your ingress controller with a
websecureentrypoint and some cert resolver configured (adjustingressroute.yaml).
Log in at /login, go to /parent, and add a Fastmail and/or iCloud account:
- Fastmail: Settings -> Password & Security -> App Passwords -> create one scoped to "Calendars (CalDAV)". Username is your full Fastmail email address.
- iCloud: https://appleid.apple.com -> Sign-In and Security -> App-Specific Passwords -> generate one. Username is your Apple ID email address.
Both providers' CalDAV URLs are pre-filled by the form; only override them if you know you need a different value.
Alternatively, bootstrap any number of accounts (0, 1, or many) automatically
on startup by mounting a calendars.json file into the directory named by
CONFIG_DIR (default /config - see "Bootstrap config files" below):
[
{
"name": "Mom's Fastmail",
"provider": "fastmail",
"username": "mom@fastmail.com",
"password": "app-specific-password"
},
{
"name": "Some Other CalDAV Server",
"provider": "generic",
"url": "https://caldav.example.com/",
"username": "someuser",
"password_file": "/secrets/caldav/someuser-password"
}
]Each entry's password can be given either as password (a plain string) or
as password_file (a path to a file containing just the password, e.g. a
Kubernetes Secret mounted separately from calendars.json itself) - setting
both on the same entry is a startup-time bootstrap error for that entry only
(the rest of the file still applies). Using password_file means
calendars.json holds no secret material, so it can live in a plain
ConfigMap instead of needing to be a Secret itself.
provider accepts fastmail, icloud, or generic (the CalDAV root URL is
pre-filled for the first two, same as the dashboard form; generic requires
an explicit url). This list is reconciled against the database on every
startup: an entry not yet present (matched by name) is created; an entry
that's already present and was itself created by this config has its
provider/URL/username/password refreshed to match. An account created
through the dashboard is left alone even if its name collides with a config
entry. Accounts created this way show as "Administratively managed" on the
dashboard and can't be edited or deleted there - the config file is their
source of truth, so change it and restart instead.
Unlike Fastmail/iCloud, Google Calendar uses OAuth2 rather than a static
username/app-password, which means you need to register your own OAuth
client with Google before the "Connect Google Calendar" option appears on
the dashboard - there's no shared/built-in client shipped with this app,
since Google ties an OAuth client to a specific redirect URL that's unique to
your deployment, and shipping a client_secret in open source code would let
anyone impersonate the app. This is the same model other self-hosted
projects (Home Assistant, Nextcloud, etc.) use for their own Google
integrations - a one-time setup per deployment, not per install.
- Go to the Google Cloud Console and create a new project (or reuse an existing one).
- APIs & Services -> Library -> search "Google Calendar API" -> Enable.
- APIs & Services -> OAuth consent screen -> configure:
- App name (e.g. "HappyHome Quest") and a support email
- Scopes: add
.../auth/calendar.readonly - User type: External, and leave the app in Testing mode (don't publish it) - see the note below on why this is the right choice for a personal/family deployment.
- Same page, Test users -> add the Google account email address(es) of whoever will connect a calendar (e.g. each parent's Gmail address).
- APIs & Services -> Credentials -> Create Credentials -> OAuth client
ID:
- Application type: Web application
- Authorized redirect URI:
<PUBLIC_BASE_URL>/parent/calendar-accounts/google/callback(e.g.https://hhq.example.com/parent/calendar-accounts/google/callback)
- Copy the resulting Client ID and Client Secret into the
GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRETenv vars (see the reference table below - both support the_FILEsuffix convention for Kubernetes Secret mounts, same as the other secrets in this app).
Once both are set, a "Connect Google Calendar" link appears on the parent dashboard - it redirects to Google's consent screen and, once approved, discovers and syncs the account's calendars the same way Fastmail/iCloud accounts do.
Why Testing mode, not Production/Published: calendar.readonly is a
"sensitive" scope, which normally requires Google to review and verify your
app before it can be used in Production by arbitrary users - a slow process
meant for apps with many external users you don't personally know. Testing
mode skips that review entirely, at the cost of capping usage to 100
explicitly-listed test users (added in step 4) who'll see an "unverified
app" warning screen with a "Continue"/"Advanced" link to click through. For
a personal kiosk used by your own family, this is a permanent, correct
choice, not a temporary workaround.
Testing locally without a public URL: Google makes a specific exception
to its HTTPS-only redirect URI requirement for http://localhost (and
http://127.0.0.1) - so you don't need this app deployed behind Traefik/TLS
to test the OAuth flow. Run the app locally with PUBLIC_BASE_URL set to
wherever it's actually listening, e.g. PUBLIC_BASE_URL=http://localhost:8080,
and add the matching http://localhost:8080/parent/calendar-accounts/google/callback
as an additional Authorized redirect URI on the same OAuth client (step 5
above supports multiple redirect URIs on one client, so you can keep both
your local and production URIs registered side by side rather than swapping
one out to test). Any other local hostname (e.g. a LAN IP or .local name)
does not get this exception and needs real HTTPS to work.
Also from /parent: add each child (they get a color used throughout the UI),
then add chores per child - recurring (pick days of week) or one-time (pick a
date). Chores show up on the kiosk starting the day they're due.
Alternatively, bootstrap children, the chore catalog, and per-child schedules
automatically on startup with children.json, chores.json, and
assignments.json in CONFIG_DIR - see "Bootstrap config files" below.
On every startup, the app scans the directory named by the CONFIG_DIR env
var (default /config) for any of five optional JSON files. Each is
independent - mount any subset of them. Every file is reconciled against the
database on every startup the same way: an entry not yet present (matched by
name, or by child+chore for assignments) is created, and an entry that's
already present and was itself created by one of these files is updated to
match every time. An entry that collides by name with something created
through the parent dashboard is left alone and logged, since that row's
config didn't come from this file. Rows created this way show as
"Administratively managed" on the dashboard and can't be edited or removed
there - the config file is their source of truth, so change it and restart
instead.
-
calendars.json- calendar accounts. See "Add your calendar accounts" above for the schema. -
children.json- children to create, e.g.:[ {"name": "Alex"}, {"name": "Sam", "color": "#3B82F6", "avatar_file": "avatars/sam.png"} ]coloris optional - if omitted, one is auto-assigned from the same palette used when a parent adds a child via the dashboard (and refreshed from the palette-assignment logic only once, at creation; a blankcoloron a later config change leaves the current color alone rather than reassigning it).avatar_fileis also optional - a path to a PNG/JPEG/GIF image (max 2 MB, 4096x4096px), resolved relative toCONFIG_DIRunless absolute, applied as this child's kiosk/dashboard avatar. This always wins on every restart: if a parent later uploads a different photo for this child from the dashboard, the next startup's reconciliation overwrites it back toavatar_file's image (mirroring howcoloralready behaves for a bootstrap-managed child). Leavingavatar_fileunset (or omitting it entirely) never touches that child's avatar, so a dashboard-uploaded photo is preserved indefinitely in that case. An unchanged file is a no-op on reconcile - only a change to the file's contents re-applies it. -
chores.json- the shared chore catalog (name + description, independent of which child(ren) it's assigned to).descriptioncan be a plain string, or a JSON array of strings that gets joined with\n- the array form avoids hand-typing\nescapes for multi-line instructions. e.g.:[ {"name": "Take out trash", "description": "Bins go to the curb Tuesday night"}, {"name": "Clean room", "description": ["Make the bed", "Put away laundry", "Vacuum"]}, {"name": "Feed the dog"} ] -
assignments.json- a JSON object keyed by child name (matched againstchildren.json), each holding an array of that child's chore assignments, pairing achore(matched by name againstchores.json) with its schedule, e.g.:{ "Alex": [ {"chore": "Take out trash", "points": 5, "days_of_week": ["tue", "fri"]} ], "Sam": [ {"chore": "Feed the dog", "points": 2, "one_off_date": "2026-08-01"} ] }days_of_weekentries are day names (case-insensitive, full or three-letter abbreviation); exactly one ofdays_of_weekorone_off_datemust be set, mirroring the recurring-vs-one-time choice on the dashboard's "Add Chore" form.pointsdefaults to 1 if omitted. The referenced child/chore don't themselves need to have come fromchildren.json/chores.json- an assignment can point at a child or chore created through the dashboard; only the assignment row itself is reconciled/locked. -
plugins.json- registers external-process plugins that extend the kiosk with their own widget and/or synthetic calendar events (e.g. a bill tracker), without their logic living in this codebase, e.g.:[ {"id": "bill-tracker", "name": "Bill Tracker", "base_url": "http://bill-tracker.default.svc:8080", "enabled": true} ]idis a stable slug (also used in the plugin's dashboard/kiosk URLs) - don't change it once deployed, since it's how hhq matches config entries to database rows across restarts.Update-available icon is automatic, nothing to configure: if a plugin serves an unauthenticated
GET {base_url}/versionreturning{"version": "1.0.0", "upgradeAvailable": true, "upgradeVersion": "1.0.2", "changelog": "feat: Update versioning", "channel": "dev"}hhq polls it periodically (same cadence as its own self-update check,
RELEASE_CHECK_INTERVAL_MINUTES) and shows a small update-available icon next to the plugin's version on the parent dashboard's Plugins card whenupgradeAvailableis true - hovering it showsupgradeVersion/changelog. hhq never talks to GitHub (or any other host) on a plugin's behalf; each plugin is responsible for knowing its own repo and checking it. A plugin that doesn't implement/versionsimply never shows the icon.Authentication is automatic, nothing to configure: hhq and the plugin agree on a shared secret the first time hhq successfully reaches the plugin's
POST /register(unauthenticated, since no secret exists yet at that point) - the plugin generates one and returns it, hhq stores it encrypted at rest (sameENCRYPTION_KEY-derived AES-256-GCM used for CalDAV passwords) and sends it asAuthorization: Bearer <token>on every request after that. A plugin only ever issues one token, ever - if the plugin isn't up yet when hhq first tries, hhq retries every 15 seconds until it succeeds, so startup ordering between hhq and its plugins doesn't matter. (If hhq and a plugin ever fall out of sync - e.g. a lost response after the plugin had already stored its token - recovery is manual: clear that plugin's stored token on both sides and restart hhq. See billtracker-plugin's own README for the exact steps.)On every startup (and periodically thereafter,
PLUGIN_SYNC_INTERVAL_MINUTES), hhq fetches{base_url}/manifestto learn whether the plugin wants a kiosk widget (and its column span/position) and whether it provides synthetic calendar events; if so, a dedicated synthetic calendar is auto-provisioned for it (shown on the parent dashboard's Plugins card like any other calendar, but never touched by real CalDAV sync). A plugin's own settings page (if it serves one atGET/POST {base_url}/settings) is reachable from the Plugins card, proxied through hhq so it's still gated by your parent login - the plugin itself never sees your session.Trust note: hhq treats a registered plugin's HTTP responses (its widget HTML, its settings page) as trusted content, not sanitized user input - it's rendered/embedded verbatim into the kiosk and parent dashboard. Only point
base_urlat a plugin you wrote or trust as much as hhq itself, the same way you'd trust any other code you run in your cluster.Local/dev convenience:
plugins.jsonentries only ever describeid/name/base_url/enabled- hhq expects the plugin to already be running atbase_urland never spawns anything itself (there used to be acommand/dirfield for that; it was removed since it couldn't be killed cleanly by a debugger's hard-stop on Windows). For local development, launch the plugin as its own VS Code debug session instead - see.vscode/launch.json'shhq + <plugin>compound configuration, which starts both and (stopAll: true) tears both down together when you hit Stop.
Any kiosk browser mode (Chromium --kiosk, a smart display's built-in browser,
etc.) pointed at your Traefik-exposed URL's root path.
| Variable | Required | Purpose |
|---|---|---|
LISTEN_ADDR |
no (default :8080) |
Address/port the HTTP server binds to |
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD |
yes | Postgres connection (sourced from the CNPG secret in k8s) |
DB_SSLMODE |
no (default disable) |
Postgres SSL mode |
ENCRYPTION_KEY |
yes | See secret generation above |
SESSION_SECRET, APPROVAL_SECRET, INVITE_SECRET, PASSWORD_RESET_SECRET |
no | Auto-generated and stored encrypted (via ENCRYPTION_KEY) on first startup if unset - see secret generation above |
SMTP_HOST, SMTP_USER, SMTP_PASSWORD |
yes | SMTP relay credentials |
SMTP_PORT, SMTP_USE_TLS, SMTP_STARTTLS, SMTP_FROM |
no | SMTP connection details. SMTP_FROM must be a bare address (e.g. hhq@example.com), not a "Name <addr>" form - some servers (Fastmail included) reject a display name on the SMTP envelope. The friendly sender name shown in recipients' inboxes comes from the app's "App Title" setting (parent dashboard), not this variable. |
PUBLIC_BASE_URL |
yes | Used to build the approve/reject links in emails - set to your externally-reachable URL |
COOKIE_SECURE |
no (default: true if PUBLIC_BASE_URL starts with https://, else false) |
Forces the session cookie's Secure flag on/off - override if Traefik terminates TLS but PUBLIC_BASE_URL isn't https, or vice versa |
APP_TITLE |
no (default HappyHome Quest) |
Initial display name shown on the kiosk and in emails, until a parent overrides it via the "App Title" setting (parent dashboard) - only takes effect before that setting has ever been saved |
CALENDAR_SYNC_INTERVAL_MINUTES |
no (default 15) | How often CalDAV/Google accounts are polled |
CALENDAR_WINDOW_DAYS |
no (default 7) | How many days ahead the kiosk calendar shows |
GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET |
no | OAuth 2.0 Client credentials from a Google Cloud project with the Calendar API enabled. If either is unset, the "Connect Google Calendar" option is hidden from the parent dashboard - Fastmail/iCloud continue to work without these. |
BOOTSTRAP_PARENT_NAME/EMAIL/PASSWORD |
no (alternative: use the /setup page) |
Creates the initial parent login on startup |
BOOTSTRAP_PARENT_AVATAR_FILE |
no | Path to a PNG/JPEG/GIF (max 2 MB) applied as the initial parent's avatar, once, when it's created - see "Add your first parent" above |
CONFIG_DIR |
no (default /config) |
Directory scanned on every startup for the optional bootstrap config files (calendars.json, children.json, chores.json, assignments.json, plugins.json) - see "Bootstrap config files" above |
PLUGIN_SYNC_INTERVAL_MINUTES |
no (default 15) | How often registered plugins are polled for synthetic calendar events (see plugins.json above) |
PLUGIN_CONNECTION_SECRET |
no (default hhq-plugin-connection) |
Shared secret hhq presents to a plugin's POST /register (see PLUGINS.md's "Authentication: self-registration") - set the same value on both hhq and the plugin if you want a real, hand-generated secret instead of the shared default |
WEATHER_LOCATION |
no | Free-text place name (e.g. Chicago, IL) geocoded to seed the weather widget's location on first startup only - a location already set (by this or the parent dashboard) is never overwritten. Ignored if WEATHER_LAT/WEATHER_LON are both set. |
WEATHER_LAT, WEATHER_LON |
no | Explicit coordinates to seed the weather location on first startup only, skipping geocoding. If WEATHER_LOCATION is also set, it's used only as the display name. |
WEATHER_UNITS |
no (default imperial) |
imperial or metric, used only when seeding the location via the variables above |
WEATHER_REFRESH_INTERVAL_MINUTES |
no (default 15) | How often the weather widget's forecast is refreshed from Open-Meteo |
LOG_LEVEL |
no (default info) |
Set to debug for verbose logs: calendar sync detail (principal/home-set discovery, event counts per calendar), email send attempts, per-request logging, chore state transitions, etc. |
LOG_FORMAT |
no (default text) |
Set to json to emit one JSON object per log line (time/level/msg) instead of the default [LEVEL] message text format - useful when logs are ingested by an aggregator like Loki/Grafana. |
RELEASE_CHECK_INTERVAL_MINUTES |
no (default 1440) | How often the app polls GitHub for a newer release, to drive the "Update Available" badge on the parent dashboard |
This is a solid, working foundation, but some things are intentionally simplified given the scope and your "learning Go" goal. In rough priority order if you keep building on this:
- Authentik/OIDC isn't implemented yet, but the schema is ready for it:
users.auth_providerandusers.external_subjectexist specifically so you can add an OIDC login path later that creates/matches a user by subject claim, without a migration. You'd add anoidc.goininternal/authimplementing the standard authorization-code flow, and a/login/oidcroute alongside the existing local login. - Approval links act on GET requests for simplicity (see the comment in
internal/handlers/approval.go). If you notice a chore getting auto-approved/rejected without anyone clicking (some email clients prefetch links for safety scanning), switch that handler to render a confirmation page with a POST button instead. - Single replica only. The scheduler (calendar sync, weekly email, weather refresh) has no distributed locking, so running 2+ replicas would double-sync and double-email. Fine for a single-family kiosk app; would need a leader election or moving the scheduler to a separate CronJob if you ever needed to scale the web tier.
- CalDAV recurring event expansion relies on the server correctly
expanding recurring events for a calendar-query time-range filter, which
both Fastmail and iCloud do - but if you add a more obscure CalDAV server
later, some may return raw
RRULEs needing client-side expansion instead (not implemented here). - The chore report is generated on-demand from live data, not stored -
if you want historical reports to remain stable/auditable even after data
changes later, consider persisting generated PDFs (e.g. to an object store)
with a
weekly_reportstable indexing them by week. - Calendar account edit/delete has no confirmation on edit (delete does
have a JS
confirm()prompt). Also, the provider field can't be changed after creation - delete and re-add if you need to switch a Fastmail account to "Other CalDAV" or similar. - Points/rewards system: only point values per chore and a weekly points total in the PDF report exist yet - no redemption/rewards feature has been built on top of points.
- Dark mode preference is per-browser (
localStorage), not per-user in the database - if the same parent logs in from a different device, the preference doesn't follow them.
Versions follow MAJOR.MINOR.PATCH, tracked entirely via git tags (no
committed version file). Work happens on dev; every push there is
automatically tagged with the next patch build (e.g. 1.1.4-dev) and
published to GHCR as ghcr.io/mscreations/hhq:1.1.4-dev /
ghcr.io/mscreations/hhq:latest-dev. Promoting dev to main is a manual
pull request on GitHub; once merged, an Action tags the next minor release
(e.g. 1.2.0), cuts a GitHub Release, publishes
ghcr.io/mscreations/hhq:1.2.0 / :latest, and opens+merges a PR syncing
main back into dev so dev picks up the new line. The running version
and a link to check for updates are shown at the bottom of the parent
dashboard.
This project was developed with substantial AI assistance (Claude Code). All AI-generated changes were reviewed and tested by the maintainer before being committed. The automated test suite was written entirely by AI, under human review.
go.sum is committed and the module builds from a clean clone (see step 2
above) - no go mod tidy needed unless your local Go version pulls in
different dependency versions. go build ./..., go vet ./..., and
go test ./... all pass with zero errors across the whole module, including
the HTML templates and static assets, which are embedded into the binary via
go:embed (web/embed.go) - so the app runs correctly regardless of the
working directory it's launched from (no pattern matches no files errors
from running the binary somewhere other than the project root).