From cf8f766d809ec7ae0cf43ce14d730b3a7e4f691b Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 14 Oct 2026 14:17:01 +0200 Subject: [PATCH] feat(search): newest-first within relevance tier, plus a colour-coded age chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(app)/search/age.ts | 72 ++++++++++++++++++++ src/app/(app)/search/page.test.tsx | 76 ++++++++++++++++++++++ src/app/(app)/search/page.tsx | 32 +++++++-- src/lib/server/universal-search-service.ts | 64 +++++++++++++++--- 4 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 src/app/(app)/search/age.ts 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],