Compare commits

..

10 Commits

Author SHA1 Message Date
Marcel
ed32de9f10 test(fts): add overflow guard and UUID-as-String regression tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m17s
CI / OCR Service Tests (pull_request) Successful in 55s
CI / Backend Unit Tests (pull_request) Failing after 3m20s
CI / Unit & Component Tests (push) Failing after 4m16s
CI / OCR Service Tests (push) Successful in 47s
CI / Backend Unit Tests (push) Failing after 3m18s
- searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt:
  proves the long→int guard fires and findFtsPageRaw is never called
- searchDocuments_relevance_handles_string_uuid_from_jdbc_driver:
  exercises the toFtsPage String fallback branch for JDBC drivers that
  return UUID columns as String instead of java.util.UUID

Addresses Sara's review concerns on PR #488.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:42:26 +02:00
Marcel
abeece7e30 refactor(fts): address PR #488 review concerns
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m48s
CI / OCR Service Tests (pull_request) Successful in 53s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
- Extract isPureTextRelevance() private static method to replace the
  7-clause inline boolean in searchDocuments
- Guard long→int cast in relevanceSortedPageFromSql to prevent silent
  overflow at page ≥43M (CWE-190)
- resolvePersonName now uses the typed API client (createApiClient)
  instead of raw fetch, aligning with project conventions
- Update DocumentServiceTest stubs to match new FTS path (findFtsPageRaw
  + findAllById instead of findAllMatchingIdsByFts)
- Rewrite page.server.spec.ts person-name tests to mock via path-based
  API dispatch, matching the new api.GET call site

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:35:54 +02:00
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
38 changed files with 854 additions and 660 deletions

View File

@@ -40,10 +40,6 @@ jobs:
run: npm test
working-directory: frontend
- name: Build frontend
run: npm run build
working-directory: frontend
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4

View File

