- Orders now match on customer invoice numbers and delivery-note
numbers carried on their lines (tagged "invoice" / "delivery note").
- New Delivery Notes source: DN number, customer PO, customer and
company names; preview pulls lines and invoices for visible hits only.
- Quick-overview dialog moved to search-preview.tsx and redesigned:
type-tinted header, status pills, stat tiles, invoice chips with
QuickBooks links, line list, icon timeline with dates and gaps.
Fixes the dialog being pinned at 24rem by the primitive's sm:max-w-sm.
- Result list: per-type colour (icon tile, accent border, badge, row
hover), status pills per row, and a jump bar; mobile-tightened.
- Stock-order preview now fetches ordered_value/created (Value row and
age chip were always empty); supplier names keep their original case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Operator request. Bigger effect than expected.
THREE constants, not one
"90" appeared in three places, and two of them are load-bearing:
universal-search-service.ts UNIVERSAL_SEARCH_WINDOW_DAYS orders scope
quotes-search-service.ts SEARCH_RECENT_WINDOW_DAYS quotes pushdown
quotes-service.ts SEARCH_RECENT_WINDOW_DAYS exported; shared
with the /quotes page + archive
The last two must stay equal — the /quotes page renders "Show recent
(last N days)" from the exported constant while the server filters on
the private one, so moving them apart desyncs the label from the
behaviour with no error anywhere. All three moved together. Universal
search calls searchQuotes without `includeOlder`, so it inherits the
quotes window; changing only the universal constant would have left
global search reading 60 days of orders and 90 days of quotes behind a
single "last 60 days" label.
The UI label reads windowDays straight off the service, so it follows.
MEASURED
Order candidate set collapses — the window was carrying far more than
the linear slice it looks like:
90d -> 5,394 orders / 16,640 items / 6,696ms scope scan
60d -> 972 orders / 3,307 items / 1,606ms scope scan
Cold search "tiaan": 17-22s -> 4.6s measured. That is most of the
mitigation the TTL bump was papering over, so the long-deferred pushdown
rewrite is now worth less than it was this morning.
Quotes genuinely drop, and it is real rather than a regression: Tiaan's
own quotes go 22 -> 6 between a 90- and 60-day window, because that
band is where most of his quotes sit. Verified against PB directly.
ALSO
Pinned the clock in the age-chip test. It asserted "4d" against a
fixture dated four days before the wall clock, so it expired the day
the date rolled past — it failed on this run for exactly that reason.
One box at /search (sidebar, under Dashboard) covering orders, quotes,
both purchase-order flavours, suppliers, companies, customers and stock
items. Replaces guessing which screen held the answer.
Window: 90 days (UNIVERSAL_SEARCH_WINDOW_DAYS) on orders and quotes —
the two that grow without bound. Suppliers, companies, customers and
stock items are searched in full because they don't expire and an
operator searching for one expects to find it at any age. The UI states
the window so a miss reads as "aged out", not "broken".
THE INVARIANT — a cap must never look like emptiness
Every group reports `total` (uncapped) separately from `hits` (capped),
plus `truncated`. The UI renders "showing 25 of 1,240 — refine". This is
the property the 500-cap bug in orders-service violated: the newest 110
orders silently vanished and it read as a missing search field. The type
makes `total` structurally independent of `hits.length`.
Symmetrically, a source that fails or times out renders as an explicit
"couldn't search quotes" row. "We couldn't search X" and "no X matched"
are different statements and must never look alike. All nine sources
fan out in parallel and settle independently.
PERFORMANCE
Profiling the first build showed 7.8s per search — ~3.5s of it pure
data-fetching (1,070 orders / 4,288 line items re-read on every
keystroke) against ~10ms of actual matching. Added:
- order snapshot (headers + items + supplier names), 60s TTL
- reference-collection cache, 5min TTL
- per-query result cache, 60s TTL
Cold ~4.5s, warm 250ms-1.6s, repeat query 0ms. The page pre-warms on
mount via an empty-query request, so the ~4.5s cold build happens while
the operator is still reading the box rather than in front of them.
Orders match supplier NAMES, not relation ids — verified live that
"truco" returns the Truco order, the 6 matching quotes AND the supplier.
NO Reps group, deliberately
A rep is a filter, not a page: this app has no user detail view and no
page accepts ?search=. A Reps group would be rows whose every link
404s. `orders.rep` is a matched field on the Orders source, so
searching "tiaan" still returns exactly his 96 orders — which is what
was actually asked for.
PO hits link to their parent order, since there is no standalone PO
detail page; orphaned POs fall back to the PO PDF.
Tests: 15 cases over the pure layer — ranking tiers, best-of-not-sum
scoring, filter escaping against injection, the window maths, group
ordering, and the cap/failure invariants. One of them caught a real
defect: scoreField lowercased the haystack but not the query, so an
uppercase query scored 0 against every row.