feat: Document list — extended sort options, broader search scope, eager tag filter, search feedback #180

Closed
opened 2026-04-06 00:23:00 +02:00 by marcel · 9 comments
Owner

User Feedback Summary

Several friction points were identified in the document list view during usability testing. Users wanted more control over sorting, expected the search to find more than it currently does, were confused about when a search was running, and found the tag filter too passive.


Problem 1 — Too few sort options

Users expected to be able to sort by fields beyond the current set. Suggested additions based on user requests:

  • Sort by sender
  • Sort by receiver
  • Sort by document type / tag
  • Sort by file upload date (when was it digitized)
  • Existing: date, title

Fix: Extend the sort dropdown with the fields above. Backend must support these sort parameters. Ensure the active sort option is clearly indicated (icon or highlight).


Problem 2 — Search only covers a subset of fields

The search input currently does not match against all relevant document fields. Users expected results when searching for sender names, receiver names, or tags — and were surprised when nothing appeared.

Fix: Extend the backend search to cover: title, sender name, receiver name(s), tags, and notes/transcription (if present). Document which fields are searched, either in a tooltip or placeholder text (e.g. "Titel, Personen, Tags durchsuchen…").


Problem 3 — Tag filter is passive (requires explicit selection)

The tag input waits for the user to select a tag from a dropdown before filtering. Users expected results to update while they were still typing — as they do in modern search UIs.

Fix: Switch to an eager/live filter: as the user types in the tag field, the document list should filter by partial tag name match in real time (debounced ~200ms). Do not require the user to select a tag chip first to trigger filtering.


Problem 4 — No feedback when a search is running

After entering a search term, there is no visual indicator that anything is happening. The result list below changes silently, which users either missed entirely or attributed to a bug.

Two feedback mechanisms are needed:

  1. Loading spinner — visible inside or adjacent to the search input while the request is in-flight
  2. Result count — after results load, show a subtle count (e.g. "12 Dokumente gefunden") near the result list header

