Repository navigation
architecture rules engine
Status: reconciled with master summary v2 — draft, needs review. The six modules confirmed in issue #7 are kept (numbering is stable, other docs refer to it) a seventh, Capacity & load, and an eighth, Focus sessions, are added. v2's hidden Eisenhower, deferral escalation, load states, pattern detection and during-day guidance are folded in. See ADR 0002.
Deterministic — same state + inputs always produce the same output, no ML/heuristics (per CLAUDE.md). Lives in JamaalCore/Sources/JamaalCore/RulesEngine/, no UI imports.
Every module conforms to one protocol:
protocol RuleModule {
func evaluate(_ context: DayContext) -> [RuleSignal]
}- Modules output typed signals (
RuleSignal), never strings. - A separate message-template layer turns signals into language in Jamaal's companion voice — calm, supportive, non-judgmental (see app-flow.md). The engine stays testable and language-free; a language model could later slot in at the message layer without touching the engine, but that is a future option, not a plan.
-
Capacity stays an enum —
low/medium/high— as the user-facing setting. It is the user's own call about how much they have in them; it is not computed. Each level maps to a minute budget for focused tasks: the user sets what a normal day is (onboarding and Settings, default 180 minutes — v2's baseline; it is themediumlevel),lowis ⅔ of that (120) andhighis 4⁄3 (240), each rounded to the nearest 5 minutes for display and calculation alike (a 205-minute normal day gives low 135 and high 275). The app never changes that number by itself: it suggests a new one from the last four weeks of actual focus time, and the user accepts or ignores it. Weekday default levels (weekendslow) are stored inUserSettings. - Two measures, kept apart. The energy budget counts tasks only — the design defines a normal day as focused work outside meetings and life admin. Free time is the working day (from a start time to a user-set end time) minus fixed Anchors, which cut it into blocks, and the minutes of flexible Anchors and habits, which just subtract. A plan has to fit both: task minutes within the budget, and each task within a free block. Anchors and habits therefore no longer eat the energy budget; they shrink the time.
-
The day ends at a user-set time, not midnight (proposal: 19:00). It is a soft planning boundary: overflow is named, never blocked, and one tap moves it to tomorrow. The day rollover — by default midnight, user-set — stays the hard boundary (auto-deferral, session auto-close,
DayPlanclose); see The day boundary. -
Deferral replaces rollover. A task moved to a later day after its due day has arrived — by the user or automatically — is a deferral with a count and a record (see task.md); moving a task that is due later is rescheduling and counts for nothing. 3rd deferral = stale + date picker (and a medium/high task is eased to
low); 5th = suggest removal. -
Importance: three levels (
lowdefault,medium,high), set mainly in Night Planning; medium/high require a due date and can't be Someday;lowtasks always sort after medium/high. - Night Planning is five steps — Review today → Carry forward → Build tomorrow → Check the load → Close the day — and its session is persisted (CloudKit sync means a session can resume on another device). Build tomorrow opens on tomorrow's fixed commitments with named free gaps; tasks are not placed into gaps. Skip tonight closes the flow without a plan.
- There is no self-reporting — no mood line, no Reflect step. Wellbeing is derived only from how well tasks and habits are being completed and Anchors attended.
- Night Planning triggers via a fixed evening notification (user-set time, default 20:00) in addition to being openable any time. Module 6 is therefore a hard dependency of module 4.
- Focus sessions (the task timer) follow the design's locked decisions: an ambient chip on every tab (Live Activity deferred to v1.1), count-up overrun with no alarm or nudge, one timer at a time settled with Done / Defer / Drop, abandon logs partial time, auto-close at midnight (the day rollover). See task.md and module 8.
- Nudge limits: at most one wellbeing nudge and one during-day guidance card per day.
Not primitives — engine state, documented here rather than in docs/schema/.
The per-day record. Replaces the earlier DailyCapacity, widened because wellbeing pattern detection (runs of heavy days, completion collapse) needs a history of how each day actually went, and that can't be reconstructed from tasks once their due dates have been deferred.
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
date |
Date |
.now |
A floating calendar date (12:00 UTC of the logical date), see The day boundary. One row per date: created by Night Planning, by moving the slider, or by the rollover for any day with activity. |
capacity |
String |
"medium" |
low / medium / high. Written by Night Planning step 4 for tomorrow; editable on Today. |
plannedTaskMinutes |
Int |
0 |
Minutes of tasks planned for the day, snapshotted when the day is closed (step 5) as the plan, then overwritten at rollover from the day as lived. Counts against the energy budget. |
freeMinutes |
Int |
0 |
Free time in the working day when the day is closed (plan, then as lived at rollover), after fixed and flexible commitments. |
committedMinutes |
Int |
0 |
Minutes of fixed Anchors, flexible Anchors and habit windows inside the working day — what can't be deferred. |
completedEffortMinutes |
Int |
0 |
Updated as tasks complete: actual focus time from sessions where there is one, else the estimate. |
loadScore |
Int |
0 |
plannedTaskMinutes / budget × 100, snapshotted when the day is closed, then recomputed at rollover from the day as lived. |
wasOverloaded |
Bool |
false |
Load state was overloaded or worse — set from the day as lived at rollover (a skipped night still gets a true value). |
completionRate |
Double |
0 |
0–1, finalised at rollover: tasks completed that day ÷ (completed + deferred or dropped that day). |
completionBasis |
Int |
0 |
The denominator of completionRate (completed + deferred + dropped that day). 0 means nothing to finish, which is not the same as nothing finished; wellbeing leaves such days out of its tasks part. |
planningCompletedAt |
Date? |
nil |
When Night Planning confirmed this day's plan. |
Persists wizard progress so closing the app mid-flow resumes correctly.
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
forDate |
Date |
.now |
The day being planned for (tomorrow at session start). |
currentStep |
String |
"review" |
One of review / carry / build / load / close. |
isComplete |
Bool |
false |
true once the day is closed (step 5). |
isShortened |
Bool |
false |
true for the morning's shortened flow (build → load → close for today), false for the full evening flow. |
skippedAt |
Date? |
nil |
Set by Skip tonight; the session ends without a plan and no DayPlan is written. |
createdAt |
Date |
.now |
|
completedAt |
Date? |
nil |
Carry-forward choices are not stored on the session: Keep/Later/Drop apply to the task immediately (deferralCount, DeferralRecord, droppedAt) and are undoable until the day is closed. On Close the day the session upserts the DayPlan for forDate.
Lets a deterministic engine enforce "one nudge a day" and "don't repeat a warning" without hidden state.
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
kind |
String |
"" |
wellbeing / guidance / fatigue / windowClosing / overload / morningPlanCard (the once-only "No plan for today" card; subjectKey is the date) / habitPromotion (v1.1 — subjectKey is the declined title) / normalDaySuggestion (subjectKey is the suggested minutes; logged when shown, and again if dismissed). |
subjectKey |
String? |
nil |
The habit/task the nudge was about (UUID string), if any. |
sentAt |
Date |
.now |
|
dismissedAt |
Date? |
nil |
The few settings that change computed results, so they must be identical on every device. A single row, synced via CloudKit. (Notification and appearance preferences stay per-device below.)
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
mediumDayMinutes |
Int |
180 |
The user's normal day of focused work; the medium budget. low = ⅔, high = 4⁄3 (rounded to 5 minutes). Set in onboarding, editable in Settings. |
dayStartMinute |
Int |
480 |
Minutes since midnight when the working day starts (08:00). Free time for tomorrow's plan starts here; for today it starts at the later of now and this. |
dayEndMinute |
Int |
1140 |
Minutes since midnight when the working day ends (19:00; users pick, typically 19:00–20:00). The soft planning boundary. |
rolloverMinute |
Int |
0 |
Minutes since midnight when the app's day rolls over (00:00 by default; the UI offers 00:00–06:00, for people who are up late or work nights). Must be earlier than dayStartMinute. See The day boundary. |
weekdayLevels |
String |
"{\"6\":\"low\",\"7\":\"low\"}" |
JSON map of ISO weekday (Mon=1 … Sun=7) → default capacity level (low / medium / high); a weekday not listed is medium. Weekends default to low. The level a day uses when no DayPlan was confirmed (for example after Skip tonight), and the one Night Planning pre-selects. Editable in Settings. |
planningMinute |
Int |
1200 |
Minutes since midnight for the evening Night Planning prompt (20:00). Synced, so every device agrees; whether a given device sends it is a separate local switch (see Preferences). |
morningMinute |
Int |
480 |
Minutes since midnight for the optional morning list nudge (08:00). Synced. |
onboardingCompletedAt |
Date? |
nil |
Set when onboarding finishes. Synced, so a second device on the same account knows the account is already set up and skips onboarding ("Welcome back"). |
firstLaunchAt |
Date? |
nil |
When the app was first launched; the synced fallback for the trial start date if StoreKit's original-download date is unavailable. Set once, by the first device. |
createdAt |
Date |
.now |
CloudKit has no unique constraints, so two devices can each seed a row. The engine keeps the earliest-created row and deletes the rest.
Validation (the settings layer enforces it, not the engine): rolloverMinute < dayStartMinute < dayEndMinute, with at least two hours between dayStartMinute and dayEndMinute. The UI offers a rollover of 00:00–06:00 only. The rollover can be changed only when the current time of day is after both the old and the new value (so both give the same logical date right now); otherwise the control is disabled ("Available after 03:00") and the new value applies from the next boundary. A quiet note appears in Settings when planningMinute is earlier than dayEndMinute; it never blocks.
Morning nudge on/off, during-day guidance on/off, wellbeing nudges on/off, appearance, larger text, auto-reorder — kept in UserDefaults/@AppStorage, per device. The planning and morning times are not here: they are synced in UserSettings so every device agrees. What is local is a "Send reminders on this device" switch: notifications are local to each device, so without it an iPhone, iPad and Mac would all fire the same prompt. It defaults on for iPhone and off for iPad and Mac, and the user can change it. NSUbiquitousKeyValueStore remains the upgrade path if more preferences ever need to sync.
The app's day rolls over at UserSettings.rolloverMinute (default 00:00). Before that time the app still treats it as the previous day, everywhere.
Logical date. logicalDate(t) = calendar date of (t − rolloverMinute), in the device's current time zone. "Today" always means the logical date: Today's list, which day a habit log or an Anchor counts for, which day a session belongs to. With the default it is the ordinary calendar day.
Calendar dates are floating. Every day-granularity field — TaskItem.dueDate, HabitEntry.date, Anchor.occurrenceDate, DayPlan.date, NightPlanningSession.forDate, and the dates inside rule startDate / endDate / exceptions and habit pausesData — is a calendar date, not an instant. Store it as a Date at 12:00 UTC of that date, so every device shows the same day whatever its time zone. Times of day (Anchor windows, session start and end) are real instants; Anchor start times are local wall-clock. After travelling across time zones the logical date is simply recomputed from now, so a day can briefly repeat or skip an hour, which is accepted.
Rollover is lazy and idempotent. The engine can't rely on running at the boundary — the app may be closed or the device asleep. On launch, foreground, background refresh and when synced data arrives, it processes every logical day that has ended since it last ran (tracked locally per device), oldest first. For each ended day D:
-
Auto-defer each live, dated task due on or before D that wasn't completed, dropped or already deferred that day — a deferral with reason
unspecified(see task.md). -
Create and finalise
DayPlan(D): create it if missing, for any ended day with activity (a completed task, a session, a decided Anchor, a habit entry or a Night Planning session; days with none get no row), then set completion rate and completed minutes. -
Close live focus sessions at the boundary instant (
autoClosed,endedAt= the rollover time, not "now"), so a device that slept through midnight never invents phantom hours. - End an unfinished Night Planning session for D as skipped, so the morning card can offer the plan (already-applied carry-forward choices stay).
Habit entries, Anchor attendance and avoid-habit days need no rollover writes: they are attributed by logical date when logged or derived when read.
As built (Rollover in JamaalCore; decisions the steps above leave open):
- First run: with no processed day on record the catch-up starts from yesterday (nothing to process), so a second device never replays history.
- The day as lived is read before the auto-deferral re-dates anything, and afterwards from deferral records (a task counts for D if it was completed on D, or live on D — due on or before it, or deferred away from it — and not completed or dropped before the day ended), so two devices racing the same day write the same numbers.
-
completedEffortMinutesis the effort of tasks completed that day (actual focus time where the task has sessions, else its estimate); a session on a task that wasn't finished that day is not in it. -
Auto-deferral sets
deferredOnto the boundary instant, moves the task to the next day, and applies the same easing as a manual deferral (the third eases medium and high to low). -
Activity earning a day a record: a task completed, a timer session, an Anchor decided that day, a habit logged (
amount > 0), or the evening review of the day (a planning session for the next day, closed or skipped). A newDayPlantakes the weekday's default level. -
Planning sessions end as skipped when the day they plan for has ended (
forDate ≤ D); one for the next day stays open, because planning at 00:30 still targets the new day. - The rollover also writes
completionBasis(the denominator), which tells a day with nothing to finish from a day where nothing was finished.
Idempotent keys make two devices converge. Each step is keyed so repeating or racing it changes nothing: auto-deferral by (task, logicalDate), DayPlan by date, session closing by the session itself.
At most one deferral per task per logical day. Whichever path gets there first — the automatic one at rollover, or the user's Keep / Later / Defer — writes the deferral; a later user choice that same day refines the existing record (its reason and deferredTo) instead of adding a second. So running Night Planning at 00:30 after a midnight rollover never double-counts.
Night Planning's target day. The plan is for the first date whose working-day start (dayStartMinute) is still in the future; the day it reviews is that date minus one. At 23:00 that is tomorrow. At 00:30 with a midnight rollover it is today's new date — the morning the user is about to wake into — and the review is yesterday. With a 03:00 rollover, at 00:30 it is tomorrow, because the logical day hasn't ended yet.
Attribution by logical date. An Anchor's occurrenceDate is logicalDate(windowStart); a habit entry's date is the logical date of the log; a session's day is logicalDate(startedAt). A night owl with a 03:00 rollover logging a habit at 01:00 counts it for the day they are still living.
- Input: today's date, all live tasks (not completed, not dropped), their deferral history.
-
Behavior:
- Derives urgency, importance and the hidden Eisenhower quadrant per task (rules in task.md). The quadrant is never shown to the user.
- Orders tasks for Today by quadrant (
doFirst, thenschedule,fitIn,letGo), thendueDate, thencreatedAt— solowtasks, which are never important, naturally follow all medium/high tasks. Manual drag ordering is an open question. - Enforces the importance rules from task.md: medium/high need a due date and never Someday; a medium/high task's 3rd deferral eases it to
low. - Runs the automatic deferral at day rollover for dated tasks the user never handled (lazily and idempotently — see The day boundary); at most one deferral per task per logical day.
- Creates the next instance of a repeating task when the live one is completed or dropped (rules in task.md); dedups by
(seriesID, dueDate). - Escalation:
deferralCount >= 3→ stale and urgency raised;>= 5→ suggest removal.
-
Signals:
.taskOrder,.stale(task),.suggestRemoval(task),.importanceEased(task)(medium/high → low on 3rd deferral),.nextInstanceDue(series),.pickPriorities(5+ tasks in a plan and fewer than twomedium/high→ "pick one or two that matter most"),.multipleDoFirst(two or moredoFirsttasks → companion asks "which matters most?").
As built, first slice (TaskPriority, TaskRules, QuickDates, TaskDeferral, TodayTasks in JamaalCore): urgency is due tomorrow or earlier, or stale; the quadrant, the order (quadrant, then due date with undated last, then the older task, then a stable id) and the prompts (shouldPickPriorities, hasMultipleDoFirst) follow the spec, and an importance this app doesn't recognise counts as low. TaskRules enforces the importance rules: raising to medium or high fills in the day being planned if the task has no date, an important or repeating task can't be parked as Someday, and a broken invariant is normalised by dating the task today. QuickDates follow the calendar's first weekday. Deferral is a function of the day being deferred from (today, except in Night Planning after midnight, where the day under review is still yesterday): if a record already exists for that day it is refined (reason and deferredTo; no second count); otherwise a task whose due day has arrived is deferred (record, count, the third eases medium and high to low, then the picker rules: the next after two is the third); otherwise it is rescheduled (no count, no record, no easing). A real deferral must go to a later day, and nothing changes if a rule would be broken. The rollover's automatic deferral now goes through the same code. Today's task list shows live tasks due today or overdue, ordered, with capacity filtering (low: only urgent-and-important; medium: all but letGo; high: all) and the hidden ones counted under Also today; undated low tasks are the backlog; completed-today tasks stay listed. Repeating tasks and completion (TaskRepeat, TaskActions): the next instance is the next occurrence strictly after max(due date, completion day) — daily is the next day, weekly the next chosen weekday (none chosen: the due date's), monthly the stored day of the month (repeatDayOfMonth, filled in at the first completion) clamped to short months without drifting. Completing or dropping a repeating task creates the next instance (a drop skips only that occurrence), with title, notes, category, effort, importance and the repeat fields copied and the deferral state reset; a seriesID is assigned if there was none; (seriesID, dueDate) dedups, so two devices don't duplicate it. Stop repeating ends the series at the live instance. Un-completing a repeating task removes the next instance if it is untouched (not completed, deferred, dropped, timed, or different from the template) and is otherwise refused with "This one already repeated", changing nothing. Still to come: closing a running focus session when its task is resolved (G-25), with the focus-session slice.
-
Input:
HabitEntryhistory,HabitTimeWindowtargets and schedule, the habit'skindandpausesData, focus sessions on habit windows, current date. -
Behavior: derives each window's density — the per-day completion grid and rolling completion rate — per the rules in habit.md. There are no streaks: nothing resets or breaks, and a partial counted or timed day is recorded as a partial, not a miss. Behaviour by kind (rules in habit.md):
-
Timed: a habit session's
actualSecondsbecomes minutes on that day's entry; the window completes when minutes reach the target. -
Avoid: a day is
missedwhen slips exceed the allowance,completeonly when slips are within it and the user engaged that day (any recorded activity or an explicit Held today tap), otherwiseempty— silence is never success. Derived when read, not stored as a default success. - Pauses: paused days are unscheduled — no cell, not counted, out of denominators, no reminders, hidden from Today and Night Planning. From density it produces the plain-language read shown beside the grid, and detects:
-
Fatigue — more than 50% of due days missed over three weeks (counted habits use
completionRatio, not binary) → suggest changing frequency. - Window closing — a due window still open and unfinished as it nears its end (a light reminder, never a warning about losing something).
- New-habit realism — four or more habits created in one week → suggest starting with one or two.
-
Timed: a habit session's
-
Signals:
.densityRead(habit),.windowClosing(window),.habitFatigue,.newHabitOverload.
As built (HabitRules, HabitPauses, Engagement in JamaalCore): a window's cell for a day is empty when the day is in the future, before the habit existed, paused, or not one of its days; otherwise complete at the entry's own (snapshotted) target, partialLow under half, partialHigh from half, and missed only for a due past day with nothing logged — never for a "N times a week" habit, and never for today. An avoid habit's cell follows the avoid rules in habit.md, with today always unresolved. The read counts resolved days (complete, partial, missed) over the last 21 days across the habit's windows, leaving out paused, unscheduled and still-open days (today counts once finished); it suggests a lighter cadence when more than half of at least seven resolved days were missed (proposals, easy to tune). One definition of "engaged" (Engagement.engagedDays) now serves avoid habits and the rollover, so an avoid habit's Held today also earns its day a DayPlan. HabitToday.dueWindows returns the windows Today shows (weekly-target habits stay on offer until the week's target is met, then show as done) and remainingMinutes is the habit minutes that free time subtracts. Today's ordering (groups first, then by window start) belongs to the Today list slice.
-
Input: all enabled
AnchorRules, a generation horizon (today + tomorrow). -
Behavior: decodes each rule's
configData(two families, shapes in anchor.md) and generates concreteAnchorinstances (occurrenceDate/slotKey/windowStart/windowEnd/title/effortMinutes, linked viarule,attendanceStatus = pending).-
A window that has already closed is never generated. An instance is only created for a window that ends after the rule's
createdAt(and, for slots added by a later edit, after the edit). Otherwise creating prayer times at 21:00 would produce Fajr to Asr already closed, and the next evaluation would finalise them as missed on day one. -
Scheduled rules (
schoolRun,binNight,plantWatering,custom): for each date in the horizon that matches the recurrence, one instance per slot, with the window at local wall-clockstartforwindowMinutes(or the whole day ifallDay), untilendDate. Dates inside anexceptionsrange produce nothing. -
afterLastrules (plants): not date-driven. Generates one live instance per slot; when it is resolved (attended/skipped/delegated) the next window opensminDayslater and closes at the end of daymaxDays, and after amissedone it opens the next day. An exception pauses the interval clock. These are floating Anchors: they count toward the day's minutes but don't split the day (see module 7). -
Prayer rules: for each date, computes the day's prayer times on-device from date, location,
method,madhab,highLatitudeand per-prayer adjustments, then builds windows Fajr → sunrise, Dhuhr → Asr, Asr → Maghrib, Maghrib → Isha, Isha →ishaEnds(the library'sSunnahTimes.middleOfTheNight: the midpoint between Maghrib and the next Fajr). IffridayLabelis on, Friday's Dhuhr Anchor is titled "Jumu'ah". Deterministic for the same inputs. -
Idempotent, keyed by
(rule, occurrenceDate, slotKey)— not window start, because a rule edit or a recalculated prayer time can move the window without changing which occurrence it is. An existing instance — including askipped,attendedormissedone — is never recreated or duplicated; two devices generating at once dedup to one. - Rule edits, disabling and location changes update pending instances from today onward in place (matched by key), remove pending instances the rule no longer produces, and never rewrite attended, missed or skipped ones.
-
Undecodable
configData(invalid, or a newerversionthan this build understands) → the rule is skipped, flagged "needs attention", and never deleted or partially generated. - One-off Anchors (no
rule) need no generation but are finalised like any other.
-
A window that has already closed is never generated. An instance is only created for a window that ends after the rule's
-
Window state and logging: derives each Anchor's window state (
upcoming/open/closingSoon/closed) from the clock and enforces the logging rules in anchor.md —attendedonly while open,skipped/delegatedany time before it closes,closedfinal apart from a same-day "Mark as done after all" correction of a missed Anchor. SetsresolvedAtwhenever the status leavespending. Signal:.windowState(anchor). -
Preview: a dry-run
preview(rule, days)returns the Anchors a rule would generate over any horizon, with no writes. The rule detail uses it to show upcoming days, and notification planning uses it to look five days ahead. -
Titles: a generated Anchor's
titleis the slot label (or the rule's title if the slot has none); a Friday Dhuhr is "Jumu'ah" whenfridayLabelis on. -
Output: new
Anchorrows. Instances whose window has passed while stillpendingare finalised asmissedat the next evaluation (skippedanddelegatedinstances are left alone and never count as misses) — no consequence beyond the record; messaging is copy, not data.
As built, first slice (AnchorConfig, AnchorGenerator, AnchorAttendance in JamaalCore): configData decodes into the two families, and a missing or newer version, bad JSON or an invalid shape makes the rule needs attention (its text is never rewritten). The scheduled family's calendar recurrences (weekly, every N days, every N weeks counted in Monday-first weeks from the week of the start date) generate one instance per slot, never for a window that ended before the rule existed, and none inside an exception or past the end date. AnchorGenerator.preview is the dry run; sync writes today and tomorrow idempotently — it creates what's missing, updates a pending instance in place when the rule changed, removes a pending one the rule no longer produces (and a disabled or archived rule's future pending ones), and never touches a decided one, so a skipped instance is never resurrected. A rule that needs attention, and one-offs, are left alone. Window state is derived (closingSoon is the last 25%, at least 10 minutes); a closed, still-pending Anchor reads as missed at once and finalizeClosed stores that at the window's end. Logging: attended only while the window is open, skipped or delegated until it closes, a quiet undo while it's open, and mark done after all for a missed Anchor through the logical day containing the instant its window closed (so a window closing at midnight is correctable through the day that follows). afterLast keeps exactly one live all-day instance per slot, created as soon as the previous one is resolved or has closed (so it exists before it opens). With none yet, the first opens minDays after the last-handled date and closes at the end of day maxDays; a last-handled date old enough that the window ended before the rule existed is treated as due now, opening on the day the rule was made, so a rule can never start dead. After an attended, skipped or delegated instance the next opens minDays after the logical day it was handled; after a missed one (stored, or pending but closed) it opens the following day and stays open for the same length. An exception pauses the clock (the window opens the day after it ends; an open-ended one generates nothing), a new exception removes a pending window that opens inside it, the end date stops generation, and afterLast Anchors are always flexible for free time. A mark done after all correction does not move a next window that was already created. Prayer windows (PrayerWindows, the only file that imports adhan-swift 1.5.0, MIT): Fajr runs to sunrise, Dhuhr to Asr, Asr to Maghrib, Maghrib to Isha, and Isha to Islamic midnight (the library's middle of the night) or the next Fajr; adjustments shift each prayer's computed start and the windows derive from the adjusted times; the chosen prayers only; Friday's Dhuhr is titled Jumu'ah when the rule says so, keeping its dhuhr slot key. The method name falls back to the Muslim World League (custom angles aren't offered), the madhab is standard or Hanafi, and a rule with no location generates nothing. Prayer rules go through the same preview and sync as scheduled ones (exceptions, the creation floor, in-place updates of pending instances, decided instances never touched), and remind at the start unless the rule says otherwise. Windows are checked against the library's own published vector (North America method, Hanafi Asr, Raleigh, 12 July 2015). The bundled country → method and madhab table is PrayerMethodSuggestion (the owner reviews it). Still to come: the device-location rule (a device writes the coordinate only after it has itself moved about 25 km), which is app-layer.
- Input: today's tasks (completed / incomplete), Habit entries, Anchor attendance, focus-session time, tomorrow's Anchors, user input per step.
-
Behavior: drives
NightPlanningSessionthroughreview → carry → build → load → close, or toskipped:- Review today — read-only look back at the day (tasks done, habit windows incl. partials, Anchor attendance, time spent against estimates).
-
Carry forward — each incomplete task gets Keep (→ tomorrow) / Later (date picker,
Somedayhidden formedium/high) / Drop. Applies the deferral rules from task.md: the 3rd+ deferral opens the date picker with reason chips, and eases amedium/hightask tolow. Passed through when nothing is incomplete. -
Build tomorrow — opens on tomorrow's working day: fixed commitments drawn in, free gaps named by what surrounds them (
before the school run,between … and …,after …; gaps under 20 minutes hidden) with their sizes, from module 7's free-block calculation. Then the user pulls tasks in and pushes them out (rescheduling, not deferral) and adds tasks and one-off Anchors (importance is set here; due date pre-filled with tomorrow for medium/high;.pickPrioritieswhen the plan has 5+ tasks and fewer than twomedium/high); tomorrow's habits are read-only and tasks have no manual order;schedule-quadrant tasks are suggested. Tasks are not assigned to gaps;.taskDoesNotFitflags any longer than the longest gap. -
Check the load — set tomorrow's capacity (
low/medium/high), written toDayPlan(forDate).capacityas soon as it is chosen; shows the engine's suggested level, budget used, the load state, and any overflow past the day's end from module 7; the companion offers one specific reschedule — the lowest-ordered movable (notdoFirst) task to the nearest of the next seven days whose load stays under 100%, else a date is asked for — and never blocks. -
Close the day — locks the plan, upserts
DayPlan, hands off to module 6 to schedule tomorrow's notifications. Done screen: what was planned, plus a plain count of nights planned ("12 nights planned" — derived from completed sessions, not a streak).
-
Skip tonight (any step): sets
skippedAt, writes noDayPlan, leaves already-applied carry-forward choices in place. If no plan was confirmed for today, module 6 shows one quiet morning card ("No plan for today — two minutes to pick?"), once; it opens a shortened flow for today (a session withforDate = todaystarting at Build, then Load, then Close). -
Re-opening: a session already exists for
forDate→ resume it; a closed one reopens at Build, a skipped one clearsskippedAtand starts at Review.
- Depends on module 6 (evening trigger, notifications, morning card) and module 7 (free blocks, load check).
-
Output: a completed or skipped
NightPlanningSession, and aDayPlanfor tomorrow unless skipped.
As built, part 1 (NightPlanning in JamaalCore): opening a date resumes its session; a closed one reopens at Build and stays confirmed until it is closed again; a skipped one clears the skip and restarts its flow; otherwise a new session starts at Review, or at Build for the shortened morning flow (isShortened). Advancing passes through Carry when nothing is due on or before the reviewed day, and back is available from the second step, skipping an empty Carry; the shortened flow only moves between Build, Load and Close. Build reads tomorrow's shape: the working day with its fixed commitments and named gaps (for the morning flow, only what is left of today), the plan's tasks in engine order (live tasks due on or before the date), the day's habits read-only with their minutes counted, schedule-quadrant suggestions, undated candidates, tasks longer than the longest gap, the two prompts and a count of missing durations. Load shows the level (the day's DayPlan capacity, else the weekday default; reading writes nothing), budget, load state, overflow past the free time, the suggested level, and — only when the day is overloaded or overflowing — one move suggestion: the lowest-ordered task that isn't doFirst, to the nearest of the next seven days whose load with it stays strictly under 100% (exactly full doesn't count), else ask for a date; moving is rescheduling. setCapacity upserts the DayPlan the moment a level is chosen. Close upserts the snapshot (the chosen level or the weekday default, planned minutes, free and committed time, load score, overloaded, planningCompletedAt), completes the session and returns the summary with nights planned — the count of distinct closed dates, so two devices closing the same night count once, and it is never a streak. As built, part 2 (NightPlanningReviewCarry): Review reads the day under review (still yesterday at 00:30) in plain counts — tasks done that day, tasks left (live ones due on or before it, including ones the rollover has already moved), habit windows as done of due with partials shown honestly (paused ones absent; a "N times a week" habit listed only on the days it was done), Anchors read so far (one still open at review time reads open, not missed), and time actually spent against each task's estimate (running and manual sessions excluded). Carry forward lists each task with the choice made so far (pending, moved to a day or Someday, dropped) and applies Keep (to the planned day), Later (a chosen day, or Someday for a low task, with a reason) and Drop at once through the deferral rules: the first two are instant, a choice on a day that already has a record refines it (so planning at 00:30 never double-counts), and Keep needs the picker from the third deferral — counted as the deferral it would make, or, if that day's record already exists, the count it already has — otherwise it throws without changing anything. Each choice returns a result that undoes it exactly (restores the date, count, importance, the record or its earlier reason, and removes a repeating task's next instance) until the session is closed. Known limit: the undo is held in memory, so it does not survive closing the app; a choice made before that can only be changed with a new choice.
-
Input:
DayPlanhistory (wasOverloaded,completionRate), Anchor attendance (attended/missed;skippedanddelegatedexcluded),HabitEntryhistory,DeferralRecords. -
Behavior: purely derived — no stored wellbeing model, and no self-reported input.
-
Active day: a logical day with a
DayPlan(rollover creates one for any day with activity). Gathering data lasts until 7 active days fall inside the last 14 logical days. -
Score: a whole number 0–100 over the last 14 logical days, averaged over active days from four parts, each 0–100: tasks 35% (mean
completionRate= completed that day ÷ (completed + deferred or dropped that day); days with neither aren't counted), Anchors 25% (attended ÷ (attended + missed); skipped and delegated excluded, a late correction counts as attended, days with no decided Anchors left out; with no Anchors at all the other weights scale up), habits 20% (mean density of due windows; paused habits excluded, weekly-target habits by week), load 20% (share of active days not overloaded).DayPlan.plannedTaskMinutes,loadScoreandwasOverloadedare set at rollover from the day as lived (module 7's definition with that day's level); the Night Planning snapshot only serves the plan preview. The sparkline plots the score at each of the last 14 days, each over its own trailing 14 days, only where at least 7 active days exist. Trend is the score against 14 days earlier, shown as steadier / about the same / heavier, never as a coloured number. The page reads strained when any pattern is active, otherwise steady. -
Patterns:
heavyRun(3+ consecutive active dayswasOverloaded),habitNeglect(3 consecutive scheduled days missed; paused, archived, weekly-target and avoid habits excluded),completionCollapse(meancompletionRateof the last 3 active days below half of the prior 14 days' mean and below 40%),weekendOverplan(a weekend day overloaded on 2 of the last 3 weekends).avoidanceis not a wellbeing pattern: a task deferred 4+ times is handled on the task (3rd-deferral easing, 5th-deferral removal suggestion). -
Actions, one per pattern, each always with Not now:
heavyRun→DayPlan(tomorrow).capacity = low;weekendOverplan→ that weekday'sweekdayLevelsentry set tolow;completionCollapse→mediumDayMinutesset to the recent average (rounded to 15 min);habitNeglect→ opens the habit's pause sheet or edit form. -
Delivery: an inline card on the Wellbeing tab and at most once a day on Today — never a notification in v1. At most one per day (
NudgeLog,subjectKey= the pattern plus the habit or weekday); after Not now the same pattern isn't offered for 7 days. The Wellbeing nudges preference turns the cards off and leaves the score.
-
Active day: a logical day with a
-
Signals:
.wellbeingScore,.gatheringData,.pattern(kind).
As built, part 1 (Wellbeing in JamaalCore): a snapshot over the 14 logical days ending yesterday. An active day has a DayPlan; with fewer than seven in the window the state is gathering data (it reports how many). Otherwise the score is a whole number from four parts, each 0–100, weighted tasks 35, Anchors 25, habits 20, load 20: tasks is the mean completion rate over days whose completionBasis is above zero; Anchors is, per active day, attended ÷ (attended + missed) — skipped and delegated left out, a late correction already counting as attended, a closed still-pending Anchor read as missed — averaged over days with any decided Anchor; habits pools every due window on every active day (complete 1, a partial its share of the target, missed 0; paused, unscheduled and unresolved days not due; archived habits out) with each full week of a "N times a week" habit as one item; load is the share of active days that weren't overloaded. A part with no data drops out and the rest are scaled up. The sparkline has 14 points, each over its own trailing 14 days, nil where that window has under seven active days; the trend compares the score with the one 14 days earlier and says steadier / about the same / heavier (a change of five points or more; none if the earlier window had no score). The snapshot also reports the tasks part as a percentage and the count of heavy days for the screen. As built, part 2 (WellbeingPatterns): the patterns are checked only once there are seven active days, and come in priority order heavyRun, completionCollapse, weekendOverplan, habitNeglect. heavyRun is three or more consecutive days ending yesterday, each with a record and overloaded (a day with no record breaks the run). completionCollapse is the last three days that had something to finish (within the window) averaging under half of the counted days in the fortnight before them — which needs at least three — and under 40%. weekendOverplan looks at the last three finished weekends (the one still going doesn't count) and fires if a weekend day was overloaded on two of them, naming the weekday to lighten (the more often overloaded; a tie lightens Saturday). habitNeglect is a habit missed on each of its last three scheduled days — every window of it, paused days skipped, a habit too new to have three scheduled days never — and never applies to paused, archived, "N times a week" or avoid habits. Strained is any pattern active. Delivery is an inline card, never a notification: currentNudge returns the first pattern in priority order that isn't resting, one a day (once one has been shown today, only that one remains until tomorrow), a pattern rests for seven days after Not now or after its action is taken, and a preference turns the cards off; markShown logs it once a day in NudgeLog (wellbeing, with the pattern's subject key, which includes the habit or weekday). Actions, each also resting the pattern: heavyRun sets tomorrow's capacity to low; weekendOverplan sets that weekday's default to low (the other weekdays are kept); completionCollapse sets the normal day to the recent average of the last fortnight's active days that had completed effort, rounded to 15 minutes within the slider's range (30 min – 6 h), and leaves it alone if there is no data; habitNeglect writes nothing and says which habit to open.
-
Input: habit windows nearing their end, upcoming Anchor windows, the planning time, capacity/load,
NudgeLog. -
Behavior: schedules local notifications (
UNUserNotificationCenter):- the evening Night Planning prompt (at
UserSettings.planningMinute), on devices whose "Send reminders on this device" switch is on — cancelled once that night's session is closed or skipped, and never repeated; - an optional morning nudge, which — if no plan was confirmed for today (Night Planning was skipped or missed) — carries a single quiet in-app morning card ("No plan for today — two minutes to pick?"), shown once and never repeated if dismissed;
- per-window habit reminders (planned as near-term individual notifications and cancelled when the habit is logged, paused, archived or its time is edited, so nothing pings for what's done) and a light window-closing nudge as a window's end approaches;
-
Anchor reminders from
remindBeforeStartMinutes/remindBeforeEndMinutes(prayer times remind at the start by default; everything else is off); - during-day guidance — a highlighted task ("Start here" / "Good now") plus one companion card, max one per day. Chosen deterministically from time of day, remaining effort vs. remaining free time, a lighter-tasks-in-the-early-afternoon curve, and open habit windows.
- the evening Night Planning prompt (at
-
Trial and subscription: when access is
readOnly, the planner stops scheduling everything — the evening prompt, morning nudge, habit, guidance and Anchor reminders (owner may revisit keeping Anchor window reminders) — except the trial-end reminders at 09:00 on days 12, 14 and 15, which are never scheduled on a device whose Send reminders switch is off and are cancelled on subscribing; everything returns on subscribing. See Access and the trial. - Permission is requested during onboarding, framed around the evening planning reminder.
- Fallback if notifications are denied: an in-app banner at planning time ("Start evening planning →") and a warning card in Settings with a deep link to iOS Settings.
-
Output: scheduled notifications and in-app cards;
NudgeLogrows.
As built (NotificationPlanner, AccessState, Trial in JamaalCore): the planner is a pure function of the models, the settings and a few inputs — now, the access state, the trial start, whether the system permission is granted, this device's Send reminders switch, and the morning-nudge preference — and returns the notifications to schedule, each with a stable id so the app can diff the plan against what is pending on launch, on foreground and on background refresh. With no permission, or this device's switch off, it returns nothing (the trial-end reminders too). Read-only stops everything except the trial-end reminders, and a subscriber has none of those. Otherwise, in priority order: trial-end reminders at 09:00 on days 12, 14 and 15 of the trial; the evening planning prompt for each of the next five nights at the planning time (tonight plans tomorrow), dropped for a night whose session is closed or skipped and never repeated; the optional morning nudge at the morning time over the same days; Anchor reminders over five days — the stored pending Anchors plus what each rule's preview adds for days not stored yet, never duplicated, with an at-start reminder remindBeforeStartMinutes before the window opens (0 = at the start, so prayer times remind at the start by default) and a closing reminder before it ends, titled "rule · slot" when they differ; then habit reminders for today and tomorrow at each window's reminder time, none for a window that is already done, paused, archived, or a "N times a week" habit whose week is met. Reminders already in the past are left out. The schedule stops at 60 slots (the system allows 64), dropping the latest of the lowest priority, and is handed over in time order. Sessions and wellbeing cards never produce a notification. AccessState is trial(daysLeft:), subscribed or readOnly: an entitlement (including a billing grace period) is subscribed at any time; otherwise the trial runs 14 logical days from the earlier of the original-download and first-launch dates (day 1 is the start day, never negative), then read-only. Read-only locks shaping the plan (canShapeThePlan) but never living the day (canLiveTheDay). Not here (app layer): reading the system permission, scheduling and cancelling the requests, deep links, and the habit window-closing nudge, which the docs mention but give no rule for.
Two independent measures, deliberately kept apart.
Energy budget (the user's call). low / medium / high sets a budget of focused-task minutes: budget = minutes(level) (medium = UserSettings.mediumDayMinutes, low = ⅔, high = 4⁄3, the last two rounded to 5 minutes). Only tasks count: loadScore = plannedTaskMinutes / budget × 100, where for today plannedTaskMinutes is the effort of every live task due on or before today plus every task completed today (by logical date), so finishing work doesn't make the meter fall. A completed task counts its actual focus time where it has sessions, otherwise its estimate — matching DayPlan.completedEffortMinutes. Items with no duration count as zero, and the engine reports how many so the UI can nudge gently (new tasks preselect 30 minutes, so this should be rare). State thresholds (from v2):
| Load score | State | Response |
|---|---|---|
| < 70% | light |
none |
| 70–90% | balanced |
none |
| 90–110% | full |
subtle |
| 110–140% | overloaded |
gentle warning |
| > 140% | exhausting |
strong suggestion to defer something |
Free time (the physical day). The working day runs from dayStartMinute to dayEndMinute; for today it starts at the later of now and dayStartMinute. Subtract:
-
fixed Anchors (
placement: fixed): each is a busy block fromwindowStartforeffortMinutes, and it cuts the day into free blocks; -
flexible Anchors (
placement: flexible, and everyafterLastAnchor) and habit windows: their minutes come off the total free time but don't cut it, because their position within the day isn't fixed.
Only busy time inside the working day counts, so an Anchor after the day's end (Isha at 20:15) doesn't fragment it. skipped and delegated Anchors, and anything already done, stop counting. The result is freeMinutes (blocks minus flexible minutes, floor 0) and longestFreeBlock.
As built (FreeTime in JamaalCore): the function takes the day's commitments and the habit minutes still to do, so the habit and Anchor-config logic stays out of it. Only pending Anchors count (skipped, delegated, attended and missed ones no longer take the day's time); a fixed commitment is clipped to the working day, and overlapping ones merge into one busy block (the commitment that ends it names the gap after). A flexible commitment counts if its window overlaps what is left of the working day, which slightly over-counts a multi-day window (a plants Anchor open Monday to Wednesday takes its minutes from each of those days; tunable). afterLast Anchors become flexible when the rule configData is parsed. Each free block carries the commitment it ends at (before the school run) and the one it begins after, and gaps under 20 minutes are left out of the shown list but still count; suggestedCapacity is the highest level whose budget fits, never below low.
A plan has to fit both:
- Energy — the load state above.
-
Time — if
plannedTaskMinutes > freeMinutes, the overflow isplannedTaskMinutes − freeMinutes, phrased "2h 10m past 19:00". -
Fit — a planned task with an estimate longer than
longestFreeBlockgets a.taskDoesNotFitflag at planning time ("the 90-minute review doesn't fit before the school run"). Quiet, never blocking.
Soft, never blocking. Nothing is refused. Overflow is named: the meter turns terracotta and reads "N min past 19:00", with one tap to move the overflow to tomorrow — deterministically, taking tasks from the bottom of Today's order (quadrant, then due date) until the plan fits; the user can adjust. Adding a task that tips the day over shows "Day is full · offer tomorrow" at capture, never blocking. The day's end is a planning boundary only; midnight remains the hard rollover.
Anchors are always shown, at every level (they are external and can't be deferred). Today visibility by level for the rest (proposed, tunable): low — Anchors, doFirst tasks and habits whose window is closing and not yet done; medium — everything due except letGo tasks; high — everything due. Anything not shown appears under a collapsed "also today" section with a count — nothing silently disappears.
Suggested capacity: the highest level whose budget fits within freeMinutes (never below low) — for example, 2 hours free against a 3-hour normal day suggests low. The user always decides.
Weekday defaults. UserSettings.weekdayLevels gives each weekday a default level (weekends low, otherwise medium). It is what a day uses when no plan was confirmed, and what Night Planning pre-selects before the free-time suggestion above is shown.
Learning the normal day — suggest only. The engine never changes mediumDayMinutes by itself. It suggests a new value, deterministically:
-
Data: the last 28 days of
DayPlan.completedEffortMinutes(actual focus time from sessions where there is one, else the estimate), counting only days with at least one completed task or session, and only once 14 such days exist. -
Suggestion: the median of those days, rounded to the nearest 15 minutes and kept within the slider's range (30 min – 6 h). It is offered only if it differs from the current value by 30 minutes or more.
-
Delivery: one quiet line in Settings next to the normal-day setting, and at most once in Night Planning's load step — "You usually do about 2h 40m — set your normal day to that?" — with Set it and Not now. It is logged in
NudgeLog(normalDaySuggestion) when shown and again if dismissed, and is not offered again for 28 days, nor if the same value was already declined. -
Weekday differences are not learned; they are the user's per-weekday defaults.
-
Signals:
.loadState,.timeOverflow(minutes),.taskDoesNotFit(task),.freeBlocks,.suggestedCapacity,.normalDaySuggestion(minutes),.missingDurations(count),.todayVisibility.
-
Input:
WorkSessionrows, the current time, today's and tomorrow's Anchors. -
Behavior: a small deterministic state machine over
WorkSession(for a task or a timed habit's window; one live session across both) (Idle → Running → Overrun,Pausedonly by explicit pause; outcomesfinished/deferred/dropped/abandoned/autoClosed— full rules in task.md).- Derives elapsed time from
startedAt, so a killed or backgrounded app never loses or invents time. - Enforces one live session: a second Begin is refused until the first is settled (Done / Defer / Drop); on a cross-device conflict the later-started is closed as
abandoned. -
Auto-closes at the day rollover (default midnight, user-set;
autoClosed,endedAt= the boundary instant; the softer working-day end doesn't stop a session), carries the task to tomorrow without counting a deferral, and emits a pick-it-back-up signal for tomorrow's list. - Closes with the task: when a task is resolved (done, deferred, dropped) while its session is live, the session closes with the matching outcome.
- Settle options: Done, Stop for now, Defer, Drop for a task; Log it / Stop for now for a habit.
- Approaching edge (shown in the chip, never blocking): the single most urgent Anchor line wins — an open, still-pending Anchor that is closing soon ("Asr closes in 10 min") outranks a fixed Anchor whose window opens within 30 minutes (threshold tunable; "Maghrib in 12 min"). Flexible Anchors never appear. It never blocks or interrupts.
- Overrun is a state, not an event: it emits no notification, colour change or nudge.
- Derives elapsed time from
-
Signals:
.sessionState,.sessionAutoClosed(task),.pickUpRow(task),.anchorApproaching(anchor, minutes). -
Output: session transitions written to
WorkSession, task effects from the settle sheet (done / deferral / drop), and no scheduled notifications — sessions never nag. The Lock Screen Live Activity (v1.1) is driven locally by the same state, not by a push.
As built (FocusSessions, HabitSessions in JamaalCore): elapsed is (endedAt ?? now) − startedAt − pausedSeconds − (a pause in progress), never negative; the state is running, overrun (elapsed past the estimate; a state only), paused (explicit pause only) or ended. Begin is refused while any session is live — task or habit — and names it; a completed or dropped task can't be begun, but one not due today can. Finish is Done (completes the task, finished) or Stop for now (abandoned, task untouched), each logging elapsed minus pauses, with an optional note appended to the task as a line stamped yyyy-MM-dd HH:mm —; undo within five seconds reopens the session with the toast's seconds counted as pause, un-completes the task (removing an untouched next instance of a repeating one) and removes the appended line, and is refused if another timer has since started. Settle (a second Begin): task — Done, Stop for now, Defer to tomorrow (deferred, a normal deferral) or Drop (dropped); habit — Log it or Stop for now. Resolving a task closes its live session with the matching outcome (G-25: TaskActions.complete → finished, drop → dropped, a real or refined TaskDeferral.defer → deferred; plain rescheduling leaves the timer running). A timed habit's entry is recomputed from the window's non-running sessions that day as round(total seconds ÷ 60) over all of them, with a snapshot of the target, completedAt set at the target and cleared if it falls below; minutes added by hand are a finished manual session with no timing of its own. The rollover carries a task being timed at the boundary to the next day without counting a deferral (its session auto-closes at the boundary), and pickUpRows offers one row for it the next day until the task is completed, dropped, moved later or timed again. approachingEdge gives the chip its one line: an open, still-pending Anchor closing soon outranks a fixed Anchor opening within 30 minutes; flexible and afterLast Anchors never appear.
EngineTick.run is everything the app keeps current on launch and whenever it becomes active, as one idempotent call (a second tick over the same moment changes nothing). In order: seed what is missing (settings, the default categories); merge duplicates another device created; catch up every day that has ended since the last tick on the user's own rollover time (auto-deferral, the day's record, timers closed at the boundary, unfinished planning ended); generate Anchors for today and tomorrow; then store missed for Anchor windows that have closed. It returns what it did and the most recent processed day, which the app keeps per device (UserDefaults, not synced: another device's progress says nothing about this one, and every step is idempotent anyway). A first run has no history to replay, so it starts from yesterday.
TodayDay.overview is the read model behind Today's header and task list: the day's level (today's DayPlan.capacity, else the weekday default), its budget, the planned minutes (the tasks Today shows plus those already done today, so ticking one off doesn't make the meter look emptier), the load score and state, the count of counted tasks with no estimate, and the three lists (shown, alsoToday, completedToday). TodayDay.setLevel upserts today's DayPlan.capacity; there is never a second plan row for a day. Tasks hidden by the level stay counted under Also today: nothing silently disappears.
TodayAnchors.items is the Anchors section's read model: today's Anchors (by occurrenceDate) plus a still-pending multi-day window (plants) that opened earlier and is still open, as plain rows or one grouped row per rule that yields several Anchors today. A plain row's title is the rule's title plus the slot label when they differ ("School run · Drop-off"); a group is titled by the rule and lists its members by slot label. Each row carries its window state, the status to show (a closed, still-pending Anchor reads missed at once), the window's progress for the bar, and "Day 2 of 3" for a multi-day window. A group counts attended out of those still counting (skipped and delegated leave the denominator, missed stays in it) and names the first member still pending. TodayAnchors.actions says what can be done with a row right now (attended only while open; not today / someone else did it until it closes; undo while the window is open; mark as done after all until the end of that logical day) and perform does it through AnchorAttendance, which refuses what the rules refuse.
TodayHabits.read is the Habits section's read model: the due windows (live, scheduled today or "N a week" until met; paused and archived hidden), as group pills first (ordered by sortOrder, then title; each with its habits and a done/total count) and then ungrouped rows by window start, all-day last, then title. A habit with several times a day names each window ("Medication · Morning"). A row carries the kind, the amount logged, the target (an avoid habit's allowance of slips) and whether it is done; done and total across the section feed "N of M".
HabitLogging.apply writes the day's HabitEntry, created on the first log with the window's target as a snapshot: a binary toggle, a counted step up or down (never below zero), and for avoid habits a slip, taking one back, Held today (which sets completedAt, the explicit engagement the density rules need) and taking that back. completedAt is set the first time the target is reached, kept while it stays reached and cleared below it. An action that doesn't fit the kind (including a tick on a timed habit, whose minutes come from sessions) is refused and writes nothing. If two devices left two entries for a day, the larger is the one edited.
TaskCreation.create validates a TaskDraft (a trimmed title; medium, high and repeating tasks need a date and are never Someday) and inserts the task with its category and repeat; nothing is written when it is refused. A weekly repeat keeps its chosen ISO weekdays (empty means the due date's weekday). The draft starts at 30 minutes, low importance and no date. A repeating task's seriesID is still assigned when it is first completed.
TaskCreation.dayFullCheck is "Day is full · offer tomorrow": it says whether adding the estimate would tip the chosen day over its budget (that day's own level; today counts what is already done, the meter's own figure; another day counts its live tasks), and offers the nearest of the next seven days where the task still fits, or none. A task with no estimate, no date or a past date is never checked, and nothing here refuses anything: the app always offers Add anyway.
TaskNotes reads a task's note (lightweight markdown) as lines: - [ ] item / * [x] item / + [X] item are checkboxes, everything else is text (blank lines are skipped but keep their line numbers). toggled(_:line:) flips one box and changes nothing else, so the detail sheet and the focus screen write the same text back; progress counts boxes only.
TaskDeferral.preview tells the sheet what Defer will do before the user chooses: which deferral this is (a refinement of today's record keeps its number), whether the picker opens (from the third), whether it is a reschedule (the task is due later: no count, no record), whether it will ease importance (and from what), whether Someday is open (not for an important task that won't ease, nor a repeating one), and whether it is the fifth, when the companion says letting it go is fine too.
FocusSessions.trackedSeconds(of:at:) is the time spent on a task: its finished sessions' time plus the live one's elapsed time, pauses excluded; the row's "Timing · 24 min" and "Done 10:55 · 74 min" read it. The rest of module 8 was already built; the app adds only what a screen needs between taps (FocusCoordinator): whether the focus screen is open, the settle sheet raised by a second Begin (the running session settled first, its time kept whichever is chosen, then the waiting one begins; Cancel changes nothing), and the five-second Undo after Done (Stop for now has none). One timer runs across tasks and timed habits; the chip never changes tone when a session overruns its estimate.
TodayTaskFilter is the category filter: it narrows the Tasks only, keeps the engine's order, compares categories by identity and hides an uncategorised task under any filter; the menu offers the active categories in their own order. The meter, the load and Also today stay whole-day (the meter says so: "2h 15m of 3h · whole day"). The filter lives on the device and the day: it clears at the rollover and on relaunch. An empty result reads "Nothing in Family today." with Show all.
The app also names the day: blank (nothing due at all), all done (no task, Anchor or habit left to decide, though tasks the level hides don't count against it) or the usual. Finished tasks stay listed under the statement, so finishing work doesn't make the list shrink away. The carried-over row (FocusSessions.pickUpRows) offers to pick a task back up after the rollover closed its session ("Last night's session closed at midnight. 42 min logged on …"), or to be dismissed for the day.
The engine (module 4) already owns the rules and persists the session, so PlanningFlow only knows which day it plans (an evening plans tomorrow and reviews today, using planningTarget; the morning's shortened flow plans today), steps through the flow, applies carry choices (keeping what is needed to undo each until the day is closed), reschedules a task out of or into the planned day (never a deferral), writes the level the moment it is chosen, skips, and closes. Carry counts as having work whenever tasks were left or already moved or dropped from the reviewed day, so going back to it after making choices still shows them. Continue on Carry applies Keep to whatever was left untouched (a task on its third deferral goes to next week instead, since that one needs a day that works).
MorningCard is due when no plan was confirmed for today (a skipped or unfinished one still gets the card; a plan for another day doesn't count), until the working day ends. It is logged once a day (NudgeLog, kind morningPlanCard, subjectKey the date), stays until it is dismissed or acted on (Pick for today and Not now both put it away for the day), and a new day starts fresh. NightPlanning.eveningPromptDue is the quiet Plan tomorrow row: from the planning time until the next day starts (so it survives past midnight), if that night's plan is neither confirmed nor skipped; an unfinished plan still gets it. The shortened morning flow (Build, Load, Close) has its own words: "This morning", "Today's shape", Not now, "Today is set.", Open Today.
HabitsOverview is the tab's read model: groups (by sortOrder, then title) with their habits, today's done/due count (a paused habit isn't due) and a 14-day aggregate (complete when every window was, partial by the share that were, missed when none were, empty while a day is open); ungrouped habits in the order they were made; and archived habits, kept with their history and restorable. Each habit carries today's amount, target and done state per window (an avoid habit is done only when Held today was tapped: silence isn't success), its week so far if it has a weekly target, and the pause covering today.
HabitPauses.pause records a pause from a day until a day or until it is resumed (an end before the start is refused; overlapping pauses merge, keeping the longer end and an open end open); resume(on:) ends the pause the day before, or removes one that hadn't started. HabitLogging.canCorrect is the 14-day rule for putting a past day right: not before the habit existed, not in the future, not on a paused day.
HabitEditing.create and update turn a HabitDraft (what the form collects) into a habit and its times of day, after checking it: a name; a target that makes sense for the kind (a count or minutes of at least one; an avoid habit's allowance may be zero, which means none allowed); some days or "N times a week" (one to seven); one to four times of day, each ending after it starts and, when there are several, each named. Nothing is written when it is refused. The frequency follows the days (daily, weekdays, custom); a weekly habit is scheduled on any day of the week. Presets (Qur'an reading, Dhikr, Exercise, Running) pre-fill a draft with the catalogue's values.
Editing never changes the kind (history is read by it) and the kind check uses the habit's own kind, not the form's claim. A time of day can be added; one with no entries can be removed; one with history can't (its entries would go with it). A changed target edits the window and leaves every past entry's snapshot alone, so it affects the future only. Groups are made from the form (createGroup, next sort order) and, in the app, only when the habit is saved, so cancelling leaves nothing behind.
AnchorEditing makes and edits scheduled rules (school run, bin night, plant watering, custom) from an AnchorRuleDraft: how it repeats (on these days, every N days, every N weeks, or after I last did it), named times (a start and a window, or all day), how long it takes, fixed or flexible (an after-last rule is always flexible), when it ends and reminders (off unless asked). It refuses a blank name, no days, no time, a bad interval (an interval is at least a day and the longest is never shorter than the shortest), an unnamed time when there are several, a window under a minute, and an end before the start; nothing is written when it does. Editing keeps the rule's exceptions and each time's key (so Anchors already made stay theirs). A prayer or unreadable rule isn't edited here.
Exceptions are date ranges (term break, holiday, travel, illness, other; the end can be open) that merge when they overlap, the longer or open end winning. Adding one removes pending Anchors inside it at the next sync; ones already decided stay. A rule inside an exception today reads as paused in the list, with why and until when. One-offs are Anchors with no rule, stored on their day with a window; the window must end after it starts and after now. AnchorsOverview is the list's read model: each rule with its state (running, paused, needs attention) and next Anchor (its dry run), the archived rules, and upcoming for a rule's detail.
PrayerEditing makes and edits the prayer rule from a PrayerRuleDraft: a title (Salah), a place, method and Asr (pre-selected from the country by the bundled table, always editable), the prayers (kept in day order), Isha ends (Islamic midnight or Fajr), the Jumu'ah label, high-latitude rule, per-prayer adjustments (up to an hour either way, zero is not stored), time each takes (10 min), fixed placement and reminders (at the start by default). It refuses a blank name, no prayers, an unknown prayer, method or option, no place (or one off the map), and an adjustment over an hour; nothing is written then. Editing keeps the rule's breaks, and breaks work on prayer rules exactly as on scheduled ones.
Places are kept coarse: two decimal places, about a kilometre. A device place follows the device only when this device has itself moved about 25 km since the stored place (PrayerLocationRules.updatedPlace, applied by followDevice); a chosen city (manual) never moves. The app reads the location only when permission was already given, and only while it is open. upcomingToday gives the form its footnote: which of today's prayers are still to come and which have already closed.
DaySettings holds the numbers on Capacity and day. The normal day moves in 15-minute steps from 30 minutes to 6 hours. The working day needs rollover < start < end and at least two hours. The rollover can change only when the clock is past both the old and the new value (rolloverAvailability / setRollover), so the logical date never flips under the user; otherwise the control says when it can change. planningBeforeDayEnd gives the quiet note when the planning prompt precedes the end of the working day. normalDaySuggestion is the suggest-only learning above: the median of the last 28 days that had work, once 14 exist, rounded to 15 minutes and kept in range, offered when it differs by 30 minutes or more. Showing it is logged once per value; Not now is logged as dismissed, and a dismissal silences the line for 28 days and the same value for good.
CategoryEditing keeps the label list: a name is trimmed, 1–24 characters and unique ignoring case and accents, archived labels included (renaming a default keeps its presetKey); at most eight are active; a new label takes the next colour not in use and goes last; restoring needs room; moving renumbers the list. Archiving never touches a task's label.
The planner's output reaches the system through the app's reminder centre. On launch, on every foreground, and shortly after any save (a habit logged, a night planned, a rule or the planning time edited) it reads the permission, runs NotificationPlanner.plan, and applies only the difference from what is pending (NotificationDiff: new or changed by id, fire time or words, and what is no longer wanted), so a logged habit's reminder disappears without the rest being touched. The words come from NotificationMessages, a separate layer in Jamaal's voice: calm, no urgency, nothing scolding. A tapped notification routes by its id (NotificationRoute): planning opens Night Planning, an Anchor or habit reminder opens Today, a trial reminder opens the subscription screen.
Three permission states are kept apart: not asked (a Turn on reminders button shows the system prompt in context), denied or turned off later (a link to system Settings), granted. This device's switch is local: on for iPhone, off for iPad and Mac. A device that is quiet by choice says nothing. When the switch is on but the permission isn't, Notifications and times shows a warning card listing exactly what won't arrive (ReminderStatus.undelivered: the planning prompt, the morning list if on, each Anchor rule that reminds, the habits with a reminder time); a configured reminder carries one quiet line; and Today shows one banner after the planning time while tonight is unplanned (ReminderStatus.showsBanner: dismissible for the day, or for good with Don't remind me), separate from the always-available Plan tomorrow row. Access isn't read from StoreKit yet, so the app plans as subscribed (no trial reminders, nothing read-only) until it is.
The Wellbeing tab reads Wellbeing.snapshot and Wellbeing.patterns and says them plainly. The snapshot now also carries the counts behind the score (tasks finished of tasks that had to be finished, Anchors attended of attended plus missed, over the window's active days), so the rows read 41 of 48 rather than a percentage; habits are only a word (Most days, Some days, Fewer days), never a figure. The read sentence names what happened (Most tasks got done, the Anchors held, and only two days ran heavy.); an active pattern leads it instead (The last three days were each over their budget.). The trend is against the fortnight before and is said in words, never a coloured number. Until seven active days exist the tab shows seven squares filling, a line saying so, and what the reading will come from; it never asks for anything.
One calm card at most (currentNudge: one a day, a pattern rests seven days after Not now or after its change), with one change that writes a definite setting (tomorrow low, a weekend day's default low, the normal day to the recent average, or the habit's own sheet). It appears on the tab and, once a day, on Today; never as a notification. Today also carries a slim strip: the score's line, or how far gathering has got, opening the tab.
Onboarding holds what first launch writes. It shows until UserSettings.onboardingCompletedAt is set; that field syncs, so a second device on the account skips it. The screens run in order (meet, the idea, the iCloud explanation only when iCloud isn't available, your normal day, when to speak, a first task, a first habit, a first Anchor, ready); only when to speak and the Anchor can be passed by. Your normal day saves the normal day, when the working day ends (it keeps its start and needs two hours) and today's level (today's plan only, never the weekday defaults) together, and writes nothing if any part is refused. When to speak saves the planning and morning times to the settings row and asks the system for notification permission in context; Not now leaves it for Settings. The first task is low importance and due today or tomorrow (so Today is never empty), with an optional label. The first habit is one of Qur'an reading, Water, A walk, Dhikr, or a bare name: the presets keep their presetKey, the other two are plain drafts, and the bare name is a binary daily habit. The first Anchor reuses the Anchor and prayer forms. Ready sets the completion date once. The first Today carries a derived card, Tonight, we'll plan tomorrow., on the day onboarding finished until tomorrow is being planned (tonightCardVisible).
UserAction is the per-action table (G-70): living the day (ticking off, every kind of habit logging and correction, Anchor outcomes, the timer, undo, preferences, export) is always allowed; shaping the plan (creating or editing anything, defer, drop, Night Planning, the capacity level and day settings, onboarding's creations) needs a trial or a subscription. EntitlementCache keeps the entitlement honest: the store's answer when it gives one, otherwise the last answer, so a failed lookup or being offline never locks anyone out, and an expiry takes effect only when the store confirms it. Paywall shows the calm paywall once on the first open of each logical day while read-only, never during a focus session, and Not now is final for the day. TodayBanner gives the one slim slot at the top of Today to the most pressing of: the trial has ended, the trial is ending (the last three days, dismissible for the day), reminders are off.
In the app a storefront wraps StoreKit 2 behind a seam: the two plans (yearly and monthly; the trial is app-managed, so there is no store offer for it), the entitlement, the store's original-download date (the trial's start is the earlier of that and the synced first launch, so a reinstall never restarts it), purchase, restore, and a stream of transaction changes. It feeds the access state to the notification planner (read-only stops the schedule; the trial adds its three 09:00 reminders; subscribing brings it all back at the next pass), to the Subscription screen and to the paywall. Read-only is a gate on user actions, not a data state. The shell offers a requireAccess check through the environment: before a plan-shaping action goes ahead it asks the access state; if the answer is no, one calm sheet opens (Adding and planning need a subscription. Everything already here keeps working for the day., with Subscribe and Not now) and the action does not start, so nothing is half done. Nothing is hidden or greyed out, except the controls that are pure planning and have nothing to explain (Plan tomorrow, the morning card). The gate stands in front of: Add; the capacity slider and the day settings; a task's Defer, Drop and Stop repeating (also from the settle sheet after a timer); adding, editing, pausing, archiving and restoring habits, Anchor rules, their breaks and categories; and the Wellbeing card's change. Ticking off, every kind of habit logging and correction, Anchor outcomes, the timer, undo, preferences and export are never behind it. Onboarding is deliberately not gated: a fresh install after the trial must still be able to finish. The locked sheet opens from the shell, so a task's detail sheet closes behind it.
Export my data (DataExport) writes one JSON file of every record of every model, always available, subscribed or not, online or not: the app and format version, the time, a count per model, and every row with its own id, dates as ISO 8601 text, absent values as null, and a link to another record as that record's id under <name>ID (a to-many link is carried by the other side's to-one). The writers are explicit, not reflection, so the file never depends on SwiftData's private layout; a test compares them with the schema's own list of fields, so a field added without a writer fails the tests. Delete my data (DataReset) removes every record; the app asks twice, seeds a fresh settings row, and starts again at onboarding, with no reminder left scheduled. Deleting the records is also what removes the copy from the account once sync is on.
Appearance is per device: the theme (System, Light, Dark), where text size is set (the system's), and whether Jamaal's suggestion cards show on Today and Wellbeing (the score stays). If the store won't open the app shows a recovery screen instead of crashing: Try again, and Reset this device's data behind two confirmations, which removes only this device's local store (never the iCloud copy) and opens again.
A task's note is plain markdown: the detail and the focus screen render and tick its checklist lines, and the editor writes them. NoteEditing holds the formatting rules as pure functions on text and a character selection: toggle a checklist on the selected lines (a plain line or a bullet becomes - [ ] ; if every selected line already is one, they turn back; only the lines touched change), wrap or unwrap bold (**), italic (*) and inline code (`), with nothing selected placing the markers with the caret between (and taking an empty pair back out), a single * beside another * being part of bold and not an italic marker, a link as [the selection](https://) with the address selected to be typed over, and Return after a checklist or bullet line starting the next item (carrying any text after the caret), and on an empty item ending the list. The screen writes the note as it is typed (an empty note is nil), so Back and Done only close. Writing a note is a plan-shaping action (locked when read-only); ticking a box is never locked.
A timed habit's minutes can be added without the timer (HabitMinutes): a finished manual session on the habit's window, so the day's total is still the sum of its sessions and two devices converge. The sheet steps in five minutes (a first step of ten, one minute to twelve hours) for today or any day in the last 14: the same days a forgotten tick can be put right, so not before the habit existed, not in the future and not on a paused day, and a refused add writes nothing. Entries added by hand are listed for the day, newest first, and one can be taken back out (the window's total follows and the target can stop being met); a timer session is never removed this way. The sheet opens from the habit's row on Today, its detail, and a day in its grid. It is living the day, so it stays open when read-only. The target shown is the one the day was logged against, so editing a target later only affects the future.
Build offers Add a task or Anchor: the Add sheet's Task | Anchor switch opens there with tomorrow already chosen, so a one-off Anchor (a dentist, a parents' evening) is made for the planned day and appears on tomorrow's timeline as a fixed commitment. Load can carry the quiet normal-day line (You usually do about 2h 45m — set your normal day to that?, with Set it and Not now): normalDaySuggestionForPlanning offers it at most once in Night Planning per value (it is logged under its own key, so having seen it in Settings doesn't use up that chance, and seeing it here doesn't hide it from Settings), and a decline anywhere silences it for 28 days and that value for good. It is fixed when the step first appears, so it stays until answered but isn't offered again if you go back and return.
At regular width (iPad, Mac, and a Duo's inner display) Today is a list with the task's detail beside it instead of a bottom sheet, as in the adaptive-layout table: picking a task fills the panel (with its row marked), picking another swaps it, and with nothing picked the panel says Pick a task to see it here. The detail is the same view as the phone's sheet; in the panel its closing actions (done, defer, drop, begin) clear the selection instead of dismissing a sheet, and its notes and defer sheets still open as sheets. At compact width nothing changes. The size class decides (AppNavigation.showsDetailPanel), so Split View and slide-over widths fall back to the sheet, and a selection made while wide survives rotation. The sidebar is the system's adaptive one the shell already uses. Later slices: Add as a panel, Night Planning's single canvas with a step rail, content panes for Habits, Wellbeing and Settings, and the macOS commands and Settings scene.
The toolbar is pinned. Today's filter, Plan tomorrow and Add are the system toolbar (a NavigationStack with .toolbar), not a row in the scrolling content, so they stay put while the list scrolls and the system can move them (to the side on a Duo's outer display). The date is the leading item with no glass behind it; the three buttons are one glass group, each a Label (title and symbol, shown icon-only). The task panel has its own toolbar with a ⋯ at the top right: Add a note / Edit note, and Stop repeating for a repeating task (Begin, Mark done, Defer and Drop stay in the action row). The stack paints its own ground, so Today sets the app background inside the stack, as Habits does. The sidebar's furniture. At regular width the system sidebar carries more than the destinations, as drawn in iPad-TD-01-TK-02: the Jamaal wordmark (display italic) as its header, a count on Today (what is left, the same figure as the headline; regular width only, so the phone's tab bar has no badge), a Categories list under the destinations (each category's colour and name; picking one filters Today, picking it again clears it, and the picked one is marked) and Plan tomorrow pinned at the bottom (it routes to Today and opens Night Planning the way a notification does, so the access gate is the same; hidden once the trial has ended, like Today's moon). The category filter moved from Today's local state to a shared TodayFilterState, so the sidebar and Today's own filter menu always agree; it is still per device and clears at the rollover and on relaunch.
Detail screens at regular width. The pushed Anchor rule detail and Habit detail (custom top bar, hidden navigation bar) are a readable column: 720 pt at most, centred in the pane, content left-aligned inside it and the top bar aligned to the same column, with the app ground running to the edges. Before, they ran edge to edge (a Coming-up row had its title and its time about 1500 pt apart) or, with little to show, shrank to their content with the system's white either side. The phone is unchanged (it is narrower than the column). A master/detail layout for Anchors and Habits stays with the content-panes slice.
Add is a panel at regular width. Tapping + on a wide Today shows the Add form in the right-hand panel (the no modals on wide row of the adaptive-layout table), in place of the task detail or the placeholder, instead of a bottom sheet. It is the same form, with the Task | Anchor switch and its close button; saving or closing returns the panel to the placeholder. Picking a task while adding switches to that task's detail, and tapping + while a task is open replaces its detail with the form (the list's selection outline is cleared, since nothing is selected). At compact width it is the sheet it always was; going from wide to narrow drops a half-written add rather than leaving it behind a sheet. Night Planning's Build step still opens the form as a sheet, since Night Planning is its own modal until its wide canvas is built.
The Habits tab's header (AN-01, HB-01). On a phone the Habits | Anchors switch is the design's pill (a track on line with a white selected pill behind the chosen option, 15 pt, semibold when chosen) and the round glass Add button (44 pt) sits on the same row, with the large title below, as drawn. Add follows the chosen segment: a habit on Habits, an Anchor rule on Anchors. The add state lives in the screen so both lists share the one button; at regular width, where Anchors is its own sidebar item and there is no switch, each list keeps Add beside its title. The system segmented control is not used here, so the tests find the switch by segment-habits and segment-anchors.
The tab bar's colours. The bar stays the system's Liquid Glass (so a Duo can move it to the side), styled through TabBarAppearance: items ink3 in Hanken Medium 11 and the chosen one ink semibold. Checked on iOS 26.4: the system applies the chosen item's colour and font (and follows light and dark), but ignores the unselected colour and the selection pill's tint, so unselected items stay the system's label colour and the pill stays the system's grey. They are set anyway for a release that honours them. Matching the frame exactly would need a hand-built bar, which the Duo guidance rules out.
Night Planning's wide canvas (NP-07). At regular width Night Planning is one canvas (NP-07 in the register): a step rail on the left and the open step on the right in a readable column (640 pt at most), with Continue and a ⌘↵ hint beneath it (the shortcut is wired but not covered by an automated test: key events sent by UI tests don't reach it in the simulator, so it is checked by hand with a hardware keyboard). The rail lists the flow's steps (the five of the evening, the three of the shortened morning) under PLANNING TUESDAY (or THIS MORNING): a step behind is a filled check, the open one a white card with a terra ring, one ahead a plain ring. The ones behind and the open one carry a one-line summary read from the engine: Review 3 done · 2 left, Carry 2 to settle while open and 1 kept · 1 moved once behind (what was left untouched is kept), Build 4 tasks · 3h 30m. A step behind can be tapped to go back to it (PlanningFlow.go(to:) steps back one at a time, so Carry is still skipped when it has no work); the ones ahead are reached with Continue, never by tapping. Skip tonight is at the bottom of the rail. The frame has no way out other than Skip, so a Close button sits at the top right (leaving mid-flow resumes at the same step). Each step reports Done, Current step or Later as its accessibility value. The Close step stays the full-screen closing moment. Compact width is unchanged.
The wide step bodies. Two steps draw differently at regular width, as in the NP-07 frames. Carry: each task is one row, its title and meta on the left and Keep | Later | Drop on the right of the same row (the day chips and the Undo line still sit beneath it). Build: the day is shown as a day-shape bar (the working day, busy time dark, the rest light, the longest free gap outlined, the hours marked every second hour beneath; decoration for VoiceOver, since the lines below say the same), the fixed commitments run down two columns in time order (title left, time right; "School run · Drop-off" is just "Drop-off" here), and the longest gap is said in one line (Longest gap: 1h 45m, between Dhuhr and the pick-up.) instead of a gap line under every commitment. Add a task or Anchor stays under them. Review, Load and Close are unchanged. Not drawn here yet: the frame's grouping of small tasks into one row (Groceries · Boiler service · Clinic, 1h 30m together).
Habits and Anchors as list + detail panes. At regular width (the Habits and Anchors sidebar items) each is a list column (320–380 pt) with the picked item's detail beside it, as in iPad-HB-02-habits, instead of pushing the detail over the list. A row selects (the picked one is outlined in a white card drawn into the margin, so its content doesn't move); with nothing picked the pane says Pick a habit / an Anchor rule to see it here. The detail is the same screen the phone pushes, embedded: no back button, and archiving it clears the selection. Group cards on Habits keep opening and closing as before and their habits select too. At compact width a row still pushes. The earlier readable-column rule (#184) still applies inside the pane. Settings is the same: at regular width the sections (Capacity and day, Notifications and times, Categories, Appearance, Subscription, Privacy and your data) are the list, and the picked one's screen is the pane beside it, with no back button (the shared SettingsPage frame hides it when isInPane is set). With nothing picked the pane says Pick a setting to see it here. At compact width each section is pushed as before. Wellbeing keeps its single pane: it has no list to pick from.
The Mac destination. Jamaal is a native macOS app on the same target (Supported Destinations: iPhone, iPad, Mac; not Catalyst, not Designed for iPad), macOS 26 and later. The Mac signs with Jamaal-Mac.entitlements (App Sandbox, outgoing network for place search, user-selected files for export, location for the prayer places); iCloud and push join it with the paid programme. The UI test target stays iOS-only. A Mac window carries no size class, so AppNavigation.isRegular is true there and every regular-width layout (sidebar, Today's panel, Night Planning's canvas, the Habits, Anchors and Settings panes) applies. The iOS-only SwiftUI calls are behind PlatformShims: a full-screen cover is a sheet on a Mac, the hidden navigation bar and inline titles are no-ops, the toolbar's leading and trailing slots map to the Mac's navigation and primary-action slots, the focus chip floats at the bottom of the window (there is no tab bar to carry it), and location "authorised" is the Mac's single state. The default window is 1180 × 780 and never smaller than 980 × 720 (MacWindow). Sheet sizes: a Mac sheet sizes to its content's ideal size, and the full-screen flows are scroll views with none, so they first opened as a ~470 × 520 box. Each now says how much window it needs (MacSheetSize): Night Planning is a canvas (960 × 700, so the step rail and its step sit side by side) and onboarding, the paywall, the focus screen and the note editor are a page (560 × 700). macOS opens a sheet at its minimum width and its ideal height, so the width is the minimum; the window minimum is always larger than any sheet. Measured from the running app's window list (the build can't be screenshotted from an automated session), not by eye. The Mac shell and its menus. SwiftUI's tab view has no sidebar style on a Mac (only a top bar), so the Mac's navigation is a split view with a real sidebar (MacShell, as in macOS-TD-01): the Jamaal wordmark, Today (with its count), Habits, Anchors and Wellbeing, the Categories that filter Today, and Plan tomorrow at the bottom, the same furniture as the iPad's sidebar. Settings isn't in the sidebar: it is in the app menu (⌘,) and opens in the main window, so the access gate, the focus session and the reminders stay shared. The menu bar acts on the focused window (JamaalCommandActions, a focused scene value, so the commands grey out with no window): New Task ⌘N (it replaces New Window; Today opens its Add panel, from whichever section is showing, as the request waits in CommandCenter until Today is on screen), Plan ▸ Plan Tomorrow ⇧⌘P, View ▸ Today, Habits, Anchors, Wellbeing ⌘1–⌘4, Settings… ⌘, and ⌘↵ for Continue in Night Planning; Esc closes a sheet. The sections' own state (an open task, say) isn't kept when you leave a section on the Mac, as a tab view would; the Today filter is.
Avoid habits (HB-08, Option A). An avoid habit says what it is doing in three moments on Today, each with the same two solid pills until it is held: None yet · up to 1 (before any slip), 1 of 1 · still within (after a slip, still inside the allowance, or 2 slips today past it) and, once Held today is tapped, a green check, the title in ink, Held today · 1 slip and Undo. Slip and Held today are both explicit and both undoable; nothing is inferred, so a day nothing was logged stays empty. The detail has the same controls beside today's line, the six-week grid and the read: Held on 31 of 38 days. Days nothing was logged stay empty — they aren't counted either way. The past 14 days are tappable to correct (Held, Nothing logged, or a number of slips). There is no "days since the last slip" counter.
The Mac look (macOS-TD-01). The focus chip is at the bottom of the sidebar: a green capsule with a clock, the task and its running time, grey while paused, and a click opens the focus screen. A Mac has no tab bar to carry it, so it no longer floats at the bottom of the window (the Undo toast still comes up from there). The task being timed is a filled in-focus row on Today, in the accent colour with white text and In focus · 24:18 counting on its right. Any other row highlights under the pointer (a Mac, or an iPad with a pointer). The window's title bar says Today with the date as its subtitle; the filter, Plan tomorrow and Add are its toolbar buttons. All of it is drawn into the row's margin, so nothing moves.
Right-click menus. On regular width a task row has a menu (right-click on a Mac, press and hold on an iPad): Open, Begin (not while it is already being timed), Mark done or Mark not done, and Drop, which asks first (Skip this one for a repeating task, which creates the next one as usual). The same access rules apply as for the buttons they stand for. The phone's rows have none.
Mac UI tests. JamaalMacUITests (macOS only; scheme JamaalMac, kept as a template in Jamaal/Schemes/ to copy into your own xcshareddata, because a tracked shared scheme would stop Xcode generating the automatic scheme CI uses) is XCUITest on a real Mac, because the iOS UI tests can't run there and an automated session can't screenshot the Mac or press keys in it. It launches the Mac app with the same debug arguments and checks: the sidebar (the four sections and no Settings, the wordmark, Plan tomorrow, a category filtering Today), the menu shortcuts (⌘1–⌘3 move between sections, ⌘, opens Settings, ⌘N opens the Add form from another section, ⇧⌘P opens Night Planning), the focus chip in the sidebar while a session runs, and the right-click menu (its items, and Begin starting the timer). It drives the real desktop, so it takes over the mouse and keyboard while it runs (about a minute and a half for all of it) and the first run may ask to allow control of the computer.
Keyboard on Today's list (Mac). With the list focused, ↓ and ↑ move the selection one task at a time (the task shown in the panel; it stops at the ends, and with none selected ↓ picks the first and ↑ the last), Space marks the selected task done or not done, and Delete (or forward delete) asks to drop it with the usual confirmation (a done task can't be dropped). The walk covers the list in the order Today draws it, then "Also today" when it is open, and follows the category filter. Clicking a row focuses the list. There is no separate Return action: moving the selection already opens the task in the panel. TaskListNavigation holds the step logic (unit tested) and MacListKeys the key handling. Four Mac UI tests (MacListKeysUITests) cover it, and they pass on a real Mac (15 Oct 2026). Running them found one bug: the Delete key (⌫) reaches a focused view on a Mac as AppKit's delete command, not as a key press, so onKeyPress(.delete) never fired; Delete now uses onDeleteCommand (forward delete is still a key press). A Mac UI test needs macOS's one-time "enable UI automation" approval, which asks for your password or Touch ID.
Export my data (Mac). The iOS share sheet is UIKit, so on a Mac the button opens the standard save panel through SwiftUI's fileExporter (the sandbox already allows user-selected files), with the file named as on iOS (Jamaal-export-yyyy-mm-dd.json). The line under the button says "Saved N records." only once the file has been saved, not when the panel opens; cancelling the panel leaves it as it was, and a failed save says "That couldn't be saved. Try again." The export content (DataExport) is unchanged, and the export stays available while read-only. ExportFile is the document the panel writes. Not checked by a UI test (the Mac UI runner couldn't see the app's window when this was written, and a save panel is a system window).
Not yet on the Mac: the Mac app icon and a macOS CI job.
Not yet done: visibility priority so Add stays visible longest when space is tight (the Duo-specific APIs are iOS 27.1 and gated).
AccessState is a pure function in JamaalCore of (now, trialStart, entitlement): trial (before the trial ends), subscribed (an active entitlement, including a billing grace period or retry, so a failed card never locks anyone out), or readOnly (otherwise). StoreKit supplies the entitlement at the app layer; the last-known entitlement is cached locally (per device, not synced) and used while offline, and an expiry only takes effect when StoreKit confirms it. Nothing is stored in the schema beyond UserSettings.firstLaunchAt.
Trial dates. The trial start is the earlier of StoreKit's original-download date and firstLaunchAt, so a reinstall or second device never restarts it. The trial is 14 logical days, day 1 being the start day, and ends at the rollover after the 14th day; "days left" counts logical days.
What read-only locks is a gate on user actions, not a data state:
- Always allowed (living the day): complete and un-complete tasks; every kind of habit logging (check, stepper, slip, Held, minutes by hand, correcting the last 14 days); Anchors attended, not today, someone else, or marked done after all; Begin, pause, finish and stop focus sessions, tick a note's checklist, pick a session back up; every undo; notification and appearance preferences; data export and delete.
- Needs a subscription (shaping the plan): create or edit a task, habit, group, Anchor rule, one-off Anchor or category; Drop or Defer by hand and Keep / Later; Night Planning and the morning card; the overload Move; pause, archive and restore; the capacity level, normal day and every day setting.
- A locked control stays visible; tapping it opens one calm sheet ("Adding and planning need a subscription" — Subscribe / Not now). Pure planning toolbar items (Plan tomorrow) are hidden.
-
The engine keeps running while read-only (auto-deferral,
DayPlanfinalisation, session auto-close, Anchor generation, a repeating task's next instance) so the record and the wellbeing score stay coherent; only notifications stop. - The paywall appears on the first open of each logical day while unsubscribed, at most once a day and never during a focus session; Not now is final for the day. The entitlement is checked when an action starts: a running session can always finish, an open Night Planning session ends as skipped at rollover, and subscribing lifts every lock at once.
- Delete my data asks twice, deletes every record locally and in iCloud, and resets the app to first launch. Export and delete are always available.
Notification permission is read on launch and every foreground, in three states: not asked (a Turn on reminders button shows the system prompt in context), denied or turned off later (a link to system Settings), granted. The planner schedules only when this device's switch is on and permission is granted. Denial costs more than the planning prompt — Anchor, habit, morning and trial reminders too — so the Settings warning card lists what isn't being delivered, and a configured reminder (an Anchor rule's or a habit's reminder row) carries one quiet line saying notifications are off. The Today banner appears only when the switch is on and permission is off, at most once per logical day, dismissible, with Don't remind me stored locally; a device that is quiet by choice shows none. It is separate from the always-available Plan tomorrow row.
Tapping a notification: planning → Night Planning for the target date (the ordinary locked sheet if read-only); Anchor or habit → Today at that row; trial → ST-05. v1 notifications have no action buttons and use the default interruption level.
iCloud problems (ST-06): Signed in, Not signed in, Restricted, Storage full, Sync is having trouble (three consecutive failures over at least a day). Offline is never a problem. The last four surface on Today as one quiet dismissible line (back after a day if still true) that says the data is safe on this device and what would fix it. On an account change or sign-out the app pauses rollover and sync-dependent work and shows "Your iCloud account changed" with Export my data first; the system's behaviour there must be verified by a real-device spike (Phase 3, before TestFlight).
Widget snapshot and timeline (not a numbered module). WidgetSnapshot.make(in:now:access:) reads Today (the same overview and Anchors reads, so a widget can't say what the app wouldn't) into a small Codable, Equatable value: tasks left and done, the first five left (in Today's order) and how many more, the capacity, up to three Anchor windows, the evening planning time and whether it has passed, and when the day ends. It changes nothing and marks no prompt as shown. Anchors still to do are listed open or closing first, then upcoming, then closed-and-unmarked (needsAttention); attended, skipped and delegated are left out. "Plan tomorrow" shows from the planning time and not when read-only. A planning time earlier than the rollover falls on the next calendar date. WidgetTimeline.plan(for:now:) gives the entry dates: now, then each later moment that changes the widget (an Anchor opening, its last quarter or at least the last ten minutes, closing; the planning time; the day's end), deduplicated, at most 12, with reloadAfter the last one. LoadState is now Codable. Not here yet: the App Group and the extension.
Links. JamaalLink parses jamaal://today, jamaal://plan and jamaal://task/<id> (scheme and host ignore case; a trailing slash is fine; anything else is nil) into the NotificationRoute a tap uses; plan means tomorrow's plan, so it needs today's logical date. NotificationRoute gains .task(UUID). The app registers the scheme in Info.plist, handles onOpenURL in one function, and Today opens the task. In debug builds -JamaalOpenURL <link> feeds a link through the same function, because the system's confirmation for outside links can't be tapped in a test. The task link has a parser test but no UI test (the sample tasks' ids change on every launch).
A rule's "next" skips what is already done (#235). The Anchors list shows each rule's next occurrence: the earliest dry-run occurrence whose window has not closed. An occurrence that was attended, skipped or marked "someone else did it" is no longer next, even while its window is still open. This matters for Isha, whose window runs past midnight: after Isha is done the Salah rule's next is tomorrow's first prayer, not that same Isha with its closing time on the next day. Undo puts it back. Today's Salah row already read as done after Isha; the Anchors list did not.
A store that won't open shows a recovery screen, not a crash: Try again, or Reset this device's data (two confirmations; the iCloud copy downloads again; never silent). Unknown raw values from a newer version are kept inert — see the schema overview.
- Numbers are proposals: the medium-day default (180), the ⅔ / 4⁄3 multipliers for low/high, the working day defaults (08:00–19:00), the load thresholds, the rollover-to-stale threshold (3), removal suggestion (5), the wellbeing window (14 days, 7 active days to start), and the normal-day suggestion thresholds (28 days of history, 14 data days, a 30-minute difference, 15-minute rounding) are all constants, easy to tune.
- Missing durations count as zero, so load can be understated until durations are filled in. Current proposal: Night Planning's capacity step gently notes how many items have no duration, rather than guessing. Habit presets and the built-in anchor types ship default durations to keep this rare.
-
Wellbeing weights (tasks 35 / Anchors 25 / habits 20 / load 20) and the pattern thresholds are constants, easy to tune once real use shows what a good week looks like.
WB-04(a derivation view) is v1.1. -
Prayer-time library: decided and checked —
adhan-swift1.5.0 (github.com/batoulapps/adhan-swift). It is MIT-licensed, actively maintained (a release in June 2026, commits into August), uses Swift 6 tools, offers exactly the methods, madhabs and high-latitude rules ourconfigDatanames, per-prayer adjustments, sunrise, andSunnahTimes.middleOfTheNight; it computes offline and deterministically. It builds cleanly with Xcode 27. Its own test suite fails 174 assertions on an en_AU machine, but every one differs from its expected text only by the letter case of am/pm (the tests format times with the system locale); no calculated time is wrong. It has no region → method helper, soJamaalCorebundles a small suggestion table. Add it as a dependency ofJamaalCore(not the app) when the Anchor work starts. - Notification limits: iOS caps pending local notifications at 64, so delivery goes through one planner: it builds the upcoming schedule in priority order — the evening planning prompt, then Anchor reminders (planned over five days with the preview), then habit reminders — until about 60 slots are used (keeping a few spare), and re-plans on launch, on foreground, on background refresh and whenever the data changes. Fixed-time habit reminders that repeat on a schedule are cheap (one slot each) but can't skip a day already done, so they are planned as individual near-term slots instead.
- Manual ordering of Today: none in v1 — a working hypothesis to validate in real use (see task.md).
- Avoid habits are specified here as a proposal (the design has only a title); they need a design pass (H-06) before being treated as settled. Detected habits (offer to promote a repeating task) are v1.1.
- Overview
- Diagrams
- Rules Engine
- ADR 0001: Anchor object type
- ADR 0002: Reconcile planning chat v2
- ADR 0003: Widgets, watch and the App Group
- Widgets and the watch
- Accessibility
- App flow (end to end)
- Screen gallery
- Screens and flows register
- Journey walkthroughs
- Today List
- Night Planning
- Onboarding
- ThreadsKit Usage
- Claude Design brief
- Design v4 handoff
- Design v4 reconciliation
- Brief: widgets and the watch
- Widgets and watch reconciliation