🐛 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:
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
10
src/lib/date-normalizer.ts
Normal file
10
src/lib/date-normalizer.ts
Normal 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);
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
83
tests/date-normalizer.test.ts
Normal file
83
tests/date-normalizer.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,8 @@
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
Reference in New Issue
Block a user