fix(search): orders searchable by customer/company name; timeline in preview

Two reports, one root cause each.

1. "teleg" didn't find order 121026-1 ENG

   Same defect class as the supplier gap in efb5a53. `partyNames` was
   resolved and used to BUILD the preview, but never added to the MATCH
   set — so an order was findable by its number and displayed with its
   company name, yet not findable BY that name. `orders.customer` and
   `orders.company` are PB relation ids; without the resolved names in
   the match set, partial company searches returned nothing.

   Now matched on both, with the matched surface reported accurately
   (customer / company / contact / rep / order) rather than a blanket
   "order". Verified live: "teleg" → ENG found, via=company.

2. Preview had no lifecycle trail

   Built from timestamps already on the order and its items, so it costs
   no extra round-trip — an audit-log query per hit would undo the
   point of the preview being free. Verified live on a progressed order:

     2026-08-04  Order raised
     2026-08-12  Driver assigned
     2026-08-12  Collected
     2026-10-14  Invoiced

   Steps with no timestamp are OMITTED, not rendered blank — a rail with
   holes reads as broken data rather than unfinished work. That is why
   121026-1 ENG shows a single step: it is only at po_generated, so
   nothing after "Order raised" has happened yet.

   Column population on live PB, for anyone extending this:
     assigned_at 1371 · drv_collected_at 1358 · invoiced_date 1626
     delivered_at 2 · drv_assigned_at 0 · collected_date 0
   `delivered_at` being effectively empty is the same defect fixed in
   918f73e; here it means the Delivered step will rarely appear.

HITS_PER_GROUP 25 → 50

Searching a company name returns every order that company has, and they
all score identically (same matched surface), so the ENG order fell off
a 25-row list that was one row short of showing it. 50 covers a single
customer's order volume. Truncation is still surfaced as "showing N of
TOTAL", so this stays a UX choice rather than a hidden guarantee.

Dialog restyled: type as an eyebrow, a "matched: <surface>" pill so the
reason a hit is present is stated plainly, a bordered label/value table
instead of a loose grid, and the timeline as a vertical rail.
This commit is contained in:
Dave 2026-10-14 13:54:52 +02:00
parent 2736ebd7dc
commit 00736320bf
3 changed files with 253 additions and 19 deletions

View file

@ -74,6 +74,10 @@ const RESULT = {
{ label: "Customer", value: "Telegenix Trading 168 CC" },
{ label: "Status", value: "po_generated" },
],
timeline: [
{ label: "Order raised", at: "2026-10-12" },
{ label: "Driver assigned", at: "2026-10-14" },
],
},
],
total: 1,
@ -322,3 +326,67 @@ describe("UniversalSearchPage — quick overview dialog", () => {
expect(screen.getAllByText("ORD-1").length).toBeGreaterThan(0);
});
});
describe("UniversalSearchPage — timeline rendering", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("renders each dated lifecycle step in order", 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);
const matches = await screen.findAllByText("ORD-1");
const row = matches.find(
(el) => el.tagName === "SPAN" && el.className.includes("font-medium"),
);
fireEvent.click(row as HTMLElement);
await waitFor(() => expect(screen.getByText(/^Timeline$/)).toBeInTheDocument());
expect(screen.getByText("Order raised")).toBeInTheDocument();
expect(screen.getByText("Driver assigned")).toBeInTheDocument();
expect(screen.getByText("2026-10-12")).toBeInTheDocument();
});
it("omits the timeline section entirely when the service sends none", async () => {
// A rail with holes reads as broken data rather than unfinished
// work, so the service omits missing steps and the UI must not
// render an empty section for them.
fetchMock.mockResolvedValue(
jsonResponse({
...RESULT,
groups: [
{
...RESULT.groups[0],
hits: [{ ...RESULT.groups[0].hits[0], timeline: undefined }],
},
],
}),
);
render(<UniversalSearchPage />);
const input = screen.getByPlaceholderText(/Order no, quote no/i);
fireEvent.change(input, { target: { value: "truco" } });
await vi.advanceTimersByTimeAsync(400);
const matches = await screen.findAllByText("ORD-1");
const row = matches.find(
(el) => el.tagName === "SPAN" && el.className.includes("font-medium"),
);
fireEvent.click(row as HTMLElement);
await waitFor(() => expect(screen.getByText("po_generated")).toBeInTheDocument());
expect(screen.queryByText(/^Timeline$/)).not.toBeInTheDocument();
});
});

View file

