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.
This commit is contained in:
Dave 2026-10-14 14:17:01 +02:00
parent 00736320bf
commit cf8f766d80
4 changed files with 230 additions and 14 deletions

View file

@ -0,0 +1,72 @@
/**
* 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";
}
}

View file

@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import UniversalSearchPage from "./page";
import { ageLabel } from "./age";
/**
* The page talks to `/api/v1/search`, which is gated by
@ -74,6 +75,7 @@ const RESULT = {
{ label: "Customer", value: "Telegenix Trading 168 CC" },
{ label: "Status", value: "po_generated" },
],
createdAt: "2026-10-10",
timeline: [
{ label: "Order raised", at: "2026-10-12" },
{ label: "Driver assigned", at: "2026-10-14" },
@ -390,3 +392,77 @@ describe("UniversalSearchPage — timeline rendering", () => {
expect(screen.queryByText(/^Timeline$/)).not.toBeInTheDocument();
});
});
describe("ageLabel", () => {
const NOW = Date.parse("2026-10-14T00:00:00.000Z");
it("formats compact relative ages", () => {
expect(ageLabel("2026-10-14", NOW)).toBe("today");
expect(ageLabel("2026-10-13", NOW)).toBe("1d");
expect(ageLabel("2026-10-05", NOW)).toBe("9d");
expect(ageLabel("2026-09-30", NOW)).toBe("2w");
// 24 days — still in the weeks band.
expect(ageLabel("2026-09-20", NOW)).toBe("3w");
// 31 days crosses the 30-day boundary, so "1mo" is reachable.
expect(ageLabel("2026-09-13", NOW)).toBe("1mo");
expect(ageLabel("2024-10-14", NOW)).toBe("2y");
});
it("returns null when there is no usable date, so the chip is omitted", () => {
expect(ageLabel(undefined, NOW)).toBeNull();
expect(ageLabel("", NOW)).toBeNull();
expect(ageLabel("not-a-date", NOW)).toBeNull();
});
});
describe("UniversalSearchPage — recency and age chip", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("renders an age chip per row carrying the created date", async () => {
fetchMock.mockResolvedValue(jsonResponse(RESULT));
render(<UniversalSearchPage />);
const input = screen.getByPlaceholderText(/Order no, quote no/i);
fireEvent.change(input, { target: { value: "truco" } });
await vi.advanceTimersByTimeAsync(400);
await waitFor(() => expect(screen.getByText("ORD-1")).toBeInTheDocument());
// Fixture date 2026-10-10 vs a "now" of 2026-10-14.
expect(screen.getByText("4d")).toBeInTheDocument();
// And the exact date is available on hover, since the chip is compact.
expect(
screen.getByTitle("Created 2026-10-10"),
).toBeInTheDocument();
});
it("omits the chip when a source supplies no created date", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
...RESULT,
groups: [
{
...RESULT.groups[0],
hits: [{ ...RESULT.groups[0].hits[0], createdAt: undefined }],
},
],
}),
);
render(<UniversalSearchPage />);
const input = screen.getByPlaceholderText(/Order no, quote no/i);
fireEvent.change(input, { target: { value: "truco" } });
await vi.advanceTimersByTimeAsync(400);
await waitFor(() => expect(screen.getByText("ORD-1")).toBeInTheDocument());
expect(screen.queryByTitle(/^Created /)).not.toBeInTheDocument();
});
});

View file

@ -12,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import getPocketBase from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
import { ageClass, ageLabel } from "./age";
/**
* Universal search — one box across orders, quotes, POs, suppliers,
@ -46,6 +47,8 @@ type SearchHit = {
preview?: PreviewRow[];
/** Dated lifecycle steps, oldest first, where timestamps exist. */
timeline?: TimelineEvent[];
/** Record creation date, YYYY-MM-DD — drives the age chip + ordering. */
createdAt?: string;
};
type Group = {
@ -349,11 +352,30 @@ export default function UniversalSearchPage() {
</p>
)}
</div>
{hit.meta && (
<span className="shrink-0 text-xs text-muted-foreground">
{hit.meta}
</span>
)}
<div className="flex shrink-0 items-center gap-2">
{hit.meta && (
<span className="text-xs text-muted-foreground">
{hit.meta}
</span>
)}
{/* Age chip — colour-coded, but the label
carries the meaning too. */}
{ageLabel(hit.createdAt) && (
<span
title={
hit.createdAt
? `Created ${hit.createdAt}`
: undefined
}
className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
ageClass(hit.createdAt),
)}
>
{ageLabel(hit.createdAt)}
</span>
)}
</div>
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</li>

View file

