schema task - fahadahmed/jamaal-app GitHub Wiki

Task

Status: reconciled with master summary v2 β€” draft, needs review. Builds on the v1-resolved schema and folds in v2's effort estimates, deferral behaviour and hidden Eisenhower. See ADR 0002 for what changed and why.

Something the user does. Created by the user, tracked via completion (not streaks or attendance β€” see Habit and Anchor for those).

Tasks live in one flat list β€” no projects, no tags, no sub-lists. category is a label and optional filter, never a grouping axis.

Fields

Field Type Default Notes
id UUID UUID() Stable identity across CloudKit sync.
title String ""
notes String? nil Optional lightweight markdown (checklists, bold/italic, links, inline code) β€” one note per task. Shown as a tappable checklist in the task detail and beneath the timer during a focus session. The finish and defer sheets can append an optional timestamped line ("Left a voicemail, call back Tuesday") rather than overwrite, so the note becomes the record of the attempts. No headings, tables or images.
dueDate Date? nil Day granularity. nil = backlog / "Someday". Required when importance is medium or high.
effortMinutes Int? nil Optional estimate in minutes. The UI offers 15 / 30 / 60 / 120 as shortcuts and an Other… stepper in 15-minute steps up to 8 hours, so any task can be expressed; 30 is preselected on a new task (the user can change it or choose No estimate, which stores nil). nil counts as zero toward load.
importance String "low" One of low / medium / high β€” there is no "none"; low is the baseline. This is the importance axis of the hidden Eisenhower lens (see below). Medium and high carry extra rules β€” see Importance rules. Raw String for CloudKit-safe simplicity.
isCompleted Bool false
completedAt Date? nil Set when isCompleted flips true; cleared if un-completed.
droppedAt Date? nil Set when the user drops a task (Night Planning carry-forward "Drop", or delete-from-detail). Soft-delete so history/wellbeing detection keep working. A dropped task never appears on Today. In v1 nothing surfaces dropped tasks (no History screen), so a drop is final once the 5-second undo has passed; the data is kept, so a later History screen can show it.
deferralCount Int 0 Times this task has been pushed to a later day β€” by the user (Keep/Later at night, defer from Today) or automatically (see below). Replaces the earlier rolloverCount.
repeatKind String "none" One of none / daily / weekly / monthly. See Repeating tasks.
repeatWeekdays String "" For weekly: ISO weekdays (Mon=1 … Sun=7), e.g. "5" for Fridays. Empty = same weekday as dueDate.
repeatDayOfMonth Int 0 For monthly: the day of the month to keep (1–31). 0 = the due date's day, which the engine fills in the first time the task is completed. Stored so a task on the 31st goes Feb 28, then back to Mar 31, instead of drifting to the 28th.
seriesID UUID? nil Shared by every instance of a repeating task; nil for one-off tasks.
createdAt Date .now

Relationships

  • category: TaskCategory? β€” optional (CloudKit). nil renders as no label.
  • deferrals: [DeferralRecord]? β€” optional (CloudKit). One record per deferral; drives reason history and avoidance detection.
  • sessions: [WorkSession]? β€” optional (CloudKit). The focus sessions run on this task; their time sums into the task's actual time (derived, not stored).

TaskCategory

An editable list, seeded on first launch with three defaults. Refines the earlier fixed personal/family/work strings.

Field Type Default Notes
id UUID UUID()
name String "" User-renamable. Trimmed, 1–24 characters, unique ignoring case among all categories (archived included).
presetKey String? nil "personal" / "family" / "work" for the seeded defaults, nil for user-created. Mirrors Habit.presetKey.
colorKey String "accent" One of accent (teal) / blue / ochre / plum / slate β€” a fixed set of ThreadsKit label tokens, never a free colour picker; terra is excluded because it carries warning and overload. Unknown value β†’ slate. Seeds: Personal accent, Family ochre, Work blue. Final hexes (light / dark) are in the design handoff Β§2: accent #1F6A58 / #8FCBB8, blue #2F5E9E / #9DB9E8, ochre #836312 / #D8BA6A, plum #7B4474 / #D5A6CC, slate #52636E / #AEBCC6.
sortOrder Int 0
isArchived Bool false Soft-delete; tasks keep and still show their label. Defaults can be renamed or archived but not hard-deleted.
createdAt Date .now