@ -7,11 +7,11 @@ import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import getPocketBase from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
/**
* Universal search — one box across orders, quotes, POs, suppliers,
@ -33,6 +33,7 @@ import getPocketBase from "@/lib/pocketbase";
*/
type PreviewRow = { label: string; value: string };
type TimelineEvent = { label: string; at: string };
type SearchHit = {
id: string;
@ -43,6 +44,8 @@ type SearchHit = {
matchedOn?: string;
/** Key/value rows the service fills from data it already has. */
preview?: PreviewRow[];
/** Dated lifecycle steps, oldest first, where timestamps exist. */
timeline?: TimelineEvent[];
};
type Group = {
@ -379,29 +382,45 @@ export default function UniversalSearchPage() {
if (!open) setPreviewHit(null);
}}
>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-xl">
{previewHit && (
<>
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2">
<DialogHeader className="space-y-1.5">
{previewGroupLabel && (
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{previewGroupLabel}
</span>
)}
<DialogTitle className="text-xl leading-tight">
{previewHit.title}
</DialogTitle>
<div className="flex flex-wrap items-center gap-2 pt-0.5">
{previewHit.matchedOn && (
<span className="rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{previewHit.matchedOn}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
matched: {previewHit.matchedOn}
</span>
)}
</DialogTitle>
{previewGroupLabel && (
<DialogDescription>{previewGroupLabel}</DialogDescription>
)}
{previewHit.meta && (
<span className="text-xs text-muted-foreground">
{previewHit.meta}
</span>
)}
</div>
</DialogHeader>
{previewHit.preview && previewHit.preview.length > 0 ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<dl className="divide-y rounded-md border">
{previewHit.preview.map((row) => (
<div key={row.label} className="contents">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="min-w-0 break-words">{row.value}</dd>
<div
key={row.label}
className="grid grid-cols-[7.5rem_1fr] gap-3 px-3 py-1.5"
>
<dt className="text-xs text-muted-foreground sm:text-sm">
{row.label}
</dt>
<dd className="min-w-0 break-words text-sm">
{row.value}
</dd>
</div>
))}
</dl>
@ -411,6 +430,48 @@ export default function UniversalSearchPage() {
</p>
)}
{/*
Lifecycle timeline. Rendered only when the service
could build one — a rail with holes would read as
broken data rather than unfinished work, so steps with
no timestamp are omitted at the source instead.
*/}
{previewHit.timeline && previewHit.timeline.length > 0 && (
<div>
<h3 className="mb-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Timeline
</h3>
<ol className="space-y-0">
{previewHit.timeline.map((event, index) => {
const isLast = index === previewHit!.timeline!.length - 1;
return (
<li key={`${event.label}-${event.at}`} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
"mt-1 size-2.5 shrink-0 rounded-full ring-4",
isLast
? "bg-primary ring-primary/15"
: "bg-border ring-background",
)}
/>
{!isLast && (
<span className="w-px flex-1 bg-border" />
)}
</div>
<div className="pb-4">
<p className="text-sm leading-tight">{event.label}</p>
<p className="text-xs text-muted-foreground">
{event.at}
</p>
</div>
</li>
);
})}
</ol>
</div>
)}
{/* The escape hatch: the preview must never be the only
way to reach a record. */}
<a

View file

@ -75,8 +75,18 @@ type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
*/
export const UNIVERSAL_SEARCH_WINDOW_DAYS = 90;
/** Rendered hits per group. `total` is always the uncapped count. */
const HITS_PER_GROUP = 25;
/**
* Rendered hits per group. `total` is always the uncapped count.
*
* Raised 25 → 50 on 2026-10-14 after searching a company name proved
* 25 too tight: "teleg" matches 28 orders that all score identically
* (they match on the same company), so the ENG order the operator was
* looking for fell off the end of a list that was one row short of
* showing it. 50 comfortably covers a single customer's order volume
* while still bounding the payload. Truncation is still surfaced as
* "showing N of TOTAL", so the number is a UX choice, not a guarantee.
*/
const HITS_PER_GROUP = 50;
/** Below this, results are noise rather than signal. */
const MIN_QUERY_CHARS = 2;
@ -139,6 +149,11 @@ export interface UniversalHit {
* Rows are ordered most-identifying-first.
*/
preview?: PreviewRow[];
/**
* Optional dated lifecycle, newest-last. Present only where the
* underlying rows carry enough timestamps to build one honestly.
*/
timeline?: TimelineEvent[];
}
export interface PreviewRow {
@ -146,6 +161,70 @@ export interface PreviewRow {
value: string;
}
/**
* One dated step in a record's lifecycle, e.g. "Collected · 12 Oct".
*
* Only steps whose timestamp actually exists are emitted — an order
* that hasn't been invoiced yet gets no invoiced step rather than a
* blank one, because a timeline full of holes reads as broken data
* rather than unfinished work.
*/
export interface TimelineEvent {
label: string;
/** YYYY-MM-DD, as stored. */
at: string;
}
/**
* Builds a lifecycle timeline for an order from timestamps that are
* actually present on its rows.
*
* Deliberately derived from data the snapshot already holds rather than
* from an audit-log query: this runs for every returned hit, so an
* extra round-trip per order would undo the point of the preview
* being free. What that trades away is precision — each step is the
* EARLIEST such timestamp across the order's items, so a mixed order
* reads as "first of each", not per-line. Good enough to answer "how
* far along is this?", which is what the dialog is for.
*
* Steps with no timestamp are OMITTED rather than rendered blank.
*/
function buildOrderTimeline(
order: Record<string, unknown>,
items: Record<string, unknown>[],
): TimelineEvent[] {
const events: TimelineEvent[] = [];
const add = (label: string, value: unknown) => {
const raw = value === null || value === undefined ? "" : String(value).trim();
// Accept both `YYYY-MM-DD` and full ISO timestamps; keep the date.
if (!raw || raw.length < 10) return;
events.push({ label, at: raw.slice(0, 10) });
};
add("Order raised", order.created);
// Earliest non-empty value per milestone across the order's lines.
const earliest = (
keys: string[],
): string | undefined => {
let best: string | undefined;
for (const item of items) {
for (const key of keys) {
const raw = String(item[key] ?? "").trim();
if (raw.length >= 10 && (!best || raw < best)) best = raw;
}
}
return best;
};
add("Driver assigned", earliest(["drv_assigned_at", "assigned_at"]));
add("Collected", earliest(["drv_collected_at", "collected_date"]));
add("Delivered", earliest(["delivered_at"]));
add("Invoiced", earliest(["invoiced_date"]));
return events.sort((a, b) => a.at.localeCompare(b.at));
}
/** Drops rows whose value is empty so previews don't render blank lines. */
function previewRows(rows: Array<[string, unknown]>): PreviewRow[] {
return rows
@ -425,8 +504,9 @@ async function fetchItemsByOrderIds(
fields:
// `item_status` is load-bearing: the quick-overview dialog
// summarises an order's status mix, and omitting it produced
// a preview with no Status row at all.
"id,order,item_status,description,po_description,sku,notes,selling_price,cost_price,supplier",
// a preview with no Status row at all. The date columns feed
// the lifecycle timeline in that same dialog.
"id,order,item_status,description,po_description,sku,notes,selling_price,cost_price,supplier,created,assigned_at,drv_assigned_at,collected_date,drv_collected_at,delivered_at,invoiced_date",
batch: 1000,
}),
),
@ -463,6 +543,13 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
for (const order of snapshot.orders) {
const orderItems = snapshot.itemsByOrder.get(String(order.id)) ?? [];
const customerName = snapshot.partyNames.get(
`c:${String(order.customer ?? "")}`,
);
const companyName = snapshot.partyNames.get(
`k:${String(order.company ?? "")}`,
);
let score = bestScore(
[
[String(order.order_number ?? ""), "id"],
@ -471,10 +558,27 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
[String(order.rep ?? ""), "name"],
[String(order.description ?? ""), "body"],
[String(order.notes ?? ""), "body"],
// Customer and company NAME. `orders.customer` / `orders.company`
// are PB relation ids, so without this the resolved names are
// used for the preview but never for matching — meaning a
// search for part of a company name ("teleg" for Telegenix
// Trading 168 CC) found the order by number but not by name,
// which is the same defect class as the supplier gap.
[customerName, "name"],
[companyName, "name"],
],
ql,
);
let matchedOn = score > 0 ? "order" : undefined;
let matchedOn: string | undefined;
if (score > 0) {
if (scoreField(customerName, ql, "name") > 0) matchedOn = "customer";
else if (scoreField(companyName, ql, "name") > 0) matchedOn = "company";
else if (scoreField(String(order.contact_name ?? ""), ql, "name") > 0)
matchedOn = "contact";
else if (scoreField(String(order.rep ?? ""), ql, "name") > 0)
matchedOn = "rep";
else matchedOn = "order";
}
if (score === 0) {
for (const item of orderItems) {
@ -556,6 +660,7 @@ async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResul
],
["Notes", clip(order.notes, 140)],
]),
timeline: buildOrderTimeline(order, orderItems),
},
});
}