diff --git a/src/lib/server/order-quote-match.test.ts b/src/lib/server/order-quote-match.test.ts index c77de0f..66de9fc 100644 --- a/src/lib/server/order-quote-match.test.ts +++ b/src/lib/server/order-quote-match.test.ts @@ -512,6 +512,12 @@ describe("quoteWindowForOrder", () => { it("throws on an unparseable order date rather than silently matching everything", () => { expect(() => quoteWindowForOrder("not-a-date", 90)).toThrow(/unparseable/i); }); + + it("accepts PocketBase's space-separated datetime form identically to the ISO T form", () => { + const iso = quoteWindowForOrder(ORDER_DATE, 90); + const pbForm = quoteWindowForOrder("2026-08-29 10:00:00.000Z", 90); + expect(pbForm).toEqual(iso); + }); }); describe("quoteToOrderAgeDays", () => { @@ -539,4 +545,29 @@ describe("quoteToOrderAgeDays", () => { expect(quoteToOrderAgeDays("2026-08-29T10:00:00.000Z", "not-a-date")).toBe(0); expect(Number.isNaN(quoteToOrderAgeDays("garbage", "also-garbage"))).toBe(false); }); + + it("returns a clean 0, not -0, when the quote lands exactly half a day after the order", () => { + // Quote created 12h after the order → (order - quote) = -0.5 days. + // Math.round(-0.5) is -0 in JS; the function must normalize that. + const days = quoteToOrderAgeDays( + "2026-08-29T22:00:00.000Z", + "2026-08-29T10:00:00.000Z", + ); + expect(days).toBe(0); + expect(Object.is(days, -0)).toBe(false); + expect(Object.is(days, 0)).toBe(true); + }); + + it("accepts PocketBase's space-separated datetime form identically to the ISO T form", () => { + const iso = quoteToOrderAgeDays( + "2026-06-01T10:00:00.000Z", + "2026-08-29T10:00:00.000Z", + ); + const pbForm = quoteToOrderAgeDays( + "2026-06-01 10:00:00.000Z", + "2026-08-29 10:00:00.000Z", + ); + expect(pbForm).toBe(iso); + expect(pbForm).toBe(89); + }); }); diff --git a/src/lib/server/order-quote-match.ts b/src/lib/server/order-quote-match.ts index 7af0813..09f533c 100644 --- a/src/lib/server/order-quote-match.ts +++ b/src/lib/server/order-quote-match.ts @@ -456,6 +456,23 @@ export const FUZZY_WINDOW_GRACE_DAYS = 7; const DAY_MS = 24 * 60 * 60 * 1000; +/** + * Parse a datetime string that may be either ISO 8601 (`T` separator, + * e.g. `2026-08-29T10:00:00.000Z`) or PocketBase's serialized form + * (a SPACE separator, e.g. `2026-08-29 10:00:00.000Z`). + * + * V8 happens to parse the space-separated form via `new Date(...)`, + * but per the ECMAScript spec that's implementation-defined — other + * engines are free to reject it. Every date this matcher touches + * ultimately comes from a PB `created` field, so we normalize the + * separator explicitly here rather than relying on engine leniency. + */ +function parseFlexibleDate(value: string): Date { + const normalized = + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value) ? value.replace(" ", "T") : value; + return new Date(normalized); +} + export interface QuoteWindow { /** Inclusive lower bound, ISO 8601. */ from: string; @@ -468,7 +485,7 @@ export function quoteWindowForOrder( orderCreated: string, days: number, ): QuoteWindow { - const anchor = new Date(orderCreated); + const anchor = parseFlexibleDate(orderCreated); if (Number.isNaN(anchor.getTime())) { throw new Error( `quoteWindowForOrder: unparseable order date ${JSON.stringify(orderCreated)}`, @@ -482,13 +499,19 @@ export function quoteWindowForOrder( /** Whole days between a quote's creation and an order's creation. * Positive means the quote came first (the normal case). Used by the - * sweep's age-gap histogram. */ + * sweep's age-gap histogram. + * + * `Math.round` ties toward +Infinity, so a negative half-day gap + * (e.g. -0.5) rounds to `-0`. `-0 === 0` is true and it stringifies + * as `"0"` almost everywhere, but it's still a footgun (e.g. + * `Object.is`, `1 / days`) — normalize it to a clean `0`. */ export function quoteToOrderAgeDays( quoteCreated: string, orderCreated: string, ): number { - const q = new Date(quoteCreated).getTime(); - const o = new Date(orderCreated).getTime(); + const q = parseFlexibleDate(quoteCreated).getTime(); + const o = parseFlexibleDate(orderCreated).getTime(); if (Number.isNaN(q) || Number.isNaN(o)) return 0; - return Math.round((o - q) / DAY_MS); + const days = Math.round((o - q) / DAY_MS); + return days === 0 ? 0 : days; }