Commit Graph

2039 Commits

Author SHA1 Message Date
Marcel
df1f56ca6d test(fts): add integration tests and update unit tests for SQL-paginated relevance
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m13s
CI / OCR Service Tests (push) Successful in 46s
CI / Backend Unit Tests (push) Failing after 3m40s
CI / Unit & Component Tests (pull_request) Failing after 4m12s
CI / OCR Service Tests (pull_request) Successful in 53s
CI / Backend Unit Tests (pull_request) Failing after 3m35s
- DocumentFtsPagedIntegrationTest: Testcontainers repo-level tests for
  findFtsPageRaw (page size, window total, last page, no matches, stopword)
- DocumentServiceSortTest: rewritten to stub findFtsPageRaw + findAllById
  for the pure-text RELEVANCE path; verifies filter-active path stays in-memory
- DocumentServiceTest: update two enrichment tests to use new SQL-path stubs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:21:05 +02:00
Marcel
7eee1e4a51 feat(fts): push FTS pagination into SQL via CTE window function
Pure-text RELEVANCE queries now use findFtsPageRaw (CTE + COUNT(*) OVER())
instead of loading all matching IDs into memory and sorting in-process.
Non-text paths (filters active, DATE sort) still use the in-memory path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:20:25 +02:00
Marcel
308e902871 refactor(fts): add FtsHit/FtsPage records; rename findRankedIdsByFts -> findAllMatchingIdsByFts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:06:24 +02:00
Marcel
823472812f docs(adr): ADR-008 SQL-level FTS pagination via window-function CTE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:04:02 +02:00
Marcel
e711416e81 test(typeahead): note resetKey smoke-test limitation in spec comment
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m0s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
CI / Unit & Component Tests (push) Failing after 3m59s
CI / OCR Service Tests (push) Successful in 41s
CI / Backend Unit Tests (push) Failing after 3m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 10:36:18 +02:00
Marcel
e8cabf4390 fix(documents): sync filter display state with URL on navigation
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m8s
CI / OCR Service Tests (push) Successful in 57s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 44s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
Three root causes prevented filters from reflecting the URL after SvelteKit
client-side navigation:

1. +page.server.ts now resolves sender/receiver display names in parallel with
   the document search (UUID validation + silent 404 drop), so initialSenderName
   / initialReceiverName land in server data ready for the UI to use.

2. +page.svelte passes initialSenderName, initialReceiverName, and navKey
   (incremented via untrack on every navigation) down to SearchFilterBar.
   The untrack() prevents the effect from re-running due to its own navKey write.

3. SearchFilterBar forwards navKey as resetKey to each PersonTypeahead, which
   already had a void resetKey guard added in the previous commit.

Together these ensure that after navigating to /documents?senderId=<uuid> the
typeahead shows the person's display name, and clicking × reset clears it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 10:29:19 +02:00
Marcel
a58f22f663 fix(person-typeahead): add resetKey prop to clear term on navigation reset
When the user types in the sender/receiver typeahead without selecting a
person and then clicks ×-reset (navigating back to /documents), the
manually-typed term was not cleared because initialName stayed '' between
navigations — the existing $effect tracking initialName never fired.

