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:
parent
00736320bf
commit
cf8f766d80
4 changed files with 230 additions and 14 deletions
72
src/app/(app)/search/age.ts
Normal file
72
src/app/(app)/search/age.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue