Skip to content

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.tasks SQL table. Date fields are collection props typed date whose value is an arbitrary string.
  • Due prop is type date (lib/system-apps.ts:225); Reminder prop is ALSO type date (lib/system-apps.ts:256) yet already stores a full ISO datetime string. So date-typed props already carry datetimes; storing a datetime in Due needs 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 path dueDateOnly()/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.ts dueTasks() projects t.due into CalendarTask.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 Reminder datetime vs now() (surfaced via app/api/notifications/route.ts). Tasks settings already hold defaultReminderTime "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 own Reminder prop 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' date props.

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).

  1. lib/tasks.ts: add exported helpers next to the existing date block (lib/tasks.ts:51-77):
  2. dueHasTime(due: string | null): boolean (true iff the string carries a T time component).
  3. splitDue(due) -> { date: string|null, time: string|null } (time as local "HH:MM" in America/Chicago).
  4. 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).
  5. deriveReminderISO(due: string|null, offset: ReminderOffset): string|null where ReminderOffset = "none"|"at"|"10m"|"1h"|"1d" (returns null when due has no time or offset is "none").
  6. Keep dueDateOnly semantics unchanged (day-boundary math stays on the date portion, so Today/Overdue/Next7 counts are unaffected for timed tasks).
  7. components/apps/tasks/taskDate.ts: mirror dueHasTime, splitDue, composeDue, and a formatDueLabel(due, timeFormat) respecting the user's timeFormat ("12h"/"24h") setting. This file is the client-safe mirror (it must not import lib/tasks.ts, per its header at taskDate.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

  1. 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 presets None / At time / 10 min before / 1 hr before / 1 day before, defaulting from the user's setting (Phase 4). Compose the outgoing due via composeDue and send reminder (derived) on submit.
  2. 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).
  3. Display: formatDueLabel used wherever a due chip/label renders with a time - audit Next7View.tsx, MatrixView.tsx, CalendarView.tsx, TasksClient.tsx and show the time only when dueHasTime (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

  1. app/api/tasks/route.ts (create) and app/api/tasks/[id]/route.ts (update): accept due containing a datetime and an optional reminderOffset; server recomputes reminder via deriveReminderISO (never trust a client-sent reminder for the derived path, but honor an explicit manual reminder when the client sends one and no offset). Pass through to createTaskRow/update in lib/tasks.ts (which already writes due/reminder strings verbatim).
  2. Guard: reject a due time 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

  1. lib/tasks.ts settings block (:139-188): add defaultReminderOffset: ReminderOffset (default "none") alongside defaultReminderTime. Sanitize + persist in the same tasks_app jsonb_set blob.
  2. 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

  1. lib/calendar.ts CalendarTask (:14-24): add allDay: boolean (derived from dueHasTime). app/api/apps/calendar/route.ts dueTasks(): set allDay and keep passing the raw due.
  2. 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 in calendarMath.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.
  3. 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

  1. Dev E2E: create a task due tomorrow 3:00pm with "1 hr before" reminder -> appears on calendar at 3pm -> confirm a Reminder datetime of 2pm is stored and the notifications sweep would surface it at 2pm (inspect /api/notifications payload / bell). Toggle the task to all-day -> reverts to gutter + no reminder.
  2. Regression: Today/Tomorrow/Next7/Overdue counts unchanged for date-only tasks; a timed task due today still counts as Today.
  3. npx tsc --noEmit clean; restart creatortrack-dev; then prod: npm run build + sudo systemctl restart creatortrack-prod; verify curl -s -o /dev/null -w "%{http_code}" https://app.creatortrack.ai/login == 200 and the calendar GET returns tasks with allDay.
  4. 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. dueDateOnly already 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]