From a7aa35bf3ec9835a5dcea871a94b5e27fc91d1ef Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Thu, 2 Apr 2026 20:28:37 -0400 Subject: [PATCH 1/8] Add due time support to task and preference APIs --- .codex/environments/environment.toml | 11 ++ package-lock.json | 19 -- scripts/launchers/dispatch-dev.sh | 0 src/app/api/dispatches/[id]/tasks/route.ts | 1 + src/app/api/me/__tests__/route.test.ts | 27 +++ src/app/api/me/route.ts | 19 +- src/app/api/recurrences/[id]/route.ts | 9 + .../api/recurrences/__tests__/route.test.ts | 174 ++++++++++++++++++ src/app/api/recurrences/route.ts | 9 + src/app/api/tasks/[id]/route.ts | 17 ++ src/app/api/tasks/__tests__/route.test.ts | 13 ++ src/app/api/tasks/route.ts | 15 ++ src/app/page.tsx | 16 +- src/app/profile/page.tsx | 3 + src/components/Dashboard.tsx | 41 ++++- src/components/DispatchPage.tsx | 7 +- src/components/IntegrationsPage.tsx | 8 +- src/components/PriorityInboxPage.tsx | 3 +- src/components/ProfilePreferences.tsx | 44 +++++ src/components/ProjectsPage.tsx | 3 +- src/components/RecurrenceSeriesModal.tsx | 18 +- src/components/RecurringTasksPage.tsx | 3 +- src/components/TaskModal.tsx | 33 +++- src/components/TasksPage.tsx | 8 +- src/db/__tests__/bootstrap.test.ts | 18 ++ src/db/bootstrap.ts | 43 ++++- src/db/schema.ts | 5 + src/db/seed.ts | 6 +- src/lib/client.ts | 8 + src/lib/due-time.ts | 34 ++++ src/lib/recurrence-series-sync.ts | 5 + src/lib/task-recurrence-preview.ts | 6 +- src/mcp-server/tools/tasks.ts | 22 +++ src/test/db.ts | 5 +- 34 files changed, 599 insertions(+), 54 deletions(-) create mode 100644 .codex/environments/environment.toml mode change 100644 => 100755 scripts/launchers/dispatch-dev.sh create mode 100644 src/app/api/recurrences/__tests__/route.test.ts create mode 100644 src/lib/due-time.ts diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..443dfd6 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "DispatchTodoApp" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "npm run dev" diff --git a/package-lock.json b/package-lock.json index 19c48b7..ac22011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -271,7 +271,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3173,7 +3172,6 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3183,7 +3181,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3249,7 +3246,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -3888,7 +3884,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4285,7 +4280,6 @@ "integrity": "sha512-VNfYJI2+tEUHmMihWTd4P76s1FRvWWKJBhpfJhxPfgsQPezQsYqK8QJZFfLa9tc9bvVVDXhHucDln3I49y2Gcg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -4382,7 +4376,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5384,7 +5377,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5468,7 +5460,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5985,7 +5976,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6630,7 +6620,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9127,7 +9116,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -9322,7 +9310,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9332,7 +9319,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10531,7 +10517,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11273,7 +11258,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11562,7 +11546,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12140,7 +12123,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12458,7 +12440,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/scripts/launchers/dispatch-dev.sh b/scripts/launchers/dispatch-dev.sh old mode 100644 new mode 100755 diff --git a/src/app/api/dispatches/[id]/tasks/route.ts b/src/app/api/dispatches/[id]/tasks/route.ts index 508965b..aca41df 100644 --- a/src/app/api/dispatches/[id]/tasks/route.ts +++ b/src/app/api/dispatches/[id]/tasks/route.ts @@ -35,6 +35,7 @@ export const GET = withAuth(async (req, session, ctx) => { status: tasks.status, priority: tasks.priority, dueDate: tasks.dueDate, + dueTime: tasks.dueTime, recurrenceType: tasks.recurrenceType, recurrenceBehavior: tasks.recurrenceBehavior, recurrenceRule: tasks.recurrenceRule, diff --git a/src/app/api/me/__tests__/route.test.ts b/src/app/api/me/__tests__/route.test.ts index aba9e46..b7a773a 100644 --- a/src/app/api/me/__tests__/route.test.ts +++ b/src/app/api/me/__tests__/route.test.ts @@ -21,6 +21,7 @@ const TEST_USER = { role: "admin" as const, showAdminQuickAccess: true, assistantEnabled: true, + dashboardDueTimesEnabled: false, timeZone: null, }; @@ -65,6 +66,7 @@ describe("Me API", () => { expect(await res.json()).toEqual({ showAdminQuickAccess: false, assistantEnabled: true, + dashboardDueTimesEnabled: false, timeZone: null, templatePresets: EMPTY_TEMPLATE_PRESETS, }); @@ -86,6 +88,7 @@ describe("Me API", () => { expect(await res.json()).toEqual({ showAdminQuickAccess: true, assistantEnabled: false, + dashboardDueTimesEnabled: false, timeZone: null, templatePresets: EMPTY_TEMPLATE_PRESETS, }); @@ -98,6 +101,28 @@ describe("Me API", () => { expect(updated.assistantEnabled).toBe(false); }); + it("PUT updates dashboard due time visibility", async () => { + const res = await PUT( + jsonReq("http://localhost/api/me", { dashboardDueTimesEnabled: true }), + {}, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + showAdminQuickAccess: true, + assistantEnabled: true, + dashboardDueTimesEnabled: true, + timeZone: null, + templatePresets: EMPTY_TEMPLATE_PRESETS, + }); + + const [updated] = testDb.db + .select({ dashboardDueTimesEnabled: users.dashboardDueTimesEnabled }) + .from(users) + .where(eq(users.id, TEST_USER.id)) + .all(); + expect(updated.dashboardDueTimesEnabled).toBe(true); + }); + it("PUT rejects invalid payload values", async () => { const res = await PUT( jsonReq("http://localhost/api/me", { showAdminQuickAccess: "nope" }), @@ -115,6 +140,7 @@ describe("Me API", () => { expect(await res.json()).toEqual({ showAdminQuickAccess: true, assistantEnabled: true, + dashboardDueTimesEnabled: false, timeZone: "America/Los_Angeles", templatePresets: EMPTY_TEMPLATE_PRESETS, }); @@ -173,6 +199,7 @@ describe("Me API", () => { expect(await res.json()).toEqual({ showAdminQuickAccess: true, assistantEnabled: true, + dashboardDueTimesEnabled: false, timeZone: null, templatePresets, }); diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index 08a6e3c..764f507 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -14,6 +14,7 @@ export const GET = withAuth(async (_req, session) => { .select({ showAdminQuickAccess: users.showAdminQuickAccess, assistantEnabled: users.assistantEnabled, + dashboardDueTimesEnabled: users.dashboardDueTimesEnabled, timeZone: users.timeZone, templatePresets: users.templatePresets, }) @@ -25,6 +26,7 @@ export const GET = withAuth(async (_req, session) => { user: session.user, showAdminQuickAccess: preferences?.showAdminQuickAccess ?? true, assistantEnabled: preferences?.assistantEnabled ?? true, + dashboardDueTimesEnabled: preferences?.dashboardDueTimesEnabled ?? false, timeZone: preferences?.timeZone ?? null, templatePresets: parseStoredTemplatePresets(preferences?.templatePresets), }); @@ -38,7 +40,13 @@ export const PUT = withAuth(async (req, session) => { return errorResponse("Invalid JSON body", 400); } - const { showAdminQuickAccess, assistantEnabled, timeZone, templatePresets } = body as Record; + const { + showAdminQuickAccess, + assistantEnabled, + dashboardDueTimesEnabled, + timeZone, + templatePresets, + } = body as Record; if (showAdminQuickAccess !== undefined && typeof showAdminQuickAccess !== "boolean") { return errorResponse("showAdminQuickAccess must be a boolean", 400); @@ -48,6 +56,10 @@ export const PUT = withAuth(async (req, session) => { return errorResponse("assistantEnabled must be a boolean", 400); } + if (dashboardDueTimesEnabled !== undefined && typeof dashboardDueTimesEnabled !== "boolean") { + return errorResponse("dashboardDueTimesEnabled must be a boolean", 400); + } + if (timeZone !== undefined && timeZone !== null && typeof timeZone !== "string") { return errorResponse("timeZone must be a string or null", 400); } @@ -74,6 +86,7 @@ export const PUT = withAuth(async (req, session) => { if ( showAdminQuickAccess === undefined && assistantEnabled === undefined + && dashboardDueTimesEnabled === undefined && timeZone === undefined && templatePresets === undefined ) { @@ -83,6 +96,7 @@ export const PUT = withAuth(async (req, session) => { const updates: Record = {}; if (showAdminQuickAccess !== undefined) updates.showAdminQuickAccess = showAdminQuickAccess; if (assistantEnabled !== undefined) updates.assistantEnabled = assistantEnabled; + if (dashboardDueTimesEnabled !== undefined) updates.dashboardDueTimesEnabled = dashboardDueTimesEnabled; if (timeZone !== undefined) updates.timeZone = typeof timeZone === "string" ? timeZone.trim() : null; if (validatedTemplatePresets !== undefined) { updates.templatePresets = serializeTemplatePresets(validatedTemplatePresets); @@ -95,6 +109,7 @@ export const PUT = withAuth(async (req, session) => { .returning({ showAdminQuickAccess: users.showAdminQuickAccess, assistantEnabled: users.assistantEnabled, + dashboardDueTimesEnabled: users.dashboardDueTimesEnabled, timeZone: users.timeZone, templatePresets: users.templatePresets, }); @@ -104,6 +119,8 @@ export const PUT = withAuth(async (req, session) => { updated?.showAdminQuickAccess ?? (showAdminQuickAccess as boolean | undefined) ?? true, assistantEnabled: updated?.assistantEnabled ?? (assistantEnabled as boolean | undefined) ?? true, + dashboardDueTimesEnabled: + updated?.dashboardDueTimesEnabled ?? (dashboardDueTimesEnabled as boolean | undefined) ?? false, timeZone: updated?.timeZone ?? (typeof timeZone === "string" ? timeZone.trim() : null), templatePresets: parseStoredTemplatePresets(updated?.templatePresets), }); diff --git a/src/app/api/recurrences/[id]/route.ts b/src/app/api/recurrences/[id]/route.ts index ac6b5f4..6103944 100644 --- a/src/app/api/recurrences/[id]/route.ts +++ b/src/app/api/recurrences/[id]/route.ts @@ -9,6 +9,7 @@ import { type TaskRecurrenceBehavior, type TaskRecurrenceType, } from "@/lib/task-recurrence"; +import { isValidDueTime } from "@/lib/due-time"; const VALID_PRIORITIES = ["low", "medium", "high"] as const; const VALID_SERIES_TYPES = ["daily", "weekly", "monthly", "custom"] as const; @@ -42,6 +43,7 @@ export const PUT = withAuth(async (req, session, ctx) => { recurrenceBehavior, recurrenceRule, nextDueDate, + dueTime, active, } = body as Record; @@ -78,6 +80,12 @@ export const PUT = withAuth(async (req, session, ctx) => { if (typeof nextDueDate === "string" && !isIsoDate(nextDueDate)) { return errorResponse("nextDueDate must be a YYYY-MM-DD date", 400); } + if (dueTime !== undefined && dueTime !== null && typeof dueTime !== "string") { + return errorResponse("dueTime must be a string (HH:MM) or null", 400); + } + if (typeof dueTime === "string" && !isValidDueTime(dueTime)) { + return errorResponse("dueTime must be a valid 24-hour time in HH:MM format", 400); + } if (active !== undefined && typeof active !== "boolean") { return errorResponse("active must be a boolean", 400); } @@ -146,6 +154,7 @@ export const PUT = withAuth(async (req, session, ctx) => { if (recurrenceBehavior !== undefined) updates.recurrenceBehavior = recurrenceBehavior as TaskRecurrenceBehavior; if (hasRecurrenceRule || hasRecurrenceType) updates.recurrenceRule = nextRule; if (nextDueDate !== undefined) updates.nextDueDate = nextDueDate; + if (dueTime !== undefined) updates.dueTime = dueTime; if (active !== undefined) updates.active = active; const [updated] = await db diff --git a/src/app/api/recurrences/__tests__/route.test.ts b/src/app/api/recurrences/__tests__/route.test.ts new file mode 100644 index 0000000..3afec0c --- /dev/null +++ b/src/app/api/recurrences/__tests__/route.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockSession } from "@/test/setup"; +import { createTestDb } from "@/test/db"; +import { recurrenceSeries, tasks, users } from "@/db/schema"; + +let testDb: ReturnType; + +vi.mock("@/db", () => ({ + get db() { + return testDb.db; + }, +})); + +const { GET, POST } = await import("@/app/api/recurrences/route"); +const { PUT, DELETE } = await import("@/app/api/recurrences/[id]/route"); +const { GET: GET_TASKS } = await import("@/app/api/tasks/route"); + +const TEST_USER = { id: "user-1", name: "Test User", email: "test@test.com", timeZone: "UTC" }; + +function todayIsoUtc() { + return new Date().toISOString().slice(0, 10); +} + +function jsonReq(url: string, method: string, body?: unknown) { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +function ctx(id: string) { + return { params: Promise.resolve({ id }) }; +} + +describe("Recurrences API", () => { + beforeEach(() => { + testDb = createTestDb(); + testDb.db.insert(users).values(TEST_USER).run(); + mockSession({ user: TEST_USER }); + }); + + it("creates a recurrence series with description and dueTime", async () => { + const res = await POST( + jsonReq("http://localhost/api/recurrences", "POST", { + title: "Morning review", + description: "Check inbox and triage tasks", + recurrenceType: "daily", + nextDueDate: "2099-01-01", + dueTime: "09:00", + }), + {}, + ); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.description).toBe("Check inbox and triage tasks"); + expect(data.dueTime).toBe("09:00"); + }); + + it("rejects invalid dueTime values", async () => { + const res = await POST( + jsonReq("http://localhost/api/recurrences", "POST", { + title: "Morning review", + recurrenceType: "daily", + nextDueDate: "2099-01-01", + dueTime: "24:30", + }), + {}, + ); + + expect(res.status).toBe(400); + }); + + it("updates dueTime for an existing recurrence series", async () => { + await testDb.db.insert(recurrenceSeries).values({ + id: "series-1", + userId: TEST_USER.id, + title: "Morning review", + recurrenceType: "daily", + recurrenceBehavior: "after_completion", + recurrenceRule: null, + nextDueDate: "2099-01-01", + dueTime: "09:00", + active: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const res = await PUT( + jsonReq("http://localhost/api/recurrences/series-1", "PUT", { + description: "Updated description", + dueTime: "09:30", + }), + ctx("series-1"), + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.description).toBe("Updated description"); + expect(data.dueTime).toBe("09:30"); + }); + + it("materializes dueTime onto generated task instances", async () => { + const now = new Date().toISOString(); + await testDb.db.insert(recurrenceSeries).values({ + id: "series-1", + userId: TEST_USER.id, + title: "Morning review", + description: "Check inbox and triage tasks", + recurrenceType: "daily", + recurrenceBehavior: "after_completion", + recurrenceRule: null, + nextDueDate: todayIsoUtc(), + dueTime: "09:00", + active: true, + createdAt: now, + updatedAt: now, + }); + + const res = await GET_TASKS(new Request("http://localhost/api/tasks"), {}); + expect(res.status).toBe(200); + const rows = await res.json(); + expect(rows).toHaveLength(1); + expect(rows[0].dueDate).toBe(todayIsoUtc()); + expect(rows[0].dueTime).toBe("09:00"); + + const taskRows = await testDb.db.select().from(tasks); + expect(taskRows).toHaveLength(1); + expect(taskRows[0].dueTime).toBe("09:00"); + }); + + it("lists dueTime on recurrence series responses", async () => { + await testDb.db.insert(recurrenceSeries).values({ + id: "series-1", + userId: TEST_USER.id, + title: "Morning review", + recurrenceType: "daily", + recurrenceBehavior: "after_completion", + recurrenceRule: null, + nextDueDate: "2099-01-01", + dueTime: "09:00", + active: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const res = await GET(new Request("http://localhost/api/recurrences"), {}); + expect(res.status).toBe(200); + const rows = await res.json(); + expect(rows).toHaveLength(1); + expect(rows[0].dueTime).toBe("09:00"); + }); + + it("soft deletes recurrence series", async () => { + await testDb.db.insert(recurrenceSeries).values({ + id: "series-1", + userId: TEST_USER.id, + title: "Morning review", + recurrenceType: "daily", + recurrenceBehavior: "after_completion", + recurrenceRule: null, + nextDueDate: "2099-01-01", + dueTime: null, + active: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const res = await DELETE(new Request("http://localhost/api/recurrences/series-1", { method: "DELETE" }), ctx("series-1")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ deleted: true }); + }); +}); diff --git a/src/app/api/recurrences/route.ts b/src/app/api/recurrences/route.ts index df9409a..86b02f9 100644 --- a/src/app/api/recurrences/route.ts +++ b/src/app/api/recurrences/route.ts @@ -11,6 +11,7 @@ import { type TaskRecurrenceBehavior, type TaskRecurrenceType, } from "@/lib/task-recurrence"; +import { isValidDueTime } from "@/lib/due-time"; const VALID_PRIORITIES = ["low", "medium", "high"] as const; const VALID_SERIES_TYPES = ["daily", "weekly", "monthly", "custom"] as const; @@ -55,6 +56,7 @@ export const POST = withAuth(async (req, session) => { recurrenceBehavior, recurrenceRule, nextDueDate, + dueTime, active, } = body as Record; @@ -91,6 +93,12 @@ export const POST = withAuth(async (req, session) => { if (!isIsoDate(nextDueDate)) { return errorResponse("nextDueDate must be a YYYY-MM-DD date", 400); } + if (dueTime !== undefined && dueTime !== null && typeof dueTime !== "string") { + return errorResponse("dueTime must be a string (HH:MM) or null", 400); + } + if (typeof dueTime === "string" && !isValidDueTime(dueTime)) { + return errorResponse("dueTime must be a valid 24-hour time in HH:MM format", 400); + } if (active !== undefined && typeof active !== "boolean") { return errorResponse("active must be a boolean", 400); } @@ -138,6 +146,7 @@ export const POST = withAuth(async (req, session) => { recurrenceBehavior: resolvedBehavior, recurrenceRule: resolvedRule, nextDueDate: nextDueDate as string, + dueTime: dueTime as string | null | undefined, active: (active as boolean | undefined) ?? true, createdAt: now, updatedAt: now, diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index fa26ba4..8eba26c 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -10,6 +10,7 @@ import { } from "@/lib/task-recurrence"; import { getTodayIsoDate } from "@/lib/task-recurrence-rollover"; import { syncRecurrenceSeriesForUser } from "@/lib/recurrence-series-sync"; +import { isValidDueTime } from "@/lib/due-time"; import { db } from "@/db"; import { tasks, projects, recurrenceSeries } from "@/db/schema"; import { eq, and, isNull } from "drizzle-orm"; @@ -55,6 +56,7 @@ export const PUT = withAuth(async (req, session, ctx) => { status, priority, dueDate, + dueTime, projectId, recurrenceType, recurrenceBehavior, @@ -93,6 +95,14 @@ export const PUT = withAuth(async (req, session, ctx) => { return errorResponse("dueDate must be a string (ISO date) or null", 400); } + if (dueTime !== undefined && dueTime !== null && typeof dueTime !== "string") { + return errorResponse("dueTime must be a string (HH:MM) or null", 400); + } + + if (typeof dueTime === "string" && !isValidDueTime(dueTime)) { + return errorResponse("dueTime must be a valid 24-hour time in HH:MM format", 400); + } + if (projectId !== undefined && projectId !== null && typeof projectId !== "string") { return errorResponse("projectId must be a string or null", 400); } @@ -133,6 +143,7 @@ export const PUT = withAuth(async (req, session, ctx) => { priority: tasks.priority, projectId: tasks.projectId, dueDate: tasks.dueDate, + dueTime: tasks.dueTime, recurrenceType: tasks.recurrenceType, recurrenceBehavior: tasks.recurrenceBehavior, recurrenceRule: tasks.recurrenceRule, @@ -158,6 +169,7 @@ export const PUT = withAuth(async (req, session, ctx) => { : existing.recurrenceBehavior; let nextRecurrenceRule = existing.recurrenceRule; const nextDueDate = dueDate !== undefined ? dueDate : existing.dueDate; + const nextDueTime = dueTime !== undefined ? dueTime : existing.dueTime; if (hasRecurrenceRule) { if (recurrenceRule === null) { @@ -199,12 +211,17 @@ export const PUT = withAuth(async (req, session, ctx) => { ); } + if (typeof nextDueTime === "string" && (!nextDueDate || typeof nextDueDate !== "string" || nextDueDate.trim().length === 0)) { + return errorResponse("dueDate is required when dueTime is set", 400); + } + const updates: Record = { updatedAt: new Date().toISOString() }; if (title !== undefined) updates.title = (title as string).trim(); if (description !== undefined) updates.description = description; if (status !== undefined) updates.status = status; if (priority !== undefined) updates.priority = priority; if (dueDate !== undefined) updates.dueDate = dueDate; + if (dueTime !== undefined) updates.dueTime = dueTime; if (projectId !== undefined) updates.projectId = resolvedProjectId; if (hasRecurrenceType) updates.recurrenceType = nextRecurrenceType; if (hasRecurrenceBehavior || hasRecurrenceType) updates.recurrenceBehavior = nextRecurrenceBehavior; diff --git a/src/app/api/tasks/__tests__/route.test.ts b/src/app/api/tasks/__tests__/route.test.ts index 10b00a4..1984675 100644 --- a/src/app/api/tasks/__tests__/route.test.ts +++ b/src/app/api/tasks/__tests__/route.test.ts @@ -100,6 +100,7 @@ describe("Tasks API", () => { status: "in_progress", priority: "high", dueDate: "2025-12-31", + dueTime: "09:30", }), {} ); @@ -110,6 +111,7 @@ describe("Tasks API", () => { expect(data.status).toBe("in_progress"); expect(data.priority).toBe("high"); expect(data.dueDate).toBe("2025-12-31"); + expect(data.dueTime).toBe("09:30"); }); it("creates a recurring task with built-in recurrence", async () => { @@ -233,6 +235,17 @@ describe("Tasks API", () => { expect(res.status).toBe(400); }); + it("requires dueDate when dueTime is provided", async () => { + const res = await POST( + jsonReq("http://localhost/api/tasks", "POST", { + title: "Timed task", + dueTime: "08:15", + }), + {}, + ); + expect(res.status).toBe(400); + }); + it("rejects recurrenceRule for non-custom recurrence", async () => { const res = await POST( jsonReq("http://localhost/api/tasks", "POST", { diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index d2827bf..ef1de69 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -10,6 +10,7 @@ import { } from "@/lib/task-recurrence"; import { getTodayIsoDate } from "@/lib/task-recurrence-rollover"; import { syncRecurrenceSeriesForUser } from "@/lib/recurrence-series-sync"; +import { isValidDueTime } from "@/lib/due-time"; import { db } from "@/db"; import { tasks, projects } from "@/db/schema"; import { eq, and, sql, isNull } from "drizzle-orm"; @@ -95,6 +96,7 @@ export const POST = withAuth(async (req, session) => { status, priority, dueDate, + dueTime, projectId, recurrenceType, recurrenceBehavior, @@ -133,6 +135,14 @@ export const POST = withAuth(async (req, session) => { return errorResponse("dueDate must be a string (ISO date)", 400); } + if (dueTime !== undefined && dueTime !== null && typeof dueTime !== "string") { + return errorResponse("dueTime must be a string (HH:MM) or null", 400); + } + + if (typeof dueTime === "string" && !isValidDueTime(dueTime)) { + return errorResponse("dueTime must be a valid 24-hour time in HH:MM format", 400); + } + if (projectId !== undefined && projectId !== null && typeof projectId !== "string") { return errorResponse("projectId must be a string or null", 400); } @@ -178,6 +188,10 @@ export const POST = withAuth(async (req, session) => { ); } + if (typeof dueTime === "string" && (!dueDate || typeof dueDate !== "string" || dueDate.trim().length === 0)) { + return errorResponse("dueDate is required when dueTime is set", 400); + } + let resolvedProjectId: string | null | undefined = undefined; if (projectId === null) { resolvedProjectId = null; @@ -204,6 +218,7 @@ export const POST = withAuth(async (req, session) => { status: (status as typeof VALID_STATUSES[number]) ?? "open", priority: (priority as typeof VALID_PRIORITIES[number]) ?? "medium", dueDate: dueDate as string | undefined, + dueTime: dueTime as string | null | undefined, recurrenceType: resolvedRecurrenceType, recurrenceBehavior: resolvedRecurrenceBehavior, recurrenceRule: resolvedRecurrenceRule, diff --git a/src/app/page.tsx b/src/app/page.tsx index bc832b0..852f259 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,9 @@ import { auth } from "@/auth"; import { redirect } from "next/navigation"; import { Dashboard } from "@/components/Dashboard"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { eq } from "drizzle-orm"; export default async function Home() { const session = await auth(); @@ -8,5 +11,16 @@ export default async function Home() { redirect("/login"); } - return ; + const [preferences] = await db + .select({ dashboardDueTimesEnabled: users.dashboardDueTimesEnabled }) + .from(users) + .where(eq(users.id, session.user.id!)) + .limit(1); + + return ( + + ); } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 0a76b3b..463b02d 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -40,6 +40,7 @@ export default async function Profile() { role: users.role, showAdminQuickAccess: users.showAdminQuickAccess, assistantEnabled: users.assistantEnabled, + dashboardDueTimesEnabled: users.dashboardDueTimesEnabled, timeZone: users.timeZone, templatePresets: users.templatePresets, }) @@ -53,6 +54,7 @@ export default async function Profile() { const isAdmin = currentUserRecord?.role === "admin"; const showAdminQuickAccess = currentUserRecord?.showAdminQuickAccess ?? true; const assistantEnabled = currentUserRecord?.assistantEnabled ?? true; + const dashboardDueTimesEnabled = currentUserRecord?.dashboardDueTimesEnabled ?? false; const timeZone = currentUserRecord?.timeZone ?? null; const templatePresets = parseStoredTemplatePresets(currentUserRecord?.templatePresets ?? null); @@ -120,6 +122,7 @@ export default async function Profile() { isAdmin={isAdmin} showAdminQuickAccess={showAdminQuickAccess} assistantEnabled={assistantEnabled} + dashboardDueTimesEnabled={dashboardDueTimesEnabled} timeZone={timeZone} templatePresets={templatePresets} /> diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index e4f3d70..e0ae2aa 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -26,6 +26,7 @@ import { IconSearch, } from "@/components/icons"; import { renderTemplate } from "@/lib/templates"; +import { compareDueDateTime, formatDueDateTime } from "@/lib/due-time"; const STATUS_BADGES: Record = { open: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", @@ -83,7 +84,13 @@ type WidgetData = { recentNoteActivity: ActivityItem[]; }; -export function Dashboard({ userName }: { userName: string }) { +export function Dashboard({ + userName, + dashboardDueTimesEnabled = false, +}: { + userName: string; + dashboardDueTimesEnabled?: boolean; +}) { const { data: session } = useSession(); const [tasks, setTasks] = useState([]); const [notes, setNotes] = useState([]); @@ -146,7 +153,7 @@ export function Dashboard({ userName }: { userName: string }) { const upcoming = tasks .filter((task) => task.dueDate && task.dueDate > today && task.status !== "done") - .sort((a, b) => a.dueDate!.localeCompare(b.dueDate!)) + .sort((a, b) => compareDueDateTime(a.dueDate, a.dueTime, b.dueDate, b.dueTime)) .slice(0, 4); const weekStart = new Date(); @@ -330,7 +337,11 @@ export function Dashboard({ userName }: { userName: string }) {
{visibleWidgets.map((widget) => (
- +
))}
@@ -341,9 +352,11 @@ export function Dashboard({ userName }: { userName: string }) { function DashboardWidget({ widgetId, data, + dashboardDueTimesEnabled, }: { widgetId: DashboardWidgetId; data: WidgetData; + dashboardDueTimesEnabled: boolean; }) { switch (widgetId) { case "hero-stats": @@ -558,7 +571,12 @@ function DashboardWidget({ ) : (
{data.upcoming.map((task, index) => ( - + ))}
)} @@ -699,8 +717,19 @@ function StatPill({ ); } -function DueItem({ task, index }: { task: Task; index: number }) { +function DueItem({ + task, + index, + showDueTimes, +}: { + task: Task; + index: number; + showDueTimes: boolean; +}) { const renderedTitle = renderTemplate(task.title, { referenceDate: task.dueDate ?? task.createdAt }); + const dueLabel = showDueTimes + ? (formatDueDateTime(task.dueDate, task.dueTime) ?? task.dueDate) + : task.dueDate; return (

{renderedTitle}

-

Due {task.dueDate}

+

Due {dueLabel}

diff --git a/src/components/DispatchPage.tsx b/src/components/DispatchPage.tsx index efdcfc4..b9cbf32 100644 --- a/src/components/DispatchPage.tsx +++ b/src/components/DispatchPage.tsx @@ -23,6 +23,7 @@ import { IconSearch, } from "@/components/icons"; import { addDaysToIsoDate, formatIsoDateForDisplay, getIsoDateForTimeZone } from "@/lib/timezone"; +import { formatDueDateTime } from "@/lib/due-time"; const STATUS_STYLES: Record = { open: { dot: "bg-blue-500", label: "Open", ring: "text-blue-500" }, @@ -748,7 +749,9 @@ export function DispatchPage() { {renderTemplate(task.title, { referenceDate: task.dueDate ?? task.createdAt })} {task.dueDate && ( - {task.dueDate} + + {formatDueDateTime(task.dueDate, task.dueTime) ?? task.dueDate} + )} @@ -916,7 +919,7 @@ function LinkedTaskRow({ {task.dueDate && ( - {task.dueDate} + {formatDueDateTime(task.dueDate, task.dueTime) ?? task.dueDate} )} diff --git a/src/components/IntegrationsPage.tsx b/src/components/IntegrationsPage.tsx index 0264d83..62bf642 100644 --- a/src/components/IntegrationsPage.tsx +++ b/src/components/IntegrationsPage.tsx @@ -10,7 +10,7 @@ const ACTIVE_API_KEY_STORAGE_KEY = "dispatch.active-api-key-id"; type Endpoint = { id: string; - category: "tasks" | "projects" | "notes" | "dispatches"; + category: "tasks" | "projects" | "notes" | "dispatches" | "recurrences"; method: Method; path: string; summary: string; @@ -39,6 +39,11 @@ const ENDPOINTS: Endpoint[] = [ { id: "dispatches-create", category: "dispatches", method: "POST", path: "/api/dispatches", summary: "Create dispatch", body: `{ "date": "2026-02-07", "summary": "Plan work" }`, response: `{ "id": "disp_1", "date": "2026-02-07" }` }, { id: "dispatches-complete", category: "dispatches", method: "POST", path: "/api/dispatches/{id}/complete", summary: "Complete dispatch", params: ["id"], response: `{ "rolledOver": 2, "nextDispatchId": "disp_2" }` }, { id: "dispatches-unfinalize", category: "dispatches", method: "POST", path: "/api/dispatches/{id}/unfinalize", summary: "Unfinalize dispatch", params: ["id"], response: `{ "hasNextDispatch": true }` }, + + { id: "recurrences-list", category: "recurrences", method: "GET", path: "/api/recurrences", summary: "List recurring series", response: `[{ "id": "series_1", "title": "Daily review", "nextDueDate": "2026-02-07", "dueTime": "09:00" }]` }, + { id: "recurrences-create", category: "recurrences", method: "POST", path: "/api/recurrences", summary: "Create recurring series", body: `{ "title": "Morning review", "description": "Check inbox and triage tasks", "recurrenceType": "daily", "nextDueDate": "2026-02-07", "dueTime": "09:00" }`, response: `{ "id": "series_1", "title": "Morning review", "dueTime": "09:00" }` }, + { id: "recurrences-update", category: "recurrences", method: "PUT", path: "/api/recurrences/{id}", summary: "Update recurring series", params: ["id"], body: `{ "description": "Check inbox, triage, and plan", "dueTime": "09:30" }`, response: `{ "id": "series_1", "description": "Check inbox, triage, and plan", "dueTime": "09:30" }` }, + { id: "recurrences-delete", category: "recurrences", method: "DELETE", path: "/api/recurrences/{id}", summary: "Delete recurring series", params: ["id"], response: `{ "deleted": true }` }, ]; const CATEGORY_LABELS: Record = { @@ -46,6 +51,7 @@ const CATEGORY_LABELS: Record = { projects: "Projects", notes: "Notes", dispatches: "Dispatches", + recurrences: "Recurring", }; const METHOD_STYLES: Record = { diff --git a/src/components/PriorityInboxPage.tsx b/src/components/PriorityInboxPage.tsx index fc0bc4e..e30335a 100644 --- a/src/components/PriorityInboxPage.tsx +++ b/src/components/PriorityInboxPage.tsx @@ -13,6 +13,7 @@ import { useToast } from "@/components/ToastProvider"; import { IconInbox, IconClock, IconTrash } from "@/components/icons"; import { PROJECT_COLORS } from "@/lib/projects"; import { addDaysToIsoDate, formatIsoDateForDisplay, getIsoDateForTimeZone } from "@/lib/timezone"; +import { formatDueDateTime } from "@/lib/due-time"; const STATUS_STYLES: Record = { open: { dot: "bg-blue-500", label: "Open", ring: "text-blue-500" }, @@ -566,7 +567,7 @@ function InboxTaskRow({ {task.dueDate && ( - {task.dueDate} + {formatDueDateTime(task.dueDate, task.dueTime) ?? task.dueDate} )} diff --git a/src/components/ProfilePreferences.tsx b/src/components/ProfilePreferences.tsx index 2e9df23..edb0452 100644 --- a/src/components/ProfilePreferences.tsx +++ b/src/components/ProfilePreferences.tsx @@ -72,12 +72,14 @@ export function ProfilePreferences({ isAdmin = false, showAdminQuickAccess = true, assistantEnabled = true, + dashboardDueTimesEnabled = false, timeZone = null, templatePresets = { tasks: [], notes: [], dispatches: [] }, }: { isAdmin?: boolean; showAdminQuickAccess?: boolean; assistantEnabled?: boolean; + dashboardDueTimesEnabled?: boolean; timeZone?: string | null; templatePresets?: TemplatePresets; }) { @@ -87,8 +89,10 @@ export function ProfilePreferences({ const [showAdminButton, setShowAdminButton] = useState(showAdminQuickAccess); const [assistantVisible, setAssistantVisible] = useState(assistantEnabled); + const [dashboardDueTimesVisible, setDashboardDueTimesVisible] = useState(dashboardDueTimesEnabled); const [savingAdminButtonPref, setSavingAdminButtonPref] = useState(false); const [savingAssistantVisibility, setSavingAssistantVisibility] = useState(false); + const [savingDashboardDueTimes, setSavingDashboardDueTimes] = useState(false); const [timezoneValue, setTimezoneValue] = useState(timeZone ?? ""); const [timeZoneOptions, setTimeZoneOptions] = useState>([ { value: "", label: "System default (auto)" }, @@ -161,6 +165,10 @@ export function ProfilePreferences({ setProfileTemplates(templatePresets); }, [templatePresets]); + useEffect(() => { + setDashboardDueTimesVisible(dashboardDueTimesEnabled); + }, [dashboardDueTimesEnabled]); + useEffect(() => { try { const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -317,6 +325,22 @@ export function ProfilePreferences({ } } + async function handleToggleDashboardDueTimes() { + const next = !dashboardDueTimesVisible; + setDashboardDueTimesVisible(next); + setSavingDashboardDueTimes(true); + + try { + await api.me.updatePreferences({ dashboardDueTimesEnabled: next }); + toast.success(next ? "Dashboard due times enabled" : "Dashboard due times hidden"); + } catch (error) { + setDashboardDueTimesVisible(!next); + toast.error(error instanceof Error ? error.message : "Failed to update dashboard due time preference"); + } finally { + setSavingDashboardDueTimes(false); + } + } + const saveTimeZone = useCallback(async (nextTimeZone: string | null, options?: { silent?: boolean }) => { setSavingTimeZone(true); try { @@ -649,6 +673,26 @@ export function ProfilePreferences({ +
+
+

Dashboard Due Times

+

+ Show task due times on the dashboard page. Hidden by default. +

+
+ +
+

Timezone

diff --git a/src/components/ProjectsPage.tsx b/src/components/ProjectsPage.tsx index 52131b0..fc0bf38 100644 --- a/src/components/ProjectsPage.tsx +++ b/src/components/ProjectsPage.tsx @@ -16,6 +16,7 @@ import { IconClock, } from "@/components/icons"; import { PROJECT_COLORS } from "@/lib/projects"; +import { formatDueDateTime } from "@/lib/due-time"; const STATUS_BADGES: Record = { open: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", @@ -576,7 +577,7 @@ export function ProjectsPage() { )} {task.dueDate && ( - {task.dueDate} + {formatDueDateTime(task.dueDate, task.dueTime) ?? task.dueDate} )}
diff --git a/src/components/RecurrenceSeriesModal.tsx b/src/components/RecurrenceSeriesModal.tsx index 85b31e8..24f3c46 100644 --- a/src/components/RecurrenceSeriesModal.tsx +++ b/src/components/RecurrenceSeriesModal.tsx @@ -11,6 +11,7 @@ import { type TaskRecurrenceBehavior, type TaskTemplatePreset, } from "@/lib/client"; +import { formatDueDateTime } from "@/lib/due-time"; import { parseTaskCustomRecurrenceRule } from "@/lib/task-recurrence"; import { PROJECT_COLORS } from "@/lib/projects"; import { CustomSelect } from "@/components/CustomSelect"; @@ -44,6 +45,7 @@ export function RecurrenceSeriesModal({ const [customInterval, setCustomInterval] = useState(String(parsedRule?.interval ?? 2)); const [customUnit, setCustomUnit] = useState(parsedRule?.unit ?? "week"); const [nextDueDate, setNextDueDate] = useState(series?.nextDueDate ?? ""); + const [dueTime, setDueTime] = useState(series?.dueTime ?? ""); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -172,6 +174,7 @@ export function RecurrenceSeriesModal({ ? { interval: Number(customInterval), unit: customUnit } : null, nextDueDate, + dueTime: dueTime || null, }; if (isEditing) { @@ -378,7 +381,7 @@ export function RecurrenceSeriesModal({ onChange={(value) => setRecurrenceBehavior(value as TaskRecurrenceBehavior)} />

- Instances are materialized at midnight on the due date. + Instances are materialized on {formatDueDateTime("the due date", dueTime || null) ?? "the due date"}.

{recurrenceType === "custom" && (
@@ -406,7 +409,7 @@ export function RecurrenceSeriesModal({ )}
-
+
+
+ + setDueTime(e.target.value)} + className="mt-2 w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none transition-colors" + /> +
diff --git a/src/components/RecurringTasksPage.tsx b/src/components/RecurringTasksPage.tsx index 8d1ab0c..75aab8b 100644 --- a/src/components/RecurringTasksPage.tsx +++ b/src/components/RecurringTasksPage.tsx @@ -12,6 +12,7 @@ import { IconPlus, } from "@/components/icons"; import { PROJECT_COLORS } from "@/lib/projects"; +import { formatDueDateTime } from "@/lib/due-time"; import { getRecurrenceSeriesPreview, RECURRENCE_BEHAVIOR_LABELS, @@ -187,7 +188,7 @@ export function RecurringTasksPage() { ) : null} {entry.nextDueDate ? ( - Next {entry.nextDueDate} + Next {formatDueDateTime(entry.nextDueDate, entry.dueTime) ?? entry.nextDueDate} ) : null}
diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index 1f24208..46a43fd 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -13,6 +13,7 @@ import { import { PROJECT_COLORS } from "@/lib/projects"; import { CustomSelect } from "@/components/CustomSelect"; import { renderTemplate } from "@/lib/templates"; +import { formatDueDateTime } from "@/lib/due-time"; export function TaskModal({ task, @@ -36,6 +37,7 @@ export function TaskModal({ task?.priority ?? "medium", ); const [dueDate, setDueDate] = useState(task?.dueDate ?? ""); + const [dueTime, setDueTime] = useState(task?.dueTime ?? ""); const [projectId, setProjectId] = useState( task?.projectId ?? defaultProjectId ?? "", ); @@ -121,6 +123,7 @@ export function TaskModal({ ...(hideStatus ? {} : { status }), priority, dueDate: dueDate || null, + dueTime: dueDate ? (dueTime || null) : null, projectId: projectId || null, }); } else { @@ -130,6 +133,7 @@ export function TaskModal({ status: hideStatus ? "open" : status, priority, dueDate: dueDate || undefined, + dueTime: dueDate ? (dueTime || undefined) : undefined, projectId: projectId || null, }); } @@ -405,7 +409,7 @@ export function TaskModal({ />
-
+

Due Date @@ -413,11 +417,30 @@ export function TaskModal({ setDueDate(e.target.value)} + onChange={(e) => { + const nextDate = e.target.value; + setDueDate(nextDate); + if (!nextDate) { + setDueTime(""); + } + }} className="mt-2 w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none transition-colors" />

+
+

+ Due Time +

+ setDueTime(e.target.value)} + disabled={!dueDate} + className="mt-2 w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none transition-colors disabled:opacity-50" + /> +
+
+ {(dueDate || dueTime) && ( +

+ Scheduled for {formatDueDateTime(dueDate || null, dueTime || null) ?? dueDate} +

+ )} +
diff --git a/src/db/__tests__/bootstrap.test.ts b/src/db/__tests__/bootstrap.test.ts index 0808986..c2cf97c 100644 --- a/src/db/__tests__/bootstrap.test.ts +++ b/src/db/__tests__/bootstrap.test.ts @@ -37,6 +37,7 @@ describe("ensureSchemaColumns", () => { const columns = columnNames(sqlite, "user"); expect(columns).toContain("timeZone"); expect(columns).toContain("templatePresets"); + expect(columns).toContain("dashboardDueTimesEnabled"); }); it("adds missing task recurrence columns", () => { @@ -55,6 +56,7 @@ describe("ensureSchemaColumns", () => { expect(columns).toContain("recurrenceBehavior"); expect(columns).toContain("recurrenceSeriesId"); expect(columns).toContain("recurrenceProcessedAt"); + expect(columns).toContain("dueTime"); }); it("creates recurrence series table when missing", () => { @@ -73,6 +75,22 @@ describe("ensureSchemaColumns", () => { "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'recurrence_series' LIMIT 1", ).get() as { name: string } | undefined; expect(tables?.name).toBe("recurrence_series"); + expect(columnNames(sqlite, "recurrence_series")).toContain("dueTime"); + }); + + it("does not create recurrence series table before base tables exist", () => { + const sqlite = createSqlite(` + CREATE TABLE "__drizzle_migrations" ( + "id" integer PRIMARY KEY NOT NULL + ); + `); + + ensureSchemaColumns(sqlite); + + const tables = sqlite.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'recurrence_series' LIMIT 1", + ).get() as { name: string } | undefined; + expect(tables).toBeUndefined(); }); it("adds missing security setting columns used by admin routes", () => { diff --git a/src/db/bootstrap.ts b/src/db/bootstrap.ts index 8b4fcd5..e763522 100644 --- a/src/db/bootstrap.ts +++ b/src/db/bootstrap.ts @@ -6,6 +6,12 @@ type ColumnBootstrap = { addSql: string; }; +type TableBootstrap = { + name: string; + prerequisites: string[]; + createSql: string; +}; + const REQUIRED_COLUMNS: ColumnBootstrap[] = [ { table: "user", @@ -17,6 +23,11 @@ const REQUIRED_COLUMNS: ColumnBootstrap[] = [ column: "templatePresets", addSql: 'ALTER TABLE "user" ADD COLUMN "templatePresets" text', }, + { + table: "user", + column: "dashboardDueTimesEnabled", + addSql: 'ALTER TABLE "user" ADD COLUMN "dashboardDueTimesEnabled" integer NOT NULL DEFAULT 0', + }, { table: "task", column: "recurrenceType", @@ -43,6 +54,16 @@ const REQUIRED_COLUMNS: ColumnBootstrap[] = [ column: "recurrenceProcessedAt", addSql: 'ALTER TABLE "task" ADD COLUMN "recurrenceProcessedAt" text', }, + { + table: "task", + column: "dueTime", + addSql: 'ALTER TABLE "task" ADD COLUMN "dueTime" text', + }, + { + table: "recurrence_series", + column: "dueTime", + addSql: 'ALTER TABLE "recurrence_series" ADD COLUMN "dueTime" text', + }, { table: "security_setting", column: "shareAiApiKeyWithUsers", @@ -57,8 +78,11 @@ const REQUIRED_COLUMNS: ColumnBootstrap[] = [ }, ]; -const REQUIRED_TABLES_SQL = [ - ` +const REQUIRED_TABLES: TableBootstrap[] = [ + { + name: "recurrence_series", + prerequisites: ["user", "project"], + createSql: ` CREATE TABLE IF NOT EXISTS "recurrence_series" ( "id" text PRIMARY KEY NOT NULL, "userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, @@ -70,6 +94,7 @@ const REQUIRED_TABLES_SQL = [ "recurrenceBehavior" text NOT NULL DEFAULT 'after_completion', "recurrenceRule" text, "nextDueDate" text NOT NULL, + "dueTime" text, "active" integer NOT NULL DEFAULT 1, "deletedAt" text, "createdAt" text NOT NULL DEFAULT (current_timestamp), @@ -80,6 +105,7 @@ const REQUIRED_TABLES_SQL = [ CREATE INDEX IF NOT EXISTS "recurrence_series_active_idx" ON "recurrence_series" ("active"); CREATE INDEX IF NOT EXISTS "recurrence_series_nextDueDate_idx" ON "recurrence_series" ("nextDueDate"); `, + }, ]; function tableExists(sqlite: Database.Database, table: string): boolean { @@ -95,8 +121,17 @@ function hasColumn(sqlite: Database.Database, table: string, column: string): bo } export function ensureSchemaColumns(sqlite: Database.Database) { - for (const createSql of REQUIRED_TABLES_SQL) { - sqlite.exec(createSql); + for (const table of REQUIRED_TABLES) { + if (tableExists(sqlite, table.name)) { + continue; + } + + const prerequisitesReady = table.prerequisites.every((dependency) => tableExists(sqlite, dependency)); + if (!prerequisitesReady) { + continue; + } + + sqlite.exec(table.createSql); } for (const spec of REQUIRED_COLUMNS) { diff --git a/src/db/schema.ts b/src/db/schema.ts index b65a7fb..7a71e65 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -22,6 +22,9 @@ export const users = sqliteTable("user", { assistantEnabled: integer("assistantEnabled", { mode: "boolean" }) .notNull() .default(true), + dashboardDueTimesEnabled: integer("dashboardDueTimesEnabled", { mode: "boolean" }) + .notNull() + .default(false), }); export const accounts = sqliteTable( @@ -102,6 +105,7 @@ export const tasks = sqliteTable( .notNull() .default("medium"), dueDate: text("dueDate"), + dueTime: text("dueTime"), recurrenceType: text("recurrenceType", { enum: ["none", "daily", "weekly", "monthly", "custom"], }) @@ -157,6 +161,7 @@ export const recurrenceSeries = sqliteTable( .default("after_completion"), recurrenceRule: text("recurrenceRule"), nextDueDate: text("nextDueDate").notNull(), + dueTime: text("dueTime"), active: integer("active", { mode: "boolean" }).notNull().default(true), deletedAt: text("deletedAt"), createdAt: text("createdAt") diff --git a/src/db/seed.ts b/src/db/seed.ts index 0613fa0..3ee4713 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,12 +1,8 @@ -import { drizzle } from "drizzle-orm/better-sqlite3"; -import Database from "better-sqlite3"; import { tasks, notes, dispatches, dispatchTasks, users, projects } from "./schema"; +import { db, sqlite } from "./index"; import { eq } from "drizzle-orm"; import bcrypt from "bcryptjs"; -const sqlite = new Database("./dispatch.db"); -const db = drizzle(sqlite); - function todayStr(offset = 0) { const d = new Date(); d.setDate(d.getDate() + offset); diff --git a/src/lib/client.ts b/src/lib/client.ts index 2af50c1..c33be55 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -67,6 +67,7 @@ export interface Task { status: TaskStatus; priority: TaskPriority; dueDate: string | null; + dueTime: string | null; recurrenceType: TaskRecurrenceType; recurrenceBehavior: TaskRecurrenceBehavior; recurrenceRule: string | null; @@ -86,6 +87,7 @@ export interface RecurrenceSeries { recurrenceBehavior: TaskRecurrenceBehavior; recurrenceRule: string | null; nextDueDate: string; + dueTime: string | null; active: boolean; createdAt: string; updatedAt: string; @@ -192,6 +194,7 @@ export interface AdminVersionStatus { interface MePreferences { showAdminQuickAccess?: boolean; assistantEnabled?: boolean; + dashboardDueTimesEnabled?: boolean; timeZone?: string | null; templatePresets?: TemplatePresets; } @@ -199,6 +202,7 @@ interface MePreferences { export interface MePreferencesPayload { showAdminQuickAccess: boolean; assistantEnabled: boolean; + dashboardDueTimesEnabled: boolean; timeZone: string | null; templatePresets: TemplatePresets; } @@ -387,6 +391,7 @@ export const api = { status?: TaskStatus; priority?: TaskPriority; dueDate?: string; + dueTime?: string; projectId?: string | null; recurrenceType?: TaskRecurrenceType; recurrenceBehavior?: TaskRecurrenceBehavior; @@ -405,6 +410,7 @@ export const api = { status?: TaskStatus; priority?: TaskPriority; dueDate?: string | null; + dueTime?: string | null; projectId?: string | null; recurrenceType?: TaskRecurrenceType; recurrenceBehavior?: TaskRecurrenceBehavior; @@ -435,6 +441,7 @@ export const api = { recurrenceBehavior?: TaskRecurrenceBehavior; recurrenceRule?: TaskCustomRecurrenceRule | null; nextDueDate: string; + dueTime?: string | null; active?: boolean; }) => request("/recurrences", { method: "POST", body: JSON.stringify(data) }), @@ -450,6 +457,7 @@ export const api = { recurrenceBehavior?: TaskRecurrenceBehavior; recurrenceRule?: TaskCustomRecurrenceRule | null; nextDueDate?: string; + dueTime?: string | null; active?: boolean; }, ) => diff --git a/src/lib/due-time.ts b/src/lib/due-time.ts new file mode 100644 index 0000000..8b38e29 --- /dev/null +++ b/src/lib/due-time.ts @@ -0,0 +1,34 @@ +export function isValidDueTime(value: string): boolean { + const match = /^(\d{2}):(\d{2})$/.exec(value); + if (!match) return false; + + const hours = Number(match[1]); + const minutes = Number(match[2]); + + return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59; +} + +export function formatDueDateTime(date?: string | null, time?: string | null): string | null { + if (date && time) return `${date} at ${time}`; + return date ?? time ?? null; +} + +export function compareDueDateTime( + leftDate?: string | null, + leftTime?: string | null, + rightDate?: string | null, + rightTime?: string | null, +): number { + if (!leftDate && !rightDate) return 0; + if (!leftDate) return 1; + if (!rightDate) return -1; + + const dateCompare = leftDate.localeCompare(rightDate); + if (dateCompare !== 0) return dateCompare; + + if (!leftTime && !rightTime) return 0; + if (!leftTime) return 1; + if (!rightTime) return -1; + + return leftTime.localeCompare(rightTime); +} diff --git a/src/lib/recurrence-series-sync.ts b/src/lib/recurrence-series-sync.ts index a164451..a6e46a7 100644 --- a/src/lib/recurrence-series-sync.ts +++ b/src/lib/recurrence-series-sync.ts @@ -10,6 +10,7 @@ type SeriesRow = { title: string; description: string | null; priority: "low" | "medium" | "high"; + dueTime: string | null; recurrenceType: "daily" | "weekly" | "monthly" | "custom"; recurrenceBehavior: "after_completion" | "duplicate_on_schedule"; recurrenceRule: string | null; @@ -31,6 +32,7 @@ async function migrateLegacyTaskRecurrencesForUser(userId: string, todayIsoDate: status: tasks.status, priority: tasks.priority, dueDate: tasks.dueDate, + dueTime: tasks.dueTime, recurrenceType: tasks.recurrenceType, recurrenceBehavior: tasks.recurrenceBehavior, recurrenceRule: tasks.recurrenceRule, @@ -71,6 +73,7 @@ async function migrateLegacyTaskRecurrencesForUser(userId: string, todayIsoDate: title: row.title, description: row.description, priority: row.priority, + dueTime: row.dueTime, recurrenceType: row.recurrenceType, recurrenceBehavior: row.recurrenceBehavior, recurrenceRule: row.recurrenceRule, @@ -141,6 +144,7 @@ async function materializeSeries(series: SeriesRow, dueDate: string) { status: "open", priority: series.priority, dueDate, + dueTime: series.dueTime, recurrenceType: "none", recurrenceBehavior: "after_completion", recurrenceRule: null, @@ -162,6 +166,7 @@ export async function syncRecurrenceSeriesForUser(userId: string, todayIsoDate: title: recurrenceSeries.title, description: recurrenceSeries.description, priority: recurrenceSeries.priority, + dueTime: recurrenceSeries.dueTime, recurrenceType: recurrenceSeries.recurrenceType, recurrenceBehavior: recurrenceSeries.recurrenceBehavior, recurrenceRule: recurrenceSeries.recurrenceRule, diff --git a/src/lib/task-recurrence-preview.ts b/src/lib/task-recurrence-preview.ts index 563172d..8be2626 100644 --- a/src/lib/task-recurrence-preview.ts +++ b/src/lib/task-recurrence-preview.ts @@ -1,4 +1,5 @@ import type { RecurrenceSeries, Task } from "@/lib/client"; +import { formatDueDateTime } from "@/lib/due-time"; import { describeTaskRecurrence, getNextTaskRecurrenceDate, @@ -65,9 +66,10 @@ export function getRecurrenceSeriesPreview(series: RecurrenceSeries): { detail: string; } { const cadence = describeTaskRecurrence(series.recurrenceType, series.recurrenceRule); + const nextDue = formatDueDateTime(series.nextDueDate, series.dueTime) ?? series.nextDueDate; const detail = series.recurrenceBehavior === "after_completion" - ? `Next instance due ${series.nextDueDate}. Completing an instance advances this date.` - : `Next scheduled instance due ${series.nextDueDate}. Instances are created at midnight on schedule dates.`; + ? `Next instance due ${nextDue}. Completing an instance advances this date.` + : `Next scheduled instance due ${nextDue}. Instances are created on schedule dates using this due time.`; return { cadence, diff --git a/src/mcp-server/tools/tasks.ts b/src/mcp-server/tools/tasks.ts index 62ed6f8..d601b4d 100644 --- a/src/mcp-server/tools/tasks.ts +++ b/src/mcp-server/tools/tasks.ts @@ -11,6 +11,7 @@ import { } from "@/lib/task-recurrence"; import { getTodayIsoDate } from "@/lib/task-recurrence-rollover"; import { syncRecurrenceSeriesForUser } from "@/lib/recurrence-series-sync"; +import { isValidDueTime } from "@/lib/due-time"; const TASK_STATUS = ["open", "in_progress", "done"] as const; const TASK_PRIORITY = ["low", "medium", "high"] as const; @@ -65,6 +66,7 @@ export function registerTaskTools(server: McpServer) { status: z.enum(TASK_STATUS).optional(), priority: z.enum(TASK_PRIORITY).optional(), dueDate: z.string().optional(), + dueTime: z.string().optional(), projectId: z.string().nullable().optional(), recurrenceType: z.enum(TASK_RECURRENCE).optional(), recurrenceBehavior: z.enum(TASK_RECURRENCE_BEHAVIOR).optional(), @@ -73,6 +75,9 @@ export function registerTaskTools(server: McpServer) { }, async (args, extra) => { const userId = requireUserId(extra); + if (args.dueTime !== undefined && !isValidDueTime(args.dueTime)) { + throw new Error("dueTime must be a valid 24-hour time in HH:MM format."); + } if (args.projectId) { const [project] = await db .select({ id: projects.id }) @@ -113,6 +118,10 @@ export function registerTaskTools(server: McpServer) { throw new Error("dueDate is required when recurrenceBehavior is duplicate_on_schedule."); } + if (args.dueTime !== undefined && (!args.dueDate || args.dueDate.trim().length === 0)) { + throw new Error("dueDate is required when dueTime is set."); + } + const now = new Date().toISOString(); const [task] = await db .insert(tasks) @@ -123,6 +132,7 @@ export function registerTaskTools(server: McpServer) { status: args.status ?? "open", priority: args.priority ?? "medium", dueDate: args.dueDate, + dueTime: args.dueTime, projectId: args.projectId ?? null, recurrenceType, recurrenceBehavior, @@ -147,6 +157,7 @@ export function registerTaskTools(server: McpServer) { status: z.enum(TASK_STATUS).optional(), priority: z.enum(TASK_PRIORITY).optional(), dueDate: z.string().nullable().optional(), + dueTime: z.string().nullable().optional(), projectId: z.string().nullable().optional(), recurrenceType: z.enum(TASK_RECURRENCE).optional(), recurrenceBehavior: z.enum(TASK_RECURRENCE_BEHAVIOR).optional(), @@ -165,6 +176,7 @@ export function registerTaskTools(server: McpServer) { priority: tasks.priority, projectId: tasks.projectId, dueDate: tasks.dueDate, + dueTime: tasks.dueTime, recurrenceType: tasks.recurrenceType, recurrenceBehavior: tasks.recurrenceBehavior, recurrenceRule: tasks.recurrenceRule, @@ -177,6 +189,10 @@ export function registerTaskTools(server: McpServer) { if (!existing) throw new Error("Task not found."); + if (args.dueTime !== undefined && args.dueTime !== null && !isValidDueTime(args.dueTime)) { + throw new Error("dueTime must be a valid 24-hour time in HH:MM format."); + } + if (args.projectId) { const [project] = await db .select({ id: projects.id }) @@ -203,6 +219,7 @@ export function registerTaskTools(server: McpServer) { : existing.recurrenceBehavior; let nextRecurrenceRule = existing.recurrenceRule; const nextDueDate = args.dueDate !== undefined ? args.dueDate : existing.dueDate; + const nextDueTime = args.dueTime !== undefined ? args.dueTime : existing.dueTime; if (hasRecurrenceRule) { if (args.recurrenceRule === null) { @@ -235,12 +252,17 @@ export function registerTaskTools(server: McpServer) { throw new Error("dueDate is required when recurrenceBehavior is duplicate_on_schedule."); } + if (nextDueTime && !nextDueDate) { + throw new Error("dueDate is required when dueTime is set."); + } + const updates: Record = { updatedAt: new Date().toISOString() }; if (args.title !== undefined) updates.title = args.title.trim(); if (args.description !== undefined) updates.description = args.description; if (args.status !== undefined) updates.status = args.status; if (args.priority !== undefined) updates.priority = args.priority; if (args.dueDate !== undefined) updates.dueDate = args.dueDate; + if (args.dueTime !== undefined) updates.dueTime = args.dueTime; if (args.projectId !== undefined) updates.projectId = args.projectId; if (hasRecurrenceType) updates.recurrenceType = nextRecurrenceType; if (hasRecurrenceBehavior || hasRecurrenceType) { diff --git a/src/test/db.ts b/src/test/db.ts index 90c31c8..30417d6 100644 --- a/src/test/db.ts +++ b/src/test/db.ts @@ -25,7 +25,8 @@ export function createTestDb() { "timeZone" text, "templatePresets" text, "showAdminQuickAccess" integer NOT NULL DEFAULT 1, - "assistantEnabled" integer NOT NULL DEFAULT 1 + "assistantEnabled" integer NOT NULL DEFAULT 1, + "dashboardDueTimesEnabled" integer NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS "user_email_unique" ON "user" ("email"); @@ -75,6 +76,7 @@ export function createTestDb() { "status" text NOT NULL DEFAULT 'open', "priority" text NOT NULL DEFAULT 'medium', "dueDate" text, + "dueTime" text, "recurrenceType" text NOT NULL DEFAULT 'none', "recurrenceBehavior" text NOT NULL DEFAULT 'after_completion', "recurrenceRule" text, @@ -102,6 +104,7 @@ export function createTestDb() { "recurrenceBehavior" text NOT NULL DEFAULT 'after_completion', "recurrenceRule" text, "nextDueDate" text NOT NULL, + "dueTime" text, "active" integer NOT NULL DEFAULT 1, "deletedAt" text, "createdAt" text NOT NULL DEFAULT (current_timestamp), From 7a8eb29b4d175c0c353deb187dcf8f364bc80a86 Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Thu, 2 Apr 2026 20:44:36 -0400 Subject: [PATCH 2/8] Validate recurrence dates against weekday rules --- src/app/api/recurrences/[id]/route.ts | 42 +- .../api/recurrences/__tests__/route.test.ts | 42 ++ src/app/api/recurrences/route.ts | 28 +- src/app/api/tasks/[id]/route.ts | 52 +- src/app/api/tasks/__tests__/route.test.ts | 36 +- src/app/api/tasks/route.ts | 35 +- src/components/RecurrenceSeriesModal.tsx | 231 ++++++++- src/lib/__tests__/task-recurrence.test.ts | 102 ++++ src/lib/__tests__/template-presets.test.ts | 28 ++ src/lib/client.ts | 10 + src/lib/task-recurrence.ts | 472 +++++++++++++++++- src/lib/template-presets.ts | 14 +- src/mcp-server/tools/tasks.ts | 88 ++-- 13 files changed, 1018 insertions(+), 162 deletions(-) create mode 100644 src/lib/__tests__/task-recurrence.test.ts diff --git a/src/app/api/recurrences/[id]/route.ts b/src/app/api/recurrences/[id]/route.ts index 6103944..957bb45 100644 --- a/src/app/api/recurrences/[id]/route.ts +++ b/src/app/api/recurrences/[id]/route.ts @@ -3,11 +3,12 @@ import { withAuth, jsonResponse, errorResponse } from "@/lib/api"; import { db } from "@/db"; import { projects, recurrenceSeries } from "@/db/schema"; import { + doesIsoDateMatchTaskRecurrenceRule, + getTaskRecurrenceDateConstraintMessage, isTaskRecurrenceBehavior, - parseTaskCustomRecurrenceRule, - serializeTaskCustomRecurrenceRule, type TaskRecurrenceBehavior, type TaskRecurrenceType, + validateTaskRecurrenceRule, } from "@/lib/task-recurrence"; import { isValidDueTime } from "@/lib/due-time"; @@ -118,31 +119,22 @@ export const PUT = withAuth(async (req, session, ctx) => { const nextType = hasRecurrenceType ? recurrenceType as Exclude : existing.recurrenceType; - let nextRule = existing.recurrenceRule; - - if (hasRecurrenceRule) { - if (recurrenceRule === null) { - nextRule = null; - } else { - const parsed = parseTaskCustomRecurrenceRule(recurrenceRule); - if (!parsed) { - return errorResponse( - "recurrenceRule must include interval (1-365) and unit (day|week|month)", - 400, - ); - } - nextRule = serializeTaskCustomRecurrenceRule(parsed); - } + const rawNextRule = hasRecurrenceRule + ? recurrenceRule + : (hasRecurrenceType && recurrenceType !== existing.recurrenceType ? null : existing.recurrenceRule); + const recurrenceValidation = validateTaskRecurrenceRule(nextType, rawNextRule); + if (recurrenceValidation.error) { + return errorResponse(recurrenceValidation.error, 400); } + const nextRule = recurrenceValidation.storedRule; - if (nextType === "custom") { - if (!nextRule) { - return errorResponse("recurrenceRule is required when recurrenceType is custom", 400); - } - } else if (hasRecurrenceRule && recurrenceRule !== null && recurrenceRule !== undefined) { - return errorResponse("recurrenceRule can only be set when recurrenceType is custom", 400); - } else if (hasRecurrenceType) { - nextRule = null; + const nextNextDueDate = nextDueDate !== undefined ? nextDueDate as string : existing.nextDueDate; + if (!doesIsoDateMatchTaskRecurrenceRule(nextNextDueDate, nextType, recurrenceValidation.parsedRule)) { + return errorResponse( + getTaskRecurrenceDateConstraintMessage("nextDueDate", nextType, recurrenceValidation.parsedRule) + ?? "nextDueDate does not match the recurrence rule", + 400, + ); } const updates: Record = { updatedAt: new Date().toISOString() }; diff --git a/src/app/api/recurrences/__tests__/route.test.ts b/src/app/api/recurrences/__tests__/route.test.ts index 3afec0c..c713df5 100644 --- a/src/app/api/recurrences/__tests__/route.test.ts +++ b/src/app/api/recurrences/__tests__/route.test.ts @@ -58,6 +58,48 @@ describe("Recurrences API", () => { expect(data.dueTime).toBe("09:00"); }); + it("creates a weekly recurrence series with selected weekdays", async () => { + const res = await POST( + jsonReq("http://localhost/api/recurrences", "POST", { + title: "Team follow-up", + recurrenceType: "weekly", + recurrenceRule: { + interval: 1, + unit: "week", + weekdays: ["mon", "wed", "fri"], + }, + nextDueDate: "2026-04-03", + }), + {}, + ); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(JSON.parse(data.recurrenceRule)).toEqual({ + interval: 1, + unit: "week", + weekdays: ["mon", "wed", "fri"], + }); + }); + + it("rejects recurrence series when nextDueDate does not match selected weekdays", async () => { + const res = await POST( + jsonReq("http://localhost/api/recurrences", "POST", { + title: "Team follow-up", + recurrenceType: "weekly", + recurrenceRule: { + interval: 1, + unit: "week", + weekdays: ["mon", "wed", "fri"], + }, + nextDueDate: "2026-04-04", + }), + {}, + ); + + expect(res.status).toBe(400); + }); + it("rejects invalid dueTime values", async () => { const res = await POST( jsonReq("http://localhost/api/recurrences", "POST", { diff --git a/src/app/api/recurrences/route.ts b/src/app/api/recurrences/route.ts index 86b02f9..2a34322 100644 --- a/src/app/api/recurrences/route.ts +++ b/src/app/api/recurrences/route.ts @@ -5,11 +5,12 @@ import { projects, recurrenceSeries } from "@/db/schema"; import { syncRecurrenceSeriesForUser } from "@/lib/recurrence-series-sync"; import { getTodayIsoDate } from "@/lib/task-recurrence-rollover"; import { + doesIsoDateMatchTaskRecurrenceRule, + getTaskRecurrenceDateConstraintMessage, isTaskRecurrenceBehavior, - parseTaskCustomRecurrenceRule, - serializeTaskCustomRecurrenceRule, type TaskRecurrenceBehavior, type TaskRecurrenceType, + validateTaskRecurrenceRule, } from "@/lib/task-recurrence"; import { isValidDueTime } from "@/lib/due-time"; @@ -118,19 +119,18 @@ export const POST = withAuth(async (req, session) => { } const resolvedBehavior = (recurrenceBehavior as TaskRecurrenceBehavior | undefined) ?? "after_completion"; - let resolvedRule: string | null = null; + const recurrenceValidation = validateTaskRecurrenceRule(recurrenceType, recurrenceRule); + if (recurrenceValidation.error) { + return errorResponse(recurrenceValidation.error, 400); + } + const resolvedRule = recurrenceValidation.storedRule; - if (recurrenceType === "custom") { - const parsed = parseTaskCustomRecurrenceRule(recurrenceRule); - if (!parsed) { - return errorResponse( - "recurrenceRule is required for custom recurrence and must include interval (1-365) and unit (day|week|month)", - 400, - ); - } - resolvedRule = serializeTaskCustomRecurrenceRule(parsed); - } else if (recurrenceRule !== undefined && recurrenceRule !== null) { - return errorResponse("recurrenceRule can only be set when recurrenceType is custom", 400); + if (!doesIsoDateMatchTaskRecurrenceRule(nextDueDate as string, recurrenceType, recurrenceValidation.parsedRule)) { + return errorResponse( + getTaskRecurrenceDateConstraintMessage("nextDueDate", recurrenceType, recurrenceValidation.parsedRule) + ?? "nextDueDate does not match the recurrence rule", + 400, + ); } const now = new Date().toISOString(); diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 8eba26c..69659f3 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -1,12 +1,13 @@ import { withAuth, jsonResponse, errorResponse } from "@/lib/api"; import { + doesIsoDateMatchTaskRecurrenceRule, getNextTaskRecurrenceDate, + getTaskRecurrenceDateConstraintMessage, isTaskRecurrenceBehavior, isTaskRecurrenceType, - parseTaskCustomRecurrenceRule, - serializeTaskCustomRecurrenceRule, type TaskRecurrenceBehavior, type TaskRecurrenceType, + validateTaskRecurrenceRule, } from "@/lib/task-recurrence"; import { getTodayIsoDate } from "@/lib/task-recurrence-rollover"; import { syncRecurrenceSeriesForUser } from "@/lib/recurrence-series-sync"; @@ -170,34 +171,14 @@ export const PUT = withAuth(async (req, session, ctx) => { let nextRecurrenceRule = existing.recurrenceRule; const nextDueDate = dueDate !== undefined ? dueDate : existing.dueDate; const nextDueTime = dueTime !== undefined ? dueTime : existing.dueTime; - - if (hasRecurrenceRule) { - if (recurrenceRule === null) { - nextRecurrenceRule = null; - } else { - const parsedRule = parseTaskCustomRecurrenceRule(recurrenceRule); - if (!parsedRule) { - return errorResponse( - "recurrenceRule must include interval (1-365) and unit (day|week|month)", - 400, - ); - } - nextRecurrenceRule = serializeTaskCustomRecurrenceRule(parsedRule); - } - } - - if (nextRecurrenceType === "custom") { - if (!nextRecurrenceRule) { - return errorResponse("recurrenceRule is required when recurrenceType is custom", 400); - } - } else { - if (hasRecurrenceRule && recurrenceRule !== null && recurrenceRule !== undefined) { - return errorResponse("recurrenceRule can only be set when recurrenceType is custom", 400); - } - if (hasRecurrenceType) { - nextRecurrenceRule = null; - } + const rawNextRecurrenceRule = hasRecurrenceRule + ? recurrenceRule + : (hasRecurrenceType && recurrenceType !== existing.recurrenceType ? null : existing.recurrenceRule); + const recurrenceValidation = validateTaskRecurrenceRule(nextRecurrenceType, rawNextRecurrenceRule); + if (recurrenceValidation.error) { + return errorResponse(recurrenceValidation.error, 400); } + nextRecurrenceRule = recurrenceValidation.storedRule; if (nextRecurrenceType === "none") { nextRecurrenceBehavior = "after_completion"; @@ -215,6 +196,19 @@ export const PUT = withAuth(async (req, session, ctx) => { return errorResponse("dueDate is required when dueTime is set", 400); } + if ( + nextRecurrenceType !== "none" + && typeof nextDueDate === "string" + && nextDueDate.trim().length > 0 + && !doesIsoDateMatchTaskRecurrenceRule(nextDueDate, nextRecurrenceType, recurrenceValidation.parsedRule) + ) { + return errorResponse( + getTaskRecurrenceDateConstraintMessage("dueDate", nextRecurrenceType, recurrenceValidation.parsedRule) + ?? "dueDate does not match the recurrence rule", + 400, + ); + } + const updates: Record = { updatedAt: new Date().toISOString() }; if (title !== undefined) updates.title = (title as string).trim(); if (description !== undefined) updates.description = description; diff --git a/src/app/api/tasks/__tests__/route.test.ts b/src/app/api/tasks/__tests__/route.test.ts index 1984675..26e156e 100644 --- a/src/app/api/tasks/__tests__/route.test.ts +++ b/src/app/api/tasks/__tests__/route.test.ts @@ -145,6 +145,26 @@ describe("Tasks API", () => { expect(JSON.parse(data.recurrenceRule)).toEqual({ interval: 2, unit: "week" }); }); + it("creates a weekly recurring task with selected weekdays", async () => { + const res = await POST( + jsonReq("http://localhost/api/tasks", "POST", { + title: "Gym schedule", + dueDate: "2026-04-03", + recurrenceType: "weekly", + recurrenceRule: { interval: 1, unit: "week", weekdays: ["mon", "wed", "fri"] }, + }), + {}, + ); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(JSON.parse(data.recurrenceRule)).toEqual({ + interval: 1, + unit: "week", + weekdays: ["mon", "wed", "fri"], + }); + }); + it("trims whitespace from title", async () => { const res = await POST( jsonReq("http://localhost/api/tasks", "POST", { title: " padded " }), @@ -246,7 +266,7 @@ describe("Tasks API", () => { expect(res.status).toBe(400); }); - it("rejects recurrenceRule for non-custom recurrence", async () => { + it("rejects recurrenceRule for daily recurrence", async () => { const res = await POST( jsonReq("http://localhost/api/tasks", "POST", { title: "test", @@ -258,6 +278,20 @@ describe("Tasks API", () => { expect(res.status).toBe(400); }); + it("rejects dueDate values that do not match selected weekdays", async () => { + const res = await POST( + jsonReq("http://localhost/api/tasks", "POST", { + title: "Gym schedule", + dueDate: "2026-04-04", + recurrenceType: "weekly", + recurrenceRule: { interval: 1, unit: "week", weekdays: ["mon", "wed", "fri"] }, + }), + {}, + ); + + expect(res.status).toBe(400); + }); + it("rejects custom recurrence without rule", async () => { const res = await POST( jsonReq("http://localhost/api/tasks", "POST", { diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index ef1de69..da2937a 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,12 +1,13 @@ import { withAuth, jsonResponse, errorResponse } from "@/lib/api"; import { parsePagination, paginatedResponse } from "@/lib/pagination"; import { + doesIsoDateMatchTaskRecurrenceRule, + getTaskRecurrenceDateConstraintMessage, isTaskRecurrenceBehavior, isTaskRecurrenceType, - parseTaskCustomRecurrenceRule, - serializeTaskCustomRecurrenceRule, type TaskRecurrenceBehavior, type TaskRecurrenceType, + validateTaskRecurrenceRule, } from "@/lib/task-recurrence"; import { getTodayIsoDate } from "@/lib/task-recurrence-rollover"; import { syncRecurrenceSeriesForUser } from "@/lib/recurrence-series-sync"; @@ -162,20 +163,11 @@ export const POST = withAuth(async (req, session) => { const resolvedRecurrenceBehavior = resolvedRecurrenceType === "none" ? "after_completion" : (recurrenceBehavior as TaskRecurrenceBehavior | undefined) ?? "after_completion"; - let resolvedRecurrenceRule: string | null = null; - - if (resolvedRecurrenceType === "custom") { - const parsedRule = parseTaskCustomRecurrenceRule(recurrenceRule); - if (!parsedRule) { - return errorResponse( - "recurrenceRule is required for custom recurrence and must include interval (1-365) and unit (day|week|month)", - 400, - ); - } - resolvedRecurrenceRule = serializeTaskCustomRecurrenceRule(parsedRule); - } else if (recurrenceRule !== undefined && recurrenceRule !== null) { - return errorResponse("recurrenceRule can only be set when recurrenceType is custom", 400); + const recurrenceValidation = validateTaskRecurrenceRule(resolvedRecurrenceType, recurrenceRule); + if (recurrenceValidation.error) { + return errorResponse(recurrenceValidation.error, 400); } + const resolvedRecurrenceRule = recurrenceValidation.storedRule; if ( resolvedRecurrenceType !== "none" @@ -192,6 +184,19 @@ export const POST = withAuth(async (req, session) => { return errorResponse("dueDate is required when dueTime is set", 400); } + if ( + resolvedRecurrenceType !== "none" + && typeof dueDate === "string" + && dueDate.trim().length > 0 + && !doesIsoDateMatchTaskRecurrenceRule(dueDate, resolvedRecurrenceType, recurrenceValidation.parsedRule) + ) { + return errorResponse( + getTaskRecurrenceDateConstraintMessage("dueDate", resolvedRecurrenceType, recurrenceValidation.parsedRule) + ?? "dueDate does not match the recurrence rule", + 400, + ); + } + let resolvedProjectId: string | null | undefined = undefined; if (projectId === null) { resolvedProjectId = null; diff --git a/src/components/RecurrenceSeriesModal.tsx b/src/components/RecurrenceSeriesModal.tsx index 24f3c46..900c426 100644 --- a/src/components/RecurrenceSeriesModal.tsx +++ b/src/components/RecurrenceSeriesModal.tsx @@ -5,6 +5,9 @@ import { createPortal } from "react-dom"; import { api, type Project, + type TaskCustomRecurrenceRule, + type TaskRecurrenceMonthlyOrdinal, + type TaskRecurrenceWeekday, type RecurrenceSeries, type TaskCustomRecurrenceUnit, type TaskPriority, @@ -12,12 +15,46 @@ import { type TaskTemplatePreset, } from "@/lib/client"; import { formatDueDateTime } from "@/lib/due-time"; -import { parseTaskCustomRecurrenceRule } from "@/lib/task-recurrence"; +import { + doesIsoDateMatchTaskRecurrenceRule, + getTaskRecurrenceDateConstraintMessage, + parseTaskCustomRecurrenceRule, + TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS, + TASK_RECURRENCE_WEEKDAY_LABELS, +} from "@/lib/task-recurrence"; import { PROJECT_COLORS } from "@/lib/projects"; import { CustomSelect } from "@/components/CustomSelect"; import { renderTemplate } from "@/lib/templates"; type SeriesType = RecurrenceSeries["recurrenceType"]; +type MonthlyMode = "same_date" | "nth_weekday"; + +const WEEKDAY_PICKER_ORDER: TaskRecurrenceWeekday[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; +const MONTHLY_ORDINAL_OPTIONS = [ + { value: "1", label: TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS[1] }, + { value: "2", label: TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS[2] }, + { value: "3", label: TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS[3] }, + { value: "4", label: TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS[4] }, + { value: "-1", label: TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS[-1] }, +]; +const WEEKDAY_SELECT_OPTIONS = WEEKDAY_PICKER_ORDER.map((weekday) => ({ + value: weekday, + label: TASK_RECURRENCE_WEEKDAY_LABELS[weekday], +})); + +function getWeekdayFromIsoDate(value?: string | null): TaskRecurrenceWeekday { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return "mon"; + } + + const date = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(date.getTime())) { + return "mon"; + } + + const weekdays = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const; + return weekdays[date.getUTCDay()]; +} export function RecurrenceSeriesModal({ series, @@ -44,6 +81,20 @@ export function RecurrenceSeriesModal({ ); const [customInterval, setCustomInterval] = useState(String(parsedRule?.interval ?? 2)); const [customUnit, setCustomUnit] = useState(parsedRule?.unit ?? "week"); + const [weeklyDays, setWeeklyDays] = useState( + parsedRule?.unit === "week" ? (parsedRule.weekdays ?? []) : [], + ); + const [monthlyMode, setMonthlyMode] = useState( + parsedRule?.unit === "month" && parsedRule.monthlyPattern ? "nth_weekday" : "same_date", + ); + const [monthlyOrdinal, setMonthlyOrdinal] = useState( + parsedRule?.unit === "month" && parsedRule.monthlyPattern ? parsedRule.monthlyPattern.ordinal : 1, + ); + const [monthlyWeekday, setMonthlyWeekday] = useState( + parsedRule?.unit === "month" && parsedRule.monthlyPattern + ? parsedRule.monthlyPattern.weekday + : getWeekdayFromIsoDate(series?.nextDueDate), + ); const [nextDueDate, setNextDueDate] = useState(series?.nextDueDate ?? ""); const [dueTime, setDueTime] = useState(series?.dueTime ?? ""); const [saving, setSaving] = useState(false); @@ -86,6 +137,7 @@ export function RecurrenceSeriesModal({ }, []); function applyTaskTemplate(template: TaskTemplatePreset) { + const parsed = parseTaskCustomRecurrenceRule(template.recurrenceRule); setTitle(template.title); setDescription(template.description); const templateSeriesType: SeriesType = template.recurrenceType === "none" @@ -98,21 +150,81 @@ export function RecurrenceSeriesModal({ : (template.recurrenceBehavior ?? "after_completion"), ); - if (templateSeriesType === "custom") { - const parsed = parseTaskCustomRecurrenceRule(template.recurrenceRule); - if (parsed) { - setCustomInterval(String(parsed.interval)); - setCustomUnit(parsed.unit); - } else { - setCustomInterval("2"); - setCustomUnit("week"); - } + if (parsed) { + setCustomInterval(String(parsed.interval)); + setCustomUnit(parsed.unit); + setWeeklyDays(parsed.unit === "week" ? (parsed.weekdays ?? []) : []); + setMonthlyMode(parsed.unit === "month" && parsed.monthlyPattern ? "nth_weekday" : "same_date"); + setMonthlyOrdinal(parsed.unit === "month" && parsed.monthlyPattern ? parsed.monthlyPattern.ordinal : 1); + setMonthlyWeekday( + parsed.unit === "month" && parsed.monthlyPattern + ? parsed.monthlyPattern.weekday + : getWeekdayFromIsoDate(nextDueDate), + ); } else { setCustomInterval("2"); setCustomUnit("week"); + setWeeklyDays([]); + setMonthlyMode("same_date"); + setMonthlyOrdinal(1); + setMonthlyWeekday(getWeekdayFromIsoDate(nextDueDate)); } } + function handleWeeklyDayToggle(day: TaskRecurrenceWeekday) { + setWeeklyDays((current) => current.includes(day) + ? current.filter((entry) => entry !== day) + : [...current, day].sort( + (left, right) => WEEKDAY_PICKER_ORDER.indexOf(left) - WEEKDAY_PICKER_ORDER.indexOf(right), + )); + } + + function buildRecurrenceRule(): TaskCustomRecurrenceRule | null { + if (recurrenceType === "daily") { + return null; + } + + if (recurrenceType === "weekly") { + return weeklyDays.length > 0 + ? { interval: 1, unit: "week", weekdays: weeklyDays } + : null; + } + + if (recurrenceType === "monthly") { + return monthlyMode === "nth_weekday" + ? { + interval: 1, + unit: "month", + monthlyPattern: { + kind: "nth_weekday", + ordinal: monthlyOrdinal, + weekday: monthlyWeekday, + }, + } + : null; + } + + const interval = Number(customInterval); + const rule: TaskCustomRecurrenceRule = { + interval, + unit: customUnit, + }; + + if (customUnit === "week" && weeklyDays.length > 0) { + rule.weekdays = weeklyDays; + } + + if (customUnit === "month" && monthlyMode === "nth_weekday") { + rule.monthlyPattern = { + kind: "nth_weekday", + ordinal: monthlyOrdinal, + weekday: monthlyWeekday, + }; + } + + return rule; + } + function insertTemplateToken(token: string) { const target = activeTemplateField === "title" ? titleInputRef.current @@ -159,6 +271,19 @@ export function RecurrenceSeriesModal({ } } + const recurrenceRule = buildRecurrenceRule(); + if ( + recurrenceType !== "daily" + && recurrenceRule + && !doesIsoDateMatchTaskRecurrenceRule(nextDueDate, recurrenceType, recurrenceRule) + ) { + setError( + getTaskRecurrenceDateConstraintMessage("nextDueDate", recurrenceType, recurrenceRule) + ?? "Next due date must match the recurrence rule.", + ); + return; + } + setSaving(true); setError(""); @@ -170,9 +295,7 @@ export function RecurrenceSeriesModal({ projectId: projectId || null, recurrenceType, recurrenceBehavior, - recurrenceRule: recurrenceType === "custom" - ? { interval: Number(customInterval), unit: customUnit } - : null, + recurrenceRule, nextDueDate, dueTime: dueTime || null, }; @@ -249,6 +372,12 @@ export function RecurrenceSeriesModal({ const previewReferenceDate = nextDueDate || new Date().toISOString(); const renderedTitlePreview = renderTemplate(title, { referenceDate: previewReferenceDate }); const renderedDescriptionPreview = renderTemplate(description, { referenceDate: previewReferenceDate }); + const showWeeklyPattern = recurrenceType === "weekly" || (recurrenceType === "custom" && customUnit === "week"); + const showMonthlyPattern = recurrenceType === "monthly" || (recurrenceType === "custom" && customUnit === "month"); + const monthlyModeOptions = [ + { value: "same_date", label: "Same Date", dot: "bg-blue-500" }, + { value: "nth_weekday", label: "Nth Weekday", dot: "bg-amber-500" }, + ]; if (!mounted) return null; @@ -407,6 +536,46 @@ export function RecurrenceSeriesModal({ />
)} + {showWeeklyPattern && ( +
+ +

+ {weeklyDays.length > 0 + ? "Only the selected weekdays will be scheduled." + : "No specific weekdays selected. The schedule follows the weekday of the next due date."} +

+
+ )} + {showMonthlyPattern && ( +
+ setMonthlyMode(value as MonthlyMode)} + /> + {monthlyMode === "nth_weekday" ? ( +
+ setMonthlyOrdinal(Number(value) as TaskRecurrenceMonthlyOrdinal)} + options={MONTHLY_ORDINAL_OPTIONS} + /> + setMonthlyWeekday(value as TaskRecurrenceWeekday)} + options={WEEKDAY_SELECT_OPTIONS} + /> +
+ ) : ( +

+ Uses the calendar date from the next due date and repeats on that same date each month. +

+ )} +
+ )}
@@ -517,3 +686,39 @@ function PillGroup({
); } + +function WeekdayPicker({ + selectedDays, + onToggle, +}: { + selectedDays: TaskRecurrenceWeekday[]; + onToggle: (day: TaskRecurrenceWeekday) => void; +}) { + return ( +
+

+ Repeat On +

+
+ {WEEKDAY_PICKER_ORDER.map((weekday) => { + const active = selectedDays.includes(weekday); + return ( + + ); + })} +
+
+ ); +} diff --git a/src/lib/__tests__/task-recurrence.test.ts b/src/lib/__tests__/task-recurrence.test.ts new file mode 100644 index 0000000..d8f3b3b --- /dev/null +++ b/src/lib/__tests__/task-recurrence.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + describeTaskRecurrence, + doesIsoDateMatchTaskRecurrenceRule, + getNextTaskRecurrenceDate, + parseTaskCustomRecurrenceRule, + validateTaskRecurrenceRule, +} from "@/lib/task-recurrence"; + +describe("task recurrence", () => { + it("normalizes weekly weekday rules", () => { + expect( + parseTaskCustomRecurrenceRule({ + interval: 1, + unit: "week", + weekdays: ["fri", "mon", "fri"], + }), + ).toEqual({ + interval: 1, + unit: "week", + weekdays: ["mon", "fri"], + }); + }); + + it("describes weekly weekday recurrences", () => { + expect( + describeTaskRecurrence("weekly", { + interval: 1, + unit: "week", + weekdays: ["mon", "wed", "fri"], + }), + ).toBe("Every week on Mon, Wed, Fri"); + }); + + it("calculates the next selected weekday in the same weekly cycle", () => { + expect( + getNextTaskRecurrenceDate("2026-04-06", "weekly", { + interval: 1, + unit: "week", + weekdays: ["mon", "fri"], + }), + ).toBe("2026-04-10"); + }); + + it("calculates the next nth-weekday monthly occurrence", () => { + expect( + getNextTaskRecurrenceDate("2026-04-13", "monthly", { + interval: 1, + unit: "month", + monthlyPattern: { + kind: "nth_weekday", + ordinal: 2, + weekday: "mon", + }, + }), + ).toBe("2026-05-11"); + }); + + it("matches dates against nth-weekday monthly rules", () => { + const rule = { + interval: 1, + unit: "month" as const, + monthlyPattern: { + kind: "nth_weekday" as const, + ordinal: -1 as const, + weekday: "fri" as const, + }, + }; + + expect(doesIsoDateMatchTaskRecurrenceRule("2026-04-24", "monthly", rule)).toBe(true); + expect(doesIsoDateMatchTaskRecurrenceRule("2026-04-17", "monthly", rule)).toBe(false); + }); + + it("accepts advanced weekly rules for weekly recurrence", () => { + const result = validateTaskRecurrenceRule("weekly", { + interval: 1, + unit: "week", + weekdays: ["mon", "thu"], + }); + + expect(result.error).toBeNull(); + expect(result.storedRule).toBe(JSON.stringify({ + interval: 1, + unit: "week", + weekdays: ["mon", "thu"], + })); + }); + + it("rejects incompatible monthly rules for weekly recurrence", () => { + const result = validateTaskRecurrenceRule("weekly", { + interval: 1, + unit: "month", + monthlyPattern: { + kind: "nth_weekday", + ordinal: 2, + weekday: "mon", + }, + }); + + expect(result.error).toContain("weekly recurrence"); + }); +}); diff --git a/src/lib/__tests__/template-presets.test.ts b/src/lib/__tests__/template-presets.test.ts index 21d9d1f..085fb98 100644 --- a/src/lib/__tests__/template-presets.test.ts +++ b/src/lib/__tests__/template-presets.test.ts @@ -63,6 +63,34 @@ describe("template presets", () => { }); }); + it("preserves advanced weekly recurrence rules on task templates", () => { + const payload = { + tasks: [ + { + id: "task-template-2", + name: "Gym", + title: "Gym", + description: "", + recurrenceType: "weekly", + recurrenceBehavior: "after_completion", + recurrenceRule: { + interval: 1, + unit: "week", + weekdays: ["mon", "fri"], + }, + }, + ], + notes: [], + dispatches: [], + }; + + expect(validateTemplatePresetsInput(payload).tasks[0].recurrenceRule).toBe(JSON.stringify({ + interval: 1, + unit: "week", + weekdays: ["mon", "fri"], + })); + }); + it("defaults recurrenceBehavior for legacy task templates", () => { const payload = { tasks: [ diff --git a/src/lib/client.ts b/src/lib/client.ts index c33be55..cb19785 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -5,13 +5,23 @@ export type TaskPriority = "low" | "medium" | "high"; export type TaskRecurrenceType = "none" | "daily" | "weekly" | "monthly" | "custom"; export type TaskRecurrenceBehavior = "after_completion" | "duplicate_on_schedule"; export type TaskCustomRecurrenceUnit = "day" | "week" | "month"; +export type TaskRecurrenceWeekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat"; +export type TaskRecurrenceMonthlyOrdinal = 1 | 2 | 3 | 4 | -1; export type ProjectStatus = "active" | "paused" | "completed"; export type UserRole = "member" | "admin"; export type AIProvider = "openai" | "anthropic" | "google" | "ollama" | "lmstudio" | "custom"; +export interface TaskMonthlyWeekdayPattern { + kind: "nth_weekday"; + ordinal: TaskRecurrenceMonthlyOrdinal; + weekday: TaskRecurrenceWeekday; +} + export interface TaskCustomRecurrenceRule { interval: number; unit: TaskCustomRecurrenceUnit; + weekdays?: TaskRecurrenceWeekday[]; + monthlyPattern?: TaskMonthlyWeekdayPattern; } export interface TaskTemplatePreset { diff --git a/src/lib/task-recurrence.ts b/src/lib/task-recurrence.ts index e998d64..787fa40 100644 --- a/src/lib/task-recurrence.ts +++ b/src/lib/task-recurrence.ts @@ -10,15 +10,179 @@ export type TaskRecurrenceBehavior = typeof TASK_RECURRENCE_BEHAVIORS[number]; export const TASK_CUSTOM_RECURRENCE_UNITS = ["day", "week", "month"] as const; export type TaskCustomRecurrenceUnit = typeof TASK_CUSTOM_RECURRENCE_UNITS[number]; +export const TASK_RECURRENCE_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const; +export type TaskRecurrenceWeekday = typeof TASK_RECURRENCE_WEEKDAYS[number]; + +export const TASK_RECURRENCE_MONTHLY_ORDINALS = [1, 2, 3, 4, -1] as const; +export type TaskRecurrenceMonthlyOrdinal = typeof TASK_RECURRENCE_MONTHLY_ORDINALS[number]; + +export const TASK_RECURRENCE_WEEKDAY_LABELS: Record = { + sun: "Sun", + mon: "Mon", + tue: "Tue", + wed: "Wed", + thu: "Thu", + fri: "Fri", + sat: "Sat", +}; + +export const TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS: Record = { + 1: "1st", + 2: "2nd", + 3: "3rd", + 4: "4th", + "-1": "last", +}; + +export interface TaskMonthlyWeekdayPattern { + kind: "nth_weekday"; + ordinal: TaskRecurrenceMonthlyOrdinal; + weekday: TaskRecurrenceWeekday; +} + export interface TaskCustomRecurrenceRule { interval: number; unit: TaskCustomRecurrenceUnit; + weekdays?: TaskRecurrenceWeekday[]; + monthlyPattern?: TaskMonthlyWeekdayPattern; } +type TaskRecurrenceValidationResult = { + parsedRule: TaskCustomRecurrenceRule | null; + storedRule: string | null; + error: string | null; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEKDAY_TO_UTC_DAY: Record = { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, +}; +const UTC_DAY_TO_WEEKDAY = TASK_RECURRENCE_WEEKDAYS; + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function pluralize(value: number, singular: string): string { + return value === 1 ? singular : `${singular}s`; +} + +function sortWeekdays(days: TaskRecurrenceWeekday[]): TaskRecurrenceWeekday[] { + return [...days].sort((left, right) => WEEKDAY_TO_UTC_DAY[left] - WEEKDAY_TO_UTC_DAY[right]); +} + +function parseTaskRecurrenceWeekdays(value: unknown): TaskRecurrenceWeekday[] | null { + if (!Array.isArray(value)) { + return null; + } + + const normalized = new Set(); + for (const entry of value) { + if ( + typeof entry !== "string" + || !(TASK_RECURRENCE_WEEKDAYS as readonly string[]).includes(entry) + ) { + return null; + } + normalized.add(entry as TaskRecurrenceWeekday); + } + + return sortWeekdays([...normalized]); +} + +function parseTaskMonthlyPattern(value: unknown): TaskMonthlyWeekdayPattern | null { + if (!isObject(value)) { + return null; + } + + const kind = value.kind; + const ordinal = value.ordinal; + const weekday = value.weekday; + + if (kind !== "nth_weekday") { + return null; + } + + if ( + !Number.isInteger(ordinal) + || !(TASK_RECURRENCE_MONTHLY_ORDINALS as readonly number[]).includes(ordinal as number) + ) { + return null; + } + + if ( + typeof weekday !== "string" + || !(TASK_RECURRENCE_WEEKDAYS as readonly string[]).includes(weekday) + ) { + return null; + } + + return { + kind, + ordinal: ordinal as TaskRecurrenceMonthlyOrdinal, + weekday: weekday as TaskRecurrenceWeekday, + }; +} + +function getDefaultTaskRecurrenceRule(recurrenceType: TaskRecurrenceType): TaskCustomRecurrenceRule | null { + if (recurrenceType === "none") return null; + if (recurrenceType === "daily") return { interval: 1, unit: "day" }; + if (recurrenceType === "weekly") return { interval: 1, unit: "week" }; + if (recurrenceType === "monthly") return { interval: 1, unit: "month" }; + return null; +} + +function isRecurringTypeWithRule(recurrenceType: TaskRecurrenceType): recurrenceType is Exclude { + return recurrenceType === "weekly" || recurrenceType === "monthly" || recurrenceType === "custom"; +} + +function getTaskRecurrenceRuleError(recurrenceType: TaskRecurrenceType): string { + if (recurrenceType === "weekly") { + return "recurrenceRule for weekly recurrence must use interval 1, unit week, and optional weekdays"; + } + + if (recurrenceType === "monthly") { + return "recurrenceRule for monthly recurrence must use interval 1, unit month, and optional monthlyPattern"; + } + + return "recurrenceRule must include interval (1-365), unit (day|week|month), and any optional weekday pattern fields"; +} + +function normalizeRuleForType( + recurrenceType: Exclude, + parsedRule: TaskCustomRecurrenceRule | null, +): TaskCustomRecurrenceRule | null { + if (!parsedRule) { + return recurrenceType === "custom" ? null : getDefaultTaskRecurrenceRule(recurrenceType); + } + + if (recurrenceType === "weekly") { + if (parsedRule.unit !== "week" || parsedRule.interval !== 1) { + return null; + } + return parsedRule; + } + + if (recurrenceType === "monthly") { + if (parsedRule.unit !== "month" || parsedRule.interval !== 1) { + return null; + } + return parsedRule; + } + + return parsedRule; +} + export function isTaskRecurrenceType(value: unknown): value is TaskRecurrenceType { return ( typeof value === "string" @@ -48,6 +212,8 @@ export function parseTaskCustomRecurrenceRule(value: unknown): TaskCustomRecurre const intervalRaw = value.interval; const unitRaw = value.unit; + const rawWeekdays = hasOwn(value, "weekdays") ? value.weekdays : undefined; + const rawMonthlyPattern = hasOwn(value, "monthlyPattern") ? value.monthlyPattern : undefined; if (!Number.isInteger(intervalRaw) || (intervalRaw as number) < 1 || (intervalRaw as number) > 365) { return null; @@ -60,10 +226,113 @@ export function parseTaskCustomRecurrenceRule(value: unknown): TaskCustomRecurre return null; } - return { + const rule: TaskCustomRecurrenceRule = { interval: intervalRaw as number, unit: unitRaw as TaskCustomRecurrenceUnit, }; + + if (rawWeekdays !== undefined) { + const weekdays = parseTaskRecurrenceWeekdays(rawWeekdays); + if (!weekdays) { + return null; + } + if (rule.unit !== "week") { + return null; + } + if (weekdays.length > 0) { + rule.weekdays = weekdays; + } + } + + if (rawMonthlyPattern !== undefined) { + const monthlyPattern = parseTaskMonthlyPattern(rawMonthlyPattern); + if (!monthlyPattern || rule.unit !== "month") { + return null; + } + rule.monthlyPattern = monthlyPattern; + } + + if (rule.unit === "day" && (rule.weekdays || rule.monthlyPattern)) { + return null; + } + + if (rule.unit === "week" && rule.monthlyPattern) { + return null; + } + + if (rule.unit === "month" && rule.weekdays) { + return null; + } + + return rule; +} + +export function validateTaskRecurrenceRule( + recurrenceType: TaskRecurrenceType, + recurrenceRule: unknown, +): TaskRecurrenceValidationResult { + const hasRuleValue = recurrenceRule !== undefined && recurrenceRule !== null; + + if (recurrenceType === "none") { + if (hasRuleValue) { + return { + parsedRule: null, + storedRule: null, + error: "recurrenceRule can only be set when recurrenceType is weekly, monthly, or custom", + }; + } + + return { parsedRule: null, storedRule: null, error: null }; + } + + if (recurrenceType === "daily") { + if (hasRuleValue) { + return { + parsedRule: null, + storedRule: null, + error: "recurrenceRule is not supported for daily recurrence", + }; + } + + return { + parsedRule: { interval: 1, unit: "day" }, + storedRule: null, + error: null, + }; + } + + const parsedRule = hasRuleValue ? parseTaskCustomRecurrenceRule(recurrenceRule) : null; + const normalizedRule = normalizeRuleForType(recurrenceType, parsedRule); + + if (recurrenceType === "custom" && !hasRuleValue) { + return { + parsedRule: null, + storedRule: null, + error: getTaskRecurrenceRuleError(recurrenceType), + }; + } + + if (hasRuleValue && !normalizedRule) { + return { + parsedRule: null, + storedRule: null, + error: getTaskRecurrenceRuleError(recurrenceType), + }; + } + + if (recurrenceType === "custom" && !normalizedRule) { + return { + parsedRule: null, + storedRule: null, + error: getTaskRecurrenceRuleError(recurrenceType), + }; + } + + return { + parsedRule: normalizedRule, + storedRule: hasRuleValue && normalizedRule ? serializeTaskCustomRecurrenceRule(normalizedRule) : null, + error: null, + }; } export function serializeTaskCustomRecurrenceRule(rule: TaskCustomRecurrenceRule): string { @@ -81,6 +350,12 @@ function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } +function addDays(date: Date, amount: number): Date { + const next = new Date(date.getTime()); + next.setUTCDate(next.getUTCDate() + amount); + return next; +} + function addMonthsClamped(date: Date, amount: number): Date { const next = new Date(date.getTime()); const originalDay = next.getUTCDate(); @@ -91,15 +366,161 @@ function addMonthsClamped(date: Date, amount: number): Date { return next; } +function startOfIsoWeek(date: Date): Date { + const next = new Date(date.getTime()); + const utcDay = next.getUTCDay(); + const diff = utcDay === 0 ? -6 : 1 - utcDay; + next.setUTCDate(next.getUTCDate() + diff); + return next; +} + +function diffWholeDays(left: Date, right: Date): number { + return Math.round((left.getTime() - right.getTime()) / DAY_MS); +} + +function getTaskRecurrenceWeekdayFromDate(date: Date): TaskRecurrenceWeekday { + return UTC_DAY_TO_WEEKDAY[date.getUTCDay()]; +} + +function getNthWeekdayOfMonth( + year: number, + monthIndex: number, + weekday: TaskRecurrenceWeekday, + ordinal: TaskRecurrenceMonthlyOrdinal, +): Date | null { + const targetUtcDay = WEEKDAY_TO_UTC_DAY[weekday]; + + if (ordinal === -1) { + const candidate = new Date(Date.UTC(year, monthIndex + 1, 0)); + const delta = (candidate.getUTCDay() - targetUtcDay + 7) % 7; + candidate.setUTCDate(candidate.getUTCDate() - delta); + return candidate; + } + + const candidate = new Date(Date.UTC(year, monthIndex, 1)); + const delta = (targetUtcDay - candidate.getUTCDay() + 7) % 7; + candidate.setUTCDate(1 + delta + ((ordinal - 1) * 7)); + + return candidate.getUTCMonth() === monthIndex ? candidate : null; +} + +function getNextWeeklyRecurrenceDate(anchorDate: Date, rule: TaskCustomRecurrenceRule): string | null { + const allowedWeekdays = new Set( + (rule.weekdays && rule.weekdays.length > 0) + ? rule.weekdays + : [getTaskRecurrenceWeekdayFromDate(anchorDate)], + ); + const anchorWeekStart = startOfIsoWeek(anchorDate); + + for (let offset = 1; offset <= 4000; offset += 1) { + const candidate = addDays(anchorDate, offset); + if (!allowedWeekdays.has(getTaskRecurrenceWeekdayFromDate(candidate))) { + continue; + } + + const candidateWeekStart = startOfIsoWeek(candidate); + const weeksFromAnchor = Math.floor(diffWholeDays(candidateWeekStart, anchorWeekStart) / 7); + if (weeksFromAnchor % rule.interval === 0) { + return toIsoDate(candidate); + } + } + + return null; +} + +function getNextMonthlyRecurrenceDate(anchorDate: Date, rule: TaskCustomRecurrenceRule): string | null { + if (!rule.monthlyPattern) { + return toIsoDate(addMonthsClamped(anchorDate, rule.interval)); + } + + const anchorMonthIndex = (anchorDate.getUTCFullYear() * 12) + anchorDate.getUTCMonth(); + for (let monthDelta = 0; monthDelta <= 5000; monthDelta += 1) { + if (monthDelta % rule.interval !== 0) { + continue; + } + + const currentMonthIndex = anchorMonthIndex + monthDelta; + const year = Math.floor(currentMonthIndex / 12); + const monthIndex = currentMonthIndex % 12; + const candidate = getNthWeekdayOfMonth( + year, + monthIndex, + rule.monthlyPattern.weekday, + rule.monthlyPattern.ordinal, + ); + + if (candidate && candidate.getTime() > anchorDate.getTime()) { + return toIsoDate(candidate); + } + } + + return null; +} + export function getResolvedTaskRecurrenceRule( recurrenceType: TaskRecurrenceType, recurrenceRule: unknown, ): TaskCustomRecurrenceRule | null { - if (recurrenceType === "none") return null; - if (recurrenceType === "daily") return { interval: 1, unit: "day" }; - if (recurrenceType === "weekly") return { interval: 1, unit: "week" }; - if (recurrenceType === "monthly") return { interval: 1, unit: "month" }; - return parseTaskCustomRecurrenceRule(recurrenceRule); + const defaultRule = getDefaultTaskRecurrenceRule(recurrenceType); + if (!isRecurringTypeWithRule(recurrenceType)) { + return defaultRule; + } + + const parsedRule = parseTaskCustomRecurrenceRule(recurrenceRule); + return normalizeRuleForType(recurrenceType, parsedRule) ?? defaultRule; +} + +export function doesIsoDateMatchTaskRecurrenceRule( + isoDate: string, + recurrenceType: TaskRecurrenceType, + recurrenceRule: unknown, +): boolean { + const date = parseIsoDate(isoDate); + if (!date) { + return false; + } + + const resolvedRule = getResolvedTaskRecurrenceRule(recurrenceType, recurrenceRule); + if (!resolvedRule) { + return false; + } + + if (resolvedRule.unit === "week" && resolvedRule.weekdays && resolvedRule.weekdays.length > 0) { + return resolvedRule.weekdays.includes(getTaskRecurrenceWeekdayFromDate(date)); + } + + if (resolvedRule.unit === "month" && resolvedRule.monthlyPattern) { + const candidate = getNthWeekdayOfMonth( + date.getUTCFullYear(), + date.getUTCMonth(), + resolvedRule.monthlyPattern.weekday, + resolvedRule.monthlyPattern.ordinal, + ); + return candidate ? toIsoDate(candidate) === isoDate : false; + } + + return true; +} + +export function getTaskRecurrenceDateConstraintMessage( + fieldName: string, + recurrenceType: TaskRecurrenceType, + recurrenceRule: unknown, +): string | null { + const resolvedRule = getResolvedTaskRecurrenceRule(recurrenceType, recurrenceRule); + if (!resolvedRule) { + return null; + } + + if (resolvedRule.unit === "week" && resolvedRule.weekdays && resolvedRule.weekdays.length > 0) { + return `${fieldName} must fall on one of the selected weekdays`; + } + + if (resolvedRule.unit === "month" && resolvedRule.monthlyPattern) { + return `${fieldName} must match the selected monthly occurrence`; + } + + return null; } export function describeTaskRecurrence( @@ -109,13 +530,29 @@ export function describeTaskRecurrence( const resolved = getResolvedTaskRecurrenceRule(recurrenceType, recurrenceRule); if (!resolved) return "No recurrence"; - if (resolved.interval === 1) { - if (resolved.unit === "day") return "Every day"; - if (resolved.unit === "week") return "Every week"; - return "Every month"; + if (resolved.unit === "day") { + return resolved.interval === 1 ? "Every day" : `Every ${resolved.interval} days`; + } + + if (resolved.unit === "week") { + const base = resolved.interval === 1 ? "Every week" : `Every ${resolved.interval} weeks`; + if (!resolved.weekdays || resolved.weekdays.length === 0) { + return base; + } + const weekdayList = resolved.weekdays + .map((weekday) => TASK_RECURRENCE_WEEKDAY_LABELS[weekday]) + .join(", "); + return `${base} on ${weekdayList}`; + } + + if (resolved.monthlyPattern) { + const base = resolved.interval === 1 ? "Every month" : `Every ${resolved.interval} months`; + return `${base} on the ${TASK_RECURRENCE_MONTHLY_ORDINAL_LABELS[resolved.monthlyPattern.ordinal]} ${TASK_RECURRENCE_WEEKDAY_LABELS[resolved.monthlyPattern.weekday]}`; } - return `Every ${resolved.interval} ${resolved.unit}s`; + return resolved.interval === 1 + ? "Every month" + : `Every ${resolved.interval} ${pluralize(resolved.interval, "month")}`; } export function getNextTaskRecurrenceDate( @@ -129,16 +566,15 @@ export function getNextTaskRecurrenceDate( const anchorDate = parseIsoDate(anchorIsoDate); if (!anchorDate) return null; - const next = new Date(anchorDate.getTime()); if (resolvedRule.unit === "day") { - next.setUTCDate(next.getUTCDate() + resolvedRule.interval); - } else if (resolvedRule.unit === "week") { - next.setUTCDate(next.getUTCDate() + (resolvedRule.interval * 7)); - } else { - return toIsoDate(addMonthsClamped(next, resolvedRule.interval)); + return toIsoDate(addDays(anchorDate, resolvedRule.interval)); + } + + if (resolvedRule.unit === "week") { + return getNextWeeklyRecurrenceDate(anchorDate, resolvedRule); } - return toIsoDate(next); + return getNextMonthlyRecurrenceDate(anchorDate, resolvedRule); } export function getNextTaskRecurrenceOnOrAfter( diff --git a/src/lib/template-presets.ts b/src/lib/template-presets.ts index 82a8798..d91f9fe 100644 --- a/src/lib/template-presets.ts +++ b/src/lib/template-presets.ts @@ -1,8 +1,7 @@ import { isTaskRecurrenceBehavior, isTaskRecurrenceType, - parseTaskCustomRecurrenceRule, - serializeTaskCustomRecurrenceRule, + validateTaskRecurrenceRule, type TaskRecurrenceBehavior, type TaskRecurrenceType, } from "@/lib/task-recurrence"; @@ -62,14 +61,11 @@ function parseTaskPreset(value: unknown): TaskTemplatePreset { ? recurrenceBehaviorRaw : "after_completion"; - let recurrenceRule: string | null = null; - if (recurrenceType === "custom") { - const parsedRule = parseTaskCustomRecurrenceRule(entry.recurrenceRule); - if (!parsedRule) { - throw new Error("task template custom recurrence requires a valid recurrenceRule"); - } - recurrenceRule = serializeTaskCustomRecurrenceRule(parsedRule); + const { storedRule, error } = validateTaskRecurrenceRule(recurrenceType, entry.recurrenceRule); + if (error) { + throw new Error(`task template ${error}`); } + const recurrenceRule = storedRule; return { id, diff --git a/src/mcp-server/tools/tasks.ts b/src/mcp-server/tools/tasks.ts index d601b4d..beda3b1 100644 --- a/src/mcp-server/tools/tasks.ts +++ b/src/mcp-server/tools/tasks.ts @@ -5,9 +5,10 @@ import { db } from "@/db"; import { projects, recurrenceSeries, tasks } from "@/db/schema"; import { requireUserId, textResult } from "@/mcp-server/tools/context"; import { + doesIsoDateMatchTaskRecurrenceRule, + getTaskRecurrenceDateConstraintMessage, getNextTaskRecurrenceDate, - parseTaskCustomRecurrenceRule, - serializeTaskCustomRecurrenceRule, + validateTaskRecurrenceRule, } from "@/lib/task-recurrence"; import { getTodayIsoDate } from "@/lib/task-recurrence-rollover"; import { syncRecurrenceSeriesForUser } from "@/lib/recurrence-series-sync"; @@ -18,10 +19,24 @@ const TASK_PRIORITY = ["low", "medium", "high"] as const; const TASK_RECURRENCE = ["none", "daily", "weekly", "monthly", "custom"] as const; const TASK_RECURRENCE_BEHAVIOR = ["after_completion", "duplicate_on_schedule"] as const; const TASK_CUSTOM_RECURRENCE_UNIT = ["day", "week", "month"] as const; +const TASK_RECURRENCE_WEEKDAY = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const; +const TASK_RECURRENCE_MONTHLY_ORDINAL = [1, 2, 3, 4, -1] as const; const customRecurrenceRuleSchema = z.object({ interval: z.number().int().min(1).max(365), unit: z.enum(TASK_CUSTOM_RECURRENCE_UNIT), + weekdays: z.array(z.enum(TASK_RECURRENCE_WEEKDAY)).min(1).optional(), + monthlyPattern: z.object({ + kind: z.literal("nth_weekday"), + ordinal: z.union(TASK_RECURRENCE_MONTHLY_ORDINAL.map((ordinal) => z.literal(ordinal)) as [ + z.ZodLiteral<1>, + z.ZodLiteral<2>, + z.ZodLiteral<3>, + z.ZodLiteral<4>, + z.ZodLiteral<-1>, + ]), + weekday: z.enum(TASK_RECURRENCE_WEEKDAY), + }).optional(), }); export function registerTaskTools(server: McpServer) { @@ -97,18 +112,11 @@ export function registerTaskTools(server: McpServer) { const recurrenceBehavior = recurrenceType === "none" ? "after_completion" : args.recurrenceBehavior ?? "after_completion"; - let recurrenceRule: string | null = null; - if (recurrenceType === "custom") { - const parsed = parseTaskCustomRecurrenceRule(args.recurrenceRule); - if (!parsed) { - throw new Error( - "Custom recurrence requires recurrenceRule with interval (1-365) and unit (day|week|month).", - ); - } - recurrenceRule = serializeTaskCustomRecurrenceRule(parsed); - } else if (args.recurrenceRule !== undefined && args.recurrenceRule !== null) { - throw new Error("recurrenceRule can only be set when recurrenceType is custom."); + const recurrenceValidation = validateTaskRecurrenceRule(recurrenceType, args.recurrenceRule); + if (recurrenceValidation.error) { + throw new Error(recurrenceValidation.error); } + const recurrenceRule = recurrenceValidation.storedRule; if ( recurrenceType !== "none" @@ -122,6 +130,17 @@ export function registerTaskTools(server: McpServer) { throw new Error("dueDate is required when dueTime is set."); } + if ( + recurrenceType !== "none" + && args.dueDate + && !doesIsoDateMatchTaskRecurrenceRule(args.dueDate, recurrenceType, recurrenceValidation.parsedRule) + ) { + throw new Error( + getTaskRecurrenceDateConstraintMessage("dueDate", recurrenceType, recurrenceValidation.parsedRule) + ?? "dueDate does not match the recurrence rule.", + ); + } + const now = new Date().toISOString(); const [task] = await db .insert(tasks) @@ -217,34 +236,16 @@ export function registerTaskTools(server: McpServer) { let nextRecurrenceBehavior = hasRecurrenceBehavior ? args.recurrenceBehavior! : existing.recurrenceBehavior; - let nextRecurrenceRule = existing.recurrenceRule; const nextDueDate = args.dueDate !== undefined ? args.dueDate : existing.dueDate; const nextDueTime = args.dueTime !== undefined ? args.dueTime : existing.dueTime; - - if (hasRecurrenceRule) { - if (args.recurrenceRule === null) { - nextRecurrenceRule = null; - } else { - const parsed = parseTaskCustomRecurrenceRule(args.recurrenceRule); - if (!parsed) { - throw new Error("recurrenceRule must include interval (1-365) and unit (day|week|month)."); - } - nextRecurrenceRule = serializeTaskCustomRecurrenceRule(parsed); - } - } - - if (nextRecurrenceType === "custom") { - if (!nextRecurrenceRule) { - throw new Error("recurrenceRule is required when recurrenceType is custom."); - } - } else { - if (hasRecurrenceRule && args.recurrenceRule !== null && args.recurrenceRule !== undefined) { - throw new Error("recurrenceRule can only be set when recurrenceType is custom."); - } - if (hasRecurrenceType) { - nextRecurrenceRule = null; - } + const rawNextRecurrenceRule = hasRecurrenceRule + ? args.recurrenceRule + : (hasRecurrenceType && args.recurrenceType !== existing.recurrenceType ? null : existing.recurrenceRule); + const recurrenceValidation = validateTaskRecurrenceRule(nextRecurrenceType, rawNextRecurrenceRule); + if (recurrenceValidation.error) { + throw new Error(recurrenceValidation.error); } + const nextRecurrenceRule = recurrenceValidation.storedRule; if (nextRecurrenceType === "none") { nextRecurrenceBehavior = "after_completion"; @@ -256,6 +257,17 @@ export function registerTaskTools(server: McpServer) { throw new Error("dueDate is required when dueTime is set."); } + if ( + nextRecurrenceType !== "none" + && nextDueDate + && !doesIsoDateMatchTaskRecurrenceRule(nextDueDate, nextRecurrenceType, recurrenceValidation.parsedRule) + ) { + throw new Error( + getTaskRecurrenceDateConstraintMessage("dueDate", nextRecurrenceType, recurrenceValidation.parsedRule) + ?? "dueDate does not match the recurrence rule.", + ); + } + const updates: Record = { updatedAt: new Date().toISOString() }; if (args.title !== undefined) updates.title = args.title.trim(); if (args.description !== undefined) updates.description = args.description; From 348cebf01f74d9588876355168c287e0a7e0d841 Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Thu, 2 Apr 2026 20:46:51 -0400 Subject: [PATCH 3/8] Ignore Codex environment file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fddcaac..a36be99 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ next-env.d.ts .claude/ .claude/settings.local.json .cursor/ +.codex/environments/environment.toml *.code-workspace # local exports From 5a86138751ed90748ba939551c0e0ac7884cbc52 Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Thu, 2 Apr 2026 20:47:57 -0400 Subject: [PATCH 4/8] Delete .codex/environments/environment.toml --- .codex/environments/environment.toml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .codex/environments/environment.toml diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml deleted file mode 100644 index 443dfd6..0000000 --- a/.codex/environments/environment.toml +++ /dev/null @@ -1,11 +0,0 @@ -# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY -version = 1 -name = "DispatchTodoApp" - -[setup] -script = "" - -[[actions]] -name = "Run" -icon = "run" -command = "npm run dev" From e472d88a7dc03355ce5d0e8530261b747fb469ab Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Thu, 2 Apr 2026 20:54:40 -0400 Subject: [PATCH 5/8] Honor dashboard time setting and add TDD guidance --- agents.md | 3 ++- src/components/Dashboard.tsx | 31 +++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/agents.md b/agents.md index 9850de5..cedfc70 100644 --- a/agents.md +++ b/agents.md @@ -44,7 +44,8 @@ npm run db:push # Push schema to DB (dev shortcut) 5. **Soft Deletes**: Tasks, notes, projects use `deletedAt` field. Never hard-delete these. 6. **Task Completion**: Optimistic UI + undo toast (`toast.undo()`) pattern across all task pages. 7. **Tests**: New API routes should have integration tests mocking `@/auth` and `@/db`. -8. **Env Vars**: `.env.local` (gitignored) — `AUTH_SECRET`, `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET`. +8. **TDD by Default**: Prefer red/green TDD. Write or update the failing test first when feasible, then implement the code change, then rerun the focused tests before broader verification. +9. **Env Vars**: `.env.local` (gitignored) — `AUTH_SECRET`, `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET`. ## Files You Should Know diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index e0ae2aa..47d4eb9 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -549,7 +549,9 @@ function DashboardWidget({ {item.status.replace("_", " ")}
-

{new Date(item.updatedAt).toLocaleString()}

+

+ {formatDashboardTimestamp(item.updatedAt, dashboardDueTimesEnabled)} +

))} @@ -590,8 +592,18 @@ function DashboardWidget({

Task and note updates across your workspace.

- - + +
); @@ -604,10 +616,12 @@ function ActivityCard({ title, emptyMessage, items, + showTimes = true, }: { title: string; emptyMessage: string; items: ActivityItem[]; + showTimes?: boolean; }) { return (
@@ -641,7 +655,7 @@ function ActivityCard({ {label}: {item.title}

- {new Date(item.date).toLocaleString()} + {formatDashboardTimestamp(item.date, showTimes)}

@@ -654,6 +668,15 @@ function ActivityCard({ ); } +function formatDashboardTimestamp(value: string, showTimes: boolean): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return showTimes ? date.toLocaleString() : date.toLocaleDateString(); +} + function FocusRing({ percent, toneClass, From ed40998153106042a4c5b70191f05ac6731aa441 Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Thu, 2 Apr 2026 21:01:43 -0400 Subject: [PATCH 6/8] Bump version to 0.5.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac22011..21bf363 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dispatch", - "version": "0.5.1", + "version": "0.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dispatch", - "version": "0.5.1", + "version": "0.5.6", "dependencies": { "@ai-sdk/anthropic": "^3.0.44", "@ai-sdk/google": "^3.0.29", diff --git a/package.json b/package.json index 7499ebb..9d65212 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dispatch", - "version": "0.5.1", + "version": "0.5.6", "private": true, "scripts": { "dev": "concurrently -k -n NEXT,MCP -c cyan,magenta \"npm run next:dev\" \"npm run mcp:dev\"", From b4233a9a6dc636d7c83e52b7f632b28e6f61ef0e Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Fri, 3 Apr 2026 21:40:29 -0400 Subject: [PATCH 7/8] Fix due date clearing and preserve dashboard activity timestamps --- src/app/api/tasks/[id]/route.ts | 8 ++++++-- src/app/api/tasks/__tests__/route.test.ts | 24 ++++++++++++++++++++++ src/components/Dashboard.tsx | 12 ++++------- src/components/__tests__/Dashboard.test.ts | 17 +++++++++++++++ src/mcp-server/tools/tasks.ts | 8 ++++++-- 5 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 src/components/__tests__/Dashboard.test.ts diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 69659f3..f622c08 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -169,8 +169,12 @@ export const PUT = withAuth(async (req, session, ctx) => { ? recurrenceBehavior as TaskRecurrenceBehavior : existing.recurrenceBehavior; let nextRecurrenceRule = existing.recurrenceRule; + const isDueDateBeingCleared = dueDate !== undefined + && (dueDate === null || (typeof dueDate === "string" && dueDate.trim().length === 0)); const nextDueDate = dueDate !== undefined ? dueDate : existing.dueDate; - const nextDueTime = dueTime !== undefined ? dueTime : existing.dueTime; + const nextDueTime = dueTime !== undefined + ? dueTime + : (isDueDateBeingCleared ? null : existing.dueTime); const rawNextRecurrenceRule = hasRecurrenceRule ? recurrenceRule : (hasRecurrenceType && recurrenceType !== existing.recurrenceType ? null : existing.recurrenceRule); @@ -215,7 +219,7 @@ export const PUT = withAuth(async (req, session, ctx) => { if (status !== undefined) updates.status = status; if (priority !== undefined) updates.priority = priority; if (dueDate !== undefined) updates.dueDate = dueDate; - if (dueTime !== undefined) updates.dueTime = dueTime; + if (dueTime !== undefined || isDueDateBeingCleared) updates.dueTime = nextDueTime; if (projectId !== undefined) updates.projectId = resolvedProjectId; if (hasRecurrenceType) updates.recurrenceType = nextRecurrenceType; if (hasRecurrenceBehavior || hasRecurrenceType) updates.recurrenceBehavior = nextRecurrenceBehavior; diff --git a/src/app/api/tasks/__tests__/route.test.ts b/src/app/api/tasks/__tests__/route.test.ts index 26e156e..32c0a51 100644 --- a/src/app/api/tasks/__tests__/route.test.ts +++ b/src/app/api/tasks/__tests__/route.test.ts @@ -606,6 +606,30 @@ describe("Tasks API", () => { expect(data.status).toBe("done"); }); + it("allows clearing dueDate without explicitly clearing an existing dueTime", async () => { + const createRes = await POST( + jsonReq("http://localhost/api/tasks", "POST", { + title: "Scheduled task", + dueDate: "2026-04-10", + dueTime: "08:15", + }), + {}, + ); + const created = await createRes.json(); + + const res = await PUT( + jsonReq(`http://localhost/api/tasks/${created.id}`, "PUT", { + dueDate: null, + }), + ctx(created.id), + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.dueDate).toBeNull(); + expect(data.dueTime).toBeNull(); + }); + it("returns 404 for nonexistent task", async () => { const res = await PUT( jsonReq("http://localhost/api/tasks/nonexistent", "PUT", { diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 47d4eb9..978f429 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -550,7 +550,7 @@ function DashboardWidget({

- {formatDashboardTimestamp(item.updatedAt, dashboardDueTimesEnabled)} + {formatDashboardTimestamp(item.updatedAt)}

))} @@ -596,13 +596,11 @@ function DashboardWidget({ title="Task Activity" emptyMessage="No task updates yet." items={data.recentTaskActivity} - showTimes={dashboardDueTimesEnabled} /> @@ -616,12 +614,10 @@ function ActivityCard({ title, emptyMessage, items, - showTimes = true, }: { title: string; emptyMessage: string; items: ActivityItem[]; - showTimes?: boolean; }) { return (
@@ -655,7 +651,7 @@ function ActivityCard({ {label}: {item.title}

- {formatDashboardTimestamp(item.date, showTimes)} + {formatDashboardTimestamp(item.date)}

@@ -668,13 +664,13 @@ function ActivityCard({ ); } -function formatDashboardTimestamp(value: string, showTimes: boolean): string { +export function formatDashboardTimestamp(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } - return showTimes ? date.toLocaleString() : date.toLocaleDateString(); + return date.toLocaleString(); } function FocusRing({ diff --git a/src/components/__tests__/Dashboard.test.ts b/src/components/__tests__/Dashboard.test.ts new file mode 100644 index 0000000..bac34da --- /dev/null +++ b/src/components/__tests__/Dashboard.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from "vitest"; +import { formatDashboardTimestamp } from "@/components/Dashboard"; + +describe("Dashboard timestamp formatting", () => { + it("keeps activity timestamps at full precision", () => { + const toLocaleString = vi + .spyOn(Date.prototype, "toLocaleString") + .mockReturnValue("Apr 3, 2026, 9:15 AM"); + const toLocaleDateString = vi + .spyOn(Date.prototype, "toLocaleDateString") + .mockReturnValue("Apr 3, 2026"); + + expect(formatDashboardTimestamp("2026-04-03T09:15:00.000Z")).toBe("Apr 3, 2026, 9:15 AM"); + expect(toLocaleString).toHaveBeenCalledTimes(1); + expect(toLocaleDateString).not.toHaveBeenCalled(); + }); +}); diff --git a/src/mcp-server/tools/tasks.ts b/src/mcp-server/tools/tasks.ts index beda3b1..ca39ba3 100644 --- a/src/mcp-server/tools/tasks.ts +++ b/src/mcp-server/tools/tasks.ts @@ -236,8 +236,12 @@ export function registerTaskTools(server: McpServer) { let nextRecurrenceBehavior = hasRecurrenceBehavior ? args.recurrenceBehavior! : existing.recurrenceBehavior; + const isDueDateBeingCleared = args.dueDate !== undefined + && (args.dueDate === null || (typeof args.dueDate === "string" && args.dueDate.trim().length === 0)); const nextDueDate = args.dueDate !== undefined ? args.dueDate : existing.dueDate; - const nextDueTime = args.dueTime !== undefined ? args.dueTime : existing.dueTime; + const nextDueTime = args.dueTime !== undefined + ? args.dueTime + : (isDueDateBeingCleared ? null : existing.dueTime); const rawNextRecurrenceRule = hasRecurrenceRule ? args.recurrenceRule : (hasRecurrenceType && args.recurrenceType !== existing.recurrenceType ? null : existing.recurrenceRule); @@ -274,7 +278,7 @@ export function registerTaskTools(server: McpServer) { if (args.status !== undefined) updates.status = args.status; if (args.priority !== undefined) updates.priority = args.priority; if (args.dueDate !== undefined) updates.dueDate = args.dueDate; - if (args.dueTime !== undefined) updates.dueTime = args.dueTime; + if (args.dueTime !== undefined || isDueDateBeingCleared) updates.dueTime = nextDueTime; if (args.projectId !== undefined) updates.projectId = args.projectId; if (hasRecurrenceType) updates.recurrenceType = nextRecurrenceType; if (hasRecurrenceBehavior || hasRecurrenceType) { From 57ccee3eb50b331959d82fa023fd8c96e7d0b1b4 Mon Sep 17 00:00:00 2001 From: Nathan Kasco Date: Fri, 3 Apr 2026 22:01:36 -0400 Subject: [PATCH 8/8] Reject malformed built-in recurrence rules --- .../api/recurrences/__tests__/route.test.ts | 22 +++++++++++++++++++ src/app/api/tasks/__tests__/route.test.ts | 14 ++++++++++++ src/lib/__tests__/task-recurrence.test.ts | 18 +++++++++++++++ src/lib/task-recurrence.ts | 8 +++++++ 4 files changed, 62 insertions(+) diff --git a/src/app/api/recurrences/__tests__/route.test.ts b/src/app/api/recurrences/__tests__/route.test.ts index c713df5..b6d0ca3 100644 --- a/src/app/api/recurrences/__tests__/route.test.ts +++ b/src/app/api/recurrences/__tests__/route.test.ts @@ -100,6 +100,28 @@ describe("Recurrences API", () => { expect(res.status).toBe(400); }); + it("rejects malformed monthly recurrenceRule payloads", async () => { + const res = await POST( + jsonReq("http://localhost/api/recurrences", "POST", { + title: "Finance review", + recurrenceType: "monthly", + recurrenceRule: { + interval: 1, + unit: "month", + monthlyPattern: { + kind: "nth_weekday", + ordinal: 9, + weekday: "mon", + }, + }, + nextDueDate: "2026-04-13", + }), + {}, + ); + + expect(res.status).toBe(400); + }); + it("rejects invalid dueTime values", async () => { const res = await POST( jsonReq("http://localhost/api/recurrences", "POST", { diff --git a/src/app/api/tasks/__tests__/route.test.ts b/src/app/api/tasks/__tests__/route.test.ts index 32c0a51..ced23ca 100644 --- a/src/app/api/tasks/__tests__/route.test.ts +++ b/src/app/api/tasks/__tests__/route.test.ts @@ -292,6 +292,20 @@ describe("Tasks API", () => { expect(res.status).toBe(400); }); + it("rejects malformed weekly recurrenceRule payloads", async () => { + const res = await POST( + jsonReq("http://localhost/api/tasks", "POST", { + title: "Gym schedule", + dueDate: "2026-04-03", + recurrenceType: "weekly", + recurrenceRule: { interval: 0, unit: "week", weekdays: ["mon", "wed", "fri"] }, + }), + {}, + ); + + expect(res.status).toBe(400); + }); + it("rejects custom recurrence without rule", async () => { const res = await POST( jsonReq("http://localhost/api/tasks", "POST", { diff --git a/src/lib/__tests__/task-recurrence.test.ts b/src/lib/__tests__/task-recurrence.test.ts index d8f3b3b..cef18cb 100644 --- a/src/lib/__tests__/task-recurrence.test.ts +++ b/src/lib/__tests__/task-recurrence.test.ts @@ -99,4 +99,22 @@ describe("task recurrence", () => { expect(result.error).toContain("weekly recurrence"); }); + + it("rejects malformed weekly rules instead of falling back to defaults", () => { + const result = validateTaskRecurrenceRule("weekly", { + interval: 0, + unit: "week", + weekdays: ["mon", "wed"], + }); + + expect(result.error).toContain("weekly recurrence"); + expect(result.storedRule).toBeNull(); + }); + + it("rejects malformed monthly rules instead of falling back to defaults", () => { + const result = validateTaskRecurrenceRule("monthly", "{\"interval\":1,\"unit\":\"month\",\"monthlyPattern\":{\"kind\":\"nth_weekday\",\"ordinal\":9,\"weekday\":\"mon\"}}"); + + expect(result.error).toContain("monthly recurrence"); + expect(result.storedRule).toBeNull(); + }); }); diff --git a/src/lib/task-recurrence.ts b/src/lib/task-recurrence.ts index 787fa40..f403f91 100644 --- a/src/lib/task-recurrence.ts +++ b/src/lib/task-recurrence.ts @@ -302,6 +302,14 @@ export function validateTaskRecurrenceRule( } const parsedRule = hasRuleValue ? parseTaskCustomRecurrenceRule(recurrenceRule) : null; + if (hasRuleValue && !parsedRule) { + return { + parsedRule: null, + storedRule: null, + error: getTaskRecurrenceRuleError(recurrenceType), + }; + } + const normalizedRule = normalizeRuleForType(recurrenceType, parsedRule); if (recurrenceType === "custom" && !hasRuleValue) {