Fix: Add a spinner to the search input (trailing icon slot, replaces search icon while loading). Add a result count label above the document list that updates after each search. If zero results, show a friendly empty state ("Keine Dokumente gefunden für „Suchbegriff"").


Acceptance Criteria

  • Sort dropdown includes: date, title, sender, receiver, tag, upload date
  • Active sort option is visually indicated
  • Search matches against: title, sender name, receiver name(s), tags, notes
  • Search input placeholder communicates which fields are searched
  • Tag filter triggers live document filtering on keystroke (debounced ≤ 200ms)
  • Spinner appears in search input while request is in-flight
  • Result count label is shown after every search ("N Dokumente")
  • Zero-results state shows a clear empty-state message with the search term
  • All interactions work at 320px viewport width
  • Keyboard navigation through sort options and tag suggestions is fully operable
## User Feedback Summary Several friction points were identified in the document list view during usability testing. Users wanted more control over sorting, expected the search to find more than it currently does, were confused about when a search was running, and found the tag filter too passive. --- ## Problem 1 — Too few sort options Users expected to be able to sort by fields beyond the current set. Suggested additions based on user requests: - Sort by **sender** - Sort by **receiver** - Sort by **document type / tag** - Sort by **file upload date** (when was it digitized) - Existing: date, title **Fix:** Extend the sort dropdown with the fields above. Backend must support these sort parameters. Ensure the active sort option is clearly indicated (icon or highlight). --- ## Problem 2 — Search only covers a subset of fields The search input currently does not match against all relevant document fields. Users expected results when searching for sender names, receiver names, or tags — and were surprised when nothing appeared. **Fix:** Extend the backend search to cover: `title`, `sender name`, `receiver name(s)`, `tags`, and `notes/transcription` (if present). Document which fields are searched, either in a tooltip or placeholder text (e.g. "Titel, Personen, Tags durchsuchen…"). --- ## Problem 3 — Tag filter is passive (requires explicit selection) The tag input waits for the user to select a tag from a dropdown before filtering. Users expected results to update while they were still typing — as they do in modern search UIs. **Fix:** Switch to an eager/live filter: as the user types in the tag field, the document list should filter by partial tag name match in real time (debounced ~200ms). Do not require the user to select a tag chip first to trigger filtering. --- ## Problem 4 — No feedback when a search is running After entering a search term, there is no visual indicator that anything is happening. The result list below changes silently, which users either missed entirely or attributed to a bug. Two feedback mechanisms are needed: 1. **Loading spinner** — visible inside or adjacent to the search input while the request is in-flight 2. **Result count** — after results load, show a subtle count (e.g. "12 Dokumente gefunden") near the result list header **Fix:** Add a spinner to the search input (trailing icon slot, replaces search icon while loading). Add a result count label above the document list that updates after each search. If zero results, show a friendly empty state ("Keine Dokumente gefunden für „Suchbegriff""). --- ## Acceptance Criteria - [ ] Sort dropdown includes: date, title, sender, receiver, tag, upload date - [ ] Active sort option is visually indicated - [ ] Search matches against: title, sender name, receiver name(s), tags, notes - [ ] Search input placeholder communicates which fields are searched - [ ] Tag filter triggers live document filtering on keystroke (debounced ≤ 200ms) - [ ] Spinner appears in search input while request is in-flight - [ ] Result count label is shown after every search ("N Dokumente") - [ ] Zero-results state shows a clear empty-state message with the search term - [ ] All interactions work at 320px viewport width - [ ] Keyboard navigation through sort options and tag suggestions is fully operable
marcel added the featureui labels 2026-04-06 00:23:07 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • Current sort mechanism: Before writing the first failing test for the new sort fields, I need to know how the backend currently handles sorting — Spring Data Sort parameter, a custom @Query, or a Specification? The new fields (sender name, receiver name, tag) require JOINs, so if the current mechanism is a simple column sort, this is a meaningful extension, not just adding an enum value.

  • Sort by "tag" and "receiver" are multi-value — needs definition: A document can have multiple tags and multiple receivers. Which value does the sort key on? Alphabetically first? Most recently added? This must be defined before implementation starts, because it directly determines the SQL and the failing test.

  • Debounce belongs in a utility, not inline: The 200ms debounce for the eager tag filter should live in a reusable debounce.ts utility function, not inlined in the component. This makes it independently testable with vi.useFakeTimers() and reusable if a second search field needs the same treatment.

  • Loading state via $state, result count via $derived: isLoading is a piece of state driven by the fetch lifecycle. The result count label is derived from the response — use $derived, not $state + $effect.

  • Component decomposition: The search bar, sort dropdown, tag filter, result count label, and empty state are five distinct visual regions. I'd plan at minimum: SearchBar.svelte, SortDropdown.svelte, ResultCount.svelte, EmptyState.svelte. The page orchestrates state; components receive props.

  • Empty state string interpolation: "Keine Dokumente gefunden für „Suchbegriff"" — the search term must be injected via Paraglide's parameter interpolation (m.no_results({ term: searchTerm })), not string concatenation. Concatenation bypasses i18n and produces untranslatable strings.

Suggestions

  • Write three separate failing tests: one for each new sort field, one for the broadened search (at least sender name), and one for the live tag filter behavior. Don't add all four sorts in one green step.
  • The sort parameter sent to the backend should map through a typed enum on the frontend — don't send raw user-controlled strings as sort column names.
  • {#each sortedDocuments as doc (doc.id)} — confirm all {#each} blocks in the document list remain keyed by doc.id after this refactor.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - **Current sort mechanism**: Before writing the first failing test for the new sort fields, I need to know how the backend currently handles sorting — Spring Data `Sort` parameter, a custom `@Query`, or a `Specification`? The new fields (sender name, receiver name, tag) require JOINs, so if the current mechanism is a simple column sort, this is a meaningful extension, not just adding an enum value. - **Sort by "tag" and "receiver" are multi-value — needs definition**: A document can have multiple tags and multiple receivers. Which value does the sort key on? Alphabetically first? Most recently added? This must be defined before implementation starts, because it directly determines the SQL and the failing test. - **Debounce belongs in a utility, not inline**: The 200ms debounce for the eager tag filter should live in a reusable `debounce.ts` utility function, not inlined in the component. This makes it independently testable with `vi.useFakeTimers()` and reusable if a second search field needs the same treatment. - **Loading state via `$state`, result count via `$derived`**: `isLoading` is a piece of state driven by the fetch lifecycle. The result count label is derived from the response — use `$derived`, not `$state` + `$effect`. - **Component decomposition**: The search bar, sort dropdown, tag filter, result count label, and empty state are five distinct visual regions. I'd plan at minimum: `SearchBar.svelte`, `SortDropdown.svelte`, `ResultCount.svelte`, `EmptyState.svelte`. The page orchestrates state; components receive props. - **Empty state string interpolation**: "Keine Dokumente gefunden für „Suchbegriff"" — the search term must be injected via Paraglide's parameter interpolation (`m.no_results({ term: searchTerm })`), not string concatenation. Concatenation bypasses i18n and produces untranslatable strings. ### Suggestions - Write three separate failing tests: one for each new sort field, one for the broadened search (at least sender name), and one for the live tag filter behavior. Don't add all four sorts in one green step. - The sort parameter sent to the backend should map through a typed enum on the frontend — don't send raw user-controlled strings as sort column names. - `{#each sortedDocuments as doc (doc.id)}` — confirm all `{#each}` blocks in the document list remain keyed by `doc.id` after this refactor.
Author
Owner

🏗️ Markus Keller — Application Architect

Questions & Observations

  • Multi-value sort fields need a design decision: Sort by sender is unambiguous (one sender per document). Sort by receiver and sort by tag are not — a document has multiple receivers and multiple tags. SQL ORDER BY on a JOIN-expanded result set will produce duplicate rows unless you use DISTINCT ON or aggregate. What's the defined sort key for these fields? Without a decision here, the query will be subtly wrong.

  • Are search and sort one backend request or two? The eager tag filter and the existing text search — do they hit the same endpoint with combined parameters, or are these separate calls? If separate, a fast typist can produce two inflight requests simultaneously. The UI will need to cancel the earlier request (AbortController) or accept that the last response wins. Either is fine, but it needs to be explicit.

  • Full-text search scope + sort scope = complex query risk: Searching across title, sender name, receiver name, tags, and notes while also supporting JOIN-based sorting will produce a non-trivial SQL query. At this scale it's probably fine, but a tsvector column on documents (PostgreSQL full-text search) would give you both the search and a ranking signal for free. Worth at least noting as a future path if the ILIKE JOIN approach gets slow.

  • Does the backend return a total count? The result count AC ("N Dokumente gefunden") requires the backend to return a total, either as a dedicated field in the response envelope or as a header. Does the current /api/documents endpoint return a count, or does it return only the list? If the latter, this needs a response shape change.

Suggestions

  • Define the sort key for receiver and tag explicitly in this issue before implementation: "sort by first receiver alphabetically" or "sort by alphabetically first tag" — whichever is chosen, it should be a comment in the SQL or JPQL.
  • For the sort parameter, validate it against an allowlist in the backend controller (Set.of("date", "title", "sender", "receiver", "tag", "uploadDate")). Never pass the sort field directly to an ORDER BY clause without validation.
  • If a response envelope change is needed for the count, consider { documents: [...], total: N } as the shape — this also enables pagination later without a further API change.
## 🏗️ Markus Keller — Application Architect ### Questions & Observations - **Multi-value sort fields need a design decision**: Sort by `sender` is unambiguous (one sender per document). Sort by `receiver` and sort by `tag` are not — a document has multiple receivers and multiple tags. SQL `ORDER BY` on a JOIN-expanded result set will produce duplicate rows unless you use `DISTINCT ON` or aggregate. What's the defined sort key for these fields? Without a decision here, the query will be subtly wrong. - **Are search and sort one backend request or two?** The eager tag filter and the existing text search — do they hit the same endpoint with combined parameters, or are these separate calls? If separate, a fast typist can produce two inflight requests simultaneously. The UI will need to cancel the earlier request (AbortController) or accept that the last response wins. Either is fine, but it needs to be explicit. - **Full-text search scope + sort scope = complex query risk**: Searching across `title`, `sender name`, `receiver name`, `tags`, and `notes` while also supporting JOIN-based sorting will produce a non-trivial SQL query. At this scale it's probably fine, but a `tsvector` column on documents (PostgreSQL full-text search) would give you both the search and a ranking signal for free. Worth at least noting as a future path if the ILIKE JOIN approach gets slow. - **Does the backend return a total count?** The result count AC ("N Dokumente gefunden") requires the backend to return a total, either as a dedicated field in the response envelope or as a header. Does the current `/api/documents` endpoint return a count, or does it return only the list? If the latter, this needs a response shape change. ### Suggestions - Define the sort key for `receiver` and `tag` explicitly in this issue before implementation: "sort by first receiver alphabetically" or "sort by alphabetically first tag" — whichever is chosen, it should be a comment in the SQL or JPQL. - For the sort parameter, validate it against an allowlist in the backend controller (`Set.of("date", "title", "sender", "receiver", "tag", "uploadDate")`). Never pass the sort field directly to an `ORDER BY` clause without validation. - If a response envelope change is needed for the count, consider `{ documents: [...], total: N }` as the shape — this also enables pagination later without a further API change.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Questions & Observations

The ACs are well-structured but have gaps that will make test cases ambiguous. A few I'd want closed before implementation:

  • Sort + search combination not specified: What happens when both a search term and a sort field are active simultaneously? E.g. search for "Karl" sorted by sender. The ACs treat these as independent features, but the most common user interaction is both at once. This needs explicit coverage.

  • Debounce test strategy: Asserting a 200ms debounce in Vitest requires vi.useFakeTimers(). Without fake timers, the test either waits 200ms (slow, fragile) or can't verify the debounce fires. This is straightforward but needs to be planned.

  • Race condition — two inflight requests: If the user types "Karl" quickly, three requests may fire. The second response arriving after the third would display stale results. Is there a cancellation strategy (AbortController)? If yes, there should be a test: fire two requests, resolve the first after the second — assert the UI shows the second response's results, not the first.

Suggested Test Cases (not yet in ACs)

Layer Test name
Unit debounce should not fire before 200ms
Unit debounce should fire exactly once after 200ms of inactivity
Integration should return documents sorted by sender name ascending
Integration should return documents sorted by receiver name
Integration should return documents matching sender name in search
Integration should return documents matching tag name in search
Integration should return total count in response envelope
@WebMvcTest should reject unknown sort field with 400
Component should show spinner while search is in-flight
Component should show result count after search completes
Component should show empty state with search term when no results
E2E (Playwright) user can sort by sender and see ordered results
E2E (Playwright) user types in tag filter and list updates without explicit submit

Testability Concerns

  • The zero-results empty state displays the search term back. Test that a search term containing <script> renders as plain text — Svelte escapes by default, but this is worth a snapshot test.
  • Sort by multi-value fields (receiver, tag) needs a fixture with documents having multiple receivers/tags to verify the sort key is applied consistently.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Questions & Observations The ACs are well-structured but have gaps that will make test cases ambiguous. A few I'd want closed before implementation: - **Sort + search combination not specified**: What happens when both a search term and a sort field are active simultaneously? E.g. search for "Karl" sorted by sender. The ACs treat these as independent features, but the most common user interaction is both at once. This needs explicit coverage. - **Debounce test strategy**: Asserting a 200ms debounce in Vitest requires `vi.useFakeTimers()`. Without fake timers, the test either waits 200ms (slow, fragile) or can't verify the debounce fires. This is straightforward but needs to be planned. - **Race condition — two inflight requests**: If the user types "Karl" quickly, three requests may fire. The second response arriving after the third would display stale results. Is there a cancellation strategy (AbortController)? If yes, there should be a test: fire two requests, resolve the first after the second — assert the UI shows the second response's results, not the first. ### Suggested Test Cases (not yet in ACs) | Layer | Test name | |---|---| | Unit | `debounce should not fire before 200ms` | | Unit | `debounce should fire exactly once after 200ms of inactivity` | | Integration | `should return documents sorted by sender name ascending` | | Integration | `should return documents sorted by receiver name` | | Integration | `should return documents matching sender name in search` | | Integration | `should return documents matching tag name in search` | | Integration | `should return total count in response envelope` | | @WebMvcTest | `should reject unknown sort field with 400` | | Component | `should show spinner while search is in-flight` | | Component | `should show result count after search completes` | | Component | `should show empty state with search term when no results` | | E2E (Playwright) | `user can sort by sender and see ordered results` | | E2E (Playwright) | `user types in tag filter and list updates without explicit submit` | ### Testability Concerns - The zero-results empty state displays the search term back. Test that a search term containing `<script>` renders as plain text — Svelte escapes by default, but this is worth a snapshot test. - Sort by multi-value fields (receiver, tag) needs a fixture with documents having multiple receivers/tags to verify the sort key is applied consistently.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security

Questions & Observations

Three areas to verify before or during implementation:

1. Sort parameter injection — CWE-89 (SQL Injection via ORDER BY)

The sort field is user-controlled and will be passed to the backend. ORDER BY clauses cannot use parameterized values in JDBC/JPA — only column names, which means if the sort value is interpolated directly into the query, it's injectable:

// ❌ Vulnerable
String query = "SELECT d FROM Document d ORDER BY " + sortField;

// ✅ Safe — validate against an allowlist before use
private static final Set<String> ALLOWED_SORT_FIELDS =
    Set.of("documentDate", "title", "senderName", "uploadDate");

if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
    throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sort field");
}

This is the most important security check for this feature. Add a @WebMvcTest that asserts ?sort=1;DROP TABLE documents-- returns 400.

2. Reflected search term in empty state — potential XSS surface

The empty state message "Keine Dokumente gefunden für „{term}"" renders the search term back to the user. Svelte's {term} syntax escapes HTML by default — this is safe. The risk arises only if someone uses {@html term} in the template. Flag this during code review: never use {@html} for user-supplied search terms.

3. Tag filter live search — unauthenticated enumeration check

The eager tag filter likely calls a tag suggestions endpoint on every keystroke. Confirm this endpoint requires authentication. An unauthenticated tag enumeration endpoint leaks the full tag taxonomy of the archive to anyone who can reach the server — which for a family archive is probably undesirable.

Other Observations

  • The broadened search (sender, receiver, tags, notes) — confirm all new ILIKE parameters are bound via JPA named parameters (:term), not string concatenation. The existing queries appear to follow this pattern; just verify the extensions do too.
  • No new dependencies, no new auth flows. Surface area increase is limited to the sort parameter and the live tag endpoint.
## 🔒 Nora "NullX" Steiner — Application Security ### Questions & Observations Three areas to verify before or during implementation: **1. Sort parameter injection — CWE-89 (SQL Injection via ORDER BY)** The sort field is user-controlled and will be passed to the backend. `ORDER BY` clauses cannot use parameterized values in JDBC/JPA — only column names, which means if the sort value is interpolated directly into the query, it's injectable: ```java // ❌ Vulnerable String query = "SELECT d FROM Document d ORDER BY " + sortField; // ✅ Safe — validate against an allowlist before use private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("documentDate", "title", "senderName", "uploadDate"); if (!ALLOWED_SORT_FIELDS.contains(sortField)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sort field"); } ``` This is the most important security check for this feature. Add a `@WebMvcTest` that asserts `?sort=1;DROP TABLE documents--` returns 400. **2. Reflected search term in empty state — potential XSS surface** The empty state message "Keine Dokumente gefunden für „{term}"" renders the search term back to the user. Svelte's `{term}` syntax escapes HTML by default — this is safe. The risk arises only if someone uses `{@html term}` in the template. Flag this during code review: never use `{@html}` for user-supplied search terms. **3. Tag filter live search — unauthenticated enumeration check** The eager tag filter likely calls a tag suggestions endpoint on every keystroke. Confirm this endpoint requires authentication. An unauthenticated tag enumeration endpoint leaks the full tag taxonomy of the archive to anyone who can reach the server — which for a family archive is probably undesirable. ### Other Observations - The broadened search (sender, receiver, tags, notes) — confirm all new ILIKE parameters are bound via JPA named parameters (`:term`), not string concatenation. The existing queries appear to follow this pattern; just verify the extensions do too. - No new dependencies, no new auth flows. Surface area increase is limited to the sort parameter and the live tag endpoint.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Questions & Observations

  • Active sort indicator design: The issue says "icon or highlight" — I'd push for both: a directional arrow (↑↓) next to the active sort label, and the active option in brand-navy weight rather than just a checkmark. A checkmark alone is too small to notice at a glance, especially on mobile. Also: does clicking the same sort option toggle direction (asc → desc)? This is expected behavior but not specified.

  • Spinner swap in the search icon slot: Replacing the search icon with a spinner during loading is a common pattern, but the transition matters. An abrupt icon swap feels like a glitch. A CSS opacity transition (fade out icon, fade in spinner) over ~100ms reads as intentional. The icon area must remain exactly the same size — no layout shift when the spinner appears.

  • Result count placement and stale state: "12 Dokumente gefunden" appearing above the list — what does this label show while the next search is loading? Options: (a) hide it, (b) show the previous count greyed out, (c) show nothing. Option (b) is most informative but requires a loading class on the count element. Whatever is chosen, it should be consistent and not flicker between states.

  • Empty state copy with search term: The proposed format is Keine Dokumente gefunden für „Suchbegriff" — the German typographic angle quotes „..." are correct. Make sure the Paraglide template produces these, not straight double quotes. Also: if the search term is very long (40+ chars), does the empty state label overflow on mobile? Clamp or truncate with ellipsis.

  • Eager tag filter — changed interaction model: Users who have learned to select a chip before results filter will be surprised when results change while they're still typing. Consider a short transitional affordance: a subtle "live search" label near the tag input, or a tooltip on first interaction. This is especially important for senior users who may interpret unexpected result changes as an error.

Suggestions

  • Sort dropdown on mobile: At 320px, a full dropdown with 6 options needs enough height. Ensure the dropdown uses font-size: 1rem (16px) minimum and each option has min-height: 44px for touch usability.
  • Keyboard navigation (AC item): Tab to sort dropdown → Space/Enter to open → Arrow keys to navigate → Enter to select → Escape to close. This is standard <select> behavior if we use a native element, but if using a custom dropdown, all of these must be implemented manually and tested.
  • Placeholder text: "Titel, Personen, Tags durchsuchen…" — this is exactly right. Keep it in all three languages in de.json, en.json, es.json. Placeholder text is often forgotten in i18n.
## 🎨 Leonie Voss — UI/UX Design Lead ### Questions & Observations - **Active sort indicator design**: The issue says "icon or highlight" — I'd push for both: a directional arrow (↑↓) next to the active sort label, and the active option in `brand-navy` weight rather than just a checkmark. A checkmark alone is too small to notice at a glance, especially on mobile. Also: does clicking the same sort option toggle direction (asc → desc)? This is expected behavior but not specified. - **Spinner swap in the search icon slot**: Replacing the search icon with a spinner during loading is a common pattern, but the transition matters. An abrupt icon swap feels like a glitch. A CSS opacity transition (fade out icon, fade in spinner) over ~100ms reads as intentional. The icon area must remain exactly the same size — no layout shift when the spinner appears. - **Result count placement and stale state**: "12 Dokumente gefunden" appearing above the list — what does this label show *while* the next search is loading? Options: (a) hide it, (b) show the previous count greyed out, (c) show nothing. Option (b) is most informative but requires a `loading` class on the count element. Whatever is chosen, it should be consistent and not flicker between states. - **Empty state copy with search term**: The proposed format is `Keine Dokumente gefunden für „Suchbegriff"` — the German typographic angle quotes `„..."` are correct. Make sure the Paraglide template produces these, not straight double quotes. Also: if the search term is very long (40+ chars), does the empty state label overflow on mobile? Clamp or truncate with ellipsis. - **Eager tag filter — changed interaction model**: Users who have learned to select a chip before results filter will be surprised when results change while they're still typing. Consider a short transitional affordance: a subtle "live search" label near the tag input, or a tooltip on first interaction. This is especially important for senior users who may interpret unexpected result changes as an error. ### Suggestions - **Sort dropdown on mobile**: At 320px, a full dropdown with 6 options needs enough height. Ensure the dropdown uses `font-size: 1rem` (16px) minimum and each option has `min-height: 44px` for touch usability. - **Keyboard navigation (AC item)**: Tab to sort dropdown → Space/Enter to open → Arrow keys to navigate → Enter to select → Escape to close. This is standard `<select>` behavior if we use a native element, but if using a custom dropdown, all of these must be implemented manually and tested. - **Placeholder text**: "Titel, Personen, Tags durchsuchen…" — this is exactly right. Keep it in all three languages in `de.json`, `en.json`, `es.json`. Placeholder text is often forgotten in i18n.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Questions & Observations

  • Request volume increase from eager tag filter: The current "search on submit" pattern produces one backend request per user search. A 200ms debounce on keystrokes produces roughly one request per 200ms of typing. For a user typing a 10-character tag name, that's ~5–8 requests vs. 1. For a family archive with a handful of concurrent users this is fine — but worth a mental note. If the search endpoint is slow (JOIN-heavy after the broadened scope), those 8 requests could queue up. A quick EXPLAIN ANALYZE on the extended search query before shipping is cheap insurance.

  • Does the backend return a total count today? If not, a response envelope change is needed ({ documents: [...], total: N }). This is a non-breaking addition if the frontend is already handling the response as a typed object — existing consumers that only read documents continue to work. Just confirm the OpenAPI spec is updated and npm run generate:api is re-run.

  • No new infrastructure required: This is entirely application-layer work. No new services, no schema changes, no Docker Compose changes. Clean from an ops perspective.

  • AbortController for inflight request cancellation: If the frontend doesn't cancel superseded requests, the backend will process all of them regardless — wasted compute. Using AbortController in the fetch call is a frontend concern, but it directly reduces unnecessary backend load. Worth a note in the implementation.

What Looks Good

  • The spinner and result count are purely frontend concerns — zero backend observability impact.
  • Extending sort options and search scope is additive to the existing endpoint — no breaking changes to the API contract if done carefully.
  • The 200ms debounce is a reasonable default. If the search query turns out to be slow, this can be bumped to 300ms without any visible UX degradation.
## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Questions & Observations - **Request volume increase from eager tag filter**: The current "search on submit" pattern produces one backend request per user search. A 200ms debounce on keystrokes produces roughly one request per 200ms of typing. For a user typing a 10-character tag name, that's ~5–8 requests vs. 1. For a family archive with a handful of concurrent users this is fine — but worth a mental note. If the search endpoint is slow (JOIN-heavy after the broadened scope), those 8 requests could queue up. A quick `EXPLAIN ANALYZE` on the extended search query before shipping is cheap insurance. - **Does the backend return a total count today?** If not, a response envelope change is needed (`{ documents: [...], total: N }`). This is a non-breaking addition if the frontend is already handling the response as a typed object — existing consumers that only read `documents` continue to work. Just confirm the OpenAPI spec is updated and `npm run generate:api` is re-run. - **No new infrastructure required**: This is entirely application-layer work. No new services, no schema changes, no Docker Compose changes. Clean from an ops perspective. - **AbortController for inflight request cancellation**: If the frontend doesn't cancel superseded requests, the backend will process all of them regardless — wasted compute. Using `AbortController` in the fetch call is a frontend concern, but it directly reduces unnecessary backend load. Worth a note in the implementation. ### What Looks Good - The spinner and result count are purely frontend concerns — zero backend observability impact. - Extending sort options and search scope is additive to the existing endpoint — no breaking changes to the API contract if done carefully. - The 200ms debounce is a reasonable default. If the search query turns out to be slow, this can be bumped to 300ms without any visible UX degradation.
Author
Owner

🏗️ Markus Keller — Application Architect (follow-up discussion)

This comment captures the outcomes of an architecture discussion on the four open items from my earlier review, plus two pre-resolved decisions brought in by the team.


Pre-resolved (before discussion)

  • No sort by tag — tags are filter-only. The "sort by tag" AC item is dropped.
  • Receiver sort key — sort all receivers on a document by last_name then first_name, take the first in that list, and use that person's last_name / first_name as the document's sort key.

Resolved items

1. SQL strategy for "first receiver alphabetically" sort

Use a native SQL subquery in the ORDER BY clause:

ORDER BY (
  SELECT p.last_name || ' ' || p.first_name
  FROM persons p
  JOIN document_receivers dr ON dr.person_id = p.id
  WHERE dr.document_id = d.id
  ORDER BY p.last_name, p.first_name
  LIMIT 1
) ASC NULLS LAST

JPQL cannot express a subquery in ORDER BY (spec limitation), so this sort case gets its own @Query(nativeQuery = true). Add an index on (document_id, last_name, first_name) on the join table side. NULLS LAST handles documents with no receivers gracefully.

2. Search + sort + tag filter — one endpoint, combined params

All filters go to the existing GET /api/documents endpoint as combined query parameters:

GET /api/documents?q=Karl&sort=receiver&tag=Bri

One request per interaction (debounced for the tag field). No separate tag suggestions endpoint. The backend applies all filters in a single query.

3. Response shape change to include total count

The current endpoint returns a plain list. The result count AC requires a total. Agreed shape:

{ "documents": [...], "total": 42 }

Switch the repository method to return Page<Document> (Spring Data) — page.getTotalElements() gives the count for free. Additive change; existing consumers that only read documents are unaffected. Update the OpenAPI spec and re-run npm run generate:api.

4. Tag filter is server-side

The tag partial match is a backend filter, not client-side. The frontend cannot filter client-side since the document list is paginated — filtering only the current page would give wrong results. The tag text goes directly into the documents query as a partial ILIKE match, consistent with item 2.


Overall this is a clean, well-bounded feature. The receiver sort is the only non-trivial query; everything else is additive to the existing endpoint. No new infrastructure, no breaking API changes.

## 🏗️ Markus Keller — Application Architect (follow-up discussion) This comment captures the outcomes of an architecture discussion on the four open items from my earlier review, plus two pre-resolved decisions brought in by the team. --- ### Pre-resolved (before discussion) - **No sort by tag** — tags are filter-only. The "sort by tag" AC item is dropped. - **Receiver sort key** — sort all receivers on a document by `last_name` then `first_name`, take the first in that list, and use that person's `last_name` / `first_name` as the document's sort key. --- ### Resolved items **1. SQL strategy for "first receiver alphabetically" sort** Use a native SQL subquery in the `ORDER BY` clause: ```sql ORDER BY ( SELECT p.last_name || ' ' || p.first_name FROM persons p JOIN document_receivers dr ON dr.person_id = p.id WHERE dr.document_id = d.id ORDER BY p.last_name, p.first_name LIMIT 1 ) ASC NULLS LAST ``` JPQL cannot express a subquery in `ORDER BY` (spec limitation), so this sort case gets its own `@Query(nativeQuery = true)`. Add an index on `(document_id, last_name, first_name)` on the join table side. `NULLS LAST` handles documents with no receivers gracefully. **2. Search + sort + tag filter — one endpoint, combined params** All filters go to the existing `GET /api/documents` endpoint as combined query parameters: ``` GET /api/documents?q=Karl&sort=receiver&tag=Bri ``` One request per interaction (debounced for the tag field). No separate tag suggestions endpoint. The backend applies all filters in a single query. **3. Response shape change to include total count** The current endpoint returns a plain list. The result count AC requires a total. Agreed shape: ```json { "documents": [...], "total": 42 } ``` Switch the repository method to return `Page<Document>` (Spring Data) — `page.getTotalElements()` gives the count for free. Additive change; existing consumers that only read `documents` are unaffected. Update the OpenAPI spec and re-run `npm run generate:api`. **4. Tag filter is server-side** The tag partial match is a backend filter, not client-side. The frontend cannot filter client-side since the document list is paginated — filtering only the current page would give wrong results. The tag text goes directly into the documents query as a partial ILIKE match, consistent with item 2. --- Overall this is a clean, well-bounded feature. The receiver sort is the only non-trivial query; everything else is additive to the existing endpoint. No new infrastructure, no breaking API changes.
Author
Owner

Design specs — Sort integration (Problem 1)

Two spec files committed to main (commit e6f12e6):

File Purpose
docs/specs/sort-integration-spec.html Exploration — all 4 placement variants with comparison matrix
docs/specs/sort-inline-final-spec.html Final spec — Variant A (inline in search bar row)

Decision: Variant A — Inline sort in search bar row

Sort dropdown sits between the text input and the Filter button in row 1. On mobile it wraps to a second row inside the search card as an equal-width button alongside Filter.

Why this variant: highest discoverability (always visible, 0 clicks to reach), clearest active state (navy border + updated label), and the cleanest keyboard/accessibility flow of the four options. The pill strip (Variant B) was the runner-up if row 1 feels too crowded at 375px.

What the final spec covers

  • §1 — Anatomy at rest (default vs. active sort, desktop)
  • §2 — Dropdown open states (hover, direction toggle, after selection) + interaction model
  • §3 — Mobile layout at 320px (row 2 split, bottom sheet on open)
  • §4 — Loading (spinner in input, skeleton rows) and empty state
  • §5 — Full-stack wiring checklist (frontend state, URL params, backend DocumentService, i18n keys)

Sort options (6)

Datum · Titel · Absender · Empfänger · Tag · Hochgeladen
Direction: ↑ ascending / ↓ descending per option. Default: Datum ↓ (omitted from URL when default).


Spec authored by Leonie Voss (@leonievoss)

## Design specs — Sort integration (Problem 1) Two spec files committed to `main` (commit `e6f12e6`): | File | Purpose | |---|---| | [`docs/specs/sort-integration-spec.html`](http://heim-nas:3005/marcel/familienarchiv/src/branch/main/docs/specs/sort-integration-spec.html) | Exploration — all 4 placement variants with comparison matrix | | [`docs/specs/sort-inline-final-spec.html`](http://heim-nas:3005/marcel/familienarchiv/src/branch/main/docs/specs/sort-inline-final-spec.html) | **Final spec — Variant A (inline in search bar row)** | ### Decision: Variant A — Inline sort in search bar row Sort dropdown sits between the text input and the Filter button in row 1. On mobile it wraps to a second row inside the search card as an equal-width button alongside Filter. **Why this variant:** highest discoverability (always visible, 0 clicks to reach), clearest active state (navy border + updated label), and the cleanest keyboard/accessibility flow of the four options. The pill strip (Variant B) was the runner-up if row 1 feels too crowded at 375px. ### What the final spec covers - §1 — Anatomy at rest (default vs. active sort, desktop) - §2 — Dropdown open states (hover, direction toggle, after selection) + interaction model - §3 — Mobile layout at 320px (row 2 split, bottom sheet on open) - §4 — Loading (spinner in input, skeleton rows) and empty state - §5 — Full-stack wiring checklist (frontend state, URL params, backend `DocumentService`, i18n keys) ### Sort options (6) `Datum` · `Titel` · `Absender` · `Empfänger` · `Tag` · `Hochgeladen` Direction: ↑ ascending / ↓ descending per option. Default: Datum ↓ (omitted from URL when default). --- *Spec authored by Leonie Voss (@leonievoss)*
Author
Owner

Implementation complete — PR #183

All tasks implemented with red/green/refactor TDD. Here's what was built:

Backend

  • DocumentSpecifications.hasText extended with EXISTS subqueries for receiver name and tag name matching (avoids duplicate rows from multi-value joins)
  • DocumentSpecifications.hasTagPartial new method for live tag substring filter (tagQ param)
  • DocumentSort enum (DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE) — Spring MVC auto-validates, returns 400 for unknown values
  • DocumentSearchResult record wrapping { List<Document> documents, long total }
  • DocumentService.searchDocuments extended with tagQ, sort, dir params; SENDER and RECEIVER sort in-memory to avoid INNER JOIN excluding nulls
  • DocumentController returns ResponseEntity<DocumentSearchResult>

Bug fixed during manual testing: SENDER sort with Sort.by("sender.lastName") generated an INNER JOIN in Hibernate, silently dropping documents with sender = null. Fixed by sorting in-memory.

Frontend

  • debounce utility in src/lib/utils/debounce.ts (5 unit tests)
  • SortDropdown component — native <select> + direction toggle button
  • TagInputonTextInput callback prop for live filter
  • SearchFilterBarsort/dir/tagQ bindable props, SortDropdown in row 1
  • +page.server.ts — reads sort/dir/tagQ from URL, passes to API, unwraps { documents, total } envelope
  • +page.sveltesort/dir/tagQ state, included in triggerSearch() URL params
  • DocumentListtotal prop (shows "N Dokumente"), q prop (shows term in empty state)
  • Translation keys added for all 3 locales (de/en/es)

Commits

  • feat(backend): extend hasText spec to match sender/receiver/tag names
  • feat(backend): add hasTagPartial spec for live tag name filter
  • feat(backend): add DocumentSort enum with Spring MVC auto-validation
  • feat(backend): wrap search response in DocumentSearchResult envelope
  • feat(backend): add sort/dir/tagQ params to searchDocuments
  • feat(frontend): regenerate API types with new search params and result envelope
  • feat(search): add debounce utility
  • feat(i18n): add sort, result count, and empty-state translation keys
  • feat(search): add SortDropdown component with direction toggle
  • feat(search): add onTextInput callback to TagInput for live tag filter
  • feat(search): add sort/dir/tagQ props to SearchFilterBar with SortDropdown
  • feat(search): read sort/dir/tagQ from URL and unwrap DocumentSearchResult envelope
  • feat(search): wire sort/dir/tagQ state into page.svelte and URL params
  • feat(search): show result count and term-aware empty state in DocumentList
  • fix(search): use in-memory sort for SENDER to include documents with null sender
## Implementation complete — PR #183 All tasks implemented with red/green/refactor TDD. Here's what was built: ### Backend - **`DocumentSpecifications.hasText`** extended with EXISTS subqueries for receiver name and tag name matching (avoids duplicate rows from multi-value joins) - **`DocumentSpecifications.hasTagPartial`** new method for live tag substring filter (`tagQ` param) - **`DocumentSort` enum** (DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE) — Spring MVC auto-validates, returns 400 for unknown values - **`DocumentSearchResult` record** wrapping `{ List<Document> documents, long total }` - **`DocumentService.searchDocuments`** extended with `tagQ`, `sort`, `dir` params; SENDER and RECEIVER sort in-memory to avoid INNER JOIN excluding nulls - **`DocumentController`** returns `ResponseEntity<DocumentSearchResult>` **Bug fixed during manual testing**: SENDER sort with `Sort.by("sender.lastName")` generated an INNER JOIN in Hibernate, silently dropping documents with `sender = null`. Fixed by sorting in-memory. ### Frontend - **`debounce` utility** in `src/lib/utils/debounce.ts` (5 unit tests) - **`SortDropdown` component** — native `<select>` + direction toggle button - **`TagInput`** — `onTextInput` callback prop for live filter - **`SearchFilterBar`** — `sort`/`dir`/`tagQ` bindable props, SortDropdown in row 1 - **`+page.server.ts`** — reads `sort`/`dir`/`tagQ` from URL, passes to API, unwraps `{ documents, total }` envelope - **`+page.svelte`** — `sort`/`dir`/`tagQ` state, included in `triggerSearch()` URL params - **`DocumentList`** — `total` prop (shows "N Dokumente"), `q` prop (shows term in empty state) - **Translation keys** added for all 3 locales (de/en/es) ### Commits - `feat(backend): extend hasText spec to match sender/receiver/tag names` - `feat(backend): add hasTagPartial spec for live tag name filter` - `feat(backend): add DocumentSort enum with Spring MVC auto-validation` - `feat(backend): wrap search response in DocumentSearchResult envelope` - `feat(backend): add sort/dir/tagQ params to searchDocuments` - `feat(frontend): regenerate API types with new search params and result envelope` - `feat(search): add debounce utility` - `feat(i18n): add sort, result count, and empty-state translation keys` - `feat(search): add SortDropdown component with direction toggle` - `feat(search): add onTextInput callback to TagInput for live tag filter` - `feat(search): add sort/dir/tagQ props to SearchFilterBar with SortDropdown` - `feat(search): read sort/dir/tagQ from URL and unwrap DocumentSearchResult envelope` - `feat(search): wire sort/dir/tagQ state into page.svelte and URL params` - `feat(search): show result count and term-aware empty state in DocumentList` - `fix(search): use in-memory sort for SENDER to include documents with null sender`
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#180