🐛 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": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@typescript/native-preview": "^7.0.0-dev.20260407.1",
|
"@typescript/native-preview": "^7.0.0-dev.20260407.1",
|
||||||
|
"bun-types": "^1.3.11",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.4.6",
|
"eslint-config-next": "15.4.6",
|
||||||
@@ -521,6 +522,8 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"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": ["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=="],
|
"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": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@typescript/native-preview": "^7.0.0-dev.20260407.1",
|
"@typescript/native-preview": "^7.0.0-dev.20260407.1",
|
||||||
|
"bun-types": "^1.3.11",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.4.6",
|
"eslint-config-next": "15.4.6",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ TypeScript type:
|
|||||||
description?: string,
|
description?: string,
|
||||||
location?: string,
|
location?: string,
|
||||||
url?: 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,
|
end?: string,
|
||||||
allDay?: boolean,
|
allDay?: boolean,
|
||||||
recurrenceRule?: string // valid iCal RRULE string like FREQ=WEEKLY;BYDAY=MO;INTERVAL=1
|
recurrenceRule?: string // valid iCal RRULE string like FREQ=WEEKLY;BYDAY=MO;INTERVAL=1
|
||||||
@@ -26,7 +26,7 @@ TypeScript type:
|
|||||||
Rules:
|
Rules:
|
||||||
- If the user describes multiple events in one prompt, return multiple objects (one per event).
|
- 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.
|
- 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 time is given, assume allDay event.
|
||||||
- If no end time is given (and event is not allDay), default to 1 hour after start.
|
- If no end time is given (and event is not allDay), default to 1 hour after start.
|
||||||
- If multiple events are described, return multiple.
|
- 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 { z } from "zod";
|
||||||
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
|
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. */
|
/** Validates that a base64 data URL string decodes to binary under the max size. */
|
||||||
const isValidImageSize = (val: string | undefined): boolean => {
|
const isValidImageSize = (val: string | undefined): boolean => {
|
||||||
@@ -29,14 +30,19 @@ export const AiEventRequestSchema = z
|
|||||||
|
|
||||||
export type AiEventRequest = z.infer<typeof AiEventRequestSchema>;
|
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({
|
export const AiEventResponseItemSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
location: z.string().optional(),
|
location: z.string().optional(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
start: z.string().datetime({ offset: true }),
|
start: aiDatetime,
|
||||||
end: z.string().datetime({ offset: true }).optional(),
|
end: aiDatetime.optional(),
|
||||||
allDay: z.boolean().optional(),
|
allDay: z.boolean().optional(),
|
||||||
recurrenceRule: z.string().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": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
},
|
||||||
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user