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:
parent
a4bc52b2ca
commit
aba3bdca5d
2 changed files with 59 additions and 5 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue