From 00736320bfcfb982cbf0c0c0b18ea777342cce66 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 14 Oct 2026 13:54:52 +0200 Subject: [PATCH] fix(search): orders searchable by customer/company name; timeline in preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: " 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. --- src/app/(app)/search/page.test.tsx | 68 ++++++++++++ src/app/(app)/search/page.tsx | 89 +++++++++++++--- src/lib/server/universal-search-service.ts | 115 ++++++++++++++++++++- 3 files changed, 253 insertions(+), 19 deletions(-) diff --git a/src/app/(app)/search/page.test.tsx b/src/app/(app)/search/page.test.tsx index c5e1e2d..4e6f1a2 100644 --- a/src/app/(app)/search/page.test.tsx +++ b/src/app/(app)/search/page.test.tsx @@ -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(); + 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(); + 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(); + }); +}); diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx index 238383f..da15484 100644 --- a/src/app/(app)/search/page.tsx +++ b/src/app/(app)/search/page.tsx @@ -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); }} > - + {previewHit && ( <> - - + + {previewGroupLabel && ( + + {previewGroupLabel} + + )} + {previewHit.title} + +
{previewHit.matchedOn && ( - - {previewHit.matchedOn} + + matched: {previewHit.matchedOn} )} - - {previewGroupLabel && ( - {previewGroupLabel} - )} + {previewHit.meta && ( + + {previewHit.meta} + + )} +
{previewHit.preview && previewHit.preview.length > 0 ? ( -
+
{previewHit.preview.map((row) => ( -
-
{row.label}
-
{row.value}
+
+
+ {row.label} +
+
+ {row.value} +
))}
@@ -411,6 +430,48 @@ export default function UniversalSearchPage() {

)} + {/* + 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 && ( +
+

+ Timeline +

+
    + {previewHit.timeline.map((event, index) => { + const isLast = index === previewHit!.timeline!.length - 1; + return ( +
  1. +
    + + {!isLast && ( + + )} +
    +
    +

    {event.label}

    +

    + {event.at} +

    +
    +
  2. + ); + })} +
+
+ )} + {/* The escape hatch: the preview must never be the only way to reach a record. */} >; */ 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, + items: Record[], +): 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 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