schema anchor - fahadahmed/jamaal-app GitHub Wiki
Anchor
Status: reviewed, resolved for v1. Positioning settled (recurring rules plus one-offs) and
configDatashapes defined. Anchor postdates the earlier planning chat (master summary v2 has no Anchor) β see ADR 0002. Rationale for Anchor as a third primitive: ADR 0001.
Something the user's life moves around. Created by the user in one of two ways: a recurring commitment (an AnchorRule, from which the app generates the instances) or a one-off Anchor added directly with no rule (a dentist appointment, a parents' evening). Tracked via attendance rather than streaks. Timing and the consequence of a miss are external to the user. Examples: prayer windows (including salah β see habit.md for why salah lives here and not as a Habit), school runs, bin night, watering plants, and one-off fixed-time events.
Users see these as "Anchors" β the name is the same in the UI and the code. (The design project's flow spec calls the same idea a "ritual"; that label is retired in favour of Anchor, and the design should be relabelled.)
Anchor vs. Task: an Anchor has a fixed time the user can't move and is tracked by attendance; a Task with a due date is flexible and tracked by completion. A dentist appointment is an Anchor; "book a dentist appointment" is a Task.
Fields
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
title |
String |
"" |
The slot label ("Fajr", "Drop-off"), or the rule's title when the slot has none; for a one-off, whatever the user typed. Display composition: see Titles on Today. |
occurrenceDate |
Date |
.now |
A floating calendar date (see The day boundary). The day this Anchor belongs to: logicalDate(windowStart) β the day its window starts, even if the window ends after midnight (Isha in summer) or, with a user-set rollover, an Anchor starting at 01:00 belongs to the previous day. Today and Night Planning filter on this, not on windowStart. |
slotKey |
String |
"" |
Stable identity of the slot within its rule: the prayer name ("fajr") or the slot's id from configData. Empty for one-offs. With rule and occurrenceDate it uniquely identifies a generated instance even if its times later change. |
windowStart |
Date |
.now |
Start of the external window this instance is anchored to. |
windowEnd |
Date |
.now |
End of the window; after this, a miss is final for this instance. May fall after midnight. |
effortMinutes |
Int? |
nil |
How long attending takes. Copied from AnchorRule.effortMinutes when an instance is generated; set directly on one-offs. Counts toward committed time in the load check. nil = unknown, counts as zero. |
attendanceStatus |
String |
"pending" |
One of pending / attended / missed / skipped / delegated. skipped ("Not today" β a bin night that isn't happening) is not a miss. delegated ("Done by someone else" β another person did the school run) is handled, not a miss. Both are excluded from wellbeing patterns and from the day's load once set, and shown neutrally. See Window state and logging rules. Sufficient on its own for v1 β no separate "consequence" field; any messaging about what a miss means is UI/copy, not data (confirmed in review). |
resolvedAt |
Date? |
nil |
When the status left pending (attended, skipped or delegated; or set to missed when a window closes), and updated by a late correction. Shows "attended 12:52" and dates a plants-style rule's last handled day. |
remindBeforeStartMinutes |
Int? |
nil |
A reminder this many minutes before the window opens; 0 = at the start; nil = none. Copied from the rule's reminder at generation; set directly on one-offs. |
remindBeforeEndMinutes |
Int? |
nil |
A closing reminder this many minutes before the window ends; nil = none. Opt-in. |
generatedAt |
Date |
.now |
Titles on Today
A plain row shows rule.title, adding the slot label when it differs ("School run Β· Drop-off"). A grouped row (a rule with several Anchors today) is titled rule.title and lists its members by slot label (Fajr, Dhuhr, β¦). A one-off shows its own title.
Relationships
rule: AnchorRule?(optional, per CloudKit relationship constraint) β the rule that generated this instance.nilmeans a one-off Anchor created directly by the user (derived, not a separate flag).
AnchorRule
Anchor instances are generated from a persisted AnchorRule (not computed on the fly), so user-defined recurring commitments work without code changes.
| Field | Type | Default | Notes |
|---|---|---|---|
id |
UUID |
UUID() |
|
title |
String |
"" |
|
sourceKey |
String |
"custom" |
One of prayerWindow / schoolRun / binNight / plantWatering / custom. |
configData |
String |
"{}" |
JSON, versioned; shape depends on the rule's family β see configData shapes. |
effortMinutes |
Int? |
nil |
How long attending takes (Fajr β 10, school run β 30) β not the window length (Fajr's window may be 90 minutes). Applies to every instance the rule generates. nil = unknown, counts as zero. |
placement |
String |
"fixed" |
fixed or flexible. Fixed = a busy block from the window start for effortMinutes, which cuts the working day into free blocks (school run, an appointment). Flexible = it takes minutes but has no fixed position inside its window, so it only reduces total free time (bin night). afterLast rules are always flexible; one-off Anchors are always fixed. Used by the capacity check β see rules-engine.md, module 7. |
isEnabled |
Bool |
true |
Paused / resumed by the user. |
isArchived |
Bool |
false |
Soft-delete. A rule is never hard-deleted: Anchor.rule == nil means a one-off, so deleting a rule would make all its past instances look like one-offs. Archiving hides the rule and removes its future pending instances; history stays linked. |
createdAt |
Date |
.now |
Also the floor for generation: an instance is never created for a window that ended before it (see rules-engine.md, module 3). |
configData shapes
configData is a JSON string (CloudKit-safe, no extra models). It always carries "version". JamaalCore decodes it into a versioned, Codable type, and there are exactly two families:
- Computed β
prayerWindow. Windows come from astronomy. - Scheduled β
schoolRun,binNight,plantWateringandcustom. These differ only in labels, icons and defaults, not structure: a recurrence plus one or more time slots.customis simply the scheduled shape with no preset.
If configData can't be decoded (invalid, or a newer version than this app understands β possible when another device runs a newer build), the rule is not generated and not deleted: it shows as "needs attention" in the rules list. Nothing crashes, and nothing is lost.
Scheduled shape
{
"version": 1,
"recurrence": { "kind": "weekly", "weekdays": [1,2,3,4,5] },
"slots": [
{ "id": "3F2Aβ¦", "label": "Drop-off", "start": "08:15", "windowMinutes": 30 },
{ "id": "9C71β¦", "label": "Pick-up", "start": "15:00", "windowMinutes": 30 }
],
"endDate": null,
"reminder": { "atStart": false, "beforeEndMinutes": null }
}
Recurrence (kind):
kind |
Fields | Meaning |
|---|---|---|
weekly |
weekdays (ISO Mon=1 β¦ Sun=7, non-empty) |
Those weekdays, every week. |
everyNDays |
n (β₯ 1), startDate |
Every n days counted from startDate (e.g. plant watering every 3 days). |
everyNWeeks |
n (β₯ 1), weekdays, startDate |
Those weekdays every nth week from the week of startDate (e.g. fortnightly bin rotation). |
afterLast |
minDays (β₯ 1), maxDays (β₯ minDays), startDate |
Interval from last done, not the calendar: the window opens minDays after the last time it was handled and closes at the end of day maxDays (e.g. plants, "every 3β4 days"). Here startDate is the date it was last handled. See below. |
afterLast semantics. It is a floating Anchor β it takes time but doesn't cut the day into fixed gaps (calendar-based kinds are the day's fixed "spine"). There is exactly one live instance per slot. startDate is the date the thing was last handled: the form asks "When did you last do this?" (default today, or Due now, which sets it minDays ago), and the first window opens minDays after it. Each next instance is created when the current one is resolved, and opens minDays after the logical date of its resolvedAt (for an attended, skipped or delegated instance). If an instance ends missed, the next one opens the following day and stays open for the same length, so a missed watering isn't pushed a further 3 days away. Its slots are always all-day (start / windowMinutes are ignored). Its window may span several days, so it appears on Today on every day it is open, and occurrenceDate is the day it opened.
Monthly patterns are intentionally not offered for Anchors; recurring monthly things ("pay rent") are repeating Tasks.
Slots (at least one). Each occurrence-day generates one Anchor per slot.
| Field | Notes |
|---|---|
id |
Stable string, used as slotKey. Never reused, so reordering or deleting slots can't mismatch instances. |
label |
e.g. "Drop-off". Used in the instance title. |
start |
Local wall-clock "HH:mm", interpreted in the device's current time zone, so travelling keeps 08:15 as 08:15. |
windowMinutes |
How long the Anchor stays open after start; the window may cross midnight. |
allDay |
Optional, default false. If true, the window is the whole day and start / windowMinutes are ignored (plant watering). |
endDate (optional) stops generation after that date.
Preset defaults (placeholders, all editable and tunable):
sourceKey |
Recurrence | Slots | effortMinutes |
|---|---|---|---|
schoolRun |
weekly MonβFri | "Drop-off" 08:15, 30 min (user can add "Pick-up") | 30 |
binNight |
weekly, one weekday (default Wed) | "Bin night" 19:00, 180 min | 10 |
plantWatering |
afterLast, 3β4 days | all-day | 10 |
custom |
weekly, no default days | none β user adds at least one | none |
Placement defaults: schoolRun fixed, binNight flexible, plantWatering flexible (always, as afterLast), custom fixed, and prayerWindow fixed at the start of each window β each prayer then cuts the day at its time, as the design draws it (Maghrib at 18:40 splitting the evening). The user can switch prayers to flexible. Only busy time inside the working day counts, so prayers after the day's end don't cut it.
Prayer shape (prayerWindow)
{
"version": 1,
"method": "northAmerica",
"madhab": "shafi",
"highLatitude": "middleOfNight",
"ishaEnds": "midnight",
"fridayLabel": true,
"reminder": { "atStart": true, "beforeEndMinutes": null },
"prayers": ["fajr", "dhuhr", "asr", "maghrib", "isha"],
"adjustmentsMinutes": { "fajr": 0, "dhuhr": 0, "asr": 0, "maghrib": 0, "isha": 0 },
"location": { "mode": "device", "latitude": -36.85, "longitude": 174.76, "name": "Auckland" }
}
| Field | Values / notes |
|---|---|
method |
Calculation method β exactly the set the prayer-time library (adhan-swift) provides: muslimWorldLeague, egyptian, karachi, ummAlQura, dubai, moonsightingCommittee, northAmerica, kuwait, qatar, singapore, tehran, turkey (its other is for custom angles and is not offered). Suggested from the user's region by the method suggestion table and always editable. |
madhab |
shafi (standard Asr) or hanafi. |
highLatitude |
middleOfNight / seventhOfNight / twilightAngle. Only matters far from the equator. |
fridayLabel |
true (default) titles Friday's Dhuhr Anchor "Jumu'ah": same window, same time, same slotKey (dhuhr); only the name changes. false keeps "Dhuhr" on Fridays. |
ishaEnds |
midnight (default: Islamic midnight, the midpoint between Maghrib and the next Fajr) or fajr. |
prayers |
Subset of the five to generate. Slot keys are the prayer names. |
adjustmentsMinutes |
Per-prayer offset (may be negative) for matching a local mosque timetable. |
location |
See below. |
Prayer method suggestion
The prayer-time library has no region helper, so a small table is bundled in JamaalCore (keyed by ISO country code, from the location's country or the device region). It only pre-selects the method and madhab in the form; the user can change both. Drafted from the library's own notes on where each method is used; the owner should review it.
| Country | Method | Asr (madhab) |
|---|---|---|
| US, CA, GB | moonsightingCommittee (the library recommends it for North America and the UK; northAmerica / ISNA is offered as the alternative) |
standard |
| SA | ummAlQura |
standard |
| AE | dubai |
standard |
| KW | kuwait |
standard |
| QA | qatar |
standard |
| SG, MY, ID | singapore |
standard |
| TR | turkey |
Hanafi |
| IR | tehran |
standard |
| EG | egyptian |
standard |
| PK, IN, BD, AF | karachi |
Hanafi |
| anywhere else | muslimWorldLeague |
standard |
Windows: Fajr β sunrise Β· Dhuhr β Asr Β· Asr β Maghrib Β· Maghrib β Isha Β· Isha β ishaEnds. Adjustments shift each prayer's computed start, and windows derive from the adjusted times. Times are computed on-device from date, location and these settings, so the result is deterministic. Isha's window may end after civil midnight in summer; the Anchor still belongs to the day it starts.
Location (location.mode):
device(default): when-in-use location permission, requested only when prayer times are chosen (not up front). Only a coarse coordinate (rounded to about 1 km) and a place name are stored β precise coordinates never sync to iCloud. The coordinate is synced with the rule, so only one device should move it: a device writes it only when it has moved materially (about 25 km) since its own last check, never merely because its position differs from the stored value. A Mac at home never writes; a travelling iPhone does. When the coordinate changes, future pending instances are regenerated (past ones are never touched).manual: a city the user searches for and picks; used automatically if location permission is denied. Never changes unless edited.
Exceptions
Both families accept an optional exceptions list in configData β date ranges when the rule's windows don't exist at all (term break, holiday, travel, illness):
"exceptions": [
{ "from": "2026-12-15", "to": "2027-01-27", "reason": "holiday" },
{ "from": "2027-03-02", "to": null, "reason": "illness" }
]
reasonis one ofterm/holiday/travel/illness/otherand is for display only.to: nullmeans open-ended, until the user ends it.- Inside an exception no instances are generated β they are not
skippedand notmissed, and simply don't appear. Adding an exception removes pending instances inside it; attended, missed, skipped and delegated ones are kept. - For
afterLastrules the interval clock pauses: no window opens during the exception, and the next one opens when it ends. - Exceptions replace the earlier "pause a rule until a date" idea. Per-instance
skippedremains for one-off cases ("no bins this week").
Reminders
Both families accept an optional reminder object in configData: atStart (a reminder when the window opens) and beforeEndMinutes (a closing reminder that many minutes before it ends, or null). Generated Anchors copy it into Anchor.remindBeforeStartMinutes (0 when atStart, else nil) and Anchor.remindBeforeEndMinutes; one-offs set those two fields directly.
- Defaults: prayer times remind at the start; every other rule has reminders off. A closing reminder is always opt-in β nothing nags.
- Scheduling: notifications are planned over the next five days using the generator's preview (not just the two stored days), inside the 64-pending-notification budget, and re-planned on launch, on foreground and on background refresh. See rules-engine.md, module 6.
Window state and logging rules
Each Anchor has a window state derived from the clock β never stored:
| State | When |
|---|---|
upcoming |
now is before windowStart |
open |
inside the window |
closingSoon |
inside the last part of the window (proposal: the last 25%, at least 10 minutes β tunable) |
closed |
now is at or after windowEnd |
The state is what an Anchor row shows on Today (with a window bar); the attendance status is the outcome. Logging rules:
attendedcan only be logged while the window isopenorclosingSoon. AnupcomingAnchor can't be ticked (the control is disabled and shows when the window opens). This also covers cases like a second medication dose logged early, without a medication-specific rule β each dose is its own Anchor with a single status.skippedanddelegatedcan be set any time before the window closes, including whileupcoming.closedis final, with one exception: a still-pendingAnchor becomesmissed, but a missed Anchor offers "Mark as done after all" until the end of that logical day (the day containing the instant its window closed), with a neutral confirmation showing the time. It setsattendedand updatesresolvedAt. It never reopensskippedordelegated, and it does not weaken the rule thatattendedcan't be logged before a window opens.- An accidental tap can be undone within the window (a quiet undo).
CloudKit constraints applied
- All properties have defaults or are optional.
- No unique constraints β instance uniqueness is enforced by the engine (see rules-engine.md, module 3), keyed by
(rule, occurrenceDate, slotKey). - The
rulerelationship is optional.
Open questions
- Editing behaviour (proposal): editing or disabling an
AnchorRuleupdates pending instances from today onward in place and never rewrites attended, missed, skipped or delegated ones; details in rules-engine.md. Editing a one-off changes just that Anchor; deleting one removes it. - Deleting a rule archives it (
isArchived): it disappears from the rules list, its future pending instances are removed, and attended / missed / skipped / delegated history stays linked to it. afterLastdetails: whether a rule may combine several slots withafterLast(probably one slot), and whetherminDays/maxDaysshould adapt with season (the design mentions drift) or stay user-set.- Per-slot duration:
effortMinutesis one value per rule, so every prayer or slot gets the same duration. Fine for v1. - One-off boundary: should one-offs support notes or a location? Not modelled; likely not needed for v1.
- Sharing/referral: deferred to v1.1 (see CLAUDE.md), no schema impact for now.