Every task now has a duration (10 min default) and a child-initiated start: a play icon on the kiosk, alongside the checkbox, begins a server-anchored countdown for that specific task. Deliberately not a beep-every-minute alarm -- audio is milestone-only (start/halfway/almost-done/time's-up), since frequent interrupting alerts can backfire for ADHD kids rather than help. Countdown is drift-free (recomputed each tick from the fixed started_at anchor, never decremented), audio is synthesized client-side via Web Audio (zero external assets, matching this app's existing hand-rolled-PNG-icon philosophy), and the child's own start tap is what unlocks audio playback for the rest of the session on iOS. Two real bugs caught and fixed during design, before they shipped: - Marking a task done now clears started_at (both the kiosk and parent routes) -- without this, unchecking a finished task later would resurrect a stale timer and instantly show "time's up" on a task the child hasn't touched today. - The parent editor's poll-pause guard only covered contenteditable fields; a plain number input for duration would have been silently unprotected from being clobbered by an incoming 4s poll mid-edit. Extended the guard to cover it, verified live that the DOM and focus both survive a poll while the field is focused. Schema: calendar_tasks gains duration_minutes (NOT NULL DEFAULT 10) and started_at (nullable). Verified against a simulated copy of the real production schema/data that the static DEFAULT backfills every existing row automatically (no business-logic backfill needed, unlike is_admin), and that repeated boots stay idempotent. Duplicate-calendar carries durations forward but always resets started_at, verified end-to-end. Kiosk countdown/milestone-firing verified live in-browser end-to-end: real timer start, halfway/almost-done/done firing at the correct proportional thresholds (not fixed minutes, so it scales from ~1min to 20+min tasks), and a mid-countdown page reload resuming at the correct remaining time with already-passed milestones backfilled silently rather than replayed.
Kids Calendar
A self-hosted weekly calendar for your family. Parents log in and edit; each child gets a read-only "kiosk" link for a tablet, where they can only tick off tasks that are already there.
- Parents: sign up, invite your spouse, create a calendar per child, edit freely, print, duplicate a calendar for the next week.
- Kids: open their kiosk link on a tablet and add it to the home screen — it launches full-screen with no browser chrome, and they can see the board and check boxes — nothing else.
- Everything a child checks off shows up on the parents' devices within a few seconds, no reload needed — and, if push notifications are set up, as an actual notification too (see below).
Running it
Requires Node.js 22.5+ (for the built-in node:sqlite module — nothing else
to compile, no native build tools needed).
npm install
export SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
node server.js
Plain node doesn't read .env files — export the variables from
.env.example yourself (as above), or use a process manager (pm2, systemd)
that does it for you. Docker (below) reads .env automatically. Then open
http://localhost:3007/signup.html.
Docker (recommended for a NAS / home server)
cp .env.example .env # set SESSION_SECRET
docker compose up -d --build
Data lives in a named volume (kids-calendar-data), so it survives
container rebuilds. To back it up, back up that volume (or bind-mount
./data:/app/data in docker-compose.yml instead and back up that folder).
Running on Unraid specifically? See UNRAID.md — it uses
docker-compose.unraid.yml, which bind-mounts to
/mnt/user/appdata/kids-calendar instead of a named volume, matching
Unraid's usual appdata convention.
Environment variables
| Variable | Default | Notes |
|---|---|---|
SESSION_SECRET |
(insecure dev default) | Required. A long random string — see .env.example for how to generate one. |
PORT |
3007 |
|
DATA_DIR |
./data |
Where the SQLite file lives. |
COOKIE_SECURE |
false |
Set true once this is behind HTTPS (e.g. a reverse proxy with a real certificate), so cookies are marked secure. Leave false for plain-HTTP LAN access, or login cookies won't be sent. |
DISABLE_PUBLIC_SIGNUP |
false |
Set true once your household(s) exist, to stop the /signup.html page from creating new ones. Existing invite links still work. |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT |
unset | Optional — enables push notifications. See "Push notifications" below. Leave all three unset to skip the feature entirely; the app degrades gracefully (no broken UI). |
Push notifications
When a kid checks off a task on the kiosk, parents can get a real push notification instead of only the silent polling-based update. This is optional — the app works fully without it.
Requires real HTTPS. Not "it's nice to have," a hard platform
requirement: Web Push needs a secure context, and iOS Safari specifically
requires a browser-trusted certificate (a self-signed one with a
click-through warning does not count). http://localhost is a
spec-defined exception for local development, but LAN access via
http://<ip>:3007 will never get push working on iOS. See "Exposing this
beyond your home network" below for your HTTPS options — you need one of
those in place first.
Setup, once HTTPS is sorted:
- Generate a VAPID keypair:
npx web-push generate-vapid-keys(ordocker run --rm node:22-alpine npx web-push generate-vapid-keysif you don't have Node locally). - Set
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY, andVAPID_SUBJECT(mailto:you@example.comor anhttps:URL) in.env, then restart. - On the dashboard, each parent clicks "Enable notifications on this device" — this is per-device, so do it on every phone/computer that should get notified.
On iPhone/iPad specifically: Safari only allows push notifications for web apps added to the Home Screen — a regular Safari tab can't subscribe at all. Tap Share → Add to Home Screen on the dashboard first, then open the app icon from your Home Screen and enable notifications from there. The dashboard's notification card explains this in place if it detects it's needed.
Regenerating the VAPID keypair invalidates every existing subscription (everyone would need to re-enable notifications) — treat it as a one-time setup step, not something to rotate casually.
How access works
- Parents have real accounts (email + password) tied to a household. The first person to sign up creates the household; from the dashboard they can generate an invite link (valid 7 days, one-time use) for a spouse to join the same household with their own login.
- Kids never get an account. Each child has an unguessable link
(
/k/<token>) that shows only their active calendar (set from the dashboard) and lets them toggle checkboxes — the server has no route at all for a kiosk link to edit text, add/delete tasks, or see other children's data. If a tablet is lost, regenerate that child's link from the dashboard to invalidate the old one.
Exposing this beyond your home network
This is built for LAN use by default (plain HTTP, cookies not marked secure). If you want access from outside your home:
- Put it behind a reverse proxy (e.g. Caddy, nginx, Traefik) that terminates
HTTPS, then set
COOKIE_SECURE=true. - Set
SESSION_SECRETto a real random value (never the dev default). - Consider setting
DISABLE_PUBLIC_SIGNUP=trueonce your family's household(s) are created.
This same HTTPS setup is also the prerequisite for push notifications (see above) — a real, browser-trusted certificate, not a self-signed one. Options that satisfy this: a domain + Let's Encrypt via the reverse proxy itself, a locally-trusted CA like mkcert with its root profile installed on your devices, or a tunnel (Tailscale Funnel, Cloudflare Tunnel) that terminates real HTTPS for you.