From cbd255916923f53992c82245101c3eacad9ec787 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Tue, 7 Apr 2026 23:01:42 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20normalize=20AI-generated?= =?UTF-8?q?=20dates=20to=20valid=20ISO=20offset=20datetimes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add normalizeAiDateString() to coerce bare ISO datetimes, date-only strings, and fractional-second variants into offset-aware format - apply via z.preprocess in AiEventResponseItemSchema so Zod validation no longer rejects AI responses missing a timezone offset - fix system prompt to use toISOString() (unambiguous UTC) and clarify expected datetime format for the AI model - install bun-types and add to tsconfig so bun:test resolves cleanly - add 8 behaviour-driven tests covering all normalizer edge cases --- bun.lock | 3 ++ package.json | 1 + src/app/api/ai-event/route.ts | 4 +- src/lib/date-normalizer.ts | 10 +++++ src/lib/types.ts | 10 ++++- tests/date-normalizer.test.ts | 83 +++++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 7 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/lib/date-normalizer.ts create mode 100644 tests/date-normalizer.test.ts diff --git a/bun.lock b/bun.lock index cca8f35..ae64e52 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@typescript/native-preview": "^7.0.0-dev.20260407.1", + "bun-types": "^1.3.11", "drizzle-kit": "^0.31.4", "eslint": "^9", "eslint-config-next": "15.4.6", @@ -521,6 +522,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/package.json b/package.json index 6fc1a73..0a98442 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@typescript/native-preview": "^7.0.0-dev.20260407.1", + "bun-types": "^1.3.11", "drizzle-kit": "^0.31.4", "eslint": "^9", "eslint-config-next": "15.4.6", diff --git a/src/app/api/ai-event/route.ts b/src/app/api/ai-event/route.ts index 755751c..651b851 100644 --- a/src/app/api/ai-event/route.ts +++ b/src/app/api/ai-event/route.ts @@ -17,7 +17,7 @@ TypeScript type: description?: string, location?: string, url?: string, - start: string, // ISO datetime + start: string, // ISO 8601 datetime with timezone, e.g. "2025-04-10T10:00:00-04:00" end?: string, allDay?: boolean, recurrenceRule?: string // valid iCal RRULE string like FREQ=WEEKLY;BYDAY=MO;INTERVAL=1 @@ -26,7 +26,7 @@ TypeScript type: Rules: - If the user describes multiple events in one prompt, return multiple objects (one per event). - Always return a valid JSON array of objects, even if there's only one event. - - Today is ${new Date().toLocaleString()}. + - Today is ${new Date().toISOString()}. - If no time is given, assume allDay event. - If no end time is given (and event is not allDay), default to 1 hour after start. - If multiple events are described, return multiple. diff --git a/src/lib/date-normalizer.ts b/src/lib/date-normalizer.ts new file mode 100644 index 0000000..51f067f --- /dev/null +++ b/src/lib/date-normalizer.ts @@ -0,0 +1,10 @@ +import { formatISO, parseISO } from "date-fns"; + +const ISO_DATETIME_WITH_OFFSET = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/; + +export const normalizeAiDateString = (input: string): string => { + if (ISO_DATETIME_WITH_OFFSET.test(input)) return input; + const parsed = parseISO(input); + return formatISO(parsed); +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index a50a9a4..0833a38 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants"; +import { normalizeAiDateString } from "@/lib/date-normalizer"; /** Validates that a base64 data URL string decodes to binary under the max size. */ const isValidImageSize = (val: string | undefined): boolean => { @@ -29,14 +30,19 @@ export const AiEventRequestSchema = z export type AiEventRequest = z.infer; +const aiDatetime = z.preprocess( + (val) => (typeof val === "string" ? normalizeAiDateString(val) : val), + z.string().datetime({ offset: true }), +); + export const AiEventResponseItemSchema = z.object({ id: z.string().optional(), title: z.string().min(1), description: z.string().optional(), location: z.string().optional(), url: z.string().optional(), - start: z.string().datetime({ offset: true }), - end: z.string().datetime({ offset: true }).optional(), + start: aiDatetime, + end: aiDatetime.optional(), allDay: z.boolean().optional(), recurrenceRule: z.string().optional(), }); diff --git a/tests/date-normalizer.test.ts b/tests/date-normalizer.test.ts new file mode 100644 index 0000000..aff4889 --- /dev/null +++ b/tests/date-normalizer.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeAiDateString } from "@/lib/date-normalizer"; +import { AiEventResponseSchema } from "@/lib/types"; + +describe("normalizeAiDateString", () => { + test("converts bare ISO datetime (no offset) to valid offset datetime accepted by Zod", () => { + const input = "2025-04-10T10:00:00"; + const result = normalizeAiDateString(input); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/); + expect(new Date(result).getHours()).toBe(10); + }); + + test("converts date-only string to midnight datetime with offset", () => { + const input = "2025-04-10"; + const result = normalizeAiDateString(input); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00(Z|[+-]\d{2}:\d{2})$/); + }); + + test("passes through already-valid datetime with offset unchanged", () => { + const input = "2025-04-10T10:00:00-04:00"; + const result = normalizeAiDateString(input); + expect(result).toBe("2025-04-10T10:00:00-04:00"); + }); + + test("passes through datetime with Z suffix unchanged", () => { + const input = "2025-04-10T10:00:00Z"; + const result = normalizeAiDateString(input); + expect(result).toBe("2025-04-10T10:00:00Z"); + }); + + test("handles datetime with fractional seconds", () => { + const input = "2025-04-10T10:00:00.000Z"; + const result = normalizeAiDateString(input); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(new Date(result).getHours()).toBe(10); + }); +}); + +describe("AiEventResponseSchema date normalization", () => { + test("accepts event with bare ISO datetime (no offset) by normalizing it", () => { + const raw = [ + { + title: "Team Meeting", + start: "2025-04-10T10:00:00", + end: "2025-04-10T11:00:00", + }, + ]; + const result = AiEventResponseSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data[0].start).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/, + ); + } + }); + + test("accepts event with date-only start and no end", () => { + const raw = [ + { + title: "All-day workshop", + start: "2025-07-20", + allDay: true, + }, + ]; + const result = AiEventResponseSchema.safeParse(raw); + expect(result.success).toBe(true); + }); + + test("accepts event with already-valid offset datetime", () => { + const raw = [ + { + title: "Standup", + start: "2025-04-10T09:00:00-05:00", + end: "2025-04-10T09:30:00-05:00", + }, + ]; + const result = AiEventResponseSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data[0].start).toBe("2025-04-10T09:00:00-05:00"); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7df89e7..5b0fd0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ ], "paths": { "@/*": ["./src/*"] - } + }, + "types": ["bun-types"] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]