Adding `resetKey` (incremented by the page on every navigation) forces
the effect to re-run via `void resetKey`, clearing searchTerm=initialName
even when initialName is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 10:27:30 +02:00
Marcel
d14c937693 fix(date-input): re-derive display when value prop changes externally
`display` was initialised once and never updated, so the text box would
show a stale German date after the parent reset `value` (e.g. × reset
button or timeline drag). A guarded `$effect` re-derives `display` from
`value` whenever the two are out of sync while preserving mid-typing
partial dates (germanToIso returns '' for incomplete input, which matches
value='' during typing → no spurious re-derive).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 09:11:29 +02:00
Marcel
a072701632 docs(adr): ADR-007 reader-dashboard permission discriminant
Some checks failed
CI / Unit & Component Tests (push) Failing after 6m33s
CI / OCR Service Tests (push) Successful in 1m7s
CI / Backend Unit Tests (push) Failing after 4m31s
Captures the architectural decision behind isReader = !canWrite &&
!canAnnotate, why BLOG_WRITE intentionally lands on the reader
dashboard, the alternatives considered (separate route, AppUser
column, middleware redirect, BLOG_WRITE exclusion), and the
implications for future permission additions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
eac2356948 docs(dashboard): comment isReader discriminant with ADR-007 pointer
Felix and Elicit both flagged that the isReader formula had no
in-code explanation at the point of definition; future maintainers
adding a new permission level need a fast pointer to the architectural
rationale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
d554fc7e6b fix(a11y): darken avatar palette teal for AA contrast against white
#007596 with white initials hits ~4.5:1 — at the AA threshold for
small text. #005F74 lifts it comfortably above 5:1, matching the
contrast margin of the other four palette entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
7bd477d24e feat(dashboard): empty-state message for ReaderPersonChips
When the top-persons fetch returns an empty list (or fails and
degrades to []), the chip area used to render the heading and the
view-all link with nothing in between, looking like a load failure.
Adds dashboard_reader_no_persons (de/en/es) and renders it above the
chip row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
b1c2132aa6 fix(a11y): view-all links meet 44px touch target
WCAG 2.2 §2.5.8 (Target Size, Minimum). The Alle Personen → and Alle
Geschichten → text links were inline elements with no enforced minimum
height — small tap targets on mobile. inline-flex + min-h-[44px] keeps
the visual layout while guaranteeing the 44px hit area.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
f7eefb525f fix(a11y): Aktualisiert badge passes WCAG AA contrast
text-ink-3 on bg-ink-3/10 (low-saturation grey on lighter grey) gave
roughly 2.8:1 contrast — below the 4.5:1 AA threshold for normal-weight
small text. Switching the foreground to text-ink-1 keeps the muted
background but lifts the text contrast well above 7:1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
500611925d fix(a11y): focus-visible ring on reader-dashboard view-all links
Both view-all links (Alle Personen → in ReaderPersonChips, Alle
Geschichten → in ReaderRecentStories) were missing the
focus-visible:ring-2 ring used by every other interactive element on
the reader dashboard, leaving keyboard users with no visible focus
indicator. WCAG 2.1 §2.4.7 (Focus Visible, Level AA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
64bcc8d031 test(dashboard): cover the {#if data.isReader} render branch
Adds a readerData fixture and five render-level assertions: the three
ReaderStatsStrip totals, the recent-docs heading, the absent
contributor mission caption, and the drafts module appearing only when
canBlogWrite is true.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
5a8a1898f8 fix(dashboard): isNew compares timestamps numerically, not by ISO string
ISO strings differing only in millisecond precision or timezone
formatting represent the same instant but failed string equality, so
freshly created documents could miss the "Neu" badge depending on
whatever shape the backend serializer emitted.

Browser specs cannot run in the worktree (birpc WebSocket closure
crash documented in the PR description); the new vitest-browser test
must be verified from a normal checkout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
b4f24f4965 fix(api): mark StatsDTO totalPersons + totalDocuments as required
Mirrors what npm run generate:api would emit against the StatsDTO
record (all three @Schema(REQUIRED) annotations). Round-1 fix only
updated totalStories; this brings the other two into line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9e1754bbb0 docs: add Reader glossary entry + clarifying comments on specs and query
- GLOSSARY.md: defines "Reader" as the permission-derived role
  (isReader = !canWrite && !canAnnotate) — addresses @Markus blocker
- GeschichteSpecifications.hasAuthor: comment explains null = no restriction
  (PUBLISHED path) — addresses @Markus suggestion
- PersonRepository.findTopByDocumentCount: comment explains alias-in-ORDER-BY
  is intentional PostgreSQL behaviour — addresses @Markus suggestion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
797852b494 test(dashboard): add partial-failure resilience test + fix i18n Dok. key
- page.server.spec.ts: new test verifies topPersons=[] when that fetch
  rejects, rest of reader data still loads — addresses @Sara concern
- ReaderPersonChips: replaces hardcoded "Dok." with
  dashboard_reader_doc_count_suffix Paraglide key (de/en/es)
  — addresses @Felix suggestion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
518334bc38 fix(a11y): fix WCAG AA contrast on reader dashboard "view all" links
brand-mint on white is ~2.8:1; brand-navy is ~10:1. Both "Alle Personen"
(ReaderPersonChips) and "Alle Geschichten" (ReaderRecentStories) links
updated: text-brand-navy underline hover:text-brand-mint.

Addresses @Leonie critical review finding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
c8b1a890be refactor(dashboard): extract settled<T>() helper; fix page.svelte.spec.ts types
Collapses 5x duplicated null-check pattern in the reader fetch branch into
a single typed helper — addresses @Felix review blocker.

Also adds isReader/incompleteDocs/incompleteTotal to page.svelte.spec.ts
baseData so it satisfies the discriminated PageData union introduced by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
1f592958d7 feat(stats): wire totalStories stat tile in reader dashboard
Manually adds totalStories to generated StatsDTO type and wires it from
readerStats into ReaderStatsStrip — resolves @Elicit: stories tile was
permanently showing "—".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9b5547757a fix(security): cap PersonController size param at 50 to prevent resource exhaustion
Addresses @Nora review: ?sort=documentCount&size=999999 could trigger a
full-table query and large serialization. Cap enforced at controller boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
92587b050e feat(stats): add totalStories to StatsDTO via GeschichteService.countPublished()
Addresses @Elicit review concern: stories stat tile was permanently showing
"—" because StatsDTO had no published-story count. Now wired end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
2be2087a95 feat(dashboard): wire reader layout to +page.svelte
Adds conditional {#if data.isReader} block that renders the 5-zone
reader layout (StatsStrip → DraftsModule → PersonChips → two-column
docs/stories row) for READ_ALL-only users, while preserving the
existing contributor layout for WRITE_ALL / ANNOTATE_ALL users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
4d9234244e feat(dashboard): add reader dashboard components
Adds 5 new components for the permission-gated reader layout:
- ReaderStatsStrip: stat tiles (documents / persons / stories) linking to list pages
- ReaderPersonChips: top-N persons by doc count with avatar + name
- ReaderDraftsModule: blog draft list for BLOG_WRITE users
- ReaderRecentDocs: 5 most-recently-updated docs with Neu/Aktualisiert badge
- ReaderRecentStories: 3 latest published stories with 150-char HTML-stripped excerpt

Each component ships with a vitest-browser spec covering the key assertions.
Avatar color/initials logic is inlined to satisfy $lib/shared → $lib/person
boundary rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9b82621770 feat(i18n): add reader dashboard message keys (de/en/es)
New keys: reader stats strip, person chips, drafts module, recent docs,
recent stories, Neu/Aktualisiert badges, and all-items links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
a58e796ffa feat(dashboard): add isReader flag + reader branch to page load
Read-only users (no WRITE_ALL or ANNOTATE_ALL) now receive lean reader
data (stats, top-4 persons, 5 recent docs, 3 recent stories, and drafts
when BLOG_WRITE) instead of the contributor transcription queues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
6a46a1e3eb chore(api): update generated types — add UPDATED_AT sort and persons size/sort params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
5b645f6374 feat(person): add findTopByDocumentCount endpoint for reader dashboard
PersonController GET /api/persons?sort=documentCount&size=N returns the top N
persons by combined sender+receiver document count for the reader dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
d76ee5fa31 fix(security): restrict DRAFT list to author — prevent cross-user draft leak
GeschichteService.list() now applies hasAuthor(currentUser()) whenever
status == DRAFT, so BLOG_WRITE users cannot read other users' unpublished stories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
5146aeb568 feat(document): add DocumentSort.UPDATED_AT for reader dashboard feed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9fd1f3cde2 refactor(documents): extract timeline drag state into rune class (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m5s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
CI / Unit & Component Tests (push) Failing after 3m57s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m22s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:50:34 +02:00
Marcel
5cd6ecc624 refactor(documents): split getDensity into resolve/load/aggregate (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:44:37 +02:00
Marcel
86de118d63 refactor(documents): bundle density filters into a record (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:42:38 +02:00
Marcel
00f35ab675 docs(documents): link density TODO to follow-up #481 (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:39:47 +02:00
Marcel
c0a1f04df5 chore(documents): density endpoint produces=application/json (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:38:29 +02:00
Marcel
7f99c64d45 docs(layout): note light-mode --timeline-bar-idle contrast ratio (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:34:03 +02:00
Marcel
18aaf1f3e8 style(documents): gate timeline bar hover under (hover: hover) (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:32:31 +02:00
Marcel
dd0a77a5a2 feat(documents): focus-visible ring on timeline controls (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:30:25 +02:00
Marcel
f68d16ef58 docs(documents): explain monthBoundaryTo day-zero idiom (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:28:58 +02:00
Marcel
301cfffd1a docs(c4): align density breakpoint with code (≥1024px) (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m4s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
CI / Unit & Component Tests (push) Failing after 4m3s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m22s
The widget hides below the Tailwind lg breakpoint to protect the
44×44 touch-target floor on tablet (Leonie's round-1 finding) but
the diagram still claimed 640px (sm). Update both the docsListPageTs
description, the timelineFilter description, and the relationship
label to match +page.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:07:16 +02:00
Marcel
bf501b7d62 docs(documents): mark clipBucketsToRange as @internal (#385)
The function has a single in-source call site (TimelineDensityFilter)
but is exported so timeline.spec.ts can pin its boundary semantics
without rendering the orchestrator. Note that explicitly so future
readers don't treat the export as a public API contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:05:46 +02:00
Marcel
5d749b2415 refactor(documents): move timeline CSS vars to layout.css (#385)
Defining --timeline-bar-idle / --timeline-bar-outside on :root from
inside a scoped <style> block leaks the contract into the global
namespace via component-local CSS, even though the selector itself
makes it work. Move both variables to layout.css next to the other
--palette / --c-* design tokens; the component <style> now only
consumes them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:05:06 +02:00
Marcel
1d6016cb19 fix(documents): pluralise timeline bar aria-label by count (#385)
The flat "{count} Dokumente / documents / documentos" keys read as
"1 Dokumente" / "1 documents" / "1 documentos" to a screen reader
when only one document falls in the month bucket. Splits each
locale into _singular + _plural keys and picks the form by count
in TimelineBars, mirroring the existing upload_banner_singular /
_plural pattern in this project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:03:19 +02:00
Marcel
48da819a54 feat(documents): focus-visible ring on timeline bar buttons (#385)
Bar buttons rendered with bg-transparent + p-0 fell back to the
default browser outline, which is invisible against bg-surface for
keyboard users. Adds the project-standard focus ring
(ring-2/brand-navy/offset-2) so the focused bar reads as focused.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:00:15 +02:00
Marcel
153752a901 fix(documents): bump timeline control buttons to 44x44 (#385)
WCAG 2.5.8 (target size, AA) requires 44×44 minimum, and the
project's senior persona makes that a hard floor on desktop too.
Reset-zoom: h-6 → h-11 + min-w-[44px] + px-3.
Clear-selection: h-6 w-6 → h-11 w-11.

Two regression tests on the TimelineDensityFilter spec assert the
sized classes so a future shrink can't slip through silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:58:59 +02:00
Marcel
3b6b117c75 fix(documents): cleanup timeline drag listeners on unmount (#385)
Pointerdown attaches three document-level listeners. Without an
explicit teardown, an unmount mid-drag (route change, view toggle,
viewport drops below lg) left them attached and they kept writing
to torn-down state cells.

Wrap the cleanup in $effect's return, which Svelte 5 invokes on
unmount. The listener-removal regression test pins this so the bug
cannot come back silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:56:58 +02:00
Marcel
2e9ce8e1da fix(documents): surface timeline density fetch failures via console.warn (#385)
Previously a 5xx, network blip, or JSON parse error all collapsed
into the same silent "no buckets" rendering. The widget still
degrades gracefully — failure should not block the document list —
but operators and Sentry now see the failure in browser devtools
instead of having to reverse-engineer a missing chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:55:04 +02:00