From 150ca63fc020bf3e9b8843334415e13959c0ecf7 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 10:03:15 +0200 Subject: [PATCH] docs(specs): search-row recording_date + softened estimated count (#61) --- .../2026-06-08-search-row-date-design.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-search-row-date-design.md diff --git a/docs/superpowers/specs/2026-06-08-search-row-date-design.md b/docs/superpowers/specs/2026-06-08-search-row-date-design.md new file mode 100644 index 0000000..74fc575 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-search-row-date-design.md @@ -0,0 +1,128 @@ +# Search-Result Date Meta + Estimated-Count Copy — Design + +**Date:** 2026-06-08 +**Status:** Approved (brainstorming) — ready for implementation planning. +**Issue:** #61 (scoped: add a date to result rows + soften the estimated count; object type/facet is +declined — no such field exists in the domain). + +## Context + +Search result rows show name + object_number + visibility badge + snippet, but no date — power users +disambiguate hits by period. The count copy reads "{{count}} results" though it's Meilisearch's +`estimated_total`. **There is no object type/category/classification field anywhere in the domain** +(`CatalogueObject` = object_number/object_name/number_of_objects/brief_description/current_location/ +current_owner/recorder/**recording_date**/visibility + flexible `fields` + timestamps), so a "type" +meta/facet is impossible without a new domain concept (out of scope). The only disambiguating date +available is `recording_date` (`YYYY-MM-DD`) — but it is **not indexed** into the search document or +returned in the hit, so surfacing it needs a backend change. + +**Facts:** `SearchDocument` (`crates/search/src/lib.rs:30`) and `SearchHit` (`:52`) carry +`id/object_number/object_name/brief_description/[current_owner/recorder]/visibility/[snippet]` — no +date. `build_document` (`:302`) projects a `CatalogueObject` → `SearchDocument` (does not copy +`recording_date`). `search_objects` (`:185`) maps Meili results → `SearchHit`. `SearchHitView` +(`crates/api/src/admin_search.rs:26`) mirrors `SearchHit`. The frontend type is +`components["schemas"]["SearchHitView"]`; `schema.d.ts` is generated by `pnpm gen:api`. `AdminObjectView` +already serializes `recording_date` as `Option` (`YYYY-MM-DD`, via `format_date`). On-write +`sync_object` re-projects an object after each catalogue write; `reindex_all` is the full rebuild path. + +### Decisions (from brainstorming) +1. Thread `recording_date` (`YYYY-MM-DD`) through `SearchDocument` → `SearchHit` → `SearchHitView`; + show it on the row when present. +2. Soften the count to `~{{count}}` (it's an estimate). +3. Decline object type/facet (no domain field). + +## Backend (`crates/search`, `crates/api`) + +### `SearchDocument` + `build_document` (`crates/search/src/lib.rs`) +- Add field: `pub recording_date: Option,` to `SearchDocument`. +- In `build_document`'s returned struct: `recording_date: object.recording_date.map(|d| d.to_string()),` + (`domain::Date`'s `Display` is ISO `YYYY-MM-DD`, matching `AdminObjectView`). Index it as a plain + string (Meili stores/returns it; it need not be filterable). + +### `SearchHit` + `search_objects` mapping (`crates/search/src/lib.rs`) +- Add `pub recording_date: Option,` to `SearchHit`. +- In `search_objects`'s hit map: `recording_date: doc.recording_date,` (the executed + `SearchDocument` deserialization carries it; old index docs missing the field deserialize to `None` + because it's `Option` — graceful). + +### `SearchHitView` (`crates/api/src/admin_search.rs`) +- Add `pub recording_date: Option,` to `SearchHitView`. +- In the map closure (`:100`): `recording_date: h.recording_date,`. + +### Indexing / backfill +- New & edited objects get `recording_date` automatically via the existing on-write `sync_object`. +- Already-indexed objects return `recording_date: None` until a `reindex_all` (the existing rebuild + path) runs — a graceful, opt-in backfill; **a `reindex` CLI command is a follow-up**, not in scope. + +### Tests (Rust — need the docker stack: Postgres :5442, Meilisearch :7700) +- Update any direct `SearchDocument`/`SearchHit` struct literals in `crates/search/tests/*` / + `crates/api/tests/*` to include `recording_date: None` (compile fix). (Object fixtures construct + `CatalogueObject`, which already has `recording_date` — unaffected.) +- Extend `crates/search/tests/search.rs` (the `search_objects_returns_hits_…` test): give one seeded + object a `recording_date` and assert the returned hit's `recording_date` is the `YYYY-MM-DD` string + (proves it flows index → hit). + +## Frontend (`web`) + +### `web/src/api/schema.d.ts` (generated) +After the backend change, regenerate via `pnpm gen:api` (stack + server up) **or** hand-add +`recording_date?: string | null;` to the `SearchHitView` block (mirroring `brief_description?: string | +null` / `snippet?: string | null`) — a later `gen:api` reproduces it identically. Either is acceptable; +the manual edit avoids running the server purely for codegen. + +### `search-result-row.tsx` +Add `recording_date` to the meta line (the `flex items-center gap-2 text-xs text-muted-foreground` +row), after `object_number`, when present: +```tsx +{hit.object_number} +{hit.recording_date && · {hit.recording_date}} + +``` +(Render the `YYYY-MM-DD` string as-is — it's a plain recording date, not a UTC timestamp, so no +timezone formatting. A separator dot before it keeps the row scannable.) + +### `search-panel.tsx` + i18n — soften the count +The count uses `t("search.resultCount", { count: total })` with `resultCount_one`/`resultCount_other`. +Change the i18n values to flag the estimate (en + sv, parity preserved): +- en: `"resultCount_one": "~{{count}} result"`, `"resultCount_other": "~{{count}} results"` +- sv: `"resultCount_one": "~{{count}} träff"`, `"resultCount_other": "~{{count}} träffar"` +No code change in `search-panel.tsx` (the key already interpolates `count`). + +### Fixtures + tests +- `web/src/test/fixtures.ts` `searchHits`: add `recording_date` to the first hit (e.g. `"1962-04-03"`); + the rest can stay `recording_date: null` (or omit — it's optional). +- `web/src/search/search.test.tsx`: assert the first result row shows the date (`getByText("1962-04-03")` + or within the row); assert the count copy includes the `~` (`getByText(/~\s*25 results/i)` — note the + existing `/25 results/i` assertion still matches the new copy; tighten it to require the `~`). + +## Data flow +DB object → `build_document` (now copies `recording_date`) → Meili doc → `search_objects` hit → API +`SearchHitView` → `useSearch` → `SearchResultRow` renders the date. The count is the same +`estimated_total`, now labelled `~`. + +## Error handling / edges +- `recording_date` is `Option` end-to-end → absent on objects without a recording date and on + not-yet-reindexed docs; the row simply omits it. +- `Date::to_string()` is ISO `YYYY-MM-DD` (matches `AdminObjectView`); no locale/timezone formatting. +- The `~` is cosmetic; pluralization (`_one`/`_other`) is unchanged. + +## Testing +- **Rust:** `cargo build --workspace`; `cargo nextest run -p search -p api` (stack up) green, incl. the + new recording_date assertion + any literal compile-fixes. +- **Frontend:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; the new row-date + + count tests pass; en/sv parity (the #60 parity test guards the changed values); no codename. +- No new dependency. + +## Acceptance criteria +1. `recording_date` is projected into the search index (`SearchDocument`/`build_document`) and returned + through `SearchHit` → `SearchHitView` → `schema.d.ts` (`SearchHitView.recording_date?: string|null`). +2. The search result row shows the recording date (when present) on its meta line. +3. The result-count copy reads `~{{count}} …` to flag the Meilisearch estimate (en + sv). +4. Rust (`cargo build` + search/api tests) green; frontend gate green; en/sv parity; no codename; no new + dependency. + +## Out of scope → follow-ups +- An object **type/classification** domain concept + a **type facet** (no such field exists today). +- A `reindex` CLI command to backfill `recording_date` onto already-indexed objects (new/edited objects + index it automatically; `reindex_all` is the existing rebuild path). +- Richer faceting; making `recording_date` filterable/sortable in search.