Compare commits
10 Commits
fix/issue-
...
ed32de9f10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed32de9f10 | ||
|
|
abeece7e30 | ||
|
|
df1f56ca6d | ||
|
|
7eee1e4a51 | ||
|
|
308e902871 | ||
|
|
823472812f | ||
|
|
e711416e81 | ||
|
|
e8cabf4390 | ||
|
|
a58f22f663 | ||
|
|
d14c937693 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
68
docs/adr/008-fts-sql-pagination.md
Normal file
68
docs/adr/008-fts-sql-pagination.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
43
frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
Normal file
43
frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
Normal 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>
|
||||
@@ -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('—');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
|
||||
// before prerendered HTML is visible.
|
||||
export const prerender = true;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user