fix(orders): parse PocketBase date format explicitly in the matcher

Two findings from review of the quote-line-pairing task:

- quoteToOrderAgeDays: Math.round ties toward +Infinity, so a negative
  half-day gap rounds to -0. Normalize to a clean 0.
- quoteWindowForOrder / quoteToOrderAgeDays relied on `new Date(...)`
  parsing PocketBase's space-separated datetime form
  ("2026-08-29 10:00:00.000Z") the same as ISO 8601. That happens to
  work on V8 but is implementation-defined per spec. Both now go
  through a shared parseFlexibleDate helper that normalizes the
  separator explicitly.

Adds tests pinning both behaviours; the existing 40 tests stay green.
This commit is contained in:
Dave 2026-08-29 13:20:40 +02:00
parent a4bc52b2ca
commit aba3bdca5d
2 changed files with 59 additions and 5 deletions

View file

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

View file

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