🐛 fix: normalize AI-generated dates to valid ISO offset datetimes

- 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
This commit is contained in:
2026-04-07 23:01:42 -04:00
parent f3ccbf5db5
commit cbd2559169
7 changed files with 109 additions and 5 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

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

View File

@@ -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);
};

View File

@@ -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<typeof AiEventRequestSchema>;
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(),
});

View File

@@ -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");
}
});
});

View File

@@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["bun-types"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]