@ -154,6 +154,13 @@ export interface UniversalHit {
* underlying rows carry enough timestamps to build one honestly.
*/
timeline?: TimelineEvent[];
/**
* When the record was created, YYYY-MM-DD. Drives the "newest first"
* ordering within a relevance tier, and the age chip in the list.
* Absent for sources where the collection has no meaningful created
* value — the chip is then omitted rather than showing a blank.
*/
createdAt?: string;
}
export interface PreviewRow {
@ -314,6 +321,37 @@ export function bestScore(
return best;
}
/**
* Orders hits within a result group.
*
* RECOGNITION FIRST, THEN RECENCY — deliberately not recency-first.
* Sorting purely by date means searching an exact order number buries
* it beneath every newer order that shares 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 bonus for exact/prefix), so comparing it puts a precise
* identifier match above any prose match automatically. Within a tier the
* bonus separates exact from prefix from contains, and the date then
* breaks what's left — which is the case that actually 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.
*/
function compareHits(
a: { score: number; hit: UniversalHit },
b: { score: number; hit: UniversalHit },
): number {
if (b.score !== a.score) return b.score - a.score;
return (b.hit.createdAt ?? "").localeCompare(a.hit.createdAt ?? "");
}
/** `created` → `YYYY-MM-DD`, or undefined when the column is empty. */
function createdYmd(value: unknown): string | undefined {
const raw = value === null || value === undefined ? "" : String(value).trim();
return raw.length >= 10 ? raw.slice(0, 10) : undefined;
}
/**
* Groups sort by their strongest hit, so the single most likely answer
* ("TIAAN06") is the first thing on screen regardless of which
@ -661,11 +699,12 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
["Notes", clip(order.notes, 140)],
]),
timeline: buildOrderTimeline(order, orderItems),
createdAt: createdYmd(order.created),
},
});
}
scored.sort((a, b) => b.score - a.score);
scored.sort(compareHits);
return {
type: "order",
label: "Orders",
@ -794,6 +833,7 @@ async function searchQuoteSource(q: string): Promise<SourceResult> {
: undefined,
href: `/quotes/${quote?.id ?? ""}`,
matchedOn: "quote",
createdAt: createdYmd(quote?.created),
preview: previewRows([
["Quote", quote?.quote_number],
["Customer", expanded?.customer?.customer],
@ -871,7 +911,7 @@ async function searchReferenceCollection<T extends Record<string, unknown>>(
if (score === 0) continue;
scored.push({ score, hit: opts.build(row) });
}
scored.sort((a, b) => b.score - a.score);
scored.sort(compareHits);
return {
type: opts.type,
@ -1012,11 +1052,12 @@ async function searchStockPurchases(
.join(" · "),
],
]),
createdAt: createdYmd(header.created),
},
});
}
scored.sort((a, b) => b.score - a.score);
scored.sort(compareHits);
return {
type: "stock_purchase",
label: "Stock Orders",
@ -1085,9 +1126,9 @@ export async function warmUniversalSearchCaches(): Promise<void> {
loadReferenceRows(pb, "companies", COMPANY_FIELDS),
loadReferenceRows(pb, "customers", CUSTOMER_FIELDS),
loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit,current_qty,cost_price,selling_price"),
loadReferenceRows(pb, "purchase_orders", "id,po_number,status,total_amount,order,supplier"),
loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier,ordered_value"),
loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit,current_qty,cost_price,selling_price,created"),
loadReferenceRows(pb, "purchase_orders", "id,po_number,status,total_amount,order,supplier,created"),
loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier,ordered_value,created"),
loadReferenceRows(
pb,
"stock_purchase_items",
@ -1156,7 +1197,7 @@ export async function universalSearch(
// detail page in this app — POs are reviewed from inside the
// order — so the parent order is the hit's destination, with
// the PDF as the fallback for an orphaned PO.
fields: "id,po_number,status,total_amount,order,supplier",
fields: "id,po_number,status,total_amount,order,supplier,created",
score: (r, q) =>
bestScore(
[
@ -1182,8 +1223,9 @@ export async function universalSearch(
"Value",
r.total_amount ? `R ${r.total_amount}` : "",
],
["Created", r.created ? String(r.created).slice(0, 10) : ""],
["Created", createdYmd(r.created)],
]),
createdAt: createdYmd(r.created),
};
},
},
@ -1219,6 +1261,7 @@ export async function universalSearch(
subtitle: r.email ? String(r.email) : undefined,
href: `/suppliers/${r.id}`,
matchedOn: "supplier",
createdAt: createdYmd(r.created),
preview: previewRows([
["Supplier", r.suppliername],
["Email", r.email],
@ -1255,6 +1298,7 @@ export async function universalSearch(
subtitle: r.address ? String(r.address) : undefined,
href: `/companies/${r.id}`,
matchedOn: "company",
createdAt: createdYmd(r.created),
preview: previewRows([
["Company", r.company],
["Address", clip(r.address, 90)],
@ -1291,6 +1335,7 @@ export async function universalSearch(
subtitle: r.email ? String(r.email) : undefined,
href: `/customers/${r.id}`,
matchedOn: "customer",
createdAt: createdYmd(r.created),
preview: previewRows([
["Customer", r.customer],
["Email", r.email],
@ -1322,7 +1367,7 @@ export async function universalSearch(
type: "stock_item",
label: "Stock Items",
collection: "stock_items",
fields: "id,name,sku,description,unit,current_qty,cost_price,selling_price",
fields: "id,name,sku,description,unit,current_qty,cost_price,selling_price,created",
score: (r, q) =>
bestScore(
[
@ -1338,6 +1383,7 @@ export async function universalSearch(
subtitle: r.sku ? String(r.sku) : undefined,
href: `/stock/${r.id}`,
matchedOn: r.sku ? "SKU" : "item",
createdAt: createdYmd(r.created),
preview: previewRows([
["Item", r.name],
["SKU", r.sku],