schema habit - fahadahmed/jamaal-app GitHub Wiki
Habit
Status: reconciled with master summary v2, then updated to density-not-streaks, four habit kinds and pauses โ draft, needs review. The v1-resolved window model is kept; v2's counted habits, groups, schedules, reminders and completion history are folded in; streaks are replaced by density. See ADR 0002.
Something the user cultivates. Created by the user, tracked by density (a grid of days shaded by how much was done) โ not streaks. Islamic practice habits (Qur'an reading, dhikr) and general habits (exercise, running) are just different presets of this same engine โ see CLAUDE.md.
Salah is not modeled here โ it's an Anchor instead, since prayer timing is externally fixed rather than self-paced. (The v2 design modelled salah as five habits in a "Salah" group; that no longer applies.)
Fields
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
title |
String |
"" |
|
notes |
String? |
nil |
|
presetKey |
String? |
nil |
Built-in preset (e.g. "quran", "dhikr") vs. nil for custom. |
kind |
String |
"binary" |
One of binary / counted / timed / avoid. Fixes what target and amount mean โ see Habit kinds. Fixed at creation: history is interpreted by kind (amount is a count, minutes or slips), so to change it the user archives the habit and creates a new one. Every other field stays editable (a changed target affects the future only). |
frequency |
String |
"daily" |
One of daily / weekdays / custom. |
scheduledDays |
String |
"1,2,3,4,5,6,7" |
ISO weekdays (Mon=1 โฆ Sun=7) the habit is due. weekdays = "1,2,3,4,5". Used when targetPerWeek == 0. |
targetPerWeek |
Int |
0 |
0 = fixed days from scheduledDays; 1โฆ7 = "N times a week, any days" (custom recurrence's times/week stepper). |
pausesData |
String |
"[]" |
JSON list of pauses (travel, illness, โฆ) โ see Pauses. |
isArchived |
Bool |
false |
Soft-delete, so history isn't lost. Editing or archiving never deletes history. Archived habits sit in a collapsed Archived section at the end of the Habits list, with Restore. |
createdAt |
Date |
.now |
Relationships
windows: [HabitTimeWindow]?(optional, CloudKit). Every habit has at least one window. Completion history lives on each window's entries, so density is always per-window.group: HabitGroup?(optional).nil= ungrouped.
HabitTimeWindow
A window is one occurrence slot in the day that keeps its own completion history. Two ways to get "multiple per day":
- Distinct slots โ medication morning and evening = two windows, two independent density grids.
- Counted โ "drink water 8ร" = one window with
target = 8; the user increments a stepper and the window is complete when the count reaches the target. (Eight windows for eight glasses would give eight meaningless grids.)
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
label |
String |
"" |
e.g. "Morning"; empty for a single-window habit. |
startMinute |
Int |
0 |
Minutes since midnight. See open question. |
endMinute |
Int |
1439 |
1439 = no real window boundary. |
target |
Int |
1 |
Meaning depends on Habit.kind: binary = 1; counted = N times; timed = N minutes; avoid = the allowance (most slips still fine), 0 = none. |
effortMinutes |
Int? |
nil |
How long one occurrence takes (e.g. 15 for a Qur'an reading window). Counts as flexible time: it reduces the day's total free time but doesn't cut it into blocks (see rules-engine.md, module 7). For a counted window it is the time for the whole target, not per increment. Presets ship a sensible default; nil = unknown, counts as zero. |
reminderMinute |
Int? |
nil |
Minutes since midnight; nil = no reminder. UI default when the toggle is switched on: 20:00. Reminders were per-habit in v2; per-window here because windows are the unit of completion history and timing. |
Relationship: entries: [HabitEntry]? (optional).
HabitEntry
The per-day completion log the earlier draft was missing. Needed for the heatmap, the partial-completion display in Night Planning review ("2/3 water โ shown honestly"), fatigue detection and wellbeing patterns.
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
date |
Date |
.now |
A floating calendar date: the logical date of the log (so a late-night log before the user's rollover counts for the day they're still living) โ see The day boundary. |
target |
Int |
1 |
Snapshot of the window's target that day (so editing the target later doesn't rewrite history). |
amount |
Int |
0 |
By kind: binary 0 or 1; counted the count so far (stepper); timed the minutes so far; avoid the slips so far. |
completedAt |
Date? |
nil |
When the target was reached. |
window |
HabitTimeWindow? |
nil |
Inverse of entries. |
Derived, not stored: for binary, counted and timed, isComplete (amount >= target), completionRatio, isPartial, remainingCount; for avoid the day's outcome follows the avoid rules instead.
HabitGroup
Groups are purely visual organisers โ no group-level score or history; each habit keeps its own density grid. Example: a "Morning routine" group. (Groups hold Habits only, never Anchors.)
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
title |
String |
"" |
|
sortOrder |
Int |
0 |
|
isArchived |
Bool |
false |
Archiving a group never deletes its habits (deleteRule: .nullify). |
createdAt |
Date |
.now |
Relationship: habits: [Habit]? (optional).
Collapsed card: completion ring (% complete today), name (no emoji anywhere, per Design), "4/5 today" badge, 14-day aggregate heatmap. Expanded card: habit rows with check button and completion time. Whether a group is expanded is local UI state (per device, not synced) and defaults to collapsed. The Today strip shows a group pill with proportional ring and count.
Times of day
A habit has one all-day window by default. The add-habit form has a Times of day section: Add another time (up to four windows) adds a labelled window with an optional start, end and reminder. Each window has its own target, its own entries and its own density grid (the detail screen shows one grid per time), so a medication taken morning and evening is one habit with two windows.
Weekly-target habits
A habit with targetPerWeek > 0 ("three times a week, any days") works differently from a fixed-day one:
- Today shows it every day of the week until the week's target is met, then as done for the rest of the week.
- Its days are never
missed: the grid has only filled and empty days (no red), because the week is what matters and nothing breaks if a particular day is skipped. - A day counts toward the week when that day's own target is met (a timed habit's minutes reach the target, a counted habit's count does, and so on).
- The plain-language read speaks in weeks ("2 of 3 this week").
- The week starts on the user's calendar's first weekday.
Correcting past days
The last 14 days of a habit's grid are tappable: binary toggles, counted uses a stepper, timed adds minutes (a manual session), avoid sets slips or Held. Older days are read-only. A correction to an avoid habit is an explicit engagement for that day.
Habit kinds
Created from a type picker with plain-language descriptions. Habit.kind fixes the meaning of target and HabitEntry.amount:
| Kind | The question | target |
amount |
On Today |
|---|---|---|---|---|
binary |
Did I do it? | 1 | 0 or 1 | tap the check |
counted |
Did I do it N times? | N | times so far | stepper |
timed |
Did I do it for N minutes? | N minutes | minutes so far | Begin (focus chip), or add minutes by hand |
avoid |
Did I avoid it? | allowance (0 = none) | slips so far | Log a slip |
"Grouped set" from the design stays HabitGroup (visual), and "time-windowed" is covered by habit windows and, for things timed by the world, Anchors.
Timed habits
- Reuse the focus engine (task.md): a
WorkSessioncan point at a habit window. The day'sHabitEntry.amountis recomputed from that window's sessions for the day โround(total seconds รท 60)over all of them, never per session โ so nothing drifts and two devices converge by summing rows. Minutes added by hand are stored as a finished session withoutcome = manual(no start or end time of its own), so everything that feeds the entry is a session and can be recomputed at any time. Stopping a session keeps the partial time. - The day is complete when minutes reach the target; density shows partial shades as it accumulates, exactly like a counted habit. Time beyond the target doesn't over-fill.
- A running habit session shows in the same chip. If another Begin is tapped, the settle sheet offers Log it and Stop for now (there is no defer or drop for a habit).
- For a timed window,
effortMinutesdefaults to the target minutes, so it counts as flexible time in the day's free-time calculation.
Avoid habits
Proposal โ the design has only a title for this (H-06, "inverted logging"), so these semantics need a design pass before they are treated as settled.
- Inverted logging. The default expectation is abstaining; the user logs a slip (each tap adds one to
amount, undoable). Wording stays neutral โ no praise, no guilt. - A day's outcome is derived when read, not stored as a default success:
missedif slips exceed the allowance;completeif slips are within the allowance and the user engaged that day. A logical day is engaged if any of these exist for it: aTaskItemcompleted that day; aHabitEntrywithamount > 0for any habit, or an avoid entry whosecompletedAtis set (Held today, or a past-day correction); anAnchorwhoseresolvedAtfalls on that day; aWorkSessionwith thatday; or aNightPlanningSessionfor that date that was closed or skipped. Opening the app alone doesn't count.- otherwise
empty. Silence is never success: a day when the app was never touched fills no cell. - The current day stays unresolved until it ends.
- No "days since the last slip" counter โ that would be a streak in disguise. The plain-language read speaks in numbers ("3 slips in the last 21 days").
- An avoid habit has a single all-day window; reminders work as for any habit.
Pauses
Habit.pausesData is a JSON list of date ranges when the habit is paused, with a reason: [{ "from": "2026-10-12", "to": "2026-10-19", "reason": "travel" }]. to may be null (open-ended, until the user resumes). reason is travel, illness, cycle or other, for display only.
- Paused days are unscheduled: no cell fills, nothing counts against the habit, they are left out of the density read's denominators, reminders don't fire, and the habit is hidden from Today and from Night Planning's habit list.
- The habit's detail shows "Paused โ resumes 19 Oct" and lets the user end or edit the pause.
- Editing or ending a pause affects only the future; past days keep their entries.
Density, not streaks
Habits are tracked by density, not streaks. There is no streak counter, no "best", and nothing that resets or "breaks" โ a missed day is one unfilled cell in a grid, not the loss of everything before it. (Decided when reviewing the design project; supersedes the earlier per-window currentStreak / longestStreak model.)
- Completion โ completing a window's target on a due day fills that day's cell fully. A partial counted day (2 of 3 glasses) is recorded and shown honestly as a partial shade and "2/3" โ it is neither a break nor a full completion.
- The read โ the grid is accompanied by a plain-language sentence generated from the numbers ("Missed 9 of the last 21. Five a week may be more than this season allows โ try three?"), phrased in the companion's voice and never as praise or blame.
- Unscheduled days neither fill nor count against the habit.
- History is data, not a score โ everything density and the read need comes from
HabitEntry, so nothing extra is stored; a streak could still be derived from entries later without a schema change, but none is shown.
Density states
Each due day is one cell in the habit's grid, shaded by how much was done. Binary and avoid habits use empty, missed, complete; counted and timed habits add two partial steps: partialLow (1โ49%) and partialHigh (50โ99%). Days the habit isn't scheduled stay empty (unfilled), never missed. A gradient is only justified for counted and timed habits.
Colours come from the design's density tokens โ three fill steps plus a miss colour (d1, d2, d3, missed) โ which ThreadsKit doesn't have yet; see threadskit-usage. Grid rules from the design: cells never hold a numeral (a label goes beside the grid, not in it), the grid fills from the trailing edge so right-to-left layouts reverse correctly, and cell size is 18 pt with a 3 pt gap and 3 pt radius.
Presets
presetKey identifies a built-in. The catalogue is bundled app data, not user data; every preset uses one all-day window, and everything is editable after creation.
Preset (presetKey) |
kind |
target |
Schedule | effortMinutes |
|---|---|---|---|---|
Qur'an reading (quran) |
timed |
15 min | daily | 15 |
Dhikr (dhikr) |
counted |
33 | daily | โ |
Exercise (exercise) |
timed |
30 min | 3 times a week | 30 |
Running (running) |
timed |
30 min | 3 times a week | 30 |
| Something else | binary |
1 | daily | โ |
The Qur'an and dhikr values are starting suggestions for the owner to correct. In onboarding, "Something else" is a binary daily habit with just a title; the other kinds come from the Habits tab.
CloudKit constraints applied
- All properties have defaults or are optional.
- No unique constraints.
- All relationships are optional.
Open questions
HabitTimeWindowtime representation:startMinute/endMinuteis a placeholder โ simple and CloudKit-safe, but doesn't handle timezone travel gracefully. Anchor uses concreteDates for its windows; keep the two consistent in the rules-engine implementation.- Weekly-target wording: the copy for the weekly read ("2 of 3 this week") and for a week that ended short is a design task for the custom-recurrence and habit-detail screens.
- Plain-language read: the engine emits a typed
.densityReadsignal (completed / due over a rolling window, plus a suggestion when the rate is low); the message layer phrases it. Templates and thresholds (e.g. when to suggest a lighter cadence) still need writing. - Avoid habits need a design pass (H-06): the Held today affordance, how a slip is logged and undone, what counts as engagement, and the allowance UI.
- Detected habits (the design's "second door": after three evenly spaced completions of a matching task title inside 21 days the app offers once to promote it, inheriting those completions as opening density) are v1.1. It needs no schema change โ a
NudgeLogkind and backdatedHabitEntryrows โ and in v1 habits are created by declaring them. - Timed habits: how manual minutes are labelled in history (they are
manualsessions). - Derived habit intelligence (fatigue: >50% missed over 3 weeks; new-habit realism: 4+ new habits in a week) reads
HabitEntryhistory at evaluation time; nothing extra is stored here. Whether a fatigue warning was already shown lives in the nudge log (see rules-engine.md).