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
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/package-lock.json b/package-lock.json
index 19c48b7..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",
@@ -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/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\"",
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..957bb45 100644
--- a/src/app/api/recurrences/[id]/route.ts
+++ b/src/app/api/recurrences/[id]/route.ts
@@ -3,12 +3,14 @@ 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";
const VALID_PRIORITIES = ["low", "medium", "high"] as const;
const VALID_SERIES_TYPES = ["daily", "weekly", "monthly", "custom"] as const;
@@ -42,6 +44,7 @@ export const PUT = withAuth(async (req, session, ctx) => {
recurrenceBehavior,
recurrenceRule,
nextDueDate,
+ dueTime,
active,
} = body as Record;
@@ -78,6 +81,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);
}
@@ -110,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() };
@@ -146,6 +146,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..b6d0ca3
--- /dev/null
+++ b/src/app/api/recurrences/__tests__/route.test.ts
@@ -0,0 +1,238 @@
+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("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 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", {
+ 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..2a34322 100644
--- a/src/app/api/recurrences/route.ts
+++ b/src/app/api/recurrences/route.ts
@@ -5,12 +5,14 @@ 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";
const VALID_PRIORITIES = ["low", "medium", "high"] as const;
const VALID_SERIES_TYPES = ["daily", "weekly", "monthly", "custom"] as const;
@@ -55,6 +57,7 @@ export const POST = withAuth(async (req, session) => {
recurrenceBehavior,
recurrenceRule,
nextDueDate,
+ dueTime,
active,
} = body as Record;
@@ -91,6 +94,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);
}
@@ -110,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();
@@ -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..f622c08 100644
--- a/src/app/api/tasks/[id]/route.ts
+++ b/src/app/api/tasks/[id]/route.ts
@@ -1,15 +1,17 @@
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";
+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 +57,7 @@ export const PUT = withAuth(async (req, session, ctx) => {
status,
priority,
dueDate,
+ dueTime,
projectId,
recurrenceType,
recurrenceBehavior,
@@ -93,6 +96,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 +144,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,
@@ -157,35 +169,20 @@ 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;
-
- 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 nextDueTime = dueTime !== undefined
+ ? dueTime
+ : (isDueDateBeingCleared ? null : existing.dueTime);
+ 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";
@@ -199,12 +196,30 @@ 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);
+ }
+
+ 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;
if (status !== undefined) updates.status = status;
if (priority !== undefined) updates.priority = priority;
if (dueDate !== undefined) updates.dueDate = dueDate;
+ 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 10b00a4..ced23ca 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 () => {
@@ -143,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 " }),
@@ -233,7 +255,18 @@ describe("Tasks API", () => {
expect(res.status).toBe(400);
});
- it("rejects recurrenceRule for non-custom recurrence", async () => {
+ 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 daily recurrence", async () => {
const res = await POST(
jsonReq("http://localhost/api/tasks", "POST", {
title: "test",
@@ -245,6 +278,34 @@ 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 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", {
@@ -559,6 +620,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/app/api/tasks/route.ts b/src/app/api/tasks/route.ts
index d2827bf..da2937a 100644
--- a/src/app/api/tasks/route.ts
+++ b/src/app/api/tasks/route.ts
@@ -1,15 +1,17 @@
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";
+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 +97,7 @@ export const POST = withAuth(async (req, session) => {
status,
priority,
dueDate,
+ dueTime,
projectId,
recurrenceType,
recurrenceBehavior,
@@ -133,6 +136,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);
}
@@ -152,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"
@@ -178,6 +180,23 @@ 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);
+ }
+
+ 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;
@@ -204,6 +223,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..978f429 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":
@@ -536,7 +549,9 @@ function DashboardWidget({
{item.status.replace("_", " ")}
- {new Date(item.updatedAt).toLocaleString()}
+
+ {formatDashboardTimestamp(item.updatedAt)}
+
))}
@@ -558,7 +573,12 @@ function DashboardWidget({
) : (
{data.upcoming.map((task, index) => (
-
+
))}
)}
@@ -572,8 +592,16 @@ function DashboardWidget({
Task and note updates across your workspace.
);
@@ -623,7 +651,7 @@ function ActivityCard({
{label}: {item.title}
- {new Date(item.date).toLocaleString()}
+ {formatDashboardTimestamp(item.date)}
@@ -636,6 +664,15 @@ function ActivityCard({
);
}
+export function formatDashboardTimestamp(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return date.toLocaleString();
+}
+
function FocusRing({
percent,
toneClass,
@@ -699,8 +736,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.
+
+
+
+ {dashboardDueTimesVisible ? "Shown" : "Hidden"}
+
+
+
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..900c426 100644
--- a/src/components/RecurrenceSeriesModal.tsx
+++ b/src/components/RecurrenceSeriesModal.tsx
@@ -5,18 +5,56 @@ import { createPortal } from "react-dom";
import {
api,
type Project,
+ type TaskCustomRecurrenceRule,
+ type TaskRecurrenceMonthlyOrdinal,
+ type TaskRecurrenceWeekday,
type RecurrenceSeries,
type TaskCustomRecurrenceUnit,
type TaskPriority,
type TaskRecurrenceBehavior,
type TaskTemplatePreset,
} from "@/lib/client";
-import { parseTaskCustomRecurrenceRule } from "@/lib/task-recurrence";
+import { formatDueDateTime } from "@/lib/due-time";
+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,
@@ -43,7 +81,22 @@ 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);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -84,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"
@@ -96,19 +150,79 @@ 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) {
@@ -157,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("");
@@ -168,10 +295,9 @@ export function RecurrenceSeriesModal({
projectId: projectId || null,
recurrenceType,
recurrenceBehavior,
- recurrenceRule: recurrenceType === "custom"
- ? { interval: Number(customInterval), unit: customUnit }
- : null,
+ recurrenceRule,
nextDueDate,
+ dueTime: dueTime || null,
};
if (isEditing) {
@@ -246,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;
@@ -378,7 +510,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" && (
@@ -404,9 +536,49 @@ 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.
+
+ )}
+
+ )}
-
+
Next Due Date
@@ -418,6 +590,17 @@ export function RecurrenceSeriesModal({
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)}
+ 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"
+ />
+
@@ -503,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 (
+ onToggle(weekday)}
+ aria-pressed={active}
+ className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-medium transition-all ${
+ active
+ ? "border-neutral-900 bg-neutral-900 text-white shadow-sm dark:border-white dark:bg-white dark:text-neutral-900"
+ : "border-neutral-200 bg-white/80 text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:text-white dark:hover:border-neutral-500"
+ }`}
+ >
+ {TASK_RECURRENCE_WEEKDAY_LABELS[weekday]}
+
+ );
+ })}
+
+
+ );
+}
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 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}
+
+ )}
+
{
if (sortBy === "dueDate") {
- if (!a.dueDate && !b.dueDate) return 0;
- if (!a.dueDate) return 1;
- if (!b.dueDate) return -1;
- return a.dueDate.localeCompare(b.dueDate);
+ return compareDueDateTime(a.dueDate, a.dueTime, b.dueDate, b.dueTime);
}
if (sortBy === "priority") {
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
@@ -856,7 +854,7 @@ function TaskRow({
{task.dueDate && (
- {task.dueDate}
+ {formatDueDateTime(task.dueDate, task.dueTime) ?? task.dueDate}
)}
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/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/__tests__/task-recurrence.test.ts b/src/lib/__tests__/task-recurrence.test.ts
new file mode 100644
index 0000000..cef18cb
--- /dev/null
+++ b/src/lib/__tests__/task-recurrence.test.ts
@@ -0,0 +1,120 @@
+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");
+ });
+
+ 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/__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 2af50c1..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 {
@@ -67,6 +77,7 @@ export interface Task {
status: TaskStatus;
priority: TaskPriority;
dueDate: string | null;
+ dueTime: string | null;
recurrenceType: TaskRecurrenceType;
recurrenceBehavior: TaskRecurrenceBehavior;
recurrenceRule: string | null;
@@ -86,6 +97,7 @@ export interface RecurrenceSeries {
recurrenceBehavior: TaskRecurrenceBehavior;
recurrenceRule: string | null;
nextDueDate: string;
+ dueTime: string | null;
active: boolean;
createdAt: string;
updatedAt: string;
@@ -192,6 +204,7 @@ export interface AdminVersionStatus {
interface MePreferences {
showAdminQuickAccess?: boolean;
assistantEnabled?: boolean;
+ dashboardDueTimesEnabled?: boolean;
timeZone?: string | null;
templatePresets?: TemplatePresets;
}
@@ -199,6 +212,7 @@ interface MePreferences {
export interface MePreferencesPayload {
showAdminQuickAccess: boolean;
assistantEnabled: boolean;
+ dashboardDueTimesEnabled: boolean;
timeZone: string | null;
templatePresets: TemplatePresets;
}
@@ -387,6 +401,7 @@ export const api = {
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: string;
+ dueTime?: string;
projectId?: string | null;
recurrenceType?: TaskRecurrenceType;
recurrenceBehavior?: TaskRecurrenceBehavior;
@@ -405,6 +420,7 @@ export const api = {
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: string | null;
+ dueTime?: string | null;
projectId?: string | null;
recurrenceType?: TaskRecurrenceType;
recurrenceBehavior?: TaskRecurrenceBehavior;
@@ -435,6 +451,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 +467,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/lib/task-recurrence.ts b/src/lib/task-recurrence.ts
index e998d64..f403f91 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,121 @@ 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;
+ if (hasRuleValue && !parsedRule) {
+ return {
+ parsedRule: null,
+ storedRule: null,
+ error: getTaskRecurrenceRuleError(recurrenceType),
+ };
+ }
+
+ 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 +358,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 +374,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 +538,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 +574,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 62ed6f8..ca39ba3 100644
--- a/src/mcp-server/tools/tasks.ts
+++ b/src/mcp-server/tools/tasks.ts
@@ -5,22 +5,38 @@ 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";
+import { isValidDueTime } from "@/lib/due-time";
const TASK_STATUS = ["open", "in_progress", "done"] as const;
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) {
@@ -65,6 +81,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 +90,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 })
@@ -92,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"
@@ -113,6 +126,21 @@ 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.");
+ }
+
+ 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)
@@ -123,6 +151,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 +176,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 +195,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 +208,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 })
@@ -201,33 +236,20 @@ export function registerTaskTools(server: McpServer) {
let nextRecurrenceBehavior = hasRecurrenceBehavior
? args.recurrenceBehavior!
: existing.recurrenceBehavior;
- let nextRecurrenceRule = existing.recurrenceRule;
+ 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;
-
- 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 nextDueTime = args.dueTime !== undefined
+ ? args.dueTime
+ : (isDueDateBeingCleared ? null : existing.dueTime);
+ 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";
@@ -235,12 +257,28 @@ 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.");
+ }
+
+ 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;
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 || isDueDateBeingCleared) updates.dueTime = nextDueTime;
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),