Skip to content

fix(tasks): refuse a due or start date the calendar does not have - #1951

Merged
h4yfans merged 3 commits into
mainfrom
task-date-calendar-validation
Sep 2, 2026
Merged

fix(tasks): refuse a due or start date the calendar does not have#1951
h4yfans merged 3 commits into
mainfrom
task-date-calendar-validation

Conversation

@h4yfans

@h4yfans h4yfans commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #1923.

dueDate and startDate were validated by a bare /^\d{4}-\d{2}-\d{2}$/, so 2026-02-30, 2025-02-29 and 2026-13-01 parsed clean and landed in the tasks row. completedAt is a z.string().datetime() and zod already refused the impossible instant, which is the asymmetry the issue names.

packages/contracts/src/calendar-date.ts holds one CalendarDateSchema. It keeps the regex as a first pass, then rebuilds the date from its parts and checks the parts survive. TaskCreateSchema and TaskUpdateSchema use it on both fields. The round trip goes through the local constructor and the local getters, the same pair localMidnight uses for the done date in #1922; new Date('2026-01-01') parses as UTC, so reading getDate() off it answers 31 December anywhere west of UTC and refuses a real date.

taskPatchSchema in the Vault MCP tool schemas gets the same schema on due, due_date and start_date. The agent's writes run handles.tasks.create into createDesktopTasksDomain, which never touches TaskCreateSchema, so that tool schema is the only gate on the path. The isoDateSchema the journal and calendar read tools share is left alone.

Validate on write, tolerate on read. Nothing parses a task row on the way out: the IPC responses, the RPC Task and the calendar projection items are plain interfaces, and the query layer compares dueDate as a SQLite string. TaskSyncPayloadSchema stays deliberately loose, because a strict date there would make a receiving device reject a peer's older row forever, on a value the user cannot see to fix.

Release note

An Obsidian task line carrying a date the calendar does not have, such as 📅 2026-02-30 or 📅 2025-02-29, no longer imports as a task with a due date that never arrives. The line is left exactly as written and a message names the refused date.

Test plan

  • vitest --project shared packages/contracts/src/ — 52 files, 1724 tests pass. tasks-api.test.ts covers 2026-02-30, 2025-02-29, 2026-13-01 and 2024-02-29 on create and update; calendar-date.test.ts pins the year boundary and the leap day in Pacific/Pago_Pago, Pacific/Kiritimati, UTC and Asia/Kolkata, and a day whose local midnight does not exist in America/Santiago and America/Havana; sync-payloads.test.ts pins that a peer's stored impossible date still parses.
  • Fourteen of those cases fail on the first commit of this branch, which lands the tests before the fix.
  • vitest --project main src/main/agent/mcp — 15 files, 124 tests pass. Reverting schemas.ts to origin/main and rerunning turns the new case red with expected true to be false, so the MCP guard is load-bearing rather than duplicated.
  • vitest --project main src/main/database/queries/tasks.test.ts — a seeded row holding dueDate: '2026-02-30' and startDate: '2025-02-29' reads back verbatim through getTaskById, appears in listTasks, sorts correctly under sortBy: 'dueDate', is found by getTasksByDueDate, counts in getTaskStats, survives duplicateTask, and does not throw from getOverdueTasks.
  • vitest --project renderer src/renderer/src/lib/task-utils — 4 files, 423 tests pass, including new cases pinning that parseDueDate rolls a stored impossible date onto a real day instead of throwing or returning Invalid Date.
  • pnpm lint, pnpm typecheck, pnpm --filter @memry/desktop typecheck:test, pnpm check:architecture, pnpm check:contracts, pnpm ipc:generate && pnpm ipc:check, pnpm --filter @memry/desktop i18n:check, git diff --check all pass.
  • pnpm docs:impact --base origin/main --strict and pnpm docs:build pass.
  • npx react-doctor reports only pre-existing findings under apps/mobile, none in files this branch touches.
  • No E2E was run from this worktree. apps/desktop/tests/e2e/obsidian-tasks-import.e2e.ts needed no edit; it uses no impossible dates.

TaskCreateSchema and TaskUpdateSchema validate dueDate and startDate with a
bare \d{4}-\d{2}-\d{2}, so 2026-02-30, 2025-02-29 and 2026-13-01 parse
clean and reach the tasks row. completedAt is a z.string().datetime() and zod
already refuses the impossible instant, which is the asymmetry these cases
record. 2024-02-29 is here to hold the line the other way: a leap day is a
real date and must keep parsing.

Fourteen of the sixteen cases fail on this commit.

Part of #1923.
CalendarDateSchema replaces the shape regex on dueDate and startDate in
TaskCreateSchema and TaskUpdateSchema. It keeps the regex as a first pass and
then rebuilds the date from its parts, so a value that rolls over to another
day is refused. The IPC boundary already turns a ZodError into
`Validation failed: dueDate: <message>`, and the task mutation hooks already
read that through extractErrorMessage, so the refusal reaches the user as a
toast instead of a row nobody asked for.

The round trip uses the local constructor and the local getters, the pair
localMidnight already uses for the done date in #1922. new Date('2026-01-01')
parses as UTC, so reading getDate() off it answers 31 December anywhere west of
UTC and refuses a real date; the test pins that in four zones plus two whose
clocks skip local midnight.

taskPatchSchema gets the same schema on due, due_date and start_date. The
agent's writes go handles.tasks.create -> createDesktopTasksDomain, which never
touches TaskCreateSchema, so the MCP tool schema is the only gate on that path.
The isoDateSchema the journal and calendar read tools share is left alone.

Validate on write, tolerate on read. Nothing parses a task row on the way out:
the IPC responses, the RPC Task and the calendar projection items are all plain
interfaces, and the query layer compares dueDate as a SQLite string. A seeded
row holding 2026-02-30 and 2025-02-29 is read back verbatim, listed, sorted,
counted and duplicated. TaskSyncPayloadSchema stays deliberately loose, because
a strict date there would make a receiving device reject a peer's old row
forever. The renderer keeps rolling such a value onto a real day rather than
throwing.

Closes #1923.
…ble date

The page already covered a done date the calendar does not have. Due and start
dates now behave the same way, and the outcome is different enough to write
down: the import stops, the checkbox stays a checkbox, the line is left as the
user wrote it, and the message names the refused date.

Part of #1923.
@github-actions github-actions Bot added bug Something isn't working dependencies documentation Improvements or additions to documentation test labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 8039032.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@h4yfans
h4yfans marked this pull request as ready for review September 2, 2026 16:19
@h4yfans
h4yfans merged commit 6654edd into main Sep 2, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working dependencies documentation Improvements or additions to documentation test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: A task date that does not exist on the calendar is accepted and stored

1 participant