Plan: Optional time on task Due, driving calendar placement + reminders¶
URL: https://mkdocs.justinsforge.com/memory/plans/tasks-due-time-calendar-reminders-2026-07-02/
Repo: /home/justinwieb/forge-suite (Next 16, RLS Postgres lifeos on CT109). App = CreatorTrack Suite Tasks + Calendar.
Goal¶
Let a task's Due carry an optional time-of-day. When a task is "due at 3pm" it renders on the Calendar at 3pm (not as an all-day chip), and its reminder can be derived from the due time via offset presets (at time / 10 min / 1 hr / 1 day before), with a manual override still allowed. Tasks with a date-only Due keep today's all-day behavior exactly.
Decisions locked with Justin (2026-07-02):
- Time model: one Due field that is a date OR a date+time (no separate start/scheduled field).
- Reminders: derive the existing Reminder datetime from the due time via offset presets; manual set still wins if used.
Architecture facts (why this is code-only, no migration)¶
- Tasks are node rows in a Notion-style collection DB, NOT the legacy
tasks.tasksSQL table. Date fields are collection props typeddatewhose value is an arbitrary string. Dueprop is typedate(lib/system-apps.ts:225);Reminderprop is ALSO typedate(lib/system-apps.ts:256) yet already stores a full ISO datetime string. Sodate-typed props already carry datetimes; storing a datetime inDueneeds no new prop type and no schema migration.- Write path
setCell(lib/tasks.ts:462-466) persists the value string verbatim (no time-stripping). Read pathdueDateOnly()/dueRaw(lib/tasks.ts:68-70,259,270) already slices.slice(0,10)for day-boundary compares and the comment states Due "may be ISO date OR datetime" — the compare logic already tolerates a time. - Calendar overlay:
app/api/apps/calendar/route.tsdueTasks()projectst.dueintoCalendarTask.due(lib/calendar.ts:14-24); Day/Week/Month views (components/apps/calendar/*) currently render tasks as all-day. - Reminder firing: the lazy notification sweep watches the
Reminderdatetime vsnow()(surfaced viaapp/api/notifications/route.ts). Tasks settings already holddefaultReminderTime"HH:MM" +remindersEnabled(lib/tasks.ts:142-150).
Non-goals¶
- No change to the standalone
app.reminders(reminders-center) primitive. This plan wires the task's ownReminderprop only. - No recurring-time / duration / end-time (that was the rejected "Start + Due times" option). A task occupies a single instant on the calendar, not a block.
- No change to habits/goals/other collections'
dateprops.
Phase 1 - Shared date/time helpers + reminder derivation (data + client mirror)¶
Single source of truth for parsing/formatting a due value that may be yyyy-mm-dd or yyyy-mm-ddThh:mmZ, and for computing a Reminder datetime from (due datetime, offset).
lib/tasks.ts: add exported helpers next to the existing date block (lib/tasks.ts:51-77):dueHasTime(due: string | null): boolean(true iff the string carries aTtime component).splitDue(due)->{ date: string|null, time: string|null }(time as local "HH:MM" in America/Chicago).composeDue(date: string|null, time: string|null): string|null-> date-only string when time is null, else an ISO instant built at the Chicago offset for that date (DST-correct; reuse the TZ constant, never naive UTC).deriveReminderISO(due: string|null, offset: ReminderOffset): string|nullwhereReminderOffset = "none"|"at"|"10m"|"1h"|"1d"(returns null when due has no time or offset is "none").- Keep
dueDateOnlysemantics unchanged (day-boundary math stays on the date portion, so Today/Overdue/Next7 counts are unaffected for timed tasks). components/apps/tasks/taskDate.ts: mirrordueHasTime,splitDue,composeDue, and aformatDueLabel(due, timeFormat)respecting the user'stimeFormat("12h"/"24h") setting. This file is the client-safe mirror (it must not importlib/tasks.ts, per its header attaskDate.ts:1-8).
Verify: npx tsc --noEmit clean. Add throwaway node asserts (or a scratch script) proving composeDue("2026-07-04","15:00") round-trips through splitDue back to {date:"2026-07-04", time:"15:00"} across a DST boundary, and deriveReminderISO(dueAt3pm,"1h") == 2pm instant.
Commit: feat(tasks): due date/time + reminder-offset helpers
Phase 2 - Task editor UI: time picker + reminder-offset presets¶
components/apps/tasks/Composer.tsx: beside the existing Due date input, add an optional time field (empty = all-day). When a time is set, reveal a Reminder dropdown with presetsNone / At time / 10 min before / 1 hr before / 1 day before, defaulting from the user's setting (Phase 4). Compose the outgoingdueviacomposeDueand sendreminder(derived) on submit.components/apps/tasks/TaskContextMenu.tsx: same time + reminder-offset controls for editing an existing task's Due (this is the row/detail quick-edit surface). Manual Reminder edit, if present here, remains authoritative (last write wins).- Display:
formatDueLabelused wherever a due chip/label renders with a time - auditNext7View.tsx,MatrixView.tsx,CalendarView.tsx,TasksClient.tsxand show the time only whendueHasTime(date-only rows look identical to today).
Verify: On dev (dev.creatortrack.ai), create a task due tomorrow 3:00pm, confirm the chip shows "3:00 PM", reopen the editor and confirm the time + reminder offset persisted (round-trips through the API).
Commit: feat(tasks): optional due time + reminder offset in editor
Phase 3 - API: accept due-with-time + persist derived reminder¶
app/api/tasks/route.ts(create) andapp/api/tasks/[id]/route.ts(update): acceptduecontaining a datetime and an optionalreminderOffset; server recomputesreminderviaderiveReminderISO(never trust a client-sent reminder for the derived path, but honor an explicit manualreminderwhen the client sends one and no offset). Pass through tocreateTaskRow/update inlib/tasks.ts(which already writesdue/reminderstrings verbatim).- Guard: reject a
duetime on a task with no date; strip time silently only if product wants date-only fallback (default: keep whatever the client composed).
Verify: curl create with a datetime due + reminderOffset:"1h"; GET the task and assert due carries the time and reminder == due-minus-1h. Repeat update path.
Commit: feat(tasks): API persists due time + derives reminder
Phase 4 - Settings: default reminder offset¶
lib/tasks.tssettings block (:139-188): adddefaultReminderOffset: ReminderOffset(default"none") alongsidedefaultReminderTime. Sanitize + persist in the sametasks_appjsonb_set blob.app/api/tasks/settings/route.ts+components/apps/tasks/taskSettingsContext.tsx+ the settings modal: expose the default-offset control. New timed tasks pre-fill their reminder preset from it.
Verify: Set default to "10 min before" in settings, create a timed task, confirm the editor pre-selects that offset and the stored reminder reflects it.
Commit: feat(tasks): default reminder-offset setting
Phase 5 - Calendar renders timed tasks at their time¶
lib/calendar.tsCalendarTask(:14-24): addallDay: boolean(derived fromdueHasTime).app/api/apps/calendar/route.tsdueTasks(): setallDayand keep passing the rawdue.components/apps/calendar/DayView.tsx+WeekView.tsx: place a timed task chip in its hour lane at the due instant (reuse the event-positioning math incalendarMath.ts); date-only tasks stay in the all-day gutter.MonthView.tsx: prefix the chip with the time label. Ensure done tasks render struck/dimmed as today.- Confirm the task chip's click/deeplink still opens the task (no regression to the existing overlay behavior).
Verify: On dev calendar Day + Week views, the 3:00pm task sits at the 3pm line; an all-day task stays in the gutter. Month view shows "3:00 PM Title".
Commit: feat(calendar): render timed tasks at their due time
Phase 6 - End-to-end QA + prod deploy¶
- Dev E2E: create a task due tomorrow 3:00pm with "1 hr before" reminder -> appears on calendar at 3pm -> confirm a
Reminderdatetime of 2pm is stored and the notifications sweep would surface it at 2pm (inspect/api/notificationspayload / bell). Toggle the task to all-day -> reverts to gutter + no reminder. - Regression: Today/Tomorrow/Next7/Overdue counts unchanged for date-only tasks; a timed task due today still counts as Today.
npx tsc --noEmitclean; restartcreatortrack-dev; then prod:npm run build+sudo systemctl restart creatortrack-prod; verifycurl -s -o /dev/null -w "%{http_code}" https://app.creatortrack.ai/login== 200 and the calendar GET returns tasks withallDay.- Update
memory/daily/2026-07-02.md+ a short note in the CreatorTrack reference; register nothing new in MEMORY.md (no new script).
Commit: feat(tasks+calendar): ship optional due time end-to-end (or fold into Phase 5 if small).
Risks / watch-items¶
- Timezone correctness: all compose/derive math must go through America/Chicago offsets, never naive UTC (doctrine:
date.today()banned; LOCAL_TZ). DST boundary is the main trap - covered by Phase 1 asserts. - Day-boundary counts: Today/Overdue must keep comparing on the date portion so a timed task isn't miscounted.
dueDateOnlyalready slices; keep it the only comparator. - Backfill: existing date-only tasks are untouched (no time = all-day), so zero data migration and no visual change for them.
- Manual vs derived reminder: last-write-wins; document that changing the due time re-derives the reminder only when an offset preset is selected, not when the user hand-set a manual Reminder.
[Claude Code]