A self-hosted family wall display built for a touchscreen on the kitchen wall, with the same page mobile-optimized for phones. Google Calendar and Apple/ICS calendars front and center, a per-person daily chore tracker with one-tap check-off, live security-camera tiles (UniFi Protect, Wyze, anything go2rtc speaks), at-a-glance weather and per-room climate cards that expand to your full dashboards on a tap, and a laundry card that counts a washer or dryer cycle down and remembers when the load finished, all in a light or dark theme with an accent color you pick.
The kitchen wall: one card system across chores, the shared calendar with to-dos, cameras, at-a-glance weather + per-room climate, the laundry portholes, and the Fleet Console status card. Shown in the default grey theme with a green accent and sample data.
The same page, reflowed to six phone tabs, carrying the same theme. The Chores tab is shown here.
Runs on any always-on Linux box with Docker. No cloud service of its own, no accounts, no telemetry: it lives on your LAN and only talks out to the calendars and services you connect. Point a wall screen (or any browser, or a phone) at one URL.
-
Wall (1920×1080): chores | calendar + to-dos | cameras | weather + climate + laundry + fleet, all in one card system. Five display modes (light, soft, blue, grey, black) with a pick-your-accent color (cyan, violet, amber, or green) and an optional subtle column separation, set from the wall itself or any phone and remembered per device.
-
Seasonal looks: turn Season on in the gear menu and the wall follows the calendar: a real photograph fills the screen and the dashboard floats on it as frosted glass in your theme's colours, leaves fall at two depths (over the cards and softly behind them), and a matching accent and a small mark sit beside the wordmark. Fall (Sep 1 to Nov 30) offers Aspen Grove (the default), Misty Road, Maple Sky, Golden Light, Aspen Sky, Autumn Road, and paintings by Cropsey, Inness and Monet, and Halloween takes October with Lantern Night (the default), Witching Hour, Haunted Pines, Moonrise and Bare Branches — bats cross the sky, a spider lets itself down on its thread, another lives on the glass, and webs hang in the corners. Thanksgiving takes November with Pumpkin Patch (the default), Golden Hour, Cranberry Bog, Cranberry Harvest, Corn Shocks, Vine Rows and Hudson Autumn: leaves fall in each photo's colours and a skein of birds crosses the sky now and then. Each look is picked per device under All settings → Seasonal looks from live preview tiles, and none is locked to its dates: Calendar follows them, while tapping any look (Pick one) keeps it all year. Each of the five themes keeps its own glass: Light and Soft show the photo bright and airy, Blue, Grey and Black at dusk. Nothing moves at night or for reduced motion, and the art (public domain, CC0, CC-BY and MIT) ships inside the app. Adding a season or holiday follows
docs/seasonal-looks.md. -
Phone / tablet (≤1000px): the same page reflows to bottom tabs — Chores / To-Dos / Calendar / Cameras / Weather / Laundry. On iPhone, open the hub in Safari and use Share → Add to Home Screen for a full-screen app with no browser toolbar (and no browser quirks); a normal browser tab also self-heals the occasional stuck-height reload on its own.
-
Calendar: next-5-days home feed, full-screen month grid + week agenda + day drill-in, tap-any-event detail cards, your own Google sidebar colors, multi-day events drawn as one bar across their span (Google-style), ended events struck through. The wall's ↻ button pulls the calendars right away (it waits up to 10 seconds) and then reloads the page, so a change made a minute ago shows without waiting for the 5-minute sync.
-
Chores: per-person cards in each person's color, streaks (🔥), a 7-day week strip, deterministic rotations (a new person each time, or one person for the whole week and the next person next week), every-other-week and every-few-days schedules, one-time chores due on a single date, a browsable day history, an away/pause mode so a trip never breaks a streak, and a one-shot confetti celebration when someone clears their day.
-
To-Dos: one shared household list for the stuff that isn't a scheduled chore — anyone adds, anyone checks off. Grouped Now / Soon / Later, items carry over until done, a checked item stays struck-through for five minutes (tap it again to undo) and then moves to a 30-day "recently done" list that can restore it. In the full list, drag an item by its handle to another list or another place in its list (touch, mouse or arrow keys), or tap it to rename it, move it or delete it.
-
Weather & climate: a glanceable weather card (temperature, a 24h temperature curve, UV index, air quality, humidity, dew point, and a 5-day high/low strip) and a per-room indoor climate card (temp + humidity, with a warn state when a room runs hot or a sensor goes stale). Tap ⛶ Full on either to open your full weather or climate dashboard. Both fail soft: a dead feed quietly hides its card, never the wall. The weather feed's shape is documented in
docs/weather-feed.md(the 5-day strip needs adailyForecastarray on the feed and stays hidden without one). -
Laundry: washer + dryer as porthole cards fed by Home Assistant. The ring around each door shows how much of the cycle is left (against the machine's own cycle length when HA reports one, else a 60-minute dial), the drum tumbles while a cycle runs (wash water and suds in the washer, a heat glow in the dryer), and the words say what the machine is doing ("Sensing load", "Rinsing", "Cooling, almost done"). A finished load shows a check with when it finished, held for half an hour even after the machine shuts itself down. After that a finished wash turns amber and reads Waiting until the load is moved: the dryer starting, or the washer being turned on, ends it (a wet load can sit for hours; this household's median was about 100 minutes). It gives up after 12 hours, and then the card keeps a quiet "last load" line. Errors name the fault when the machine does ("won't drain", "load is unbalanced"), and a delayed start says when it will start. LG's placeholder "1 min left" at the start of a dryer cycle is caught and replaced with the real length. The card is real-time: a server-side watcher re-reads Home Assistant every 5 seconds for the whole cycle and pushes each change to open walls over a live stream (
GET /api/laundry/stream, server-sent events), so a status change shows in seconds — machines that power themselves off right after the end-of-cycle chime can't slip through, and a finish that still lands between reads is reconstructed and shown as Done. Every observed phase transition is also logged in the database (GET /api/laundry/log, a year of cycle history, covered by the standard backups) so the finish heuristics can be tuned from real cycles — and because the server does the observing, the log fills even with no wall open. Built for LG ThinQ's sensors, but any HA integration exposing a status enum + a remaining-time timestamp works. Fails soft like weather/climate. -
Fleet Console: an optional card proxying a separate home-lab dashboard's compact status rollup — a system-health line ("N of M hosts up", the worst problem in words when something's down) over a 3D printer's state, job, progress bar, ETA, and nozzle/bed temps. Rides the panels column under Laundry on the wall and the Weather tab on the phone (no tab of its own). Tap ⛶ Console to open the full dashboard full-screen, when configured. Fails soft like weather/climate; off entirely with no
fleetconfig block. -
Manage: tap Edit chores on the wall's Chores page to add/edit chores and people (rename, recolor, deactivate, or delete) right on the touchscreen, no phone needed. Mark someone away here too, with an optional backup to cover their chores, and tap I'm back when they return. The same page works from any browser on the LAN, so a phone or laptop manages everything too. No app to install. The chore form asks plain questions ("How often?", "Who does it?"), shows who does the chore on the next few days before you save, and can add the same chore for several people at once; the page lists every chore, including the ones not on today.
Dumb edge, smart box. One FastAPI + SQLite container does everything; the frontend is dependency-free vanilla JS baked into the image.
Wall / phones ─► http://<your-server>:8138/ family-hub (FastAPI + SQLite)
│ server-side sync/proxies
├─► Google Calendar API (read-only, polled every 5 min + on ↻)
├─► any ICS/webcal feeds (iCloud, school, holidays…)
├─► iCloud CalDAV (reminders + chore mirror, two-way, optional)
├─► Home Assistant (laundry, optional)
├─► weather / climate / fleet feeds (tiles, optional)
├─► go2rtc (camera tiles, optional; proxied
│ at /go2rtc/, never on the LAN)
└─► your own dashboards (embedded panels, optional)
- No auth, LAN-only — on purpose. Anyone on your LAN can check off chores;
that is the point of a family wall. Bind it to a LAN interface (
HUB_BINDin.env) and never port-forward it. Away from home, reach it the way you'd reach any other LAN-only box: VPN into your home network first (WireGuard, Tailscale, or your router's built-in VPN all work), then open the same phone URL — that keeps the no-auth trust model intact instead of putting the hub on the open internet. - Fails soft. The hub renders fine with zero cameras, zero panels, and no calendar configured; a dead upstream grays its tile with a quiet "offline"; a dead calendar feed keeps showing its last-synced events.
- Secrets never live in git. OAuth tokens, camera URLs (Protect RTSPS
URLs are per-camera capability tokens!), and Wyze credentials all live in
the git-ignored
data/directory on the box. - No microphones. Every camera stream is video-only, enforced twice
(
#media=videoon every go2rtc stream,ENABLE_AUDIO=Falseon the Wyze bridge). A family wall should never be a listening device.
git clone https://github.com/drench44/family-hub.git && cd family-hub
cp config.example.json config.json # edit: your calendars/cameras/panels
cp .env.example .env # edit: your server's LAN IP + timezone
mkdir -p data && chmod 700 data
docker compose up -d --build web # just the hub; cameras come later
curl -s http://<your-server>:8138/health # {"status":"ok"}Open the wall at http://<your-server>:8138/, open All chores, tap Edit chores,
and add your people and chores — the wall comes alive. Everything below is
optional and independent — add the pieces you have.
The frontend is baked into the image: after changing anything under
src/family_hub/web/static,docker compose build web— a bare restart keeps the old files.
/health is liveness for the container healthcheck: the process answers and
can read hub.db. It stays {"status":"ok"} when an upstream is down, so an
outage never marks the container unhealthy.
/health/full answers "does the hub actually work?" and is what a deploy
should gate on. It always returns 200 with a report:
sources: one entry per thing the wall shows (calendar,laundry,weather,climate,fleet,cameras), each withok, astatusword (ok,off,waiting,stale,needs_auth,error,upstream_down,degraded) and the timestamps that prove freshness. Only reads made by the running process count: the calendar'slast_syncmust be newer thanprocess_started_at, and the laundry watcher's last good Home Assistant read must be under a minute old. Where the upstream stamps its own data (wx.jsonts, the climate rooms' ages, the fleet rollup'sgeneratedAt), that stamp isdata_tsand must be recent too.settings:HA_TOKENwhen laundry is configured, the Google token file when Google calendars are, and a laundry block that survived validation.config: the sha256 ofconfig.jsonas loaded, as on disk now, and as the deploy recorded it (matches_deploy).deploy: what the deploy baked intosrc/family_hub/build_info.json(engine_commit,overlay_commit,config_sha256,built_at), or null for an image built by hand.statusisokonly when nothing above failed;problemsnames each failure.notes(the backup heartbeat) is information only.
curl -s http://<your-server>:8138/health/full | jq '{status, problems}'A deploy script can write build_info.json into the tree it builds from
(never commit it; it is git-ignored) to make matches_deploy and
deploy.engine_commit meaningful.
Want to see the whole wall before wiring up anything? Run it with DEMO=1 and
it comes up as a fully populated sample: a fake family (Ava, Milo, Ruby) with
chores, streaks and a week strip, a shared to-do list, a few calendar events,
canned weather and per-room climate cards, and placeholder camera tiles. No
config, no calendars, no cameras, no feeds needed. Nothing reaches the network.
Needs Python 3.12 and the runtime dependencies (a virtualenv is tidiest):
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
DEMO=1 DB_PATH="$(mktemp -d)/demo.db" CONFIG_PATH=config.demo.json \
PYTHONPATH=src DISABLE_SYNC=1 \
python3 -m uvicorn family_hub.app:app --port 8139Then open http://localhost:8139/. Everything is fake sample data seeded on
first launch, so it's safe to poke at. (docker compose users can pass the
same DEMO=1 in the web service's environment.)
Any always-on browser works. Two setups are written up:
- A used all-in-one touchscreen PC (what we run). One box, with the screen, touch, speakers and power built in, and nothing to wire up. Ours is a used HP All-in-One 24 (23.8", 1920×1080, 10-point touch, Intel i3, 16 GB), bought for $150. A used all-in-one is often cheaper than a Raspberry Pi plus a touchscreen, and faster. See docs/all-in-one-wall.md for what to look for and the setup tips we learned the hard way (sleep, overnight dimming, full screen at login, power-on after an outage).
- A Raspberry Pi kiosk. It boots straight into the dashboard, full screen,
with no browser chrome or cursor, and never sleeps. See
docs/raspberry-pi-kiosk.md, with
copy-paste files in
docs/kiosk/.
Typing on the wall. A wall has no keyboard, so tapping a text field pops up
a built-in on-screen keyboard (letters, symbols, and emoji). It's for the wall
only — phones and laptops keep their own keyboard — so open the hub on the wall
once with ?kiosk=1 to turn it on (the setting is then remembered;
?kiosk=0 clears it). The same setting hides the mouse pointer after a finger tap
and brings it back when someone uses a mouse. See
docs/on-screen-keyboard.md.
| Key | What it is |
|---|---|
port |
Hub port (default 8138) |
calendars |
Calendar sources, in display order (see below) |
calendar_window_days / calendar_past_days |
Sync window forward / back (400 / 45 in the example config). The month view can page anywhere, but only days inside this window are actually fetched — outside it an empty day is marked "not synced" rather than shown as free. Raising it past 400 also needs CAL_MAX_DAYS in app.py raised, and the frontend's fixed fetch in hub.js. The reported window is additionally capped by what the last SUCCESSFUL sync actually covered, so an install that has never completed one — or whose source keeps failing — marks days as "not synced" even inside this configured window, rather than claiming days it never fetched. |
cameras |
go2rtc streams shown as tiles: {"src","label"} + optional "hd" (higher-res twin used full-screen) |
camera_page |
The phone/tablet Cameras tab as a 2×2 (row-major) live grid — same entry shape as cameras, but its own set and order, so the tab can show cameras the wall column doesn't. Omit to reuse cameras. |
panels |
Always-on dashboard embeds (see below) |
go2rtc_base |
Where the hub reaches go2rtc (http://go2rtc:1984 in the compose stack; the GO2RTC_FETCH_BASE env var overrides it). Browsers never use it: they get the player through the hub. Omit if no cameras |
weather_base |
Base URL of a weather JSON feed for the native weather card (the card shows for a configured weather panel; empty base = "unavailable" note) |
climate_base |
Base URL of a per-room climate JSON feed for the native climate card (shows for a configured climate panel; empty base = "unavailable" note) |
laundry |
Washer/dryer status via Home Assistant: {"ha_base", "machines": [{"id","label","kind","status_entity","remaining_entity"}]} — kind is washer or dryer (sets the drum tint), the entities are HA sensor ids (LG ThinQ's Current status enum + Remaining time timestamp, or equivalents). Optional per machine: total_entity (cycle length in minutes; LG Total time), start_entity (LG Delayed start timestamp) and error_entity (LG Error event). Each only adds detail; leave any out. The HA long-lived token comes from the HA_TOKEN env var, never this file. Omit to skip the card. |
fleet |
Fleet Console card: {"base": "http://192.168.1.50:3000"} — the base URL of a separate home-lab dashboard app exposing a compact /api/rollup status endpoint (host + 3D-printer status). Optional "label". Omit to skip the card. Pair with a "fleet" entry in panels (below) to get the ⛶ Console full-screen button. |
theme |
House default display theme — {"mode","accent","columns","layout","idleReturn","season"} (mode: light/soft/dark/grey/black, accent: cyan/violet/amber/green, columns: none/wells/lines, layout: auto/desktop, idleReturn: on/off, season: on/off for seasonal looks). Applied on a fresh device with no saved override |
Each entry: {"id": "you@gmail.com", "label": "You", "color": "#5BC9F0"}.
The id is the calendar's ID from Google Calendar settings (your address for
your primary calendar). One-time auth, from any desktop:
- In the Google Cloud console: create a
project → enable the Google Calendar API → configure the OAuth consent
screen (External) → publish the app to Production (otherwise tokens
expire every 7 days) → create an OAuth client of type Desktop app and
download its JSON as
scripts/client_secret.json. cd scripts && python3 google-auth.py(needspip install google-auth-oauthlib). Approve read-only access; it writestoken.json.- Copy
token.jsoninto the box'sdata/directory. Done — the next 5-min sync picks it up. Your own sidebar colors and per-event colors carry over.
Add "kind": "ics" and a "url" — no auth needed:
{ "id": "school", "label": "School", "color": "#C39BEA",
"kind": "ics", "url": "webcal://p123-caldav.icloud.com/published/2/…" }For iCloud: Calendar app → right-click a calendar → Sharing → Public
Calendar, copy the webcal:// link. Works equally for school calendars,
sports team feeds, national holidays — anything that publishes ICS.
Recurring events are fully expanded. A feed that goes dark keeps its
last-synced events on the wall instead of vanishing.
Each panel renders a live page at a fixed virtual viewport and scales it to fit its slot — so a dashboard designed for a big screen reads perfectly in a column. Optionally crop to just the part you want:
{ "id": "weather", "label": "Weather", "url": "http://192.168.1.50:8137/",
"vw": 1024, "vh": 600, "full": "fit" }| Field | Meaning |
|---|---|
vw, vh |
The visible region's design size (px) |
page_w |
Lay the page out wider than the visible region (for cropping) |
crop_top, crop_left |
Pan the visible region to a specific card |
full |
Full-screen mode: "native" (default; embeds the page raw) or "fit" (scales a fixed vw×vh sheet to fill the screen — for kiosk-style pages) |
full_url |
Different URL for full-screen (defaults to url) |
Camera tiles are live sub-second WebRTC streams via go2rtc:
cp go2rtc.yaml.example data/go2rtc.yamland fill in your streams — the example covers UniFi Protect (including thertspx://fallback) and Wyze. Setwebrtc.candidatesto your server's LAN IP.docker compose up -d go2rtc- Add each stream to
camerasin config.json.
go2rtc's API stays off your LAN. It has no login, and it hands anyone who
asks every camera URL it knows (often a password or a per-camera token) and
lets them rewrite its config. So the compose file publishes only go2rtc's
WebRTC port (8555, video only) on the LAN. The hub serves the player itself at
/go2rtc/ and passes the player's WebSocket through to go2rtc, for the
streams in your cameras / camera_page and nothing else. data/go2rtc.yaml
is mounted read-only: edit it on the server and docker compose restart go2rtc. To use go2rtc's own web page for debugging, it is on the server's
loopback: ssh -L 1984:127.0.0.1:1984 <server>, then open
http://127.0.0.1:1984/ on your computer.
On phones and tablets the Cameras tab shows a 2×2 live grid instead of the
wall's stacked column. It defaults to your cameras; set camera_page to give
that grid its own set and order (top-left, top-right, bottom-left, bottom-right)
— handy for surfacing a camera there that isn't on the wall.
How a tile decides it is live. Two signals: a snapshot check of each
camera on screen (every 30 seconds, with a few quick re-checks over the
first minute while a tile is offline or starting), and the tile's own
player, whose count of decoded video frames the wall reads through the
same-origin /go2rtc/ page. A moving picture is live even if a snapshot is
slow, so a playing camera never blanks. A camera that answers but whose
player gave up, never started, or froze shows "connecting" instead of LIVE
and is reloaded after a short wait, with the wait doubling each time it
doesn't take (up to four minutes; after three tries it says "no video").
"Offline" means the camera itself isn't answering.
Slow first picture? Check the camera's keyframe interval. An H.264 viewer can only start decoding at a keyframe, and go2rtc passes streams through without transcoding — so every viewer that joins an already-running stream (the wall keeps tile streams running 24/7) waits up to one full keyframe interval showing black. UniFi Protect defaults every channel to a 5-second interval (
idrInterval: 5), which reads as "the cameras take forever to load." Protect's UI doesn't expose the setting, but its API does — drop the interval to 1s on the channel your tile embeds (the Medium channel here, which is not the recording stream, so recorded footage and storage are untouched):# cookie login; the x-csrf-token RESPONSE header authorizes the PATCH curl -sk -c /tmp/uos -D /tmp/hdrs -H 'Content-Type: application/json' \ -d '{"username":"USER","password":"PASS"}' https://CONSOLE/api/auth/login CSRF=$(grep -i '^x-csrf-token:' /tmp/hdrs | tr -d '\r' | cut -d' ' -f2) # camera ids: GET https://CONSOLE/proxy/protect/api/cameras curl -sk -b /tmp/uos -X PATCH -H 'Content-Type: application/json' \ -H "X-CSRF-Token: $CSRF" \ -d '{"channels":[{"id":1,"idrInterval":1}]}' \ https://CONSOLE/proxy/protect/api/cameras/CAMERA_IDThe camera applies it live (no reboot). Wyze firmware keyframes every 2s with no exposed setting to change it — that's the floor for Wyze tiles.
Wyze cams have no native RTSP — the bundled wyze-bridge service bridges
them: put your Wyze email/password/API-key in data/wyze.env
(WYZE_EMAIL=…, WYZE_PASSWORD=…, API_ID=…, API_KEY=…; get an API key at
developer-api-console.wyze.com), docker compose up -d wyze-bridge, find each
camera's slug in the bridge WebUI, and reference it from go2rtc.yaml. The
WebUI has no login, so it is published on the server's loopback only: from
your computer, ssh -L 5050:127.0.0.1:5050 <server> and open
http://127.0.0.1:5050/. Remove the service from the compose file if you don't need it.
Wyze Cam v3/v4 on recent firmware (the IOTC_ER_TIMEOUT problem): Wyze's 2025 firmware (v4 4.52.9+) disabled the local TUTK P2P protocol, so the once-standard
mrlt8/wyze-bridgecan no longer connect to these cameras — streams fail withIOTC_ER_TIMEOUTeven though the Wyze app still plays them. The compose here uses the actively-maintained IDisposable fork, which auto-falls-back to Wyze's WebRTC backend (the path that still works), so these cameras stream again with no downgrade. Two gotchas: this fork slugs stream names with underscores (not the original's dashes) — copy the exact slug from the bridge WebUI; and WebRTC streams are on-demand (they connect when a viewer opens the tile). If one specific camera fails its WebRTC handshake while its neighbors work, it's usually that camera's cloud session — a full power-cycle (unplug 30s) or remove/re-add in the Wyze app clears it.
A camera that's offline shows an honest gray "offline" tile — the hub probes each stream every 30s and never fakes a LIVE badge.
Blurry full-screen? A camera full-screens the same stream its tile uses — there's no magic upscaler — so a tile-resolution stream stretched to fill the wall looks soft. Give the camera a distinct higher-res twin: add a second go2rtc stream (
porch_hd,wyze_hd) pointing at the camera's main / 4K channel and reference it as"hd"in that camera'sconfig.jsonentry. The tile stays on the light stream; full-screen cross-fades up to the twin once it's live. One ceiling to know: a Wyze v4 on recent firmware runs the bridge's WebRTC fallback, and Wyze's cloud WebRTC path is capped at SD — for those cameras the main stream is already the sharpest source, so a twin won't help. Older Wyze models and UniFi Protect cams expose a real full-res channel that does.
People are nicknames with a color — the one expressive hue on the wall.
A chore has a schedule (daily, specific weekdays, or once — a one-time
chore due on a single date, then gone from the wall) and an assignment
(fixed to one person, or a rotation: an ordered list of people, repeats
allowed, assigned deterministically — rotation_order[n mod len] over the
chore's occurrence count, so there is no stored "whose turn" state to drift).
A rotation's rotate_every is turn (the default: the next person each time
the chore comes up) or week (n counts Monday-started weeks instead, so one
person has every occurrence of the week and the next person all of the next
week; on an every-other-week schedule it counts the weeks the chore runs).
A one-time chore is always one person on that date.
Rotations skip deactivated people — their turns fall to the remaining members.
Completion is one tap; past days are read-only. History is frozen: each
served day's plan is recorded (the occurrence_log table), and past days
render from that record — so editing a schedule, reshuffling a rotation,
deactivating, or even deleting a chore changes today and the future only.
Nobody's streak is rewritten by an edit, and a deleted chore still shows on
the days it was actually done. Streaks count consecutive completed days (rest
days skip, an unfinished today is forgiven).
Away / pause: mark someone away for a stretch — open-ended, so you set it
when they leave and clear it when they're back — and those days read as rest,
so a trip never breaks their streak; on return they pick up where they left
off, whatever day it is. While away, a rotation turn falls to whoever's home,
and a fixed chore can pass to an optional backup who covers it (and gets
the credit). Away is a pure overlay on top of the frozen history — it never
rewrites a recorded day, so clearing an away period restores exactly what was
there, and you can even back-date one to repair a streak after the fact.
People and chores are managed
right on the wall's Chores page (tap Edit chores), on the touchscreen or from any
browser on the LAN. Deleting
a person is history-safe: their frozen past days stay recorded, and they're
stripped from every chore's rotation or fixed assignment; Deactivate is the
reversible alternative.
The SQLite DB (data/hub.db) is the only state that originates here —
everything else re-syncs. backup/ contains a WAL-safe nightly snapshot
script + systemd units (fail-loud, atomic, keeps 14): edit the paths in the
.service file, then install script + units and
systemctl enable --now family-hub-backup.timer.
Restore: stop the container, copy a snapshot over data/hub.db, start.
Work through docs/adding-a-feature.md as a
checklist — registry toggle, fail-soft backend, demo payload, mobile surface,
tests and guards, visual gates, docs + screenshot, review, and post-deploy
verification. Every item cites the real bug that created it; the wall ships
nothing that skipped a gate.
The hub carries a real SemVer in the root VERSION file,
and every change is recorded in CHANGELOG.md
(Keep a Changelog format) — that changelog and the
git tags / GitHub Releases are the "what's new". The running app exposes only a
small debug readout: GET /api/version returns {version, build}, and the
Settings overlay shows a quiet family-hub v1.2.3 line so you can see what's
deployed without a shell. Nothing release-notes-y shows on the family wall.
Every code-changing PR adds a line under ## [Unreleased]. This is enforced: a
CI check and a local pre-commit hook both block a src/** change that forgot
its changelog entry (docs-only and test-only diffs are exempt). Run
scripts/install-hooks.sh once to enable the local hook.
Cut a release with one command — it bumps VERSION, dates the changelog
section, stamps the static-asset cache-busts to the new version, commits, and
tags:
python scripts/release.py {major|minor|patch} # then: git push --follow-tagsPushing the vX.Y.Z tag triggers the release workflow, which publishes a
GitHub Release whose notes are that version's changelog section — so the
changelog is the single source for the release notes too. The asset ?v=
cache-busts are unified to the app version, so one release busts every asset at
once (a test guards against any drift).
The release commit is the only commit that reaches main without a pull
request. Two shared checks from
drench44/ci-shared run here:
pr-policy on every PR (the body states which review band ran, a PR that says
it fixes a regression changes a test, and a size limit), and main-watch on
every push to main, which opens an issue labeled main-watch for any commit
that did not come through a merged PR with green checks. It needs every check
on the PR head green, waits (an hourly scheduled run re-checks, up to 3 hours)
for checks still running when the PR was merged, and marks each commit it
judges with a ci-policy/main-watch status that the hosted audit
reads to notice a main-watch that never ran. Its allowlist accepts
exactly release: vX.Y.Z commits that touch only VERSION, CHANGELOG.md and
src/family_hub/web/static/index.html, which is what release.py writes.
Four layers, no external services needed (Python 3.12):
pip install -r requirements.txt -c requirements.lock pytest # pinned deps CI + the image use, + pytest
PYTHONPATH=src python3 -m pytest tests -qPure chore logic (rotation/streak math) · API (FastAPI + temp SQLite, all
upstreams mocked) · static frontend contracts (every referenced class styled,
no external resources, no hardcoded URLs) · executable JS helpers
(node --test, needs Node ≥ 20).
The JS layer runs via tests/test_js.py, which shells out to node (or a
Docker node:20-alpine fallback). If neither is present it skips — a skip
means the JS layer went unchecked, not that it passed, so install Node or Docker
to exercise it.
If Family Hub is useful to you, you can sponsor it on GitHub: https://github.com/sponsors/drench44
MIT.
The seasonal look artwork credits (photographs in the public domain or CC0;
Twemoji and Noto Animated Emoji, CC-BY 4.0; Phosphor Icons and the Bug.js
spider, MIT) are in
src/family_hub/web/static/seasons/CREDITS.md.


