feat(orders): add order-age util with bucket thresholds
Pure function: getOrderAge(created, now?) returns {daysOld, bucket, label}
in calendar-day delta for Africa/Johannesburg. bucketToBadgeClass maps
to tailwind classes (fresh/recent/slipping/stale). No React, no DB.
Vitest unit tests cover all 5 bucket thresholds, future-date clamp, and
the JNB timezone edge case (same calendar day in JNB = daysOld 0).
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
f84d14fe4d
commit
6fcef50b3e
2 changed files with 212 additions and 0 deletions
114
src/lib/order-age.test.ts
Normal file
114
src/lib/order-age.test.ts
Normal file
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
98
src/lib/order-age.ts
Normal file
98
src/lib/order-age.ts
Normal file
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue