merge: search-row recording_date + softened estimated count (#61)
CI / web (push) Has been cancelled

This commit is contained in:
2026-06-08 13:49:31 +02:00
11 changed files with 287 additions and 5 deletions
+2
View File
@@ -30,6 +30,7 @@ pub(crate) struct SearchHitView {
pub brief_description: Option<String>,
#[schema(value_type = domain::Visibility)]
pub visibility: String,
pub recording_date: Option<String>,
pub snippet: Option<String>,
}
@@ -103,6 +104,7 @@ pub(crate) async fn search_objects(
object_name: h.object_name,
brief_description: h.brief_description,
visibility: h.visibility,
recording_date: h.recording_date,
snippet: h.snippet,
})
.collect(),
+4
View File
@@ -34,6 +34,7 @@ pub struct SearchDocument {
pub brief_description: Option<String>,
pub current_owner: Option<String>,
pub recorder: Option<String>,
pub recording_date: Option<String>,
/// Filterable: "draft" | "internal" | "public".
pub visibility: String,
/// Flexible field values flattened to searchable text.
@@ -55,6 +56,7 @@ pub struct SearchHit {
pub object_name: String,
pub brief_description: Option<String>,
pub visibility: String,
pub recording_date: Option<String>,
pub snippet: Option<String>,
}
@@ -233,6 +235,7 @@ impl SearchClient {
object_name: doc.object_name,
brief_description: doc.brief_description,
visibility: doc.visibility,
recording_date: doc.recording_date,
snippet,
}
})
@@ -367,6 +370,7 @@ pub async fn build_document(
brief_description: object.brief_description.clone(),
current_owner: object.current_owner.clone(),
recorder: object.recorder.clone(),
recording_date: object.recording_date.map(|d| d.to_string()),
visibility: object.visibility.as_str().to_owned(),
fields_text,
})
+3
View File
@@ -19,6 +19,7 @@ fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
brief_description: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: "draft".to_string(),
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
}
@@ -66,6 +67,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
&["cast bronze with green patina"],
);
bronze_a.visibility = "public".to_string();
bronze_a.recording_date = Some("1962-04-03".to_string());
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
bronze_b.visibility = "public".to_string();
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
@@ -87,6 +89,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
"snippet must mark the match"
);
assert!(snippet.contains(search::HL_POST));
assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));
let public = client
.search_objects("bronze", Some("public"), 0, 20)
@@ -0,0 +1,140 @@
# Search-Result Date Meta + Estimated-Count Copy — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Thread `recording_date` through the search index/hit/view (Rust), show it on the result row, and soften the estimated-count copy.
**Architecture:** Backend adds `recording_date: Option<String>` (`YYYY-MM-DD`) to `SearchDocument` (projected in `build_document`), `SearchHit` (mapped in `search_objects`), and `SearchHitView` (API). The frontend type comes from the regenerated/edited `schema.d.ts`; the row renders the date and the count copy gains a `~`.
**Tech Stack:** Rust (search + api crates, Meilisearch, utoipa), React + TS + pnpm. Tests need the docker stack (Postgres :5442, Meilisearch :7700 — already up). Rust test runner: `cargo nextest`. Frontend: `pnpm test`.
**Conventions:** `cargo +nightly fmt` + `cargo clippy --workspace --all-targets -- -D warnings` for Rust; pnpm + no `any`/`eslint-disable` + en/sv parity for web; no codename; manage deps via cargo-mcp if any (none here). Run a single test pass.
**Spec:** `docs/superpowers/specs/2026-06-08-search-row-date-design.md`
**Key facts:**
- `crates/search/src/lib.rs`: `SearchDocument` (`:30`), `SearchHit` (`:52`), `build_document` (`:302`, returns the `SearchDocument { … }` literal around `:363`), `search_objects` hit map (`~:220`). `domain::Date` Display = ISO `YYYY-MM-DD`; `CatalogueObject.recording_date: Option<Date>`.
- `crates/api/src/admin_search.rs`: `SearchHitView` (`:26`) + map closure (`:100`).
- `crates/search/tests/search.rs`: a `doc(...)` helper builds a `SearchDocument` literal; `search_objects_returns_hits_…` (`:55`) seeds 3 docs + asserts. `tests/sync.rs`/`tests/reindex.rs` construct `CatalogueObject` (already has `recording_date` — unaffected) but may also build `SearchDocument` — check.
- `web/src/api/schema.d.ts` `SearchHitView` block (`:601`): keys are alphabetized — insert `recording_date?: string | null;` between `object_number` and `snippet`.
- `web/src/search/search-result-row.tsx`: meta line `<span>{hit.object_number}</span> <VisibilityBadge/>`.
- i18n `search.resultCount_one/_other`; `web/src/test/fixtures.ts` `searchHits`; `web/src/search/search.test.tsx` asserts `/25 results/i`.
---
# Task 1: Backend — `recording_date` through index → hit → view (+ schema)
**Files:** `crates/search/src/lib.rs`, `crates/api/src/admin_search.rs`, `crates/search/tests/search.rs` (+ any test with a `SearchDocument`/`SearchHit` literal), `web/src/api/schema.d.ts`.
- [ ] **Step 1: `SearchDocument` + `build_document`** (`crates/search/src/lib.rs`):
- Add `pub recording_date: Option<String>,` to `SearchDocument` (after `recorder`, before `visibility` — keep a sensible order).
- In the `Ok(SearchDocument { … })` literal at the end of `build_document`, add `recording_date: object.recording_date.map(|d| d.to_string()),`.
- [ ] **Step 2: `SearchHit` + `search_objects` mapping** (`crates/search/src/lib.rs`):
- Add `pub recording_date: Option<String>,` to `SearchHit` (after `visibility`, before `snippet` or near it).
- In `search_objects`, the `SearchHit { id, object_number, object_name, brief_description, visibility, snippet }` literal: add `recording_date: doc.recording_date,`.
- [ ] **Step 3: `SearchHitView`** (`crates/api/src/admin_search.rs`):
- Add `pub recording_date: Option<String>,` to `SearchHitView`.
- In the `.map(|h| SearchHitView { … })` closure: add `recording_date: h.recording_date,`.
- [ ] **Step 4: Fix test literals + extend the search test** (`crates/search/tests/search.rs`):
- The `doc(...)` helper builds a `SearchDocument` literal — add `recording_date: None,` to it (compile fix). Grep the search + api test dirs for other `SearchDocument {`/`SearchHit {` literals and add `recording_date: None` where needed (object fixtures that build `CatalogueObject` are unaffected — they already have `recording_date`).
- In `search_objects_returns_hits_with_highlight_filter_and_paging`, set a date on one seeded doc before indexing: `bronze_a.recording_date = Some("1962-04-03".to_string());` and after the existing `hit` lookup assert: `assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));`.
- [ ] **Step 5: Build + lint + test (stack is up).**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
cargo build --workspace
cargo +nightly fmt
cargo clippy --workspace --all-targets -- -D warnings
cargo nextest run -p search -p api
```
Expected: compiles, fmt clean, clippy clean, tests pass (incl. the new `recording_date` assertion). Run the test suite ONCE (single Postgres — no concurrent cargo test runs). If clippy/fmt change files, include them.
- [ ] **Step 6: Update `web/src/api/schema.d.ts`** — add the field to the `SearchHitView` block (alphabetical, between `object_number` and `snippet`):
```ts
SearchHitView: {
brief_description?: string | null;
id: string;
object_name: string;
object_number: string;
recording_date?: string | null;
snippet?: string | null;
visibility: components["schemas"]["Visibility"];
};
```
(This mirrors what `pnpm gen:api` would emit now that the backend returns the field; a later regen reproduces it identically. Optionally verify with `pnpm gen:api` if a server is running — not required.)
- [ ] **Step 7: Commit**
```bash
git add crates/search/src/lib.rs crates/api/src/admin_search.rs crates/search/tests/search.rs web/src/api/schema.d.ts
# (+ any other test files you fixed)
git commit -m "feat(search): index + return recording_date on search hits (#61)"
```
---
# Task 2: Frontend — row date + softened count + tests + gate
**Files:** `web/src/search/search-result-row.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/test/fixtures.ts`, `web/src/search/search.test.tsx`.
- [ ] **Step 1: `search-result-row.tsx`** — show the date on the meta line when present:
```tsx
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span>{hit.object_number}</span>
{hit.recording_date && <span>· {hit.recording_date}</span>}
<VisibilityBadge visibility={hit.visibility} />
</div>
```
(Render the `YYYY-MM-DD` string verbatim — it's a recording date, not a UTC timestamp.)
- [ ] **Step 2: i18n — soften the count** (both locales, parity; the `#60` parity test guards this):
- en.json `search`: `"resultCount_one": "~{{count}} result"`, `"resultCount_other": "~{{count}} results"`.
- sv.json `search`: `"resultCount_one": "~{{count}} träff"`, `"resultCount_other": "~{{count}} träffar"`.
(No code change in `search-panel.tsx` — the key already interpolates `count`.)
- [ ] **Step 3: Fixtures** (`web/src/test/fixtures.ts`) — add `recording_date` to the first `searchHits` entry:
`recording_date: "1962-04-03",` (the generated rest can be `recording_date: null` or omit it — it's optional; set `null` on the mapped `Array.from(...)` items to satisfy the type cleanly).
- [ ] **Step 4: Tests** (`web/src/search/search.test.tsx`):
- Assert the first result row shows the date: after results render, `expect(screen.getByText("1962-04-03")).toBeInTheDocument();` (or scope within the row).
- Tighten the count assertion to require the `~`: change `getByText(/25 results/i)``getByText(/~\s*25 results/i)` (or assert `getByText(/~25 results/i)`).
Keep the existing load-more + visibility-filter tests green.
- [ ] **Step 5: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. Report test totals, largest chunk, check:colors line.
- [ ] **Step 6: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src crates; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 7: Manual smoke (recommended).** With the stack + server + web dev up: search the collection — result rows show the recording date (for objects that have one and have been (re)indexed); the count reads "~N results".
- [ ] **Step 8: Commit**
```bash
git add web/src/search/search-result-row.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/test/fixtures.ts web/src/search/search.test.tsx
git commit -m "feat(web): show recording_date on search rows; flag estimated count as approximate (#61)"
```
---
## Self-Review (completed)
**Spec coverage:** recording_date in SearchDocument/build_document (T1 S1), SearchHit/search_objects (T1 S2), SearchHitView (T1 S3), schema.d.ts (T1 S6); Rust test literal fixes + flow assertion (T1 S4); row date (T2 S1); softened count i18n (T2 S2); fixtures + tests (T2 S3S4); Rust + frontend gates (T1 S5, T2 S5). Acceptance criteria 14 mapped. ✓
**Placeholder scan:** the schema.d.ts edit is given verbatim; the test changes name exact assertions; "grep for other SearchDocument/SearchHit literals" is a concrete compile-driven step (the compiler names them if missed). No vague steps. ✓
**Type/consistency:** `recording_date: Option<String>` (Rust) ↔ `recording_date?: string | null` (TS) consistent; `object.recording_date.map(|d| d.to_string())` (YYYY-MM-DD) matches `AdminObjectView`; the frontend reads `hit.recording_date` from the regenerated type. ✓
## Notes
- No new dependency. en/sv parity preserved (only `resultCount_*` values change; guarded by #60).
- Backfill: already-indexed objects show no date until `reindex_all`; new/edited objects index it via `sync_object`. A `reindex` CLI is a follow-up.
- Rust tests need the docker stack (up); run cargo tests ONCE (shared Postgres).
- `cargo nextest run -p search -p api` covers the touched crates; a full `cargo build --workspace` ensures nothing else broke from the struct change.
@@ -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<String>` (`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<String>,` 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<String>,` 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<String>,` 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
<span>{hit.object_number}</span>
{hit.recording_date && <span>· {hit.recording_date}</span>}
<VisibilityBadge visibility={hit.visibility} />
```
(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.
+1
View File
@@ -603,6 +603,7 @@ export interface components {
id: string;
object_name: string;
object_number: string;
recording_date?: string | null;
snippet?: string | null;
visibility: components["schemas"]["Visibility"];
};
+2 -2
View File
@@ -28,8 +28,8 @@
"loadError": "Search is unavailable",
"unavailable": "Search is not available on this server",
"loadMore": "Load more",
"resultCount_one": "{{count}} result",
"resultCount_other": "{{count}} results",
"resultCount_one": "~{{count}} result",
"resultCount_other": "~{{count}} results",
"selectPrompt": "Select a result to see the full record"
},
"fields": {
+2 -2
View File
@@ -28,8 +28,8 @@
"loadError": "Sök är inte tillgängligt",
"unavailable": "Sök är inte tillgängligt på den här servern",
"loadMore": "Visa fler",
"resultCount_one": "{{count}} träff",
"resultCount_other": "{{count}} träffar",
"resultCount_one": "~{{count}} träff",
"resultCount_other": "~{{count}} träffar",
"selectPrompt": "Välj en träff för att se hela posten"
},
"fields": {
+1
View File
@@ -18,6 +18,7 @@ export function SearchResultRow({ hit }: { hit: SearchHitView }) {
<div className="text-sm font-semibold">{hit.object_name}</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span>{hit.object_number}</span>
{hit.recording_date && <span>· {hit.recording_date}</span>}
<VisibilityBadge visibility={hit.visibility} />
</div>
{hit.snippet && (
+2 -1
View File
@@ -60,7 +60,8 @@ test("typing searches and renders highlighted rich rows", async () => {
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
const mark = await screen.findByText("bronze");
expect(mark.tagName).toBe("MARK");
expect(screen.getByText(/25 results/i)).toBeInTheDocument();
expect(screen.getByText(/~\s*25 results/i)).toBeInTheDocument();
expect(screen.getByText(/1962-04-03/)).toBeInTheDocument();
});
test("Load more appends the next page", async () => {
+2
View File
@@ -73,6 +73,7 @@ export const searchHits: SearchHitView[] = [
object_number: "2019.4.12",
object_name: "Bronze figurine",
brief_description: "A small cast figure.",
recording_date: "1962-04-03",
visibility: "public",
snippet: "cast bronze with green patina",
},
@@ -81,6 +82,7 @@ export const searchHits: SearchHitView[] = [
object_number: `N-${i + 2}`,
object_name: `Object ${i + 2}`,
brief_description: null,
recording_date: null,
visibility: "internal" as const,
snippet: null,
})),