/profile: two-card layout with personal info form (name, birth date,
email, contact) and password change form, each with independent actions.
/users/[id]: read-only public view showing name, username, email, contact
with avatar circle initials.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Show user initials (e.g. MM) in a circular button when name is set,
or a fallback person icon. Clicking opens a dropdown with links to
/profile and a logout form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add profile_* message keys for the profile page forms in de/en/es.
Add EMAIL_ALREADY_IN_USE and WRONG_CURRENT_PASSWORD to ErrorCode type and
getErrorMessage switch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The persons list search input used value={data.q || ''} bound directly to
server data, so every navigation completion would reset it to the URL value
mid-typing, dropping keystrokes just like issue #34 on the home page.
Apply the same focus-guard fix: introduce local `q` state, a `qFocused`
flag, and a guarded $effect that only syncs URL → state when the input is
not focused. Adds a regression test matching the home-page pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The swap button was conditionally removed from the DOM with {#if}, which
caused the receiver input to collapse into the narrow auto column of the
grid-cols-[1fr_auto_1fr] layout on desktop when no persons were selected.
The button is now always rendered. On desktop it becomes invisible
(visibility:hidden) when no persons are selected, preserving the middle
column width so both 1fr columns stay equal. On mobile it remains hidden
(display:none) via the hidden class so no empty gap appears between the
stacked inputs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When navigating from the conversations page via the 'New document in this
correspondence' link, the senderId and receiverId query params are now read
in the server load, resolved to person names, and used to pre-populate the
sender typeahead and receiver multi-select on the form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers: empty state, swap button (visible/hidden, goto called with
swapped params), summary content, year dividers, and new document link
visibility gated by canWrite.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The guard was lost when the button was moved into the grid between the
two person inputs. Without it the button rendered even when no persons
were selected, breaking the UX and the E2E assertion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The link navigates to a page that requires WRITE_ALL. Guard it with
data.canWrite (supplied by the layout) so read-only users never see a
link that leads to a 403.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On desktop the button sits between the two typeaheads as an icon-only
button (icon rotated 90° to point left/right) aligned to the input
baseline. On mobile it renders full-width with the label text between
the stacked fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a link next to the summary that navigates to the new-document form
with senderId and receiverId pre-filled from the current conversation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renders a horizontal rule with the year label between consecutive
documents that belong to different years.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows a summary line above the conversation listing with total document
count and the year span, e.g. "4 Dokumente · 1923–1965".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a button between the two person typeaheads that swaps sender and
receiver, then reloads the conversation view.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#29
Backend:
- Add PersonRepository.findCorrespondents / findCorrespondentsWithFilter
(native SQL, orders by shared document count DESC, limit 10)
- Add PersonService.findCorrespondents(personId, q) delegating to the
correct repository method based on whether a query string is present
- Expose GET /api/persons/{id}/correspondents?q= in PersonController
Frontend:
- Add optional restrictToCorrespondentsOf prop to PersonTypeahead
- On focus with the prop set, fetch correspondents immediately (no typing
required) — opens the dropdown showing top correspondents
- On input with the prop set, hit the correspondents endpoint with q= param
- Without the prop, keep existing /api/persons?q= behaviour unchanged
- Wire the prop bidirectionally in /conversations: sender restricts receiver
and vice versa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sync $effect on the home page unconditionally overwrote the local `q`
state with the URL value after every navigation. When users typed faster
than a navigation round-trip (debounce fires → goto() → data reloads),
the completed navigation wrote the stale URL value back into the input,
dropping the characters typed in the interim.
Guard the `q` assignment in the effect with a `qFocused` flag (set via
onfocus/onblur on the text input). Covers issue #34.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Pre-commit hook
- Add .husky/pre-commit at repo root: runs `cd frontend && npm run lint`
- Update prepare script in package.json to auto-configure git hooks path
on npm install (git -C .. config core.hooksPath .husky)
- Add lint step to CI unit-tests job so it catches issues before tests run
- Add generated dirs to .prettierignore (paraglide_bak*, test-results, .auth)
- Add src/lib/paraglide_bak* to .gitignore so ESLint can ignore them
## ESLint fixes (all pre-existing)
- Disable svelte/no-navigation-without-resolve: false positive in SvelteKit
(rule targets Svelte 5 standalone routing, not SvelteKit <a href>)
- Fix svelte/require-each-key: add (item.id)/(item) keys to all {#each} blocks
across 10 files — improves Svelte reconciliation performance
- Fix svelte/prefer-writable-derived in PersonTypeahead: $state+$effect → $derived
- Fix svelte/prefer-svelte-reactivity: URLSearchParams → SvelteURLSearchParams,
Map → SvelteMap (enables Svelte reactive tracking)
- Fix @typescript-eslint/no-unused-vars: remove dead imports/variables
## Prettier
- Run npm run format to bring all source files in line with .prettierrc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Standard JSON does not allow trailing commas. The comma after the last
key in de/en/es.json caused paraglide to fail compilation, which meant
messages.js was never generated and all component tests crashed on import.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace 5 inline Intl.DateTimeFormat blocks with formatDate() across
home, conversations, persons detail, and document detail pages
- Fix coCorrespondents: $derived(() => ...) → $derived.by(...) —
the old form typed the value as a function, breaking template call sites
- Persons list: throw error on API failure instead of silently returning []
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Admin page: replace 7 identical error-handling blocks with a single
toActionResult() helper — DRY without over-abstraction
- New date.ts util: formatDate(isoDate) centralises the T12:00:00
timezone guard and Intl.DateTimeFormat locale config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The co-correspondent chips already link directly to the conversation view
pre-filled with both persons, making the generic "Konversationen anzeigen"
header link redundant. Removed the link and the person_btn_conversations
i18n key from all three locales.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced the single shared sort control with per-section sort buttons placed
inline in each heading row (right-aligned via ml-auto). Each section now sorts
independently, which matches user expectation and keeps the control visually
anchored to the list it affects.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The floating stats bar was visually disconnected and showed a combined document
count already visible from the per-section badges. Replaced it with a year range
shown inline next to each section heading (e.g. "Gesendete Dokumente · 12 · 1921–1945"),
making the range contextually relevant per direction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Client-side fetch('/api/documents/{id}/file') bypassed the handleFetch hook
that injects the Authorization header, causing the browser to receive a 401
with WWW-Authenticate: Basic and show a native auth dialog.
Added a SvelteKit server route at /api/documents/[id]/file that proxies the
request through the server, where handleFetch injects the auth cookie correctly.
Also fixed E2E default password (admin → admin123) to match application.yaml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add person_show_more key (DE/EN/ES)
- Limit sent/received document lists to 5 with a translated "show more" button
- Co-correspondent chips now link to /conversations?senderId=...&receiverId=...
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Gesendet/Empfangen badge is redundant since documents already appear
in separate Gesendete/Empfangene sections.
Refs #21
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Block direct URL navigation to /persons/new, /documents/new,
/documents/:id/edit for users without WRITE_ALL permission.
E2E tests verify admin user retains access to all write routes.
Closes#17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap write-only elements with {#if data.canWrite} in:
- Home page: Neues Dokument link
- Persons list: Neue Person link
- Document detail: Bearbeiten button
- Person detail: edit button, edit form, merge section
Refs #17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Derives canWrite from WRITE_ALL permission in user groups, available
as page.data.canWrite on every page without per-page boilerplate.
Refs #17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Split document list into Gesendete / Empfangene Dokumente sections
- Add role badges (Gesendet / Empfangen) on each document card
- Add statistics strip showing total count and year range
- Add co-correspondents section with frequency-sorted chips
- Single sort toggle applies to both sections
Closes#1Closes#19Closes#21Closes#22
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split single documents load into sentDocuments and receivedDocuments,
fetched in parallel via Promise.all.
Refs #1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add missing person_btn_conversations translation to de.json
- Fix birth/death year test: exclude /persons/new link + wait for hydration
- Fix lang test switching back to DE: wait for hydration + clear locale cookie
(headless Chromium doesn't reliably delete cookies via document.cookie)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves merge conflicts with main (feat/person-notes merged first).
Combines both features: birth/death years and notes field on person detail.
Renames migration V5__add_birth_death_years to V6 to avoid Flyway conflict.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V5 Flyway migration adds TEXT notes column; Person entity, service, and
controller updated to persist notes. Frontend edit form adds textarea and
view mode renders the notes section. Backed by 2 new service unit tests
(persist + blank clears).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V5 Flyway migration adds birth_year and death_year INTEGER columns.
Service validates birthYear <= deathYear (400 otherwise). Frontend edit
form adds year number inputs; view mode renders * year / † year. Backed
by 3 backend service tests and 1 E2E test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracted sortDocumentsByDate utility with full Vitest coverage (6 tests),
wired it into the person detail page with a DESC/ASC toggle button, and
added an E2E smoke test for the toggle interaction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a "Konversationen anzeigen" link to the person detail page header
that navigates to /conversations?senderId={id}, pre-filling the person
as Person A. Includes i18n in de/en/es and an E2E test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Paraglide's client-side setLocale writes the locale via document.cookie,
which silently fails for HttpOnly cookies. SvelteKit's cookies.set()
defaults to httpOnly: true, so locale switching never worked in tests.
Fix by setting httpOnly: false on the locale cookie (it's a UI preference,
not a credential — no security concern).
Add waitForSelector('[data-hydrated]') before any click that relies on
SvelteKit JavaScript event handlers. Without this, the click fires before
hydration and the onclick handler is not yet registered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add exact: true to all language button selectors in lang.spec.ts to
prevent Playwright from matching "Abmelden" (contains "de") alongside
the DE language button
- Fix goto in conversations applyFilters to use absolute path
/conversations?... instead of relative ?... so URL updates correctly
- Set locale: 'de-DE' in playwright.config.ts so Accept-Language header
is consistent and locale detection defaults to German during tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract detectLocale() from hooks.server.ts into src/lib/server/locale.ts
so it can be tested in isolation. Add 7 unit tests covering:
- German, English, Spanish browser preferences
- Fallback when primary language is unsupported
- Quality value (q=) ordering
- Fully unsupported language → null
- Empty Accept-Language header → null
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On first visit (no PARAGLIDE_LOCALE cookie), parse the Accept-Language
request header and set the cookie to the best matching supported locale
(de/en/es). The user's manual choice via the switcher always takes
precedence since the detection is skipped when the cookie exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract all hardcoded German strings from every .svelte file and component
into Paraglide message keys. Add complete translations for all keys in
messages/en.json (English) and messages/es.json (Spanish/Mexico).
Changes:
- messages/de.json: 100+ keys covering navigation, buttons, form labels,
placeholders, section headings, empty states, and error messages
- messages/en.json, messages/es.json: complete translations for all keys
- project.inlang/settings.json: change baseLocale from "en" to "de"
- +layout.svelte: add DE/EN/ES language selector in header using setLocale();
active language is bold, choice persists via Paraglide cookie strategy
- All 10 route pages + 3 shared components: replace hardcoded German with m.key()
- e2e/lang.spec.ts: E2E tests for language selector visibility, switching,
persistence across navigation, and active state highlighting
Closes#2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both SvelteKit API proxy routes were hardcoding http://localhost:8080,
breaking typeahead search in Docker environments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The userGroup hook was hardcoding http://localhost:8080 instead of
reading API_INTERNAL_URL from the environment. In Docker this caused
the /api/users/me fetch to fail silently, leaving event.locals.user
unset and triggering the handleAuth guard to redirect every page to
/login — including the login form action itself, creating an infinite
redirect loop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>