@@ -100,7 +100,45 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
d.meta_date DESC NULLS LAST
""")
List<UUID> findRankedIdsByFts(@Param("query") String query);
// Unpaged path — for bulk-edit "select all" and density chart
List<UUID> findAllMatchingIdsByFts(@Param("query") String query);
/**
* Returns one page of FTS-ranked document IDs with the total match count.
*
* <p>Each row contains (in column order):
* <ol>
* <li>UUID — document id</li>
* <li>double — ts_rank score</li>
* <li>long — COUNT(*) OVER () — full match count, not page count</li>
* </ol>
*
* <p>Returns an empty list when the query matches no documents (including
* stopword-only queries where websearch_to_tsquery returns an empty tsquery).
* Use findAllMatchingIdsByFts for the unpaged bulk-edit path.
*/
@Query(nativeQuery = true, value = """
WITH q AS (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
'g'))
END AS pq
), matches AS (
SELECT d.id, ts_rank(d.search_vector, q.pq) AS rank
FROM documents d, q
WHERE d.search_vector @@ q.pq
)
SELECT id, rank, COUNT(*) OVER () AS total
FROM matches
ORDER BY rank DESC, id
OFFSET :offset LIMIT :limit
""")
List<Object[]> findFtsPageRaw(@Param("query") String query,
@Param("offset") int offset,
@Param("limit") int limit);
/**
* Returns match-enrichment data for a set of documents identified by their IDs.

View File

@@ -162,7 +162,7 @@ public class DocumentService {
*/
private List<UUID> resolveFtsIds(String text) {
if (!StringUtils.hasText(text)) return null;
return documentRepository.findRankedIdsByFts(text);
return documentRepository.findAllMatchingIdsByFts(text);
}
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
@@ -485,7 +485,7 @@ public class DocumentService {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
}
@@ -645,39 +645,43 @@ public class DocumentService {
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
if (isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
return relevanceSortedPageFromSql(text, pageable);
}
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
// rank list. Cost scales linearly with match count — acceptable while documents
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
// documents with null sender/receivers. Cost scales with match count —
// acceptable while documents stays under ~10k rows. (ADR-008)
if (sort == DocumentSort.RECEIVER) {
// In-memory sort on page slice (≤ page size rows) — acceptable
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
if (sort == DocumentSort.SENDER) {
// In-memory sort on page slice (≤ page size rows) — acceptable
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
// RELEVANCE: default when text present and no explicit sort given
// RELEVANCE with active filters: load filtered subset and sort in-memory by rank.
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
if (useRankOrder) {
List<Document> results = documentRepository.findAll(spec);
Map<UUID, Integer> rankMap = new HashMap<>();
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
List<Document> sorted = results.stream()
.sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
List<Document> sorted = documentRepository.findAll(spec).stream()
.sorted(Comparator.comparingInt(doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
@@ -688,6 +692,39 @@ public class DocumentService {
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
}
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status) {
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
&& from == null && to == null && sender == null && receiver == null
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
}
/**
* Pure-text RELEVANCE path — pagination and ts_rank ordering pushed into SQL.
* Called when no non-text filters are active (ADR-008).
*/
private DocumentSearchResult relevanceSortedPageFromSql(String text, Pageable pageable) {
long rawOffset = pageable.getOffset();
if (rawOffset > Integer.MAX_VALUE) return DocumentSearchResult.of(List.of());
int offset = (int) rawOffset;
int limit = pageable.getPageSize();
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
// Preserve ts_rank order from SQL across the JPA findAllById call.
Map<UUID, Integer> rankMap = new HashMap<>();
List<UUID> pageIds = new ArrayList<>();
for (int i = 0; i < ftsPage.hits().size(); i++) {
rankMap.put(ftsPage.hits().get(i).id(), i);
pageIds.add(ftsPage.hits().get(i).id());
}
List<Document> docs = documentRepository.findAllById(pageIds).stream()
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(docs, text, pageable, ftsPage.total());
}
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
int from = Math.min((int) pageable.getOffset(), sorted.size());
int to = Math.min(from + pageable.getPageSize(), sorted.size());
@@ -1013,6 +1050,28 @@ public class DocumentService {
return result;
}
private static final int COL_ID = 0;
private static final int COL_RANK = 1;
private static final int COL_TOTAL = 2;
/**
* Maps raw Object[] rows from {@link DocumentRepository#findFtsPageRaw} to an
* {@link FtsPage}. Uses pattern-matching UUID cast to guard against driver-level
* type variance (some JDBC drivers return UUID as String).
*/
private static FtsPage toFtsPage(List<Object[]> rows) {
if (rows.isEmpty()) return new FtsPage(List.of(), 0);
long total = ((Number) rows.get(0)[COL_TOTAL]).longValue();
List<FtsHit> hits = rows.stream()
.map(r -> {
UUID id = r[COL_ID] instanceof UUID u ? u : UUID.fromString(r[COL_ID].toString());
double rank = ((Number) r[COL_RANK]).doubleValue();
return new FtsHit(id, rank);
})
.toList();
return new FtsPage(hits, total);
}
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.document;
import java.util.UUID;
/** A single document hit from a paginated FTS query — id and its ts_rank score. */
record FtsHit(UUID id, double rank) {}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.document;
import java.util.List;
/** One page of FTS results — the ranked hit list for this page and the total match count. */
record FtsPage(List<FtsHit> hits, long total) {}

View File

@@ -0,0 +1,109 @@
package org.raddatz.familienarchiv.document;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Repository-level integration tests for {@code findFtsPageRaw}: verifies that the
* paginated FTS query returns exactly page-size rows and that the window-function
* total reflects the full match count, not just the page count.
*
* <p>Uses real Postgres via Testcontainers so the GIN index, tsvector trigger, and
* {@code websearch_to_tsquery} semantics are identical to production.
*
* <p>{@code AFTER_CLASS} dirty-context keeps the Spring context alive for all tests
* in this class and rebuilds it once at the end, rather than after every test.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class DocumentFtsPagedIntegrationTest {
@Autowired DocumentRepository documentRepository;
@Autowired EntityManager em;
// 60 docs match "Walter"; 10 docs with "Hans" do not.
private static final int WALTER_COUNT = 60;
private static final int PAGE_SIZE = 50;
@BeforeEach
void seed() {
documentRepository.deleteAll();
em.flush();
for (int i = 0; i < WALTER_COUNT; i++) {
documentRepository.saveAndFlush(doc("Brief von Walter Nr. " + i));
}
for (int i = 0; i < 10; i++) {
documentRepository.saveAndFlush(doc("Brief von Hans Nr. " + i));
}
em.clear();
}
@Test
void findFtsPageRaw_firstPage_returnsPageSizeRows() {
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
assertThat(rows).hasSize(PAGE_SIZE);
}
@Test
void findFtsPageRaw_windowTotal_equalsFullMatchCount_notPageSize() {
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
long total = ((Number) rows.get(0)[2]).longValue();
assertThat(total).isEqualTo(WALTER_COUNT);
}
@Test
void findFtsPageRaw_lastPage_returnsRemainder() {
int remainder = WALTER_COUNT % PAGE_SIZE; // 60 % 50 = 10
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", PAGE_SIZE, PAGE_SIZE);
assertThat(rows).hasSize(remainder);
long total = ((Number) rows.get(0)[2]).longValue();
assertThat(total).isEqualTo(WALTER_COUNT);
}
@Test
void findFtsPageRaw_noMatches_returnsEmptyList() {
List<Object[]> rows = documentRepository.findFtsPageRaw("XYZ_KEIN_TREFFER", 0, PAGE_SIZE);
assertThat(rows).isEmpty();
}
@Test
void findFtsPageRaw_stopwordOnlyQuery_returnsEmptyList_noException() {
assertThatNoException().isThrownBy(() -> {
List<Object[]> rows = documentRepository.findFtsPageRaw("der die das und", 0, PAGE_SIZE);
assertThat(rows).isEmpty();
});
}
// ─── Helper ───────────────────────────────────────────────────────────────
private Document doc(String title) {
return Document.builder()
.title(title)
.originalFilename(title.replace(" ", "_") + ".pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
}

View File

@@ -69,7 +69,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Alter Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
assertThat(ids).hasSize(1);
}
@@ -79,7 +79,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Alter Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Briefe");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Briefe");
assertThat(ids).hasSize(1);
}
@@ -89,7 +89,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("furchtb");
assertThat(ids).hasSize(1);
}
@@ -99,7 +99,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Familienfoto"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
assertThat(ids).isEmpty();
}
@@ -115,7 +115,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("schreiben");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("schreiben");
assertThat(ids).contains(doc.getId());
}
@@ -125,14 +125,14 @@ class DocumentFtsTest {
Document doc = documentRepository.saveAndFlush(document("Leeres Dokument"));
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).isEmpty();
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).isEmpty();
UUID annotationId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0));
em.flush();
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
}
@Test
@@ -144,13 +144,13 @@ class DocumentFtsTest {
em.flush();
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
blockRepository.deleteById(block.getId());
em.flush();
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).doesNotContain(doc.getId());
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).doesNotContain(doc.getId());
}
// ─── Ranking ───────────────────────────────────────────────────────────────
@@ -166,7 +166,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Grundbuch");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Grundbuch");
assertThat(ids).hasSize(2);
assertThat(ids.get(0)).isEqualTo(docA.getId());
@@ -179,7 +179,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Ein Brief von der Oma"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("der die das und");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("der die das und");
assertThat(ids).isEmpty();
}
@@ -195,7 +195,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Wille");
assertThat(ids).contains(doc.getId());
}
@@ -205,7 +205,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Brief"));
em.clear();
assertThatNoException().isThrownBy(() -> documentRepository.findRankedIdsByFts("((("));
assertThatNoException().isThrownBy(() -> documentRepository.findAllMatchingIdsByFts("((("));
}
// ─── Weight C: sender/receiver names ───────────────────────────────────────
@@ -223,7 +223,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Schmidt");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Schmidt");
assertThat(ids).contains(doc.getId());
}
@@ -241,7 +241,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Raddatz");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Raddatz");
assertThat(ids).contains(doc.getId());
}
@@ -260,7 +260,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Familiengeschichte");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Familiengeschichte");
assertThat(ids).hasSize(1);
}
@@ -278,7 +278,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> rankedIds = documentRepository.findRankedIdsByFts("Grundbuch");
List<UUID> rankedIds = documentRepository.findAllMatchingIdsByFts("Grundbuch");
Specification<Document> spec = Specification.where(hasIds(rankedIds))
.and(hasStatus(DocumentStatus.UPLOADED));

View File

@@ -21,17 +21,22 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DocumentServiceSortTest {
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
private static final Pageable PAGE = org.springframework.data.domain.PageRequest.of(0, 10_000);
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@@ -43,12 +48,12 @@ class DocumentServiceSortTest {
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@InjectMocks DocumentService documentService;
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
// ─── DATE sort ────────────────────────────────────────────────────────────
@Test
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
UUID id1 = UUID.randomUUID(); // rank position 0 (higher relevance, older doc)
UUID id2 = UUID.randomUUID(); // rank position 1 (lower relevance, newer doc)
UUID id1 = UUID.randomUUID(); // higher relevance, older doc
UUID id2 = UUID.randomUUID(); // lower relevance, newer doc
Document older = Document.builder().id(id1)
.title("Brief").status(DocumentStatus.UPLOADED)
@@ -57,38 +62,48 @@ class DocumentServiceSortTest {
.title("Brief").status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1960, 1, 1)).build();
// FTS returns id1 first (higher rank), id2 second
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
// findAll(spec, pageable) — the correct date path — returns date-DESC order
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
}
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
@Test
void searchDocuments_relevance_pureText_calls_findFtsPageRaw_not_findAllMatchingIds() {
UUID id1 = UUID.randomUUID();
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any()))
.thenReturn(List.of(doc(id1)));
documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
}
@Test
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
UUID id1 = UUID.randomUUID(); // rank position 0
UUID id2 = UUID.randomUUID(); // rank position 1
UUID id1 = UUID.randomUUID(); // higher rank — must appear first
UUID id2 = UUID.randomUUID(); // lower rank
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1)); // unordered from DB
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
// Expect: rank order restored (id1 first)
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
@@ -97,16 +112,82 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1));
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
@Test
void searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt() {
// offset = pageNumber * pageSize; choose values so offset > Integer.MAX_VALUE
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, hugePage);
assertThat(result.items()).isEmpty();
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
}
// ─── toFtsPage — UUID-as-String JDBC driver variance ────────────────────
@Test
void searchDocuments_relevance_handles_string_uuid_from_jdbc_driver() {
String stringId = "11111111-1111-1111-1111-111111111111";
UUID uuidId = UUID.fromString(stringId);
// Simulate a JDBC driver that returns the id column as String instead of UUID
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
}
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
@Test
void searchDocuments_relevance_with_active_filter_uses_inMemory_path() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc(id2), doc(id1)));
// sender filter is active → triggers in-memory path, not findFtsPageRaw
LocalDate from = LocalDate.of(1900, 1, 1);
documentService.searchDocuments(
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository).findAllMatchingIdsByFts("Brief");
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private static Document doc(UUID id) {
return Document.builder().id(id).title("Brief").status(DocumentStatus.UPLOADED).build();
}
private static List<Object[]> ftsRows(UUID id, double rank, long total) {
List<Object[]> rows = new ArrayList<>();
rows.add(new Object[]{id, rank, total});
return rows;
}
}

View File

@@ -1620,9 +1620,10 @@ class DocumentServiceTest {
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
List<Object[]> ftsRows = new java.util.ArrayList<>();
ftsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -1654,9 +1655,10 @@ class DocumentServiceTest {
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -2202,7 +2204,7 @@ class DocumentServiceTest {
@Test
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter(
"xyz", null, null, null, null, null, null, null, null);
@@ -2386,7 +2388,7 @@ class DocumentServiceTest {
@Test
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(
new DensityFilters("xyz", null, null, null, null, null, null));

View File

@@ -0,0 +1,68 @@
# ADR-008: SQL-level pagination for full-text search via window-function CTE
## Status
Accepted
## Context
`DocumentRepository.findAllMatchingIdsByFts` (formerly `findRankedIdsByFts`) returns all matching document IDs for a FTS query. `DocumentService.searchDocuments` then paginates in memory on the RELEVANCE sort path.
A pre-production audit against 1,520 documents measured:
```
rows_per_call: 911 / call (query: "walter")
```
At current scale this is acceptable — 911 UUIDs ≈ 14 KB, ms-level DB time. At 100 K+ documents two failure modes emerge:
1. **Memory**: a broad query returns ~60 K UUIDs ≈ 1 MB per request, multiplied by concurrent users.
2. **Latency**: the `LATERAL` join does work proportional to match-set size; at 60 K matches the FTS step alone exceeds 100 ms per query.
Tracked as finding **F-31 (High)** in the pre-production architectural review.
## Decision
Push pagination and rank ordering into SQL for the RELEVANCE sort path when no non-text filters are active (pure full-text search):
```sql
WITH q AS (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''', '''\\1'':*', 'g'))
END AS pq
), matches AS (
SELECT d.id, ts_rank(d.search_vector, q.pq) AS rank
FROM documents d, q
WHERE d.search_vector @@ q.pq
)
SELECT id, rank, COUNT(*) OVER () AS total
FROM matches
ORDER BY rank DESC, id
OFFSET :offset LIMIT :limit
```
`COUNT(*) OVER ()` returns the full match count alongside each page row in a single round-trip — no separate count query needed.
`rows_per_call` for the FTS query drops from match-set size (911) to page size (≤ 50).
When non-text filters (date range, sender, receiver, tags, status) are also active, the existing path is preserved: `findAllMatchingIdsByFts` returns all ranked IDs, which are passed as an `IN` clause to the JPA Specification, and `totalElements` comes from the JPA `Page.getTotalElements()`. This keeps the count accurate across the combined filter set.
## Alternatives Considered
**1. Two-query approach (separate COUNT + paged SELECT)**
Correct, but doubles round-trips. The window function achieves the same result in one query.
**2. Capped result set with a user-visible warning**
Return at most N results (e.g. 500) and show "showing top 500 of many results". Simpler, but degrades UX for broad queries and doesn't reduce latency proportionally (still scans N rows).
**3. Full SQL rewrite combining FTS + JPA Specification filters**
Possible via a native query that embeds all filter predicates. Eliminates the in-memory SENDER/RECEIVER sort paths and the two-phase approach. High complexity, tight coupling to schema details, loses type-safe JPA Specification composition. Deferred to a future refactor if scale demands it.
## Consequences
- **`rows_per_call` for pure-text FTS searches drops to ≤ page size** — the primary metric.
- **SENDER and RECEIVER sort paths stay in-memory** for combined text+filter queries. For pure-text queries with SENDER/RECEIVER sort, the current approach (fetch all matched IDs, build spec, load all matched entities, sort in-memory) still runs. This is acceptable while the archive stays under ~10 K documents.
- **RELEVANCE sort with text+filters still loads the full filtered entity set in-memory.** The filtered set is typically much smaller than the raw FTS match set, so the cost is bounded by filter selectivity, not total match count.
- **`findAllMatchingIdsByFts` is retained** for: (a) the bulk-edit "select all" fast path (`findIdsForFilter`), (b) the document density chart (`getDensity`), and (c) the SENDER/RECEIVER in-memory sort paths.

View File

@@ -459,17 +459,9 @@
"dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert",
"dashboard_reader_recent_stories_heading": "Neue Geschichten",
"dashboard_badge_new": "Neu",
"dashboard_badge_updated": "Aktualisiert",
"dashboard_reader_all_stories": "Alle Geschichten →",
"dashboard_reader_doc_count_suffix": "Dok.",
"dashboard_all_documents": "Alle Dokumente",
"dashboard_greeting_time_morning": "Morgen",
"dashboard_greeting_time_afternoon": "Mittag",
"dashboard_greeting_time_evening": "Abend",
"dashboard_welcome": "Herzlich willkommen, {name}.",
"dashboard_reader_stats_documents_short": "Dok.",
"dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Gesch.",
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",

View File

@@ -459,17 +459,9 @@
"dashboard_reader_recent_docs_heading": "Recently Updated",
"dashboard_reader_recent_stories_heading": "New Stories",
"dashboard_badge_new": "New",
"dashboard_badge_updated": "Updated",
"dashboard_reader_all_stories": "All Stories →",
"dashboard_reader_doc_count_suffix": "docs.",
"dashboard_all_documents": "All Documents",
"dashboard_greeting_time_morning": "Morning",
"dashboard_greeting_time_afternoon": "Afternoon",
"dashboard_greeting_time_evening": "Evening",
"dashboard_welcome": "Welcome, {name}.",
"dashboard_reader_stats_documents_short": "Docs.",
"dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Stor.",
"dashboard_reader_draft_meta": "Draft · last edited {relative}",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",

View File

@@ -459,17 +459,9 @@
"dashboard_reader_recent_docs_heading": "Actualizados recientemente",
"dashboard_reader_recent_stories_heading": "Nuevas historias",
"dashboard_badge_new": "Nuevo",
"dashboard_badge_updated": "Actualizado",
"dashboard_reader_all_stories": "Todas las historias →",
"dashboard_reader_doc_count_suffix": "docs.",
"dashboard_all_documents": "Todos los documentos",
"dashboard_greeting_time_morning": "Mañana",
"dashboard_greeting_time_afternoon": "Tarde",
"dashboard_greeting_time_evening": "Noche",
"dashboard_welcome": "Bienvenido, {name}.",
"dashboard_reader_stats_documents_short": "Docs.",
"dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Hist.",
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",

View File

@@ -21,6 +21,7 @@ interface Props {
restrictToCorrespondentsOf?: string;
excludePersonId?: string;
badge?: 'additive' | 'replace';
resetKey?: number;
onchange?: (value: string) => void;
onfocused?: () => void;
}
@@ -39,17 +40,20 @@ let {
restrictToCorrespondentsOf,
excludePersonId,
badge,
resetKey = 0,
onchange,
onfocused
}: Props = $props();
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
// $effect is the correct pattern here — writable $derived is read-only and won't work.
// eslint-disable-next-line svelte/prefer-writable-derived
let searchTerm = $state(initialName);
// Sync display text when the selected person changes externally (e.g. swap, navigation).
// Sync display text when initialName changes OR when resetKey increments (navigation reset).
// resetKey is incremented by the page on every SvelteKit navigation so that a manually-typed
// term that was never committed (no person selected) gets cleared even if initialName stays ''.
$effect(() => {
void resetKey;
searchTerm = initialName;
});

View File

@@ -270,6 +270,33 @@ describe('PersonTypeahead correspondent mode', () => {
});
});
// ─── resetKey ─────────────────────────────────────────────────────────────────
describe('PersonTypeahead resetKey', () => {
// Note: rerender() in vitest-browser-svelte causes a full re-mount, not an in-place prop
// update. This is a smoke test — the $effect(resetKey) path that fires during SvelteKit
// navigation (prop update on a live instance) cannot be isolated at this level.
it('clears a manually-typed term when resetKey changes even if initialName stays empty', async () => {
mockFetchWithPersons([]);
const { rerender } = render(PersonTypeahead, {
name: 'senderId',
label: 'Absender',
initialName: '',
resetKey: 0
});
const input = page.getByPlaceholder('Namen tippen...');
// User types something without selecting a person
await input.fill('Max');
await waitForDebounce();
await expect.element(input).toHaveValue('Max');
// Navigation resets: initialName stays '', but resetKey increments
await rerender({ name: 'senderId', label: 'Absender', initialName: '', resetKey: 1 });
await expect.element(input).toHaveValue('');
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonTypeahead click outside', () => {

View File

@@ -12,47 +12,24 @@ interface Props {
const { drafts }: Props = $props();
</script>
<div
class="flex flex-col overflow-hidden rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface"
>
<!-- Card-head -->
<div class="flex items-center border-b border-line px-3 py-1.5">
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.dashboard_reader_drafts_heading()}
</h3>
</div>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_drafts_heading()}
</h2>
{#if drafts.length === 0}
<p class="px-3 py-3 font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
{:else}
<ul class="flex flex-col">
<ul class="flex flex-col gap-2">
{#each drafts as draft (draft.id)}
<li>
<a
href="/geschichten/{draft.id}/edit"
class="flex min-h-[44px] items-center justify-between border-b border-line/50 px-3 py-1.5 last:border-b-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
class="flex min-h-[44px] items-center justify-between gap-4 rounded-sm py-2 transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="flex min-w-0 flex-col">
<span class="truncate font-serif text-sm text-ink">{draft.title}</span>
<span class="text-[11px] text-ink-3">
{m.dashboard_reader_draft_meta({ relative: relativeTimeDe(new Date(draft.updatedAt)) })}
</span>
<span class="text-ink-1 truncate font-serif text-sm">{draft.title}</span>
<span class="shrink-0 font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(draft.updatedAt))}
</span>
<svg
width="7"
height="7"
viewBox="0 0 7 7"
fill="none"
aria-hidden="true"
class="shrink-0 text-ink-3"
>
<path
d="M1.5 1 L5.5 3.5 L1.5 6"
stroke="currentColor"
stroke-width="1.5"
fill="none"
/>
</svg>
</a>
</li>
{/each}

View File

@@ -36,12 +36,10 @@ describe('ReaderDraftsModule', () => {
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
});
it('shows heading as h3 (not h2)', async () => {
it('shows heading "Meine Entwürfe"', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const h3 = page.getByRole('heading', { level: 3 });
await expect.element(h3).toBeInTheDocument();
const h2 = page.getByRole('heading', { level: 2 });
await expect.element(h2).not.toBeInTheDocument();
const heading = page.getByRole('heading', { name: /Meine Entwürfe/i });
await expect.element(heading).toBeInTheDocument();
});
it('shows empty state when drafts is empty', async () => {
@@ -55,45 +53,4 @@ describe('ReaderDraftsModule', () => {
const emptyText = page.getByText(/Keine Entwürfe/i);
await expect.element(emptyText).not.toBeInTheDocument();
});
it('card wrapper has mint left-border classes', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const h3 = page.getByRole('heading', { level: 3 });
const card = ((await h3.element()) as HTMLElement).closest('div[class]');
const rootCard = card?.parentElement;
const cls = rootCard?.className ?? '';
expect(cls).toMatch(/border-l-\[3px\]/);
expect(cls).toMatch(/border-l-brand-mint/);
});
it('draft-row link has min-h-[44px] touch target', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('draft title has text-ink class', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const el = (await link.element()) as HTMLElement;
const titleEl = el.querySelector('[class*="text-ink"]');
expect(titleEl).not.toBeNull();
expect(titleEl?.textContent?.trim()).toBe('Mein erster Entwurf');
});
it('draft meta contains "Entwurf" text', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const el = (await link.element()) as HTMLElement;
expect(el.textContent).toMatch(/Entwurf/);
});
it('chevron SVG is present in each draft row', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const el = (await link.element()) as HTMLElement;
const svg = el.querySelector('svg');
expect(svg).not.toBeNull();
});
});

View File

@@ -1,87 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
name: string;
documents: number | null;
persons: number | null;
stories: number | null;
hour?: number;
}
const { name, documents, persons, stories, hour }: Props = $props();
const timeLabel = $derived.by(() => {
const h = hour ?? new Date().getHours();
if (h < 12) return m.dashboard_greeting_time_morning();
if (h < 18) return m.dashboard_greeting_time_afternoon();
return m.dashboard_greeting_time_evening();
});
</script>
<header
class="flex flex-col items-start gap-4 rounded-sm border border-line bg-surface px-4 py-3 sm:flex-row sm:items-center dark:border-white/8"
>
<!-- Greeting -->
<div class="min-w-0 flex-1">
<span class="block text-[11px] font-bold tracking-[.8px] text-ink uppercase">
{timeLabel}
</span>
<span class="block font-serif text-xl text-ink">
{m.dashboard_welcome({ name })}
</span>
</div>
<!-- Vertical divider — desktop only -->
<div class="hidden w-px shrink-0 self-stretch bg-line sm:block" aria-hidden="true"></div>
<!-- Stats -->
<div
class="flex w-full items-center border-t border-line-2 pt-1.5 sm:w-auto sm:border-t-0 sm:pt-0"
>
<a
href="/documents"
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-2 px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-ink">{documents ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_documents()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_documents_short()}</span
>
</a>
<a
href="/persons"
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-2 px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-ink">{persons ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_persons()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_persons_short()}</span
>
</a>
<a
href="/geschichten"
class="flex min-h-[44px] flex-col items-center justify-center px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-ink">{stories ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_stories()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_stories_short()}</span
>
</a>
</div>
</header>

View File

@@ -1,99 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderHeaderBar from './ReaderHeaderBar.svelte';
afterEach(() => {
cleanup();
});
describe('ReaderHeaderBar', () => {
it('renders a link to /documents with document count', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /42/ });
await expect.element(link).toHaveAttribute('href', '/documents');
});
it('renders a link to /persons with person count', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /7/ });
await expect.element(link).toHaveAttribute('href', '/persons');
});
it('renders a link to /geschichten with story count', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
await expect.element(link).toHaveAttribute('href', '/geschichten');
});
it('documents stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /42/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('persons stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /7/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('stories stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('shows "—" when counts are null', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: null, persons: null, stories: null });
const wrapper = page.getByRole('banner');
const text = ((await wrapper.element()) as HTMLElement).textContent;
expect(text?.match(/—/g)?.length).toBeGreaterThanOrEqual(3);
});
it('time label uses text-ink class for morning hour', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
const timeLabel = page.getByText(/Morgen/i);
await expect.element(timeLabel).toBeInTheDocument();
const cls = ((await timeLabel.element()) as HTMLElement).className;
expect(cls).toMatch(/\btext-ink\b/);
});
it('shows afternoon label for hour 14', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 14 });
const timeLabel = page.getByText(/Mittag/i);
await expect.element(timeLabel).toBeInTheDocument();
});
it('shows evening label for hour 20', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 20 });
const timeLabel = page.getByText(/Abend/i);
await expect.element(timeLabel).toBeInTheDocument();
});
it('welcome line contains the user name', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
const welcome = page.getByText(/Anna/);
await expect.element(welcome).toBeInTheDocument();
});
it('wrapper uses bg-surface (CSS-variable-backed, dark-mode-aware)', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
const wrapper = page.getByRole('banner');
const cls = ((await wrapper.element()) as HTMLElement).className;
expect(cls).toMatch(/\bbg-surface\b/);
});
it('renders a vertical divider with bg-line class', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
const wrapper = page.getByRole('banner');
const el = (await wrapper.element()) as HTMLElement;
const divider = el.querySelector('[aria-hidden="true"]');
expect(divider).not.toBeNull();
expect(divider!.className).toMatch(/bg-line/);
});
});

View File

@@ -27,38 +27,37 @@ interface Props {
const { persons }: Props = $props();
</script>
<section aria-label={m.dashboard_reader_person_chips_heading()}>
<div class="flex flex-col gap-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_person_chips_heading()}
</h2>
{#if persons.length === 0}
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_no_persons()}</p>
{/if}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="flex flex-wrap gap-2">
{#each persons as p (p.id)}
<a
href="/persons/{p.id}"
class="group flex min-h-[44px] flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center no-underline shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
class="flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-base font-bold text-white shadow-sm dark:shadow-none dark:ring-1 dark:ring-white/10"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {personAvatarColor(p.id ?? '')}"
>
{getInitials(p.displayName ?? p.lastName ?? '')}
</span>
<span class="truncate font-serif text-sm font-bold text-ink group-hover:underline"
>{p.displayName ?? p.lastName}</span
>
{#if (p.documentCount ?? 0) > 0}
<span
class="mt-1 inline-flex items-center rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-[11px] font-semibold text-ink-2"
<span class="flex min-w-0 flex-col">
<span class="text-ink-1 truncate font-serif text-sm">{p.displayName ?? p.lastName}</span>
<span class="font-sans text-xs text-ink-3"
>{p.documentCount ?? 0} {m.dashboard_reader_doc_count_suffix()}</span
>
{p.documentCount}
</span>
{/if}
</span>
</a>
{/each}
</div>
<a
href="/persons"
class="mt-1 flex min-h-[44px] items-center justify-end text-right text-xs font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
class="inline-flex min-h-[44px] items-center self-end rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
>{m.dashboard_reader_all_persons()}</a
>
</section>
</div>

View File

@@ -32,7 +32,7 @@ const person2: PersonSummaryDTO = {
};
describe('ReaderPersonChips', () => {
it('renders a card for each person with correct href', async () => {
it('renders a chip for each person with correct href', async () => {
render(ReaderPersonChips, { persons: [person1, person2] });
const link1 = page.getByRole('link', { name: /Anna Müller/ });
await expect
@@ -44,46 +44,12 @@ describe('ReaderPersonChips', () => {
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
});
it('person card has min-h-[44px] touch target', async () => {
it('shows document count in each chip', async () => {
render(ReaderPersonChips, { persons: [person1] });
const link = page.getByRole('link', { name: /Anna Müller/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('doc count renders as neutral chip with bg-muted', async () => {
render(ReaderPersonChips, { persons: [person1] });
const link = page.getByRole('link', { name: /Anna Müller/ });
const el = (await link.element()) as HTMLElement;
const chip = el.querySelector('[class*="bg-muted"]');
expect(chip).not.toBeNull();
expect(chip!.textContent).toContain('23');
});
it('doc count chip has rounded-full and border-line classes', async () => {
render(ReaderPersonChips, { persons: [person1] });
const link = page.getByRole('link', { name: /Anna Müller/ });
const el = (await link.element()) as HTMLElement;
const chip = el.querySelector('[class*="bg-muted"]');
expect(chip).not.toBeNull();
expect(chip!.className).toMatch(/rounded-full/);
expect(chip!.className).toMatch(/border-line/);
});
it('person grid uses grid layout', async () => {
render(ReaderPersonChips, { persons: [person1, person2] });
const section = page.getByRole('region');
const el = (await section.element()) as HTMLElement;
const grid = el.querySelector('[class*="grid"]');
expect(grid).not.toBeNull();
});
it('wrapper is a section with aria-label', async () => {
render(ReaderPersonChips, { persons: [person1] });
const section = page.getByRole('region');
await expect.element(section).toBeInTheDocument();
const label = ((await section.element()) as HTMLElement).getAttribute('aria-label');
expect(label).toBeTruthy();
const chip = page.getByRole('link', { name: /Anna Müller/ });
await expect.element(chip).toBeInTheDocument();
const text = ((await chip.element()) as HTMLElement).textContent;
expect(text).toContain('23');
});
it('renders an "Alle Personen" link to /persons', async () => {
@@ -92,13 +58,6 @@ describe('ReaderPersonChips', () => {
await expect.element(allLink).toHaveAttribute('href', '/persons');
});
it('"Alle Personen" link has text-ink-2 class', async () => {
render(ReaderPersonChips, { persons: [person1] });
const allLink = page.getByRole('link', { name: /Alle Personen/i });
const cls = ((await allLink.element()) as HTMLElement).className;
expect(cls).toMatch(/text-ink-2/);
});
it('exposes a focus-visible ring on the "Alle Personen" link', async () => {
render(ReaderPersonChips, { persons: [person1] });
const allLink = page.getByRole('link', { name: /Alle Personen/i });
@@ -114,13 +73,7 @@ describe('ReaderPersonChips', () => {
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('does not render h2 heading', async () => {
render(ReaderPersonChips, { persons: [person1] });
const heading = page.getByRole('heading', { level: 2 });
await expect.element(heading).not.toBeInTheDocument();
});
it('renders empty state without person cards when persons array is empty', async () => {
it('renders empty state without chips when persons array is empty', async () => {
render(ReaderPersonChips, { persons: [] });
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
await expect.element(chips).not.toBeInTheDocument();

View File

@@ -16,71 +16,49 @@ function isNew(doc: Document): boolean {
}
</script>
<div class="flex flex-col overflow-hidden rounded-sm border border-line bg-surface">
<!-- Card-head -->
<div class="flex items-center justify-between border-b border-line px-3 py-1.5">
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.dashboard_reader_recent_docs_heading()}
</h3>
<a
href="/documents"
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{m.dashboard_all_documents()}
</a>
</div>
<!-- Doc list -->
<ul class="flex flex-col">
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_recent_docs_heading()}
</h2>
<ul class="flex flex-col divide-y divide-line">
{#each documents as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] items-center gap-2 border-b border-line/50 px-3 py-3 last:border-b-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<!-- Thumb -->
<span
class="flex h-6 w-5 shrink-0 items-center justify-center rounded-[2px] border border-line bg-canvas"
>
<svg
width="10"
height="12"
viewBox="0 0 10 12"
fill="none"
aria-hidden="true"
class="text-ink-3"
>
<path d="M1 1h5.5L9 3.5V11H1V1z" stroke="currentColor" stroke-width="1" fill="none" />
<path d="M6 1v3h3" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
</span>
<!-- Middle -->
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="flex flex-wrap items-center gap-1.5">
<span class="truncate font-serif text-sm text-ink">{doc.title}</span>
<li class="py-3 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<a
href="/documents/{doc.id}"
class="text-ink-1 truncate rounded-sm font-serif text-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{doc.title}
</a>
{#if isNew(doc)}
<span
class="shrink-0 rounded-full bg-accent-bg px-1.5 py-px text-[11px] font-bold text-ink"
class="rounded bg-brand-mint/20 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-brand-navy uppercase"
>
{m.dashboard_badge_new()}
</span>
{/if}
</span>
<span class="text-xs text-ink-3">
{#if doc.sender}
{doc.sender.displayName ?? doc.sender.lastName}
{:else}
<span
class="text-ink-1 rounded bg-ink-3/10 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide uppercase"
>
{m.dashboard_badge_updated()}
</span>
{/if}
</span>
</span>
<!-- Date -->
<span class="shrink-0 text-[11px] text-ink-3">
</div>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="font-sans text-xs text-ink-3 transition-colors hover:text-brand-mint"
>
{doc.sender.displayName ?? doc.sender.lastName}
</a>
{/if}
</div>
<span class="shrink-0 font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(doc.updatedAt))}
</span>
</a>
</div>
</li>
{/each}
</ul>

View File

@@ -37,73 +37,30 @@ describe('ReaderRecentDocs', () => {
await expect.element(link).toHaveAttribute('href', '/documents/doc1');
});
it('card has overflow-hidden and flex-col classes (no p-6, no shadow-sm)', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const heading = page.getByRole('heading', { level: 3 });
const card = (await heading.element())?.closest('div');
const rootCard = card?.parentElement;
const cls = rootCard?.className ?? '';
expect(cls).toMatch(/overflow-hidden/);
expect(cls).toMatch(/flex-col/);
expect(cls).not.toMatch(/\bp-6\b/);
expect(cls).not.toMatch(/shadow-sm/);
});
it('card-head contains an h3 (not h2)', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const h3 = page.getByRole('heading', { level: 3 });
await expect.element(h3).toBeInTheDocument();
const h2 = page.getByRole('heading', { level: 2 });
await expect.element(h2).not.toBeInTheDocument();
});
it('"Alle Dokumente" link in card-head points to /documents', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Alle Dokumente/i });
await expect.element(link).toHaveAttribute('href', '/documents');
});
it('"Alle Dokumente" link has min-h-[44px]', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Alle Dokumente/i });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('doc-row link has min-h-[44px] touch target', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('thumb element has correct classes', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
const el = (await link.element()) as HTMLElement;
const thumb = el.querySelector('[class*="w-5"][class*="h-6"]');
expect(thumb).not.toBeNull();
expect(thumb!.className).toMatch(/bg-canvas/);
expect(thumb!.className).toMatch(/border-line/);
expect(thumb!.className).toMatch(/rounded-/);
});
it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => {
it('shows "Neu" badge when createdAt equals updatedAt', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument();
const cls = ((await badge.element()) as HTMLElement).className;
expect(cls).toMatch(/bg-accent-bg/);
expect(cls).toMatch(/rounded-full/);
expect(cls).toMatch(/\btext-ink\b/);
});
it('shows no badge when updatedAt differs from createdAt', async () => {
it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Aktualisiert$/i);
await expect.element(badge).toBeInTheDocument();
});
it('renders the "Aktualisiert" badge with high-contrast text-ink-1', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Aktualisiert$/i);
const cls = ((await badge.element()) as HTMLElement).className;
expect(cls).toMatch(/text-ink-1/);
expect(cls).not.toMatch(/text-ink-3(?!\/)/);
});
it('does not show "Neu" badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).not.toBeInTheDocument();
const updatedBadge = page.getByText(/^Aktualisiert$/i);
await expect.element(updatedBadge).not.toBeInTheDocument();
});
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
@@ -118,7 +75,7 @@ describe('ReaderRecentDocs', () => {
await expect.element(badge).toBeInTheDocument();
});
it('renders sender name text when sender is present', async () => {
it('renders sender link when sender is present', async () => {
const docWithSender: Document = {
...baseDoc,
sender: {
@@ -131,15 +88,7 @@ describe('ReaderRecentDocs', () => {
}
};
render(ReaderRecentDocs, { documents: [docWithSender] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
const el = (await link.element()) as HTMLElement;
expect(el.textContent).toContain('Anna Müller');
});
it('shows em-dash when sender is absent', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
const el = (await link.element()) as HTMLElement;
expect(el.textContent).toContain('—');
const senderLink = page.getByRole('link', { name: /Anna Müller/ });
await expect.element(senderLink).toHaveAttribute('href', '/persons/p1');
});
});

View File

@@ -24,38 +24,33 @@ function excerpt(body: string | undefined): string {
</script>
{#if stories.length > 0}
<div class="flex flex-col overflow-hidden rounded-sm border border-line bg-surface">
<!-- Card-head -->
<div class="flex items-center justify-between border-b border-line px-3 py-1.5">
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.dashboard_reader_recent_stories_heading()}
</h3>
<a
href="/geschichten"
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{m.dashboard_reader_all_stories()}
</a>
</div>
<!-- Story list -->
<ul class="flex flex-col">
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_recent_stories_heading()}
</h2>
<ul class="flex flex-col divide-y divide-line">
{#each stories as story (story.id)}
<li>
<li class="py-4 first:pt-0 last:pb-0">
<a
href="/geschichten/{story.id}"
class="flex min-h-[44px] flex-col gap-1 border-b border-line/50 px-3 py-2 last:border-b-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
class="flex flex-col gap-1 rounded-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-base text-ink italic">{story.title}</span>
<span class="text-ink-1 font-serif text-base italic">{story.title}</span>
{#if story.body}
<p class="line-clamp-2 text-xs leading-relaxed text-ink-2">{excerpt(story.body)}</p>
<p class="line-clamp-2 font-sans text-xs text-ink-3">{excerpt(story.body)}</p>
{/if}
<span class="text-[11px] text-ink-3">
<span class="font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
</span>
</a>
</li>
{/each}
</ul>
<a
href="/geschichten"
class="mt-4 inline-flex min-h-[44px] items-center rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
>
{m.dashboard_reader_all_stories()}
</a>
</div>
{/if}

View File

@@ -52,7 +52,7 @@ describe('ReaderRecentStories', () => {
await expect.element(links).not.toBeInTheDocument();
});
it('renders "Alle Geschichten" link pointing to /geschichten', async () => {
it('renders "Alle Geschichten" link', async () => {
render(ReaderRecentStories, { stories: [story1] });
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
@@ -72,44 +72,4 @@ describe('ReaderRecentStories', () => {
const cls = ((await allLink.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('card-head contains an h3 (not h2)', async () => {
render(ReaderRecentStories, { stories: [story1] });
const h3 = page.getByRole('heading', { level: 3 });
await expect.element(h3).toBeInTheDocument();
const h2 = page.getByRole('heading', { level: 2 });
await expect.element(h2).not.toBeInTheDocument();
});
it('card-head div has border-b and border-line classes', async () => {
render(ReaderRecentStories, { stories: [story1] });
const h3 = page.getByRole('heading', { level: 3 });
const cardHead = ((await h3.element()) as HTMLElement).parentElement;
expect(cardHead?.className).toMatch(/border-b/);
expect(cardHead?.className).toMatch(/border-line/);
});
it('"Alle Geschichten" link is inside the card-head (sibling of h3)', async () => {
render(ReaderRecentStories, { stories: [story1] });
const h3 = page.getByRole('heading', { level: 3 });
const cardHead = ((await h3.element()) as HTMLElement).parentElement;
const allLink = cardHead?.querySelector('a');
expect(allLink).not.toBeNull();
expect(allLink?.textContent?.trim()).toMatch(/Alle Geschichten/i);
});
it('story-row link has min-h-[44px] touch target', async () => {
render(ReaderRecentStories, { stories: [story1] });
const link = page.getByRole('link', { name: /Die Familie Müller/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('excerpt has text-ink-2 class', async () => {
render(ReaderRecentStories, { stories: [story1] });
const link = page.getByRole('link', { name: /Die Familie Müller/ });
const el = (await link.element()) as HTMLElement;
const excerptEl = el.querySelector('p');
expect(excerptEl?.className).toMatch(/text-ink-2/);
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
documents: number | null;
persons: number | null;
stories: number | null;
}
const { documents, persons, stories }: Props = $props();
</script>
<div class="hidden gap-4 sm:flex">
<a
href="/documents"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{documents ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_documents()}</span
>
</a>
<a
href="/persons"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{persons ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_persons()}</span
>
</a>
<a
href="/geschichten"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{stories ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_stories()}</span
>
</a>
</div>

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderStatsStrip from './ReaderStatsStrip.svelte';
afterEach(() => {
cleanup();
});
describe('ReaderStatsStrip', () => {
it('renders a link to /documents', async () => {
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /42/ });
await expect.element(link).toHaveAttribute('href', '/documents');
});
it('renders a link to /persons', async () => {
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /7/ });
await expect.element(link).toHaveAttribute('href', '/persons');
});
it('renders a link to /geschichten', async () => {
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
await expect.element(link).toHaveAttribute('href', '/geschichten');
});
it('shows "—" when documents count is null', async () => {
render(ReaderStatsStrip, { documents: null, persons: null, stories: null });
const links = page.getByRole('link');
await expect.element(links.first()).toBeInTheDocument();
const text = ((await links.first().element()) as HTMLElement).textContent;
expect(text).toContain('—');
});
});

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
@@ -24,6 +25,16 @@ let {
let display = $state(isoToGerman(value ?? ''));
// Re-derive display when value changes externally (e.g. timeline drag, reset nav).
// Guard prevents overwriting while the user is mid-typing a partial date:
// germanToIso returns '' for partial input, matching value '' → no re-derive.
$effect(() => {
const externalIso = value ?? '';
if (germanToIso(untrack(() => display)) !== externalIso) {
display = isoToGerman(externalIso);
}
});
// ─── Validation helper ────────────────────────────────────────────────────
function isCalendarValid(iso: string): boolean {
if (!iso) return false;

View File

@@ -183,6 +183,26 @@ describe('DateInput clearing the date', () => {
});
});
// ─── External value changes ───────────────────────────────────────────────────
describe('DateInput external value changes', () => {
it('clears display when value prop is reset to empty externally', async () => {
const { rerender } = render(DateInput, { value: '1920-01-01' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('01.01.1920');
await rerender({ value: '' });
await expect.element(input).toHaveValue('');
});
it('updates display when value prop changes to a new date externally', async () => {
const { rerender } = render(DateInput, { value: '1920-01-01' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('01.01.1920');
await rerender({ value: '1945-05-08' });
await expect.element(input).toHaveValue('08.05.1945');
});
});
// ─── Hidden input ─────────────────────────────────────────────────────────────
describe('DateInput hidden input for form submission', () => {

View File

@@ -5,7 +5,7 @@ import MissionControlStrip from '$lib/document/MissionControlStrip.svelte';
import DashboardFamilyPulse from '$lib/shared/dashboard/DashboardFamilyPulse.svelte';
import DashboardActivityFeed from '$lib/activity/DashboardActivityFeed.svelte';
import EnrichmentBlock from '$lib/document/EnrichmentBlock.svelte';
import ReaderHeaderBar from '$lib/shared/dashboard/ReaderHeaderBar.svelte';
import ReaderStatsStrip from '$lib/shared/dashboard/ReaderStatsStrip.svelte';
import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
@@ -30,10 +30,15 @@ const greetingText = $derived.by(() => {
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
{#if data?.user}
<div class="mb-6">
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div>
{/if}
{#if data.isReader}
<div class="flex flex-col gap-5">
<ReaderHeaderBar
name={data.user?.firstName ?? ''}
<ReaderStatsStrip
documents={data.readerStats?.totalDocuments ?? null}
persons={data.readerStats?.totalPersons ?? null}
stories={data.readerStats?.totalStories ?? null}
@@ -45,17 +50,16 @@ const greetingText = $derived.by(() => {
<ReaderPersonChips persons={data.topPersons ?? []} />
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<ReaderRecentDocs documents={data.recentDocs ?? []} />
<ReaderRecentStories stories={data.recentStories ?? []} />
<div class="flex flex-col gap-5 md:flex-row">
<div class="flex-[3]">
<ReaderRecentDocs documents={data.recentDocs ?? []} />
</div>
<div class="flex-[2]">
<ReaderRecentStories stories={data.recentStories ?? []} />
</div>
</div>
</div>
{:else}
{#if data?.user}
<div class="mb-6">
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div>
{/if}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />

View File

@@ -20,6 +20,7 @@ let {
showAdvanced = $bindable(false),
initialSenderName = '',
initialReceiverName = '',
navKey = 0,
isLoading = false,
onSearch,
onSearchImmediate,
@@ -39,6 +40,7 @@ let {
showAdvanced?: boolean;
initialSenderName?: string;
initialReceiverName?: string;
navKey?: number;
isLoading?: boolean;
onSearch: () => void;
onSearchImmediate?: () => void;
@@ -197,6 +199,7 @@ $effect(() => {
label={m.docs_filter_label_sender()}
bind:value={senderId}
initialName={initialSenderName}
resetKey={navKey}
onchange={onSearch}
/>
</div>
@@ -212,6 +215,7 @@ $effect(() => {
label={m.docs_filter_label_receivers()}
bind:value={receiverId}
initialName={initialReceiverName}
resetKey={navKey}
onchange={onSearch}
/>
</div>

View File

@@ -3,6 +3,23 @@ import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
async function resolvePersonName(
id: string,
api: ReturnType<typeof createApiClient>
): Promise<string> {
if (!UUID_RE.test(id)) return '';
try {
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
if (!result.response.ok) return '';
return result.data?.displayName ?? '';
} catch (e) {
console.error('[resolvePersonName] failed for id', id, e);
return '';
}
}
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
@@ -34,25 +51,30 @@ export async function load({ url, fetch }) {
const api = createApiClient(fetch);
let result;
let initialSenderName = '';
let initialReceiverName = '';
try {
result = await api.GET('/api/documents/search', {
params: {
query: {
q: q || undefined,
from: from || undefined,
to: to || undefined,
senderId: senderId || undefined,
receiverId: receiverId || undefined,
tag: tags.length ? tags : undefined,
tagQ: tagQ && !tags.length ? tagQ : undefined,
tagOp: tagOp === 'OR' ? 'OR' : undefined,
sort,
dir: dir || undefined,
page,
size: PAGE_SIZE
[result, [initialSenderName, initialReceiverName]] = await Promise.all([
api.GET('/api/documents/search', {
params: {
query: {
q: q || undefined,
from: from || undefined,
to: to || undefined,
senderId: senderId || undefined,
receiverId: receiverId || undefined,
tag: tags.length ? tags : undefined,
tagQ: tagQ && !tags.length ? tagQ : undefined,
tagOp: tagOp === 'OR' ? 'OR' : undefined,
sort,
dir: dir || undefined,
page,
size: PAGE_SIZE
}
}
}
});
}),
Promise.all([resolvePersonName(senderId, api), resolvePersonName(receiverId, api)])
]);
} catch {
return {
items: [] as DocumentSearchItem[],
@@ -65,6 +87,8 @@ export async function load({ url, fetch }) {
to,
senderId,
receiverId,
initialSenderName: '',
initialReceiverName: '',
tags,
sort,
dir,
@@ -94,6 +118,8 @@ export async function load({ url, fetch }) {
to,
senderId,
receiverId,
initialSenderName,
initialReceiverName,
tags,
sort,
dir,

View File

@@ -22,6 +22,9 @@ let from = $state(untrack(() => data.from || ''));
let to = $state(untrack(() => data.to || ''));
let senderId = $state(untrack(() => data.senderId || ''));
let receiverId = $state(untrack(() => data.receiverId || ''));
let initialSenderName = $state(untrack(() => data.initialSenderName ?? ''));
let initialReceiverName = $state(untrack(() => data.initialReceiverName ?? ''));
let navKey = $state(0);
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
untrack(() => (data.tags || []).map((name: string) => ({ name })))
);
@@ -207,12 +210,17 @@ async function editAllMatching() {
// Keep local filter state in sync with server data after navigation completes.
// Guard q: skip overwrite while the user is actively typing.
// navKey increments on every navigation so PersonTypeahead clears manually-typed
// terms even when initialSenderName/initialReceiverName stays '' across navigations.
$effect(() => {
if (!qFocused) q = data.q || '';
from = data.from || '';
to = data.to || '';
senderId = data.senderId || '';
receiverId = data.receiverId || '';
initialSenderName = data.initialSenderName ?? '';
initialReceiverName = data.initialReceiverName ?? '';
untrack(() => navKey++);
tagNames = (data.tags || []).map((name: string) => ({ name }));
sort = data.sort || 'DATE';
dir = data.dir || 'desc';
@@ -247,6 +255,9 @@ $effect(() => {
bind:dir={dir}
bind:tagQ={tagQ}
bind:tagOperator={tagOperator}
initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName}
navKey={navKey}
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}

View File

@@ -167,3 +167,76 @@ describe('documents page load — network error fallback', () => {
expect(result.items).toEqual([]);
});
});
// ─── person name resolution ───────────────────────────────────────────────────
describe('documents page load — person name resolution', () => {
function makeSearchMock(personResult?: { ok: boolean; displayName?: string }) {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/api/documents/search') {
return Promise.resolve({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
}
// person lookup via api.GET('/api/persons/{id}', ...)
if (!personResult?.ok) {
return Promise.resolve({ response: { ok: false, status: 404 }, data: undefined });
}
return Promise.resolve({
response: { ok: true, status: 200 },
data: { displayName: personResult.displayName ?? '' }
});
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
return mockGet;
}
it('returns initialSenderName from person lookup when senderId is a valid UUID', async () => {
makeSearchMock({ ok: true, displayName: 'Max Mustermann' });
const result = await load({
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.initialSenderName).toBe('Max Mustermann');
});
it('returns initialReceiverName from person lookup when receiverId is a valid UUID', async () => {
makeSearchMock({ ok: true, displayName: 'Anna Musterfrau' });
const result = await load({
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.initialReceiverName).toBe('Anna Musterfrau');
});
it('returns empty string when senderId is not a valid UUID', async () => {
const mockGet = makeSearchMock();
const result = await load({
url: makeUrl({ senderId: 'not-a-uuid' }),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.initialSenderName).toBe('');
// UUID guard fires before any api.GET call — only document search is called
expect(mockGet).toHaveBeenCalledTimes(1);
});
it('returns empty string when person api returns 404', async () => {
makeSearchMock({ ok: false });
const result = await load({
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.initialSenderName).toBe('');
});
});

View File

@@ -23,6 +23,8 @@ function makeData(overrides: Record<string, unknown> = {}) {
to: '',
senderId: '',
receiverId: '',
initialSenderName: '',
initialReceiverName: '',
tags: [],
sort: 'DATE',
dir: 'desc',
@@ -136,6 +138,22 @@ describe('documents page — URL building', () => {
});
});
// ─── Sender / receiver name display ──────────────────────────────────────────
describe('documents page — sender/receiver display', () => {
it('pre-fills sender typeahead from initialSenderName when senderId filter is active', async () => {
render(Page, {
data: makeData({
senderId: '11111111-1111-1111-1111-111111111111',
initialSenderName: 'Max Mustermann'
})
});
// Advanced filters are auto-shown when senderId is set
const inputs = page.getByPlaceholder('Namen tippen...');
await expect.element(inputs.first()).toHaveValue('Max Mustermann');
});
});
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
describe('documents page — timeline density widget', () => {

View File

@@ -1 +1,3 @@
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
// before prerendered HTML is visible.
export const prerender = true;

View File

@@ -102,19 +102,13 @@ describe('Home page dashboard layout', () => {
// ─── Reader dashboard layout ──────────────────────────────────────────────────
describe('Home page reader dashboard layout', () => {
it('renders reader header-bar totals when isReader is true', async () => {
it('renders ReaderStatsStrip totals when isReader is true', async () => {
render(Page, { data: readerData });
await expect.element(page.getByText('34')).toBeInTheDocument();
await expect.element(page.getByText('12')).toBeInTheDocument();
await expect.element(page.getByText('5')).toBeInTheDocument();
});
it('reader branch does not render h1 heading', async () => {
render(Page, { data: readerData });
const h1 = page.getByRole('heading', { level: 1 });
await expect.element(h1).not.toBeInTheDocument();
});
it('renders the recent-docs heading when isReader is true', async () => {
render(Page, { data: readerData });
await expect.element(page.getByText('Zuletzt aktualisiert')).toBeInTheDocument();

View File

@@ -6,10 +6,7 @@ const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
prerender: { entries: ['/hilfe/transkription'] }
}
kit: { adapter: adapter() }
};
export default config;