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.
72 lines
No EOL
2.5 KiB
TypeScript
72 lines
No EOL
2.5 KiB
TypeScript
/**
|
||
* 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";
|
||
}
|
||
} |