WFDASH-WEBAPP/src/app/(app)/search/age.ts
Dave cf8f766d80 feat(search): newest-first within relevance tier, plus a colour-coded age chip
ORDERING — recognition first, then recency

Not recency-first, deliberately. Sorting purely by date buries an exact
order number under every newer order sharing a weaker match, which is
the opposite of what you typed the number for. `score` already encodes
the tier (identifier 100 > name 60 > body 20, with a <=25 exact/prefix
bonus), so comparing it pins precise matches above prose matches for
free; the date then breaks what's left.

That "what's left" is the case that matters day to day: searching a
company name matches every order they have, all with an identical score,
and newest-first is the only useful order for those. Verified live on
"teleg" — 28 orders, identifier-prefix matches first, everything else
descending by date; and "121026-1 ENG" still returns exactly one hit.

Quotes already sort newest-first inside quotes-search-service, so they
inherit this without change.

AGE CHIP

Every row now carries a compact age chip — today / 3d / 2w / 1mo / 2y —
in three colour bands: emerald ≤7d, sky ≤30d, slate beyond. The numeric
label is always rendered, so colour is a scanning aid and never the only
carrier of meaning; the exact date is the chip's tooltip.

No created date from a source means the chip is omitted rather than
rendered blank, matching how the timeline drops undated steps.

ageLabel/ageBand/ageClass moved to ./age because Next.js App Router
pages may only export a fixed set of names — an extra named export from
page.tsx fails the generated route-type check.

That split also surfaced a formatting bug worth recording: the original
bands capped weeks at 60 days, so 30–59 days rendered as "4w"–"8w" and
60+ as "2mo" — "1mo" was unreachable. Month band now starts at 30 days.

Adds `createdAt` to all eight hit sources and `created` to the field
lists that lacked it.
2026-10-14 14:17:01 +02:00

72 lines
No EOL
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Compact age formatting for the universal-search result list.
*
* Lives outside the page file because Next.js App Router pages may only
* export a fixed set of names (default, metadata, generateMetadata, …)
* — an extra named export fails the generated route-type check.
*/
export type AgeBand = "fresh" | "recent" | "settled";
/**
* Relative age of a record, e.g. "today", "3d", "2w", "1mo".
*
* Returns null when there is no usable date so the caller omits the
* chip rather than rendering a blank one.
*
* Compact by necessity: a group can carry 50 rows, and a full date on
* every one is noise. The exact date rides along as the chip's tooltip.
*
* Band boundaries are 14 days and 30 days — chosen so "1mo" is actually
* reachable. An earlier version used 60 days for the week ceiling,
* which made 30–59 days render as "4w"–"8w" and 60+ as "2mo", skipping
* "1mo" entirely.
*/
export function ageLabel(
createdAt: string | undefined,
now: number = Date.now(),
): string | null {
const then = Date.parse(createdAt ?? "");
if (Number.isNaN(then)) return null;
const days = Math.max(0, Math.floor((now - then) / 86_400_000));
if (days === 0) return "today";
if (days === 1) return "1d";
if (days < 14) return `${days}d`;
if (days < 30) return `${Math.floor(days / 7)}w`;
if (days < 365) return `${Math.floor(days / 30)}mo`;
return `${Math.floor(days / 365)}y`;
}
export function ageBand(
createdAt: string | undefined,
now: number = Date.now(),
): AgeBand | null {
const then = Date.parse(createdAt ?? "");
if (Number.isNaN(then)) return null;
const days = Math.max(0, (now - then) / 86_400_000);
if (days <= 7) return "fresh";
if (days <= 30) return "recent";
return "settled";
}
/**
* Tailwind classes per band.
*
* Three buckets, deliberately not a rainbow. The numeric label is
* always rendered alongside, so colour is a scanning aid and never the
* only carrier of meaning.
*/
export function ageClass(
createdAt: string | undefined,
now: number = Date.now(),
): string {
switch (ageBand(createdAt, now)) {
case "fresh":
return "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/70 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30";
case "recent":
return "bg-sky-100 text-sky-800 ring-1 ring-sky-200/70 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/30";
default:
return "bg-slate-100 text-slate-600 ring-1 ring-slate-200/70 dark:bg-slate-500/15 dark:text-slate-400 dark:ring-slate-400/30";
}
}