Rules: one category per task; at most eight active categories; a label is always a dot plus the name; an archived category is absent from the picker and filter (except as the current selection on a task being edited, shown "β€” archived"), and a repeating task copies it; a new task has none, unless a Today filter is active, in which case it inherits it; categories are labels + an optional Today filter only; they never create sections, never nest, never carry their own settings. That is what keeps this compatible with "no projects, no tags β€” ever".

DeferralRecord

Field Type Default Notes
id UUID UUID()
deferredOn Date .now When the user (or the engine) deferred it (an instant).
day Date .now The logical date this deferral counts for β€” a floating calendar date. With task it is the idempotency key: a task gains at most one deferral per day, and a later same-day choice refines this record (see The day boundary).
deferredTo Date? nil nil = Someday.
reason String "unspecified" One of tooMuch / notReady / noLonger / reschedule / unspecified. Reason chips in the UI: Too much on / Not ready / No longer relevant. unspecified is used for automatic deferrals. A user deferral with no chip (the instant 1st and 2nd) is reschedule; one made with a chip stores that chip's value.
task TaskItem? nil Inverse of TaskItem.deferrals.

Importance rules

Importance has three levels β€” low (the default and the baseline), medium, high β€” and is chosen by the user, mainly set during Night Planning's plan step (it can also be set when adding a task or from the detail sheet).

  1. Medium and high require a due date. Choosing either reveals a due-date row, pre-filled with the day being planned (today when adding on Today; tomorrow when prioritising in Night Planning), which cannot be cleared. Invariant: priority ∈ {medium, high} β‡’ dueDate != nil. Enforced in the engine/view-model (SwiftData can't express it), and normalised on read.
  2. No "Someday" for medium/high. The Someday option is hidden in every date picker for these tasks, with a line such as "Important tasks need a day. Lower its importance to park it."
  3. Important tasks can't keep slipping. On a medium/high task's 3rd deferral its importance is set to low, and the companion says so plainly ("This one keeps slipping, so I've eased it to low. Raise it again when it's real."). Never silent. Once it is low, the date requirement and the Someday restriction no longer apply.
  4. Prioritisation prompt. If a day's plan has five or more tasks and fewer than two of them are medium/high, the companion prompts the user to pick one or two that matter most. It's a prompt, not a block. This appears in Night Planning's plan step (and can appear on Today).
  5. Ordering. By quadrant (doFirst, schedule, fitIn, letGo), then due date, then createdAt. Because low tasks are never important, they always fall in fitIn/letGo and so sort after every medium/high task. A low task with no date is a backlog task.

Focus sessions (Begin / pause / finish)

A task can be worked in a focus session: the user taps Begin, a running chip appears on every tab, and the app stays fully usable. The session records real time so the day can be reviewed honestly and estimates can be learned from.

WorkSession

Field Type Default Notes
id UUID UUID()
startedAt Date .now When Begin was tapped. Elapsed time is always derived from this, never accumulated in memory, so a killed app rebuilds the session exactly.
day Date .now The logical date of startedAt β€” a floating calendar date. Stored so that changing the rollover time later can't silently re-bucket past sessions into different days.
endedAt Date? nil nil while the session is live.
pausedSeconds Int 0 Total time spent in completed pauses.
pausedAt Date? nil Non-nil while an explicit pause is in progress. Backgrounding the app does not pause.
estimateMinutes Int? nil Snapshot of TaskItem.effortMinutes at Begin, so later edits don't rewrite what was estimated.
outcome String "running" One of running / finished / deferred / dropped / abandoned / autoClosed / manual. manual is a habit's minutes added by hand: a finished session with no timing of its own (see Habit β†’ Timed habits).
actualSeconds Int 0 Written when the session closes: elapsed minus pauses.
task TaskItem? nil Inverse of TaskItem.sessions.
habitWindow HabitTimeWindow? nil Set instead of task for a timed habit's session (see habit.md). Exactly one of task / habitWindow is set.

Derived, not stored: elapsed = (endedAt ?? now) βˆ’ startedAt βˆ’ pausedSeconds βˆ’ (pausedAt.map { now βˆ’ $0 } ?? 0); a session is paused when pausedAt != nil, and overrun when elapsed passes estimateMinutes. A session belongs to either a task or a timed habit's window. Everything below is written for tasks; for a habit session there is no task to complete, defer or drop β€” finishing or abandoning simply adds the minutes to that day's habit entry, and the settle sheet offers only Log it.

States and outcomes

Idle β†’ Running β†’ Overrun, with Paused reachable only by an explicit pause from Running or Overrun.

  • Overrun counts up and says nothing. No alarm, no colour change, no nudge, no haptic. The estimate is a guess we learn from, not a contract; "75 of 60" is shown neutrally.
  • Finish (from the chip, the focus screen, or later the Lock Screen): a finish sheet shows actual against estimate and offers one optional note line, with two buttons β€” Done (the task is marked done: isCompleted, completedAt = endedAt) and Stop for now (the session ends as abandoned, the time is logged, the task stays live). A quiet toast offers undo for 5 seconds, which reopens the session and un-completes the task; the time since endedAt is added to pausedSeconds, so the undo window doesn't count as work.
  • One timer at a time. Tapping Begin on a second task, while one is live, raises a settle sheet naming the running task: Done (finished), Stop for now (abandoned β€” time logged, task stays live), Defer to tomorrow (deferred β€” a normal deferral with its count and record), or Drop (dropped β€” sets droppedAt). The new session only starts after the user chooses, and the elapsed time is logged whichever they pick.
  • Stop for now ends the session as abandoned: the partial time is kept, the task stays live and undone. "Abandoning is not failing."
  • Resolving the task ends its session. Ticking a task done, or deferring or dropping it from Today or its detail, while its session is live closes the session with the matching outcome β€” done β†’ finished, defer β†’ deferred, drop β†’ dropped β€” and logs the time.
  • Day rollover: a session still running at the rollover (default midnight, user-set) auto-closes as autoClosed with its partial time, and endedAt is the boundary instant β€” not "now" β€” so a device that slept through it invents no phantom hours. The task is carried to tomorrow without counting a deferral (it was in progress), and tomorrow's list opens with a single row offering to pick it back up. The user-set end of the working day (default 19:00) is only a soft planning boundary and never stops a session; the rollover is the hard one.
  • Multi-device: at most one live session. If two devices each Begin, the later-started one is closed as abandoned (its time is logged) and the user sees one quiet line on next open: "Two timers were running; the later one was stopped and its time logged."

Only tasks with time worth recording need a session; Mark done without one still works, and a task with no session simply has no actual time.

Repeating tasks

A simple repeat for recurring "do" items ("submit timesheet every Friday", "pay rent monthly"). Recurring things that are self-paced stay Habits, and things externally timed and attendance-based stay Anchors; a repeating Task is the completion-based case neither covers.

  • Kinds: daily; weekly on chosen days; monthly (same day-of-month as dueDate, clamped to month end). No end dates and no per-occurrence exceptions in v1.
  • Requires a due date. repeatKind != none β‡’ dueDate != nil. Someday is hidden for repeating tasks.
  • One live instance per series. Only one incomplete instance exists at a time, so a neglected weekly task never piles up into a backlog of copies.
  • Next instance: when the live instance is completed or dropped, the next is created with the next occurrence strictly after max(dueDate, completion day). So finishing a weekly Friday task on a Sunday schedules the next Friday, not the one already passed.
  • Copied to the next instance: title, notes, category, effortMinutes, importance, repeat fields and seriesID. Reset: isCompleted, deferralCount, deferral records, droppedAt.
  • Drop skips only this occurrence β€” the series continues. A separate Stop repeating action in the detail sheet ends it (sets repeatKind to none on the live instance).
  • Deferral rules apply per instance (the 3rd-deferral easing of medium/high importance included). Moving an instance with Keep/Later changes only that instance's date; the following instance still follows the pattern.
  • Un-completing a repeating task: un-ticking it after the next instance exists removes that next instance if it is untouched (not completed, deferred, dropped, edited or timed); otherwise the un-tick is refused with "This one already repeated", so there are never two live instances.
  • Idempotency: two devices may both try to create the next instance. The engine treats (seriesID, dueDate) as its dedup key and removes duplicates (CloudKit has no unique constraints).

Quick dates

Used by the schedule row and the defer picker. They follow the user's calendar's first weekday:

  • Today and Tomorrow β€” as named.
  • Later this week β€” three days from now, but only if that is still in the current week; otherwise it isn't offered.
  • Next week β€” the first day of next week.
  • Someday β€” no date (nil); hidden for medium and high importance.
  • Pick a date β€” the calendar.

Deferral behaviour

A task is deferred when it is moved to a later day after its due day has arrived β€” that is, its dueDate is on or before the current logical date and it is pushed past it without being completed. deferralCount increments and a DeferralRecord is written each time. Moving a task that is due later (pushing tomorrow's task to Thursday while planning, or the overload Move in Night Planning) is rescheduling, not a deferral: no count, no record, no easing. Only real deferrals feed the 3rd-deferral picker, the easing of medium/high importance, the stale flag and the avoidance pattern.

Deferral # What happens
1st, 2nd Instant: task moves to tomorrow, no prompt.
3rd onwards A date picker opens (quick options: Later this week / Next week / Someday, plus calendar) with reason chips. Companion copy: "This one keeps slipping. Pick a day that actually works." If the task was medium/high, its priority is first eased to low (see Importance rules), which is what makes Someday available in this picker.
5th onwards Rules engine also raises a suggest removal signal.

Where it happens:

  • Night Planning step 2 (Carry forward): each incomplete task gets Keep (β†’ tomorrow, counts as a deferral) / Later (date picker) / Drop. See night-planning.md.
  • Today: a task can be deferred directly from its detail sheet.
  • Automatic: at day rollover (see The day boundary), if a dated task is still incomplete and wasn't already deferred that day, the engine defers it to today-again as an overdue item: deferralCount += 1, reason unspecified. So skipping planning never loses a task, and the 3-deferral rule still applies. A task gains at most one deferral per logical day, whichever path gets there first; if the user then chooses Keep / Later that same day (for example running Night Planning at 00:30 after a midnight rollover), their choice refines the existing record β€” its reason and deferredTo β€” instead of adding a second.

Derived, never stored

These are computed by the rules engine at read time (module 1), not persisted:

  • Urgency β€” a task is urgent if dueDate is tomorrow or earlier (overdue included), or deferralCount >= 3. The "tomorrow" window is a tunable constant.

  • Importance β€” important if importance is medium or high. Because those levels always have a due date, urgency is always defined for important tasks.

  • Eisenhower quadrant β€” from those two booleans. Never shown to the user as a matrix or label; it only drives ordering, capacity filtering and prompts.

    Important Not important
    Urgent doFirst fitIn
    Not urgent schedule letGo

    (doFirst is from the v2 design; the other three names are proposals.)

  • Stale β€” deferralCount >= 3.

CloudKit constraints applied

  • All properties have defaults or are optional.
  • No @Attribute(.unique).
  • All relationships are optional.

Open questions

  • Learning from actuals: sessions record actual time against the estimate, but how that feeds back (suggested estimates on new tasks, calibrating the medium-day length) is part of the still-open capacity-model decision.
  • Category colours are decided (five label tokens; new ThreadsKit 1.2.0 tokens blue, ochre, plum, slate) β€” the hues are to be confirmed against the Claude Design tokens before they are cut; see threadskit-usage.
  • Re-raising after an auto-downgrade: if the user raises a downgraded task back to medium/high, its deferralCount is still 3+, so its next deferral downgrades it again immediately. Probably right ("keeps slipping"), but confirm β€” the alternative is to reset the count used for this rule when importance is re-raised.
  • Prioritisation prompt: soft (dismissible) as written. Should Night Planning instead require at least one priority task before the day can be closed when the threshold is hit?
  • Manual ordering: none in v1. Today's order comes from the engine (quadrant, then due date); the user steers it with importance and dates. This is a working hypothesis to validate in real use β€” if the order feels wrong, a per-task sortOrder can be added later without a migration problem.
  • Sharing/referral: deferred to v1.1 (see CLAUDE.md), no schema impact for now.