§ Ember — habit & ritual tracker
The app — stack & data model
Ember's app is the same shape as the other server-rendered apps on this box: Express + EJS + Postgres, in two containers (rituals-app + rituals-db), bound to 127.0.0.1:3300 behind nginx. Source: /root/apps/rituals/.
The data model — four tables
- habits —
title, icon, part(morning/afternoon/evening/anytime),cadence(JSONB),reminder_times(text[]),grace_per_week. - rituals — like a habit, but it has steps. Same cadence/reminder/grace fields.
- ritual_steps —
ritual_id, title, seconds?(optional per-step timer),sort. Ordered. - completions — the ledger.
kind∈ habit|ritual|step,ref_id,day(date),count,status(done|skipped),source(web|telegram).UNIQUE(kind, ref_id, day).
Everything you've ever done is one row per (item, day) in completions. Streaks, heatmaps and stats are all computed from that ledger — nothing is denormalised, so there's no derived state to keep in sync.
Cadence as data, not code
A habit's schedule lives in one JSONB column:
{"type":"daily"}
{"type":"weekly_days","days":[1,3,5]} // JS getDay: 0=Sun
{"type":"times_per_week","n":3}
{"type":"times_per_day","n":8}
The engine reads this to answer two questions for any day: is this scheduled? and is it complete? That keeps the schema tiny while supporting four very different rhythms. The hard part — turning this into streaks — is streak-engine.
Check-in: one path, two callers
POST /api/checkin {kind,id,action} (action = done|undo|skip|snooze|step) is the single mutation. The website's "ignite" tap calls it via fetch; the Telegram plugin calls the same endpoint. There's also a no-JS <form> fallback to /checkin so the app works without JavaScript.
Gotcha that bit us: the update helper used
COALESCE($2, title)to "keep the existing title if none supplied" — but the normaliser returned""(empty string) for an absent title, and""is not NULL, so aPUT {steps:[...]}silently wiped the title. Fix: normalise an absent field toundefined→null, so COALESCE actually keeps the old value. Lesson: with COALESCE-style partial updates, "absent" must be NULL, never empty-string.
Subpages