diff --git a/src/app/(app)/search/age.ts b/src/app/(app)/search/age.ts
new file mode 100644
index 0000000..950b7d7
--- /dev/null
+++ b/src/app/(app)/search/age.ts
@@ -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";
+ }
+}
\ No newline at end of file
diff --git a/src/app/(app)/search/page.test.tsx b/src/app/(app)/search/page.test.tsx
index 4e6f1a2..9d41a1d 100644
--- a/src/app/(app)/search/page.test.tsx
+++ b/src/app/(app)/search/page.test.tsx
@@ -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();
+ 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();
+ 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();
+ });
+});
diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx
index da15484..08bd733 100644
--- a/src/app/(app)/search/page.tsx
+++ b/src/app/(app)/search/page.tsx
@@ -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() {
)}
- {hit.meta && (
-
- {hit.meta}
-
- )}
+
+ {hit.meta && (
+
+ {hit.meta}
+
+ )}
+ {/* Age chip — colour-coded, but the label
+ carries the meaning too. */}
+ {ageLabel(hit.createdAt) && (
+
+ {ageLabel(hit.createdAt)}
+
+ )}
+
diff --git a/src/lib/server/universal-search-service.ts b/src/lib/server/universal-search-service.ts
index 2366b5a..61e2582 100644
--- a/src/lib/server/universal-search-service.ts
+++ b/src/lib/server/universal-search-service.ts
@@ -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 b.score - a.score);
+ scored.sort(compareHits);
return {
type: "order",
label: "Orders",
@@ -794,6 +833,7 @@ async function searchQuoteSource(q: string): Promise {
: 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>(
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 {
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],