CODEX // oaoisme wiki

§ Ember — habit & ritual tracker

The app — stack & data model

updated 2026-06-02

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

  • habitstitle, 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_stepsritual_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 a PUT {steps:[...]} silently wiped the title. Fix: normalise an absent field to undefinednull, so COALESCE actually keeps the old value. Lesson: with COALESCE-style partial updates, "absent" must be NULL, never empty-string.

Subpages