schema overview - fahadahmed/jamaal-app GitHub Wiki

Schema overview (freeze candidate)

Status: freeze candidate β€” draft, needs review. One consolidated list of every persisted model, generated from the per-model docs, plus the conventions, relationships, dedup keys and CloudKit limits that the Swift models must follow. The per-model docs (task, habit, anchor, rules-engine) hold the rationale; this page holds the shape. If they disagree, fix the per-model doc and regenerate this list.

Why a freeze

CloudKit's production schema is effectively append-only. Once deployed you can add models, attributes and relationships, but you cannot rename, remove or retype them. So every name and type below has to be right before the first production deploy (TestFlight with CloudKit development schema is the last safe moment to change). New raw-string values and additive fields are always safe; renames are not.

Conventions

  • Every attribute has a default or is optional. No bare let without one.
  • Naming. The model is TaskItem throughout the docs and the code β€” Task remains the product word for the primitive ("Tasks, Habits, Anchors"). It is not called Task because that would shadow Swift.Task inside the app and the engine, and the CloudKit record type derives from the class name, so the name is fixed before the first deploy. Every other model uses its documented name. Each raw-string field keeps its documented stored name and has a typed accessor (for example importance / importanceLevel) that reads an unknown value as .unknown and never writes it back.
  • No unique constraints (@Attribute(.unique) is forbidden under CloudKit). Uniqueness is enforced by the engine β€” see Dedup keys.
  • Every relationship is optional and has an inverse.
  • Enums are raw Strings, wrapped by typed enums in Swift. New cases can be added later without a schema change; see Raw values.
  • Dates come in two kinds.
    • Floating calendar dates β€” stored as a Date at 12:00 UTC of the calendar date so every device agrees whatever its time zone (see The day boundary): TaskItem.dueDate, DeferralRecord.day, DeferralRecord.deferredTo, WorkSession.day, HabitEntry.date, Anchor.occurrenceDate, DayPlan.date, NightPlanningSession.forDate.
    • Instants β€” real moments: every …At field, Anchor.windowStart / windowEnd, WorkSession.startedAt / endedAt, DeferralRecord.deferredOn.
    • Times of day are minutes since local midnight (startMinute, reminderMinute, dayStartMinute …).
  • JSON-in-a-string fields (CloudKit-safe, no extra models): AnchorRule.configData (versioned; unknown newer versions must be left untouched, never deleted) and Habit.pausesData. Dates inside them are ISO yyyy-MM-dd strings and times "HH:mm".
  • Soft delete, not hard delete, wherever history matters: TaskItem.droppedAt, and isArchived on TaskCategory, Habit, HabitGroup and AnchorRule.
  • Derived values are never stored unless noted (elapsed time, density, urgency, Eisenhower quadrant, window state, load state, streak-like numbers β€” there are none).
  • Local-only state (not in the schema): appearance preferences, the per-device "Send reminders on this device" switch, whether a habit group is expanded, the engine's "last processed day". (The planning and morning times are synced in UserSettings.)

Field manifest

Format: name: Type = default. Relationship attributes are listed here where the docs show them; the full relationship list follows.

TaskItem

id: UUID = UUID()
title: String = ""
notes: String? = nil
dueDate: Date? = nil
effortMinutes: Int? = nil
importance: String = "low"
isCompleted: Bool = false
completedAt: Date? = nil
droppedAt: Date? = nil
deferralCount: Int = 0
repeatKind: String = "none"
repeatWeekdays: String = ""
repeatDayOfMonth: Int = 0
seriesID: UUID? = nil
createdAt: Date = .now

TaskCategory

id: UUID = UUID()
name: String = ""
presetKey: String? = nil
colorKey: String = "accent"
sortOrder: Int = 0
isArchived: Bool = false
createdAt: Date = .now

DeferralRecord

id: UUID = UUID()
deferredOn: Date = .now
day: Date = .now
deferredTo: Date? = nil
reason: String = "unspecified"
task: Task? = nil

WorkSession

id: UUID = UUID()
startedAt: Date = .now
day: Date = .now
endedAt: Date? = nil
pausedSeconds: Int = 0
pausedAt: Date? = nil
estimateMinutes: Int? = nil
outcome: String = "running"
actualSeconds: Int = 0
task: Task? = nil
habitWindow: HabitTimeWindow? = nil

Habit

id: UUID = UUID()
title: String = ""
notes: String? = nil
presetKey: String? = nil
kind: String = "binary"
frequency: String = "daily"
scheduledDays: String = "1,2,3,4,5,6,7"
targetPerWeek: Int = 0
pausesData: String = "[]"
isArchived: Bool = false
createdAt: Date = .now

HabitTimeWindow

