WFDASH-WEBAPP/src/lib/order-age.ts
Dave 6fcef50b3e 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>
2026-09-01 01:58:46 +02:00

98 lines
No EOL
3.5 KiB
TypeScript

/**
* 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";
}
}