Commit graph

3 commits

Author SHA1 Message Date
a1a2cc73e2 feat(search): invoice + delivery-note search, richer colour-coded preview
- 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>
2026-10-16 18:45:44 +02:00
d55e54680b perf(search): narrow the recency window 90d -> 60d
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.
2026-10-15 16:02:34 +02:00
1ee3555239 feat(search): universal search across every collection
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.
2026-10-14 10:32:14 +02:00