id: UUID = UUID()
label: String = ""
startMinute: Int = 0
endMinute: Int = 1439
target: Int = 1
effortMinutes: Int? = nil
reminderMinute: Int? = nil

HabitEntry

id: UUID = UUID()
date: Date = .now
target: Int = 1
amount: Int = 0
completedAt: Date? = nil
window: HabitTimeWindow? = nil

HabitGroup

id: UUID = UUID()
title: String = ""
sortOrder: Int = 0
isArchived: Bool = false
createdAt: Date = .now

Anchor

id: UUID = UUID()
title: String = ""
occurrenceDate: Date = .now
slotKey: String = ""
windowStart: Date = .now
windowEnd: Date = .now
effortMinutes: Int? = nil
attendanceStatus: String = "pending"
resolvedAt: Date? = nil
remindBeforeStartMinutes: Int? = nil
remindBeforeEndMinutes: Int? = nil
generatedAt: Date = .now

AnchorRule

id: UUID = UUID()
title: String = ""
sourceKey: String = "custom"
configData: String = "{}"
effortMinutes: Int? = nil
placement: String = "fixed"
isEnabled: Bool = true
isArchived: Bool = false
createdAt: Date = .now

DayPlan

id: UUID = UUID()
date: Date = .now
capacity: String = "medium"
plannedTaskMinutes: Int = 0
freeMinutes: Int = 0
committedMinutes: Int = 0
completedEffortMinutes: Int = 0
loadScore: Int = 0
wasOverloaded: Bool = false
completionRate: Double = 0
completionBasis: Int = 0
planningCompletedAt: Date? = nil

NightPlanningSession

id: UUID = UUID()
forDate: Date = .now
currentStep: String = "review"
isComplete: Bool = false
isShortened: Bool = false
skippedAt: Date? = nil
createdAt: Date = .now
completedAt: Date? = nil

NudgeLog

id: UUID = UUID()
kind: String = ""
subjectKey: String? = nil
sentAt: Date = .now
dismissedAt: Date? = nil

UserSettings

id: UUID = UUID()
mediumDayMinutes: Int = 180
dayStartMinute: Int = 480
dayEndMinute: Int = 1140
rolloverMinute: Int = 0
weekdayLevels: String = "{\"6\":\"low\",\"7\":\"low\"}"
planningMinute: Int = 1200
morningMinute: Int = 480
onboardingCompletedAt: Date? = nil
firstLaunchAt: Date? = nil
createdAt: Date = .now

Relationships

From To Inverse Delete rule
TaskItem.category TaskCategory? TaskCategory.tasks nullify (categories are archived, not deleted)
TaskItem.deferrals [DeferralRecord]? DeferralRecord.task cascade
TaskItem.sessions [WorkSession]? WorkSession.task cascade
Habit.windows [HabitTimeWindow]? HabitTimeWindow.habit cascade
Habit.group HabitGroup? HabitGroup.habits nullify (archiving a group never deletes its habits)
HabitTimeWindow.entries [HabitEntry]? HabitEntry.window cascade
HabitTimeWindow.sessions [WorkSession]? WorkSession.habitWindow cascade
AnchorRule.anchors [Anchor]? Anchor.rule nullify (rules are archived, never hard-deleted)

WorkSession has exactly one of task / habitWindow set (engine-enforced). Anchor.rule == nil means a one-off, which is why a rule is never hard-deleted. DayPlan, NightPlanningSession, NudgeLog and UserSettings have no relationships.

Dedup keys

CloudKit can't enforce uniqueness, and two devices can each create the same thing, so the engine deduplicates. Each rule must be safe to apply repeatedly and in any order.

Model Key When duplicates exist
UserSettings single row keep the earliest createdAt; delete the rest
TaskCategory presetKey (seeded defaults), else the normalised name (trimmed, case-folded) keep the earliest; move tasks onto it. A category has no settings of its own, so merging identical labels loses nothing
TaskItem (seriesID, dueDate) for a repeating task's next instance keep the earliest createdAt
DeferralRecord (task, day) one per task per day; keep the earliest and refine its reason / deferredTo to the latest choice
WorkSession at most one live (endedAt == nil) the earliest startedAt stays live; the other closes as abandoned at the moment the clash is found, its time logged (elapsed minus pauses). The device that finds the clash shows the one quiet notice
HabitEntry (window, date) keep one; amount = the larger (see limits)
Anchor (rule, occurrenceDate, slotKey) for generated instances keep the earliest; never resurrect a skipped one β€” in practice a decided instance (anything but pending) is kept in preference to a pending one, since deleting it would discard something the user did. One-offs have no key
DayPlan date keep the one with planningCompletedAt (the latest if several), else the one with the most completedEffortMinutes, then the lowest id β€” DayPlan has no timestamp to say which is "most recent" (an additive updatedAt could replace this later)
NightPlanningSession forDate prefer isComplete, then the furthest currentStep, then the latest createdAt
NudgeLog none "max one per day" checks read every row, so duplicates are harmless
Habit, HabitTimeWindow, HabitGroup, AnchorRule none user-created; two real duplicates are the user's to merge

Raw values

Reading an unknown value. An older app may read a raw value a newer app wrote. Every raw-string read falls back to an inert unknown case: the row is kept and never rewritten or deleted, shown neutrally or hidden, excluded from counts and the wellbeing score, and the older app shows a quiet "Update Jamaal to see this" where a row can't be displayed.

Field Values
TaskItem.importance low (default) / medium / high
TaskItem.repeatKind none / daily / weekly / monthly
DeferralRecord.reason tooMuch / notReady / noLonger / reschedule / unspecified
WorkSession.outcome running / finished / deferred / dropped / abandoned / autoClosed / manual
TaskCategory.presetKey personal / family / work (seeded); colorKey: accent / blue / ochre / plum / slate
Habit.kind binary / counted / timed / avoid
Habit.frequency daily / weekdays / custom
Habit.presetKey quran / dhikr / exercise / running (more as presets are defined)
Anchor.attendanceStatus pending / attended / missed / skipped / delegated
AnchorRule.sourceKey prayerWindow / schoolRun / binNight / plantWatering / custom
AnchorRule.placement fixed / flexible
DayPlan.capacity low / medium / high
NightPlanningSession.currentStep review / carry / build / load / close
NudgeLog.kind wellbeing / guidance / fatigue / windowClosing / overload / morningPlanCard / habitPromotion / normalDaySuggestion
UserSettings.weekdayLevels keys ISO weekday "1"…"7" β†’ low / medium / high
Habit.pausesData reasons travel / illness / cycle / other
Anchor exception reasons (in configData) term / holiday / travel / illness / other

Known CloudKit limits

  • Last writer wins per record. If two devices edit the same record at once, one edit is lost. Usually harmless, but counters such as HabitEntry.amount can lose a concurrent increment (two taps on two devices). Accepted for v1; the alternative β€” an append-only event log per tap β€” is heavier than a single-user app needs, and can be added later as an additive model.
  • No uniqueness, no transactions across records. The dedup keys above are the substitute; the rollover and generation steps are written to be idempotent for the same reason.
  • Schema is append-only in production (see above). Adding fields and models later is fine; renames and removals are not.
  • Plan migrations from day one with versioned schemas (VersionedSchema / a migration plan), even while the first version is the only one.

Additive later (deliberately not in the schema yet)

Safe to add without breaking anything, so they are not blockers: TaskItem.sortOrder (manual ordering, open), AnchorRule.sortOrder, a per-tap event log for habit counters, ThreadsKit-dependent colour keys for categories, and the detected-habit offer's supporting data (v1.1).

Freeze checklist

  • 14 models listed, every attribute has a default or is optional
  • No unique constraints; every relationship optional with an inverse and a delete rule
  • Misleading names corrected: TaskItem.priority β†’ importance, targetCount β†’ target, completedCount β†’ amount
  • Day fields added where the dedup key needs a stable day (DeferralRecord.day, WorkSession.day)
  • AnchorRule.isArchived so a rule is never hard-deleted; unused HabitEntry.skippedReason removed; HabitGroup.isExpanded made local
  • Every model that two devices can create twice has a dedup key
  • Dates classified as floating or instant
  • Learning decision made (suggest only), so UserSettings.weekdayLevels is in the schema
  • Journey 4 (Anchors) added Anchor.resolvedAt, remindBeforeStartMinutes and remindBeforeEndMinutes
  • Journey 1 (first launch) added UserSettings.planningMinute, morningMinute and onboardingCompletedAt
  • TaskCategory.colorKey values decided (accent / blue / ochre / plum / slate); the tokens ship in ThreadsKit 1.2.0
  • TaskItem.repeatDayOfMonth added while building the repeating-task rules (monthly on the 31st would otherwise drift); defaulted, so safe
  • NightPlanningSession.isShortened added while building Night Planning (the morning's shortened flow needs to be told apart from the evening one, whose Build step it shares); defaulted, so safe
  • DayPlan.completionBasis added while building wellbeing (the rollover writes the denominator of completionRate, so a day with nothing to finish isn't read as a day where nothing was finished); defaulted, so safe
  • HabitGroup.emoji removed before the freeze (Design draws no emoji anywhere); nothing was in production
  • Habit preset definitions (presetKey is a string, so can follow)
  • Review by the owner, then implement the models in JamaalCore with tests first