diff --git a/src/lib/order-age.test.ts b/src/lib/order-age.test.ts new file mode 100644 index 0000000..9fb44df --- /dev/null +++ b/src/lib/order-age.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { getOrderAge, bucketToBadgeClass } from "./order-age"; + +describe("getOrderAge", () => { + // Anchor `now` so the tests don't drift with wall-clock time. + // 2026-09-01 12:00 in Africa/Johannesburg = 2026-09-01T10:00:00Z + const NOW = new Date("2026-09-01T10:00:00.000Z"); + + it("returns daysOld=0 with label 'today' when created today", () => { + const result = getOrderAge("2026-09-01T08:00:00.000Z", NOW); + expect(result).toEqual({ daysOld: 0, bucket: "fresh", label: "today" }); + }); + + it("returns daysOld=1 with bucket fresh for 1-day-old orders", () => { + const result = getOrderAge("2026-08-31T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(1); + expect(result.bucket).toBe("fresh"); + expect(result.label).toBe("1d old"); + }); + + it("returns daysOld=2 with bucket fresh at the upper edge of fresh", () => { + const result = getOrderAge("2026-08-30T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(2); + expect(result.bucket).toBe("fresh"); + expect(result.label).toBe("2d old"); + }); + + it("returns daysOld=3 with bucket recent at the lower edge of recent", () => { + const result = getOrderAge("2026-08-29T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(3); + expect(result.bucket).toBe("recent"); + expect(result.label).toBe("3d old"); + }); + + it("returns daysOld=7 with bucket recent at the upper edge of recent", () => { + const result = getOrderAge("2026-08-25T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(7); + expect(result.bucket).toBe("recent"); + expect(result.label).toBe("7d old"); + }); + + it("returns daysOld=8 with bucket slipping at the lower edge of slipping", () => { + const result = getOrderAge("2026-08-24T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(8); + expect(result.bucket).toBe("slipping"); + expect(result.label).toBe("8d old"); + }); + + it("returns daysOld=14 with bucket slipping at the upper edge of slipping", () => { + const result = getOrderAge("2026-08-18T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(14); + expect(result.bucket).toBe("slipping"); + expect(result.label).toBe("14d old"); + }); + + it("returns daysOld=15 with bucket stale at the lower edge of stale", () => { + const result = getOrderAge("2026-08-17T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(15); + expect(result.bucket).toBe("stale"); + expect(result.label).toBe("15d old"); + }); + + it("returns daysOld=365 with bucket stale for very old orders", () => { + const result = getOrderAge("2025-09-01T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(365); + expect(result.bucket).toBe("stale"); + expect(result.label).toBe("365d old"); + }); + + it("clamps future-dated created to daysOld=0 (clock skew tolerance)", () => { + const result = getOrderAge("2026-09-02T08:00:00.000Z", NOW); + expect(result.daysOld).toBe(0); + expect(result.bucket).toBe("fresh"); + expect(result.label).toBe("today"); + }); + + it("computes calendar-day delta in Africa/Johannesburg, not millisecond delta", () => { + // 23:00 SAST on 2026-08-31 (UTC+2) → 21:00 UTC + // 01:00 SAST on 2026-09-01 → 23:00 UTC on 2026-08-31 + // Both are in the SAME calendar day in JNB (Sep 1 vs Aug 31). + // Millisecond delta is 2 hours, but the day delta must be 1. + const created = new Date("2026-08-31T21:00:00.000Z"); // 23:00 SAST Aug 31 + const now = new Date("2026-08-31T23:00:00.000Z"); // 01:00 SAST Sep 1 + const result = getOrderAge(created, now); + expect(result.daysOld).toBe(1); + expect(result.label).toBe("1d old"); + }); + + it("treats an order created at 23:00 yesterday JNB and read at the same JNB day as today", () => { + // 23:00 SAST Aug 31 = 21:00 UTC Aug 31 + // 21:00 SAST Aug 31 = 19:00 UTC Aug 31 — same JNB calendar day + const created = new Date("2026-08-31T21:00:00.000Z"); + const sameDayNow = new Date("2026-08-31T19:00:00.000Z"); + const result = getOrderAge(created, sameDayNow); + expect(result.daysOld).toBe(0); + expect(result.label).toBe("today"); + }); +}); + +describe("bucketToBadgeClass", () => { + it("returns a non-empty string for every bucket", () => { + expect(bucketToBadgeClass("fresh").length).toBeGreaterThan(0); + expect(bucketToBadgeClass("recent").length).toBeGreaterThan(0); + expect(bucketToBadgeClass("slipping").length).toBeGreaterThan(0); + expect(bucketToBadgeClass("stale").length).toBeGreaterThan(0); + }); + + it("is stable — same input returns same output", () => { + expect(bucketToBadgeClass("fresh")).toBe(bucketToBadgeClass("fresh")); + expect(bucketToBadgeClass("recent")).toBe(bucketToBadgeClass("recent")); + expect(bucketToBadgeClass("slipping")).toBe(bucketToBadgeClass("slipping")); + expect(bucketToBadgeClass("stale")).toBe(bucketToBadgeClass("stale")); + }); +}); \ No newline at end of file diff --git a/src/lib/order-age.ts b/src/lib/order-age.ts new file mode 100644 index 0000000..9df4dfe --- /dev/null +++ b/src/lib/order-age.ts @@ -0,0 +1,98 @@ +/** + * Order-age utility — calendar-day delta + colour-bucket mapping for + * the `/orders` list card age badge. See: + * docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md + * + * Why a separate util: keeps the page component free of date math; + * trivial to unit-test in isolation; pure function (no React, no PB). + */ + +export type OrderAgeBucket = "fresh" | "recent" | "slipping" | "stale"; + +export interface OrderAge { + /** Integer calendar-day delta. 0 means "today". Never negative. */ + daysOld: number; + bucket: OrderAgeBucket; + /** Human-readable badge label. */ + label: string; +} + +/** + * Office timezone — the rest of the app doesn't deal in TZs explicitly, + * but the age badge reads "today" if the order was created on the same + * calendar day in JNB, not if it's been less than 24h since `created`. + * Hardcoded for now; centralise if a second locale ever needs it. + */ +const OFFICE_TZ = "Africa/Johannesburg"; + +/** + * Format a Date as YYYY-MM-DD in the given IANA timezone. + * Used to compute the calendar-day delta independent of the + * machine's local timezone (vitest runs in UTC by default). + */ +function toLocalDayKey(d: Date, tz: string): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(d); + const y = parts.find((p) => p.type === "year")!.value; + const m = parts.find((p) => p.type === "month")!.value; + const day = parts.find((p) => p.type === "day")!.value; + return `${y}-${m}-${day}`; +} + +/** + * Compute the age of an order relative to `now` (defaults to `new + * Date()`). The `daysOld` value is a **calendar-day** delta in the + * office's local timezone (Africa/Johannesburg) — an order created at + * 23:00 JNB yesterday read at 01:00 JNB today is `daysOld = 0` + * ("today"), not 1. The day delta is computed as + * `(localTodayKey - localCreatedKey)` parsed as UTC midnight dates + * and subtracted. Never returns a negative number; future-dated + * `created` values clamp to 0. + */ +export function getOrderAge( + created: string | Date, + now: Date = new Date(), +): OrderAge { + const createdDate = typeof created === "string" ? new Date(created) : created; + + const createdKey = toLocalDayKey(createdDate, OFFICE_TZ); + const todayKey = toLocalDayKey(now, OFFICE_TZ); + + // Both keys parse as UTC midnight, so subtraction yields whole days. + const createdMs = new Date(`${createdKey}T00:00:00Z`).getTime(); + const todayMs = new Date(`${todayKey}T00:00:00Z`).getTime(); + const rawDays = Math.round((todayMs - createdMs) / 86_400_000); + const daysOld = rawDays < 0 ? 0 : rawDays; + + let bucket: OrderAgeBucket; + if (daysOld <= 2) bucket = "fresh"; + else if (daysOld <= 7) bucket = "recent"; + else if (daysOld <= 14) bucket = "slipping"; + else bucket = "stale"; + + const label = daysOld === 0 ? "today" : `${daysOld}d old`; + + return { daysOld, bucket, label }; +} + +/** + * Map an age bucket to a tailwind className for the badge. Initial + * palette per the spec; centralise here so a future palette tweak is + * a one-line change. + */ +export function bucketToBadgeClass(bucket: OrderAgeBucket): string { + switch (bucket) { + case "fresh": + return "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"; + case "recent": + return "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300"; + case "slipping": + return "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300"; + case "stale": + return "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300"; + } +} \ No newline at end of file