Compare commits
146 Commits
12487d187f
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ec4815e24 | ||
|
|
a7bbf2424f | ||
|
|
7c2c4741ab | ||
|
|
d464bca9f3 | ||
|
|
2283f733cc | ||
|
|
cc20583ae6 | ||
|
|
86d75d91be | ||
|
|
a98ca0e5d3 | ||
|
|
1c515a3145 | ||
|
|
43d36c898c | ||
|
|
60326cfb0a | ||
|
|
e598f5a506 | ||
|
|
e1c78e3fbe | ||
|
|
ae6355d206 | ||
|
|
b5f9fcfdfd | ||
|
|
2f48dfabd1 | ||
|
|
495210052f | ||
|
|
a072701632 | ||
|
|
eac2356948 | ||
|
|
d554fc7e6b | ||
|
|
7bd477d24e | ||
|
|
b1c2132aa6 | ||
|
|
f7eefb525f | ||
|
|
500611925d | ||
|
|
64bcc8d031 | ||
|
|
5a8a1898f8 | ||
|
|
b4f24f4965 | ||
|
|
9e1754bbb0 | ||
|
|
797852b494 | ||
|
|
518334bc38 | ||
|
|
c8b1a890be | ||
|
|
1f592958d7 | ||
|
|
9b5547757a | ||
|
|
92587b050e | ||
|
|
2be2087a95 | ||
|
|
4d9234244e | ||
|
|
9b82621770 | ||
|
|
a58e796ffa | ||
|
|
6a46a1e3eb | ||
|
|
5b645f6374 | ||
|
|
d76ee5fa31 | ||
|
|
5146aeb568 | ||
|
|
9fd1f3cde2 | ||
|
|
5cd6ecc624 | ||
|
|
86de118d63 | ||
|
|
00f35ab675 | ||
|
|
c0a1f04df5 | ||
|
|
7f99c64d45 | ||
|
|
18aaf1f3e8 | ||
|
|
dd0a77a5a2 | ||
|
|
f68d16ef58 | ||
|
|
301cfffd1a | ||
|
|
bf501b7d62 | ||
|
|
5d749b2415 | ||
|
|
1d6016cb19 | ||
|
|
48da819a54 | ||
|
|
153752a901 | ||
|
|
3b6b117c75 | ||
|
|
2e9ce8e1da | ||
|
|
c9be6cc165 | ||
|
|
ffe617dba8 | ||
|
|
47841b9110 | ||
|
|
360db1ae33 | ||
|
|
e5739d7f8e | ||
|
|
219d9a816e | ||
|
|
00682bac4f | ||
|
|
77d282bbeb | ||
|
|
52827ccc87 | ||
|
|
61d1c1793b | ||
|
|
c06987da95 | ||
|
|
5028082da4 | ||
|
|
ea106e9414 | ||
|
|
dfdcacdb85 | ||
|
|
c9fb677499 | ||
|
|
6aceafda8e | ||
|
|
5d92f5a32b | ||
|
|
a6123e1867 | ||
|
|
bd81ff81f9 | ||
|
|
76023a99ed | ||
|
|
e92e9e452e | ||
|
|
59a2faa145 | ||
|
|
8e29f428d7 | ||
|
|
e8fb8150b7 | ||
|
|
6786c0112d | ||
|
|
d43d73f231 | ||
|
|
ad82f2e1e2 | ||
|
|
5fdcc95c3d | ||
|
|
142459b916 | ||
|
|
b31979c4f0 | ||
|
|
1060be7def | ||
|
|
fbf4725e97 | ||
|
|
c90b42d045 | ||
|
|
e61e3797d1 | ||
|
|
ce0c013f0f | ||
|
|
baa0a9811c | ||
|
|
9ef3c82398 | ||
|
|
708fd9d63e | ||
|
|
abe8ab8668 | ||
|
|
e3a3f209f9 | ||
|
|
e877847b7e | ||
|
|
7c25d08506 | ||
|
|
c10e8e8a3a | ||
|
|
0c765d8112 | ||
|
|
cdb54c7545 | ||
|
|
6ab7abb9df | ||
|
|
d28c455991 | ||
|
|
0fa90d58cb | ||
|
|
172bafe202 | ||
|
|
ba0bfc6a7e | ||
|
|
d4b5c14a26 | ||
|
|
e209d4877d | ||
|
|
66c1998d2f | ||
|
|
62bef1d267 | ||
|
|
c3d4762ca0 | ||
|
|
421d7ffd37 | ||
|
|
dbf19037fe | ||
|
|
9387fcc17b | ||
|
|
264db4e1c9 | ||
|
|
12f0e21b21 | ||
|
|
3e33021129 | ||
|
|
32396c6253 | ||
|
|
11b4206fe2 | ||
|
|
eede9f93a7 | ||
|
|
260bb8e164 | ||
|
|
9b82d8e7dd | ||
|
|
ab6117c87e | ||
|
|
b1f9f1603c | ||
|
|
f2a901eabf | ||
|
|
d6ca0f12c9 | ||
|
|
537bfb79f0 | ||
|
|
f74b586f29 | ||
|
|
eb464b351a | ||
|
|
9ad172084a | ||
|
|
0582edd840 | ||
|
|
9986af7c3d | ||
|
|
a4bde0953e | ||
|
|
1b55588aee | ||
|
|
1c560289c8 | ||
|
|
61e58e98ba | ||
|
|
3608a9723a | ||
|
|
63f00ce0a0 | ||
|
|
0a5b290e6c | ||
|
|
ab1a1d1a3d | ||
|
|
9d22a5134f | ||
|
|
883c3381a7 | ||
|
|
f34967f764 |
@@ -410,6 +410,23 @@ Never Kafka for teams under 10 or <100k events/day. Never gRPC inside a monolith
|
||||
4. Identify missing database-layer enforcement (constraints, RLS)
|
||||
5. Check transport choices — simpler protocol available?
|
||||
6. Propose a concrete simpler alternative, not just a critique
|
||||
7. Verify documentation currency. For each category below, check whether the PR triggered the update. Flag missing updates as blockers.
|
||||
|
||||
| PR contains | Required doc update |
|
||||
|---|---|
|
||||
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` |
|
||||
| New `@ManyToMany` join table or FK | Both DB diagrams |
|
||||
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||
| New SvelteKit route | `CLAUDE.md` route table + matching `docs/architecture/c4/l3-frontend-*.puml` |
|
||||
| New Docker service or infrastructure component | `docs/architecture/c4/l2-containers.puml` + `docs/DEPLOYMENT.md` |
|
||||
| New external system integrated | `docs/architecture/c4/l1-context.puml` |
|
||||
| Auth or upload flow change | `docs/architecture/c4/seq-auth-flow.puml` or `docs/architecture/c4/seq-document-upload.puml` |
|
||||
| New `ErrorCode` or `Permission` value | `CLAUDE.md` + `docs/ARCHITECTURE.md` |
|
||||
| New domain concept or term | `docs/GLOSSARY.md` |
|
||||
| Architectural decision with lasting consequences | New ADR in `docs/adr/` |
|
||||
|
||||
A doc omission is a blocker, not a concern — the PR does not merge until the diagram or text matches the code.
|
||||
|
||||
### Designing Systems
|
||||
1. Start with the data model — get the schema right before application code
|
||||
|
||||
@@ -980,6 +980,24 @@ Mark with `@pytest.mark.asyncio` so pytest runs the coroutine. Without it, the t
|
||||
5. Refactor — apply clean code, extract if 3+ duplications, rename for intent
|
||||
6. Repeat for the next behavior
|
||||
7. When all behaviors are green, review for SOLID violations across the full stack
|
||||
8. Update documentation before opening the PR. Use the table below to know which doc to touch.
|
||||
|
||||
| What changed in code | Doc(s) to update |
|
||||
|---|---|
|
||||
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) |
|
||||
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
|
||||
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
|
||||
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |
|
||||
| New SvelteKit route (`+page.svelte`) | `CLAUDE.md` (route structure section) **and** the matching `docs/architecture/c4/l3-frontend-*.puml` diagram |
|
||||
| New Docker service / infrastructure component | `docs/architecture/c4/l2-containers.puml` **and** `docs/DEPLOYMENT.md` |
|
||||
| New external system integrated (new API, new S3 bucket, etc.) | `docs/architecture/c4/l1-context.puml` |
|
||||
| Auth flow or document-upload flow changes | `docs/architecture/c4/seq-auth-flow.puml` or `docs/architecture/c4/seq-document-upload.puml` |
|
||||
| New `ErrorCode` enum value | `CLAUDE.md` error handling section **and** `CONTRIBUTING.md` |
|
||||
| New `Permission` enum value | `CLAUDE.md` security section **and** `docs/ARCHITECTURE.md` |
|
||||
| New domain term introduced (entity name, status, concept) | `docs/GLOSSARY.md` |
|
||||
| Architectural decision with lasting consequences (new tech, new transport protocol, new pattern) | New ADR in `docs/adr/` |
|
||||
|
||||
Skip a doc only if the change genuinely does not affect what that doc describes.
|
||||
|
||||
### Reviewing Code
|
||||
1. TDD evidence — are there tests? Do they precede the implementation?
|
||||
|
||||
@@ -40,6 +40,10 @@ 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
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,5 +18,11 @@ scripts/large-data.sql
|
||||
.claude/worktrees/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Run artifacts from verification tooling
|
||||
proofshot-artifacts/
|
||||
|
||||
# Root-level Node.js tooling artifacts
|
||||
node_modules/
|
||||
|
||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||
frontend/yarn.lock
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"plantuml.render": "PlantUMLServer",
|
||||
"plantuml.server": "http://heim-nas:8500"
|
||||
}
|
||||
@@ -190,6 +190,13 @@
|
||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
||||
<version>20240325.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- HTML → plain-text extraction for comment previews -->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.18.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
@@ -29,5 +29,11 @@ public record ActivityFeedItemDTO(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
||||
)
|
||||
UUID annotationId
|
||||
UUID annotationId,
|
||||
@Nullable
|
||||
@Schema(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments."
|
||||
)
|
||||
String commentPreview
|
||||
) {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentData;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
@@ -133,9 +134,9 @@ public class DashboardService {
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
|
||||
Map<UUID, CommentData> commentDataByComment = commentIds.isEmpty()
|
||||
? Map.of()
|
||||
: commentService.findAnnotationIdsByIds(commentIds);
|
||||
: commentService.findDataByIds(commentIds);
|
||||
|
||||
return rows.stream().map(row -> {
|
||||
ActivityActorDTO actor = row.getActorId() != null
|
||||
@@ -146,7 +147,10 @@ public class DashboardService {
|
||||
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
||||
: null;
|
||||
UUID commentId = row.getCommentId();
|
||||
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
|
||||
CommentData commentData = commentId != null ? commentDataByComment.get(commentId) : null;
|
||||
UUID annotationId = commentData != null ? commentData.annotationId() : null;
|
||||
String commentPreview = commentData != null && !commentData.preview().isBlank()
|
||||
? commentData.preview() : null;
|
||||
return new ActivityFeedItemDTO(
|
||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
||||
actor,
|
||||
@@ -158,7 +162,8 @@ public class DashboardService {
|
||||
row.getCount(),
|
||||
happenedAtUntil,
|
||||
commentId,
|
||||
annotationId
|
||||
annotationId,
|
||||
commentPreview
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* Aggregate counts for the dashboard/persons stats bar.
|
||||
*/
|
||||
public record StatsDTO(long totalPersons, long totalDocuments) {
|
||||
public record StatsDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalPersons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalDocuments,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalStories) {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -12,8 +13,9 @@ public class StatsService {
|
||||
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final GeschichteService geschichteService;
|
||||
|
||||
public StatsDTO getStats() {
|
||||
return new StatsDTO(personService.count(), documentService.count());
|
||||
return new StatsDTO(personService.count(), documentService.count(), geschichteService.countPublished());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The non-date filters honoured by {@link DocumentService#getDensity(DensityFilters)}.
|
||||
* Date bounds (from/to) are deliberately excluded — see the service Javadoc for why.
|
||||
*
|
||||
* Kept as a record so the seven values are passed as one named bundle instead of a
|
||||
* positional argument list where two UUIDs (sender vs. receiver) can be swapped by
|
||||
* accident at the call site.
|
||||
*/
|
||||
public record DensityFilters(
|
||||
String text,
|
||||
UUID sender,
|
||||
UUID receiver,
|
||||
List<String> tags,
|
||||
String tagQ,
|
||||
DocumentStatus status,
|
||||
TagOperator tagOperator) {}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -388,6 +390,23 @@ public class DocumentController {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<DocumentDensityResult> density(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
|
||||
.body(result);
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
public record TrainingLabelRequest(String label, boolean enrolled) {}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of the timeline density aggregation.
|
||||
*
|
||||
* <p>{@code minDate} / {@code maxDate} are intentionally not marked
|
||||
* {@code @Schema(requiredMode = REQUIRED)} — the empty-result case (no
|
||||
* documents match the filter) returns them as {@code null}, which surfaces in
|
||||
* the generated TypeScript as {@code minDate?: string | null}. Frontend code
|
||||
* must treat them as optional.
|
||||
*/
|
||||
public record DocumentDensityResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<MonthBucket> buckets,
|
||||
LocalDate minDate,
|
||||
LocalDate maxDate
|
||||
) {
|
||||
/** The "no documents match the filter" result, with no buckets and null date bounds. */
|
||||
public static DocumentDensityResult empty() {
|
||||
return new DocumentDensityResult(List.of(), null, null);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
@@ -125,6 +126,74 @@ public class DocumentService {
|
||||
return titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-month document counts for the timeline density widget (issue #385).
|
||||
*
|
||||
* <p>Filter-reactive: the chart recomputes when other filters (sender,
|
||||
* receiver, tag, q, status) change so it always matches the list it sits
|
||||
* above. Date bounds (`from`/`to`) are deliberately omitted — the chart is
|
||||
* the surface for picking those, so it must always span the broader space
|
||||
* the user is selecting within.
|
||||
*
|
||||
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
||||
* because the existing {@link Specification} predicates compose easily
|
||||
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
||||
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
|
||||
* controller layer absorbs repeated browse loads.
|
||||
*
|
||||
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
|
||||
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
|
||||
* 'YYYY-MM')) and accept that the criteria/specification surface needs a
|
||||
* parallel native-query path.
|
||||
*/
|
||||
public DocumentDensityResult getDensity(DensityFilters filters) {
|
||||
List<UUID> ftsIds = resolveFtsIds(filters.text());
|
||||
if (ftsIds != null && ftsIds.isEmpty()) {
|
||||
return DocumentDensityResult.empty();
|
||||
}
|
||||
List<LocalDate> dates = loadFilteredDates(filters, ftsIds);
|
||||
return aggregateByMonth(dates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the FTS-ranked document IDs when {@code text} is non-blank, or {@code null}
|
||||
* when no full-text query is active. An empty list means the FTS query ran but
|
||||
* matched zero documents — the caller short-circuits on that signal.
|
||||
*/
|
||||
private List<UUID> resolveFtsIds(String text) {
|
||||
if (!StringUtils.hasText(text)) return null;
|
||||
return documentRepository.findRankedIdsByFts(text);
|
||||
}
|
||||
|
||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
||||
boolean hasFts = ftsIds != null;
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasFts, ftsIds, null, null,
|
||||
filters.sender(), filters.receiver(),
|
||||
filters.tags(), filters.tagQ(),
|
||||
filters.status(), filters.tagOperator());
|
||||
return documentRepository.findAll(spec).stream()
|
||||
.map(Document::getDocumentDate)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Buckets {@code dates} into one {@link MonthBucket} per YYYY-MM and computes min/max. */
|
||||
private DocumentDensityResult aggregateByMonth(List<LocalDate> dates) {
|
||||
if (dates.isEmpty()) return DocumentDensityResult.empty();
|
||||
Map<String, Integer> counts = new java.util.TreeMap<>();
|
||||
for (LocalDate d : dates) {
|
||||
counts.merge(YearMonth.from(d).toString(), 1, Integer::sum);
|
||||
}
|
||||
List<MonthBucket> buckets = counts.entrySet().stream()
|
||||
.map(e -> new MonthBucket(e.getKey(), e.getValue()))
|
||||
.toList();
|
||||
LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null);
|
||||
LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null);
|
||||
return new DocumentDensityResult(buckets, minDate, maxDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch.
|
||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||
@@ -658,6 +727,7 @@ public class DocumentService {
|
||||
return switch (sort) {
|
||||
case TITLE -> Sort.by(direction, "title");
|
||||
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
|
||||
case UPDATED_AT -> Sort.by(direction, "updatedAt");
|
||||
default -> Sort.by(direction, "documentDate");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
public enum DocumentSort {
|
||||
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE
|
||||
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, UPDATED_AT, RELEVANCE
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record MonthBucket(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "1915-08")
|
||||
String month,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int count
|
||||
) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.document.comment;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CommentData(@Nullable UUID annotationId, String preview) {}
|
||||
@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.document.comment.DocumentComment;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentRepository;
|
||||
import org.raddatz.familienarchiv.notification.NotificationService;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -28,21 +29,29 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class CommentService {
|
||||
|
||||
private static final int PREVIEW_MAX_CHARS = 120;
|
||||
|
||||
private final CommentRepository commentRepository;
|
||||
private final UserService userService;
|
||||
private final NotificationService notificationService;
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionService transcriptionService;
|
||||
|
||||
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
|
||||
public Map<UUID, CommentData> findDataByIds(Collection<UUID> commentIds) {
|
||||
if (commentIds == null || commentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, UUID> result = new HashMap<>();
|
||||
Map<UUID, CommentData> result = new HashMap<>();
|
||||
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
|
||||
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
|
||||
result.put(c.getId(), new CommentData(c.getAnnotationId(), stripAndTruncate(c.getContent())));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String stripAndTruncate(String html) {
|
||||
if (html == null || html.isBlank()) return "";
|
||||
String text = Jsoup.parse(html).text().trim();
|
||||
return text.length() > PREVIEW_MAX_CHARS ? text.substring(0, PREVIEW_MAX_CHARS) : text;
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||
return withRepliesAndMentions(roots);
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
// "Handler" is Spring's @RestControllerAdvice naming convention — not a generic suffix.
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@@ -56,6 +56,10 @@ public class GeschichteService {
|
||||
|
||||
// ─── Read API ────────────────────────────────────────────────────────────
|
||||
|
||||
public long countPublished() {
|
||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||
}
|
||||
|
||||
public Geschichte getById(UUID id) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
@@ -77,8 +81,10 @@ public class GeschichteService {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
|
||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||
Specification<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAuthor(authorId),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
|
||||
@@ -42,6 +42,12 @@ public final class GeschichteSpecifications {
|
||||
};
|
||||
}
|
||||
|
||||
// null authorId → no restriction (PUBLISHED path passes null; Spring Data skips null predicates)
|
||||
public static Specification<Geschichte> hasAuthor(UUID authorId) {
|
||||
return (root, query, cb) ->
|
||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||
}
|
||||
|
||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||
return (root, query, cb) -> {
|
||||
if (documentId == null) return null;
|
||||
|
||||
@@ -35,7 +35,14 @@ public class PersonController {
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false, defaultValue = "0") int size,
|
||||
@RequestParam(required = false) String sort) {
|
||||
if ("documentCount".equals(sort) && size > 0 && q == null) {
|
||||
int safeSize = Math.min(size, 50);
|
||||
return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize));
|
||||
}
|
||||
return ResponseEntity.ok(personService.findAll(q));
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,22 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
nativeQuery = true)
|
||||
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
|
||||
|
||||
// ORDER BY uses the computed alias "documentCount" — valid PostgreSQL (aliases allowed in ORDER BY,
|
||||
// unlike WHERE/HAVING). This is intentional; it would silently fail on MySQL or H2.
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.family_member AS familyMember,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
FROM persons p
|
||||
ORDER BY documentCount DESC
|
||||
LIMIT :limit
|
||||
""",
|
||||
nativeQuery = true)
|
||||
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
|
||||
@@ -41,6 +41,10 @@ public class PersonService {
|
||||
return personRepository.searchWithDocumentCount(q.trim());
|
||||
}
|
||||
|
||||
public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
|
||||
return personRepository.findTopByDocumentCount(limit);
|
||||
}
|
||||
|
||||
public Person getById(UUID id) {
|
||||
return personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
// Cross-cutting auth helper; no domain home — "Utils" is the correct suffix here.
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);
|
||||
@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentData;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
@@ -142,7 +143,8 @@ class DashboardServiceTest {
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
|
||||
when(commentService.findDataByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, new CommentData(null, "preview text")));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
@@ -162,8 +164,8 @@ class DashboardServiceTest {
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, annotationId));
|
||||
when(commentService.findDataByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, new CommentData(annotationId, "preview text")));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
@@ -187,7 +189,62 @@ class DashboardServiceTest {
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isNull();
|
||||
assertThat(items.get(0).annotationId()).isNull();
|
||||
verify(commentService, never()).findAnnotationIdsByIds(anyList());
|
||||
verify(commentService, never()).findDataByIds(anyList());
|
||||
}
|
||||
|
||||
// ─── getActivity commentPreview ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getActivity_populates_commentPreview_for_COMMENT_ADDED_rows() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findDataByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, new CommentData(null, "Hello family!")));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items.get(0).commentPreview()).isEqualTo("Hello family!");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_leaves_commentPreview_null_for_TEXT_SAVED_rows() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items.get(0).commentPreview()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_leaves_commentPreview_null_when_comment_is_deleted() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID deletedCommentId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", deletedCommentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findDataByIds(List.of(deletedCommentId))).thenReturn(Map.of());
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items.get(0).commentPreview()).isNull();
|
||||
}
|
||||
|
||||
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
|
||||
|
||||
@@ -44,7 +44,7 @@ class StatsControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getStats_returns200_withCorrectCounts() throws Exception {
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L));
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L, 2L));
|
||||
|
||||
mockMvc.perform(get("/api/stats"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -55,7 +55,7 @@ class StatsControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getStats_returns200_withZeroCounts() throws Exception {
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L));
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L, 0L));
|
||||
|
||||
mockMvc.perform(get("/api/stats"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -17,6 +18,7 @@ class StatsServiceTest {
|
||||
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock GeschichteService geschichteService;
|
||||
@InjectMocks StatsService statsService;
|
||||
|
||||
@Test
|
||||
@@ -30,6 +32,17 @@ class StatsServiceTest {
|
||||
assertThat(stats.totalDocuments()).isEqualTo(12L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStats_includes_totalStories() {
|
||||
when(personService.count()).thenReturn(3L);
|
||||
when(documentService.count()).thenReturn(7L);
|
||||
when(geschichteService.countPublished()).thenReturn(5L);
|
||||
|
||||
StatsDTO stats = statsService.getStats();
|
||||
|
||||
assertThat(stats.totalStories()).isEqualTo(5L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStats_returnsZero_whenNoEntities() {
|
||||
when(personService.count()).thenReturn(0L);
|
||||
|
||||
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -1240,4 +1241,100 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.errors[0].message").value(
|
||||
org.hamcrest.Matchers.containsString("not found")));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/density ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void density_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_returns200_withResultBody_whenAuthenticated() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(
|
||||
List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)),
|
||||
java.time.LocalDate.of(1915, 8, 3),
|
||||
java.time.LocalDate.of(1915, 9, 1)));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.buckets").isArray())
|
||||
.andExpect(jsonPath("$.buckets[0].month").value("1915-08"))
|
||||
.andExpect(jsonPath("$.buckets[0].count").value(2))
|
||||
.andExpect(jsonPath("$.minDate").value("1915-08-03"))
|
||||
.andExpect(jsonPath("$.maxDate").value("1915-09-01"));
|
||||
}
|
||||
|
||||
// Pins produces=APPLICATION_JSON_VALUE on the density mapping so the OpenAPI/TypeScript
|
||||
// codegen records application/json instead of the wildcard. Without produces= the
|
||||
// request-mapping accepts any Accept header and the OpenAPI emit falls back to the
|
||||
// wildcard. Sending an Accept header that JSON cannot satisfy must NOT return 200 —
|
||||
// Spring rejects with 406 (HttpMediaTypeNotAcceptableException), which our
|
||||
// GlobalExceptionHandler may surface as 400. Either way it proves the route is
|
||||
// locked to JSON.
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_declaresApplicationJsonContentType() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.accept(MediaType.APPLICATION_XML))
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_emitsPrivateCacheControlHeader() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("max-age=300")))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("private")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_forwardsSenderAndTagFilters() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
UUID senderId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.param("senderId", senderId.toString())
|
||||
.param("tag", "Familie")
|
||||
.param("tag", "Urlaub")
|
||||
.param("tagOp", "OR"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getDensity(eq(new DensityFilters(
|
||||
null, senderId, null,
|
||||
List.of("Familie", "Urlaub"),
|
||||
null, null,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.OR)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_forwardsStatusAndQueryText() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.param("q", "Brief")
|
||||
.param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getDensity(eq(new DensityFilters(
|
||||
"Brief", null, null, null, null,
|
||||
DocumentStatus.REVIEWED,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end test for the filter-reactive density aggregation.
|
||||
* Density bars must recompute as the user changes other filters (sender, tag,
|
||||
* status, …). The endpoint deliberately does NOT honour `from`/`to` — the chart
|
||||
* is the surface for picking those, so it must always span the broader space
|
||||
* the user is selecting within.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class DocumentDensityIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
|
||||
private Person hans;
|
||||
private Person anna;
|
||||
private Tag familieTag;
|
||||
private Tag urlaubTag;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
anna = personRepository.save(Person.builder().firstName("Anna").lastName("Weber").build());
|
||||
familieTag = tagRepository.save(Tag.builder().name("Familie").build());
|
||||
urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build());
|
||||
}
|
||||
|
||||
private static DensityFilters noFilters() {
|
||||
return new DensityFilters(null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsAllMonths_whenNoFiltersApplied() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of());
|
||||
save("b", LocalDate.of(1915, 8, 17), null, Set.of());
|
||||
save("c", LocalDate.of(1915, 9, 1), null, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(noFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1915-09");
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
|
||||
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersBySender() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
|
||||
save("b", LocalDate.of(1916, 1, 4), hans, Set.of());
|
||||
save("c", LocalDate.of(1920, 5, 1), anna, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, hans.getId(), null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1916-01");
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1916, 1, 4));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersByTag() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of(familieTag));
|
||||
save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag));
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, null, null, List.of("Familie"), null, null, TagOperator.AND));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_combinesSenderAndTag() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of(familieTag));
|
||||
save("b", LocalDate.of(1916, 1, 4), hans, Set.of(urlaubTag));
|
||||
save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag));
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersByStatus() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of(), DocumentStatus.UPLOADED);
|
||||
save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER);
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, null, null, null, null, DocumentStatus.UPLOADED, null));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsEmpty_whenNoDocumentsMatch() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, anna.getId(), null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
assertThat(result.minDate()).isNull();
|
||||
assertThat(result.maxDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_excludesDocumentsWithNullDate() {
|
||||
save("dated", LocalDate.of(1915, 8, 3), null, Set.of());
|
||||
save("undated", null, null, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(noFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
|
||||
}
|
||||
|
||||
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags) {
|
||||
save(suffix, date, sender, tags, DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags, DocumentStatus status) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Doc " + suffix)
|
||||
.originalFilename("doc-" + suffix + "-" + UUID.randomUUID() + ".pdf")
|
||||
.status(status)
|
||||
.documentDate(date)
|
||||
.sender(sender)
|
||||
.tags(new HashSet<>(tags))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -1402,6 +1403,21 @@ class DocumentServiceTest {
|
||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() {
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
DocumentSort.UPDATED_AT, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getSort())
|
||||
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
||||
@@ -2321,4 +2337,61 @@ class DocumentServiceTest {
|
||||
assertThat(documentService.save(doc)).isEqualTo(doc);
|
||||
verify(documentRepository).save(doc);
|
||||
}
|
||||
|
||||
// ─── getDensity ────────────────────────────────────────────────────────────
|
||||
|
||||
private static DensityFilters anyFilters() {
|
||||
return new DensityFilters(null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsEmptyResult_whenNoDocumentsMatch() {
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
assertThat(result.minDate()).isNull();
|
||||
assertThat(result.maxDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_groupsMatchingDocumentsByMonth() {
|
||||
Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
|
||||
Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build();
|
||||
Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build();
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c));
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1915-09");
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
|
||||
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_excludesDocumentsWithNullDate() {
|
||||
Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
|
||||
Document undated = Document.builder().documentDate(null).build();
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated));
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
|
||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters("xyz", null, null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.raddatz.familienarchiv.notification.NotificationService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -644,62 +645,99 @@ class CommentServiceTest {
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
|
||||
// ─── findDataByIds ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
|
||||
UUID commentA = UUID.randomUUID();
|
||||
UUID annotationA = UUID.randomUUID();
|
||||
UUID commentB = UUID.randomUUID();
|
||||
UUID annotationB = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(commentA, commentB)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(commentA).annotationId(annotationA).build(),
|
||||
DocumentComment.builder().id(commentB).annotationId(annotationB).build()
|
||||
));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB)))
|
||||
.containsOnly(
|
||||
java.util.Map.entry(commentA, annotationA),
|
||||
java.util.Map.entry(commentB, annotationB)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() {
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty();
|
||||
void findDataByIds_returns_empty_map_when_input_is_empty() {
|
||||
assertThat(commentService.findDataByIds(List.of())).isEmpty();
|
||||
verify(commentRepository, never()).findAllById(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsUnknownIds() {
|
||||
UUID known = UUID.randomUUID();
|
||||
UUID knownAnnotation = UUID.randomUUID();
|
||||
UUID missing = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(known, missing)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
|
||||
));
|
||||
void findDataByIds_strips_html_and_extracts_plain_text() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||
.content("<p><strong>Hello</strong> world</p>").build()));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
|
||||
.containsOnly(java.util.Map.entry(known, knownAnnotation))
|
||||
.doesNotContainKey(missing);
|
||||
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(id));
|
||||
|
||||
assertThat(result.get(id).preview()).isEqualTo("Hello world");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
|
||||
UUID legacy = UUID.randomUUID();
|
||||
UUID block = UUID.randomUUID();
|
||||
UUID annotation = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(legacy, block)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(legacy).annotationId(null).build(),
|
||||
DocumentComment.builder().id(block).annotationId(annotation).build()
|
||||
));
|
||||
void findDataByIds_truncates_at_exactly_120_chars() {
|
||||
UUID id = UUID.randomUUID();
|
||||
String text121 = "a".repeat(121);
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(text121).build()));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
|
||||
.containsOnly(java.util.Map.entry(block, annotation))
|
||||
.doesNotContainKey(legacy);
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_preserves_content_at_exactly_120_chars() {
|
||||
UUID id = UUID.randomUUID();
|
||||
String text120 = "a".repeat(120);
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(text120).build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_returns_empty_string_for_blank_content() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(" ").build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_returns_empty_string_for_null_content() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(null).build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_omits_deleted_comments_from_result_map() {
|
||||
UUID present = UUID.randomUUID();
|
||||
UUID deleted = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(present, deleted)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(present).content("Hi").build()));
|
||||
|
||||
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(present, deleted));
|
||||
|
||||
assertThat(result).containsKey(present);
|
||||
assertThat(result).doesNotContainKey(deleted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_preserves_annotationId_alongside_preview() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||
.annotationId(annotationId).content("Text").build()));
|
||||
|
||||
CommentData data = commentService.findDataByIds(List.of(id)).get(id);
|
||||
|
||||
assertThat(data.annotationId()).isEqualTo(annotationId);
|
||||
assertThat(data.preview()).isEqualTo("Text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_sets_null_annotationId_when_comment_has_no_annotation() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||
.annotationId(null).content("Text").build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).annotationId()).isNull();
|
||||
}
|
||||
|
||||
private void stubBlock(UUID docId, UUID blockId) {
|
||||
|
||||
@@ -159,6 +159,26 @@ class GeschichteServiceIntegrationTest {
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_DRAFT_does_not_return_other_users_drafts() {
|
||||
// writer creates a draft; writer2 (also BLOG_WRITE) should not see it
|
||||
AppUser writer2 = appUserRepository.save(AppUser.builder()
|
||||
.email("writer2-int@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Writer 1 draft");
|
||||
dto.setBody("<p>private</p>");
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle(title);
|
||||
|
||||
@@ -81,6 +81,29 @@ class PersonControllerTest {
|
||||
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_delegatesTopByDocumentCount_whenSortAndSizeGiven() throws Exception {
|
||||
PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
|
||||
when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));
|
||||
|
||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].firstName").value("Käthe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_capsTopByDocumentCount_atFifty() throws Exception {
|
||||
ArgumentCaptor<Integer> sizeCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||
when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "999"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(sizeCaptor.getValue()).isEqualTo(50);
|
||||
}
|
||||
|
||||
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||
return new PersonSummaryDTO() {
|
||||
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||
|
||||
@@ -13,6 +13,9 @@ For domain package structure see [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) _(com
|
||||
**AppUser** (`AppUser`) — a real person who can log into the system (a family member or administrator). `AppUser` records carry login credentials, group memberships, and notification history.
|
||||
_Not to be confused with [Person](#person-person)_ — an AppUser is never recorded as a document sender, receiver, or historical individual.
|
||||
|
||||
**Reader** — an `AppUser` whose effective permissions include `READ_ALL` but neither `WRITE_ALL` nor `ANNOTATE_ALL`. Readers see a dedicated dashboard (`isReader = !canWrite && !canAnnotate`) focused on browsing documents, persons, and stories rather than contribution tasks. A user who also holds `BLOG_WRITE` is still classified as a Reader and additionally sees a drafts module.
|
||||
_Not to be confused with [AppUser](#appuser-appuser)_ — Reader is a permission-derived role, not an entity.
|
||||
|
||||
**Permission** — a discrete capability string assigned to a `UserGroup` (e.g. `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`). Enforced via the `@RequirePermission` AOP annotation on controller methods, checked at runtime by `PermissionAspect`; not via Spring Security's `@PreAuthorize`.
|
||||
|
||||
**Person** (`Person`) — a historical individual in the family archive (sender, receiver of letters, person mentioned in transcriptions). NEVER has a login account and NEVER appears as an `AppUser`.
|
||||
|
||||
52
docs/adr/007-reader-dashboard-permission-discriminant.md
Normal file
52
docs/adr/007-reader-dashboard-permission-discriminant.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# ADR-007: Reader-dashboard permission discriminant
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #447 introduced two distinct user cohorts on the home page:
|
||||
|
||||
- **Contributors** — transcribe, annotate, upload. The existing `MissionControlStrip`, `EnrichmentBlock`, `DashboardResumeStrip`, `DashboardFamilyPulse`, `DashboardActivityFeed`, and `DropZone` are aimed at them.
|
||||
- **Readers** — browse and consume finished content. Older, less technical, on smaller devices. The contribution-focused widgets are noise to them.
|
||||
|
||||
`AppUser` permissions are already derived in `+layout.server.ts` and exposed via `$page.data` as `canWrite`, `canAnnotate`, and `canBlogWrite`. The home route needs a single boolean to switch its layout and its data fetch set, and that boolean has to be load-bearing — every future permission introduced has to be classified against it.
|
||||
|
||||
## Decision
|
||||
|
||||
```ts
|
||||
const isReader = !canWrite && !canAnnotate;
|
||||
```
|
||||
|
||||
Computed at the start of `+page.server.ts` `load()`. When true, the loader fetches a lean reader set (stats / top-4 persons / recent docs / recent stories — and drafts when `canBlogWrite`) via `Promise.allSettled` and returns a discriminated-union shape the page distinguishes via `data.isReader`.
|
||||
|
||||
`BLOG_WRITE` is **not** part of the discriminant. A `READ_ALL + BLOG_WRITE` user is still a reader and additionally sees the `ReaderDraftsModule`. Story writers are conceptually closer to readers than to transcribers: they consume the archive, occasionally publish narrative on top of it, and have no business with the transcription queue.
|
||||
|
||||
A `BLOG_WRITE`-only user (no `READ_ALL`) is also classified as a reader by this formula. Because every reader API requires `READ_ALL`, all four content tiles degrade to empty via `Promise.allSettled`. They see the empty reader shell plus the drafts module — acceptable behaviour, since this permission combination is degenerate by configuration. Documented in `docs/GLOSSARY.md`.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| New `/reader-home` route with a server-side redirect from `/` | Two routes that mostly answer the same product question (home page). Bookmarks split, breadcrumbs split, header `home` link has to know which to use. The conditional-render keeps a single canonical URL and lets the auth state drive the layout, matching how `canWrite` already gates the upload zone in the contributor branch. |
|
||||
| `AppUser.dashboardVariant` column persisted in the DB | Permissions already encode the relevant signal; a separate field has to be kept in sync with permission changes. Drift is a feature foot-gun: a user gets `WRITE_ALL` granted but their `dashboardVariant` field still says `reader` and they keep seeing the wrong UI. |
|
||||
| Middleware/handle hook redirecting based on permissions | Same logical issue as the dedicated route plus a network round-trip on every dashboard hit. The discriminant runs once inside the same `load()` that's already fetching the user. |
|
||||
| `isReader = !canWrite && !canAnnotate && !canBlogWrite` (exclude `BLOG_WRITE` from readers) | Treats blog writers as contributors. They would land on the `MissionControlStrip` they cannot meaningfully use (no `WRITE_ALL`, no `ANNOTATE_ALL`) and would have to scroll past the transcription queue to find their own drafts. The reader shell + drafts module fits their actual workflow. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- Reader and contributor views share one canonical home URL — no redirect, no routing fork.
|
||||
- Adding a new content tile to the reader dashboard is a single-file change inside the `if (isReader)` branch of `load()` plus a new component import in `+page.svelte`.
|
||||
- Backend `@RequirePermission(READ_ALL)` on every reader API call remains the load-bearing security gate. `isReader` is purely a UX flag — manipulating it client-side serves a different layout to the same authenticated user with the same permissions.
|
||||
|
||||
**Harder:**
|
||||
- Every future `Permission` value has to be explicitly classified against this formula. Adding a permission that grants contribution rights but not `WRITE_ALL`/`ANNOTATE_ALL` would silently leave its bearers on the reader dashboard. Mitigation: keep this ADR linked from `+page.server.ts` and from the `Permission` enum's Javadoc.
|
||||
- The discriminated-union return type of `load()` (`{isReader: true} | {isReader: false}`) requires every consumer to narrow on `data.isReader` before accessing branch-specific fields. The current `+page.svelte` already does this with the top-level `{#if data.isReader}`; new consumers of the home loader must follow suit.
|
||||
|
||||
## Future Direction
|
||||
|
||||
If a third cohort emerges (e.g. an admin home with system-health tiles), promote the discriminant to a tagged-union: `dashboard: 'reader' | 'contributor' | 'admin'`. The discriminant computation moves from `+page.server.ts` into a small helper in `lib/shared/server/`, callable from any route that needs the same classification (e.g. a future `/welcome` onboarding flow).
|
||||
|
||||
If `BLOG_WRITE`-only access becomes a real product mode (rather than the degenerate combination it is today), revisit whether the formula should add a `canRead` precondition: `isReader = canRead && !canWrite && !canAnnotate`.
|
||||
@@ -1,6 +1,8 @@
|
||||
# Familienarchiv — C4 Architecture Diagrams
|
||||
|
||||
> For domain terminology used in these diagrams, see [docs/GLOSSARY.md](../GLOSSARY.md).
|
||||
>
|
||||
> **Cross-diagram stubs:** Components placed outside a `System_Boundary` block with a "See diagram X" annotation are reference stubs — they represent a component fully defined in another sub-diagram and appear here only to show the cross-domain dependency without duplicating the full definition.
|
||||
|
||||
## Level 1 — System Context
|
||||
|
||||
@@ -10,13 +12,15 @@ Who uses the system and what external systems does it interact with.
|
||||
C4Context
|
||||
title System Context: Familienarchiv
|
||||
|
||||
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews documents")
|
||||
Person(member, "Family Member", "Searches, browses, and reads archived documents")
|
||||
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews and transcribes documents")
|
||||
Person(member, "Family Member", "Access by administrator invite. Searches, browses, reads, and transcribes archived documents.")
|
||||
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches and views via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
```
|
||||
|
||||
---
|
||||
@@ -30,9 +34,10 @@ C4Container
|
||||
title Container Diagram: Familienarchiv
|
||||
|
||||
Person(user, "User", "Admin or family member")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification and password-reset emails.")
|
||||
|
||||
System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles session cookies, search UI, document viewer, and admin panel.")
|
||||
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
|
||||
|
||||
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications.")
|
||||
|
||||
@@ -51,6 +56,7 @@ C4Container
|
||||
Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL")
|
||||
Rel(backend, storage, "Uploads and streams document files", "HTTP / S3 API (AWS SDK v2)")
|
||||
Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JSON")
|
||||
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI")
|
||||
```
|
||||
@@ -59,149 +65,444 @@ C4Container
|
||||
|
||||
## Level 3 — Components: API Backend
|
||||
|
||||
The internal structure of the Spring Boot backend.
|
||||
The internal structure of the Spring Boot backend, split into seven focused sub-diagrams.
|
||||
|
||||
### 3a — Security & Authentication
|
||||
|
||||
How requests are authenticated and write operations are authorised.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend
|
||||
title Component Diagram: API Backend — Security & Authentication
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
|
||||
}
|
||||
|
||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods", "")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService", "")
|
||||
Rel(userDetails, db, "Loads user by email", "JDBC")
|
||||
```
|
||||
|
||||
### 3b — Document Management & Import
|
||||
|
||||
Document management, file storage, and bulk Excel/ODS import.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Document Management & Import
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
ContainerDb(minio, "MinIO")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
|
||||
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and validates credentials via BCrypt.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
|
||||
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel/ODS files from /import mount. Tracks import state (IDLE/RUNNING/DONE/FAILED) and delegates to ExcelService. Returns immediately; processing runs asynchronously.")
|
||||
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel/ODS workbooks (Apache POI). Column indices configurable via application.properties. Creates/updates document records per row.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents. Endpoints: search, get by ID, update metadata, upload file, download file, get conversation thread.")
|
||||
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Also returns all documents sent by a person.")
|
||||
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me). Creates and deletes users (requires ADMIN_USER permission).")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel mass import (requires ADMIN permission).")
|
||||
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
|
||||
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead.")
|
||||
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core business logic: store, update, search documents. Resolves persons and tags. Delegates file I/O to FileService. Builds JPA Specifications for dynamic search queries.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths. Downloads with content-type detection (PDF, JPEG, PNG, octet-stream).")
|
||||
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel workbooks (Apache POI). Column indices are configurable via application.properties. Creates/updates document records per row.")
|
||||
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel files from /import mount. Delegates to ExcelService. Runs asynchronously so the HTTP response returns immediately.")
|
||||
Component(userSvc, "UserService", "Spring Service", "User CRUD. Encodes passwords with BCrypt. Assigns users to permission groups.")
|
||||
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data (persons, documents) if DB is empty.")
|
||||
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents. Supports Specification-based dynamic search, conversation thread queries (bidirectional sender/receiver), and filename lookups.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable query predicates: hasText (full-text across title/filename/transcription/location), hasSender, hasReceiver (join), isBetween (date range), hasTags (subquery AND logic).")
|
||||
Component(personRepo, "PersonRepository", "Spring Data JPA", "Lists all persons sorted by last name. Supports name search for typeahead.")
|
||||
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by username. Used by Spring Security and UserService.")
|
||||
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive).")
|
||||
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
|
||||
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client bean with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by username from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
|
||||
}
|
||||
|
||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
||||
Rel(secFilter, permAspect, "Authenticated requests proceed", "")
|
||||
|
||||
Rel(secFilter, docCtrl, "Routes to", "")
|
||||
Rel(secFilter, personCtrl, "Routes to", "")
|
||||
Rel(secFilter, userCtrl, "Routes to", "")
|
||||
Rel(secFilter, adminCtrl, "Routes to", "")
|
||||
|
||||
Rel(permAspect, docCtrl, "Guards", "AOP @Around")
|
||||
Rel(permAspect, userCtrl, "Guards", "AOP @Around")
|
||||
Rel(permAspect, adminCtrl, "Guards", "AOP @Around")
|
||||
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. Called by DocumentService to resolve sender / receiver persons by ID.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "See diagram 3d. Called by DocumentService to find or create tags by name.")
|
||||
|
||||
Rel(frontend, docCtrl, "Document requests", "HTTP / JSON")
|
||||
Rel(frontend, adminCtrl, "Trigger import", "HTTP / JSON")
|
||||
Rel(docCtrl, docSvc, "Delegates to", "")
|
||||
Rel(adminCtrl, massImport, "Triggers", "")
|
||||
Rel(userCtrl, userSvc, "Delegates to", "")
|
||||
|
||||
Rel(docSvc, fileSvc, "Upload / download files", "")
|
||||
Rel(docSvc, docRepo, "Reads / writes documents", "")
|
||||
Rel(docSvc, docSpec, "Builds search predicates", "")
|
||||
Rel(docSvc, personRepo, "Resolves sender / receivers", "")
|
||||
Rel(docSvc, tagRepo, "Finds or creates tags", "")
|
||||
|
||||
Rel(massImport, excelSvc, "Parses Excel file", "")
|
||||
Rel(docSvc, personSvc, "Resolves sender / receivers", "")
|
||||
Rel(docSvc, tagSvc, "Finds or creates tags", "")
|
||||
Rel(massImport, excelSvc, "Parses Excel/ODS file", "")
|
||||
Rel(excelSvc, docSvc, "Creates / updates documents", "")
|
||||
Rel(minioConf, fileSvc, "Provides S3Client and S3Presigner beans", "")
|
||||
Rel(fileSvc, minio, "PUT / GET / presigned URL objects", "S3 API / HTTP")
|
||||
Rel(docRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3c — Document Transcription Pipeline
|
||||
|
||||
Annotation-driven transcription: page markup, text blocks, versioning, and comment threads.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Document Transcription Pipeline
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(transcriptionCtrl, "TranscriptionBlockController", "Spring MVC — /api/transcription", "CRUD for transcription text blocks per document page. Manages sort order, review status, and block version history.")
|
||||
Component(annotationCtrl, "AnnotationController", "Spring MVC — /api/documents/{id}/annotations", "CRUD for free-form page annotations with polygon coordinates, colour coding, and file-hash tracking.")
|
||||
Component(commentCtrl, "CommentController", "Spring MVC — /api/documents/{id}/comments", "Threaded comment CRUD on transcription blocks with @mention support and notification triggers.")
|
||||
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "Creates and updates transcription blocks from annotation regions. Tracks block versions, sanitizes text with an HTML allow-list, and triggers mentions.")
|
||||
Component(transcriptionQueueSvc, "TranscriptionQueueService", "Spring Service", "Assembles segmentation, transcription, and review queue projections by delegating to DocumentService and AuditLogQueryService.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "Manages document page annotations with polygon coordinates. Called by OcrAsyncRunner to persist OCR-generated block boundaries.")
|
||||
Component(commentSvc, "CommentService", "Spring Service", "Creates and manages threaded comments with @mention parsing. Triggers NotificationService for REPLY and MENTION events.")
|
||||
|
||||
Component(blockRepo, "TranscriptionBlockRepository", "Spring Data JPA", "Reads and writes TranscriptionBlock and TranscriptionBlockVersion records.")
|
||||
Component(annotationRepo, "AnnotationRepository", "Spring Data JPA", "Reads and writes DocumentAnnotation records.")
|
||||
Component(commentRepo, "CommentRepository", "Spring Data JPA", "Reads and writes DocumentComment records.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by TranscriptionQueueService to assemble pipeline queue projections.")
|
||||
Component(auditQuerySvc, "AuditLogQueryService", "Spring Service", "See diagram 3g. Called by TranscriptionQueueService for pipeline activity data.")
|
||||
|
||||
Rel(frontend, transcriptionCtrl, "Transcription block requests", "HTTP / JSON")
|
||||
Rel(frontend, annotationCtrl, "Annotation requests", "HTTP / JSON")
|
||||
Rel(frontend, commentCtrl, "Comment requests", "HTTP / JSON")
|
||||
Rel(transcriptionCtrl, transcriptionSvc, "Delegates to", "")
|
||||
Rel(transcriptionCtrl, transcriptionQueueSvc, "Queries pipeline queues", "")
|
||||
Rel(annotationCtrl, annotationSvc, "Delegates to", "")
|
||||
Rel(commentCtrl, commentSvc, "Delegates to", "")
|
||||
Rel(transcriptionSvc, blockRepo, "Reads / writes blocks and versions", "")
|
||||
Rel(annotationSvc, annotationRepo, "Reads / writes annotations", "")
|
||||
Rel(commentSvc, commentRepo, "Reads / writes comments", "")
|
||||
Rel(transcriptionQueueSvc, documentSvc, "Queries pipeline document state", "")
|
||||
Rel(transcriptionQueueSvc, auditQuerySvc, "Queries pipeline activity data", "")
|
||||
Rel(blockRepo, db, "SQL queries", "JDBC")
|
||||
Rel(annotationRepo, db, "SQL queries", "JDBC")
|
||||
Rel(commentRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3d — Users, Groups & Administration
|
||||
|
||||
User lifecycle, permission groups, tag management, and authentication endpoints.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Users, Groups & Administration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me), creates and deletes users (requires ADMIN_USER), supports user search and profile updates.")
|
||||
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
|
||||
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead, supports tag merge, tree structure, and subtree deletion.")
|
||||
Component(inviteCtrl, "InviteController", "Spring MVC — /api/auth/invite", "Creates invite codes and validates them at registration time. Rate-limited via WebConfig interceptor.")
|
||||
Component(authCtrl, "AuthController", "Spring MVC — /api/auth", "Handles user registration (POST /register) and password reset token endpoints (/forgot-password, /reset-password).")
|
||||
|
||||
Component(userSvc, "UserService", "Spring Service", "User CRUD with BCrypt password encoding, group assignment, and audit logging. Orchestrates invite-based registration and password reset tokens.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "Tag CRUD with name search, hierarchical tree structure, merge/reparent operations, and recursive subtree deletion.")
|
||||
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data if DB is empty.")
|
||||
|
||||
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by email. Supports search by email or display name.")
|
||||
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
|
||||
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive). Supports recursive ancestor/descendant CTE queries and merge/reparent helpers.")
|
||||
}
|
||||
|
||||
Rel(frontend, userCtrl, "User requests", "HTTP / JSON")
|
||||
Rel(frontend, groupCtrl, "Group requests", "HTTP / JSON")
|
||||
Rel(frontend, tagCtrl, "Tag requests", "HTTP / JSON")
|
||||
Rel(frontend, inviteCtrl, "Invite validation", "HTTP / JSON")
|
||||
Rel(frontend, authCtrl, "Registration and password reset", "HTTP / JSON")
|
||||
Rel(userCtrl, userSvc, "Delegates to", "")
|
||||
Rel(groupCtrl, userSvc, "Delegates to", "")
|
||||
Rel(tagCtrl, tagSvc, "Delegates to", "")
|
||||
Rel(tagSvc, tagRepo, "Reads / writes tags", "")
|
||||
Rel(inviteCtrl, userSvc, "Creates and validates invites", "")
|
||||
Rel(authCtrl, userSvc, "Registers users, resets passwords", "")
|
||||
Rel(userSvc, userRepo, "Reads / writes users", "")
|
||||
Rel(userSvc, groupRepo, "Assigns groups", "")
|
||||
Rel(userDetails, userRepo, "Loads user by username", "")
|
||||
|
||||
Rel(fileSvc, minio, "PUT / GET objects", "S3 API / HTTP")
|
||||
Rel(docRepo, db, "SQL queries", "JDBC")
|
||||
Rel(personRepo, db, "SQL queries", "JDBC")
|
||||
Rel(userRepo, db, "SQL queries", "JDBC")
|
||||
Rel(tagRepo, db, "SQL queries", "JDBC")
|
||||
Rel(groupRepo, db, "SQL queries", "JDBC")
|
||||
Rel(dataInit, db, "Seeds initial data", "JDBC")
|
||||
Rel(secConf, userDetails, "Wires", "")
|
||||
Rel(minioConf, fileSvc, "Provides S3Client bean", "")
|
||||
Rel(userRepo, db, "SQL queries", "JDBC")
|
||||
Rel(groupRepo, db, "SQL queries", "JDBC")
|
||||
Rel(tagRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3e — Persons & Family Graph
|
||||
|
||||
Person management including family relationship modelling and transitive inference.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Persons & Family Graph
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Returns documents sent by or received by a person, correspondent suggestions, and person summary with document counts.")
|
||||
Component(relCtrl, "RelationshipController", "Spring MVC — /api/network, /api/persons/{id}/relationships", "CRUD for explicit person relationships and the full family network graph (nodes + edges) used by the Stammbaum view.")
|
||||
|
||||
Component(personSvc, "PersonService", "Spring Service", "Person CRUD, alias management, and merge operations (reassigns all document sender/receiver references before deleting duplicate persons).")
|
||||
Component(relSvc, "RelationshipService", "Spring Service", "Manages explicit directional family relationships (PARENT_OF, SPOUSE_OF, SIBLING_OF, etc.) with optional date ranges and notes.")
|
||||
Component(relInference, "RelationshipInferenceService", "Spring Service", "Computes transitive family relationships from explicit edges to infer grandparent/grandchild, aunt/uncle, and other extended-family links for the network graph.")
|
||||
|
||||
Component(personRepo, "PersonRepository", "Spring Data JPA", "Queries persons with name search (including aliases), correspondent discovery, person summaries with document counts, and merge/reassignment helpers.")
|
||||
Component(relRepo, "PersonRelationshipRepository", "Spring Data JPA", "Reads and writes PersonRelationship records. Supports lookup by person ID, by relation type, and existence checks for deduplication.")
|
||||
}
|
||||
|
||||
Rel(frontend, personCtrl, "Person requests", "HTTP / JSON")
|
||||
Rel(frontend, relCtrl, "Relationship and graph requests", "HTTP / JSON")
|
||||
Rel(personCtrl, personSvc, "Delegates to", "")
|
||||
Rel(relCtrl, relSvc, "Delegates to", "")
|
||||
Rel(relCtrl, relInference, "Queries inferred graph", "")
|
||||
Rel(personSvc, personRepo, "Reads / writes persons", "")
|
||||
Rel(relSvc, relRepo, "Reads / writes relationships", "")
|
||||
Rel(relInference, relRepo, "Reads relationships for inference", "")
|
||||
Rel(personRepo, db, "SQL queries", "JDBC")
|
||||
Rel(relRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3f — OCR Orchestration
|
||||
|
||||
How the Spring Boot backend manages OCR jobs, streams results, and trains recognition models.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — OCR Orchestration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
ContainerDb(minio, "MinIO")
|
||||
Container(ocrPy, "OCR Service", "Python FastAPI")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(ocrCtrl, "OcrController", "Spring MVC — /api/ocr", "REST entry point: trigger single or batch OCR jobs, stream progress via SSE, query job status, and manage training runs and per-sender models.")
|
||||
Component(ocrSvc, "OcrService", "Spring Service", "Creates OcrJob and OcrJobDocument records, checks Python service health, and delegates async execution to OcrAsyncRunner.")
|
||||
Component(ocrBatch, "OcrBatchService", "Spring Service", "Orchestrates multi-document OCR jobs, iterating documents and delegating each to OcrAsyncRunner.")
|
||||
Component(ocrAsync, "OcrAsyncRunner", "Spring Component — @Async", "Async worker that streams OCR results from Python page by page, persists transcription blocks and annotations via domain services, and emits progress via SSE.")
|
||||
Component(ocrClient, "RestClientOcrClient", "Spring Component", "HTTP client wrapping the Python service: POST /ocr/stream (NDJSON), /train, /segtrain, and /train-sender. Falls back from streaming to batch on 404.")
|
||||
Component(ocrTraining, "OcrTrainingService", "Spring Service", "Orchestrates model training: exports training data as ZIP, calls Python /train or /segtrain, persists training metrics in OcrTrainingRunRepository.")
|
||||
Component(ocrJobRepo, "OcrJobRepository, OcrJobDocumentRepository", "Spring Data JPA", "Reads and writes OcrJob and OcrJobDocument records. Tracks job status (RUNNING/DONE/FAILED), per-document progress, page counts, and error messages.")
|
||||
}
|
||||
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist transcription blocks per page.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist OCR-generated annotation regions per page.")
|
||||
|
||||
Rel(frontend, ocrCtrl, "OCR trigger, status, and progress requests", "HTTP / JSON / SSE")
|
||||
Rel(ocrCtrl, ocrSvc, "Single-document jobs", "")
|
||||
Rel(ocrCtrl, ocrBatch, "Batch jobs", "")
|
||||
Rel(ocrCtrl, ocrTraining, "Training runs", "")
|
||||
Rel(ocrSvc, ocrAsync, "Delegates async execution", "")
|
||||
Rel(ocrBatch, ocrAsync, "Delegates async execution", "")
|
||||
Rel(ocrAsync, ocrClient, "Streams OCR results page by page", "HTTP / NDJSON")
|
||||
Rel(ocrTraining, ocrClient, "Sends training data ZIP", "HTTP / multipart")
|
||||
Rel(ocrClient, ocrPy, "POST /ocr/stream, /train, /segtrain, /train-sender", "HTTP / REST")
|
||||
Rel(ocrAsync, transcriptionSvc, "Saves transcription blocks per page", "")
|
||||
Rel(ocrAsync, annotationSvc, "Saves annotation regions per page", "")
|
||||
Rel(ocrAsync, ocrJobRepo, "Reads / writes OCR job state", "")
|
||||
Rel(ocrJobRepo, db, "SQL queries", "JDBC")
|
||||
Rel(ocrAsync, minio, "Generates presigned URLs for PDF fetch", "S3 API")
|
||||
Rel(ocrPy, minio, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(ocrTraining, db, "Persists training run metrics", "JDBC")
|
||||
```
|
||||
|
||||
### 3g — Supporting Domains
|
||||
|
||||
Audit logging, dashboard stats, SSE notifications, stories (Geschichten), and cross-cutting exception handling.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Supporting Domains
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(auditSvc, "AuditService", "Spring Service — @Async", "Writes audit log entries asynchronously via a dedicated TaskExecutor, with transaction-aware logging to prevent deadlocks on concurrent saves.")
|
||||
Component(auditQuery, "AuditLogQueryService", "Spring Service", "Queries audit logs for activity feeds, pulse stats, recent contributors, and per-document history. Facade over AuditLogRepository.")
|
||||
|
||||
Component(dashCtrl, "DashboardController", "Spring MVC — /api/dashboard", "REST endpoints for the user dashboard: recent document resume (/resume), weekly transcription pulse stats (/pulse), and activity feed (/activity) with kind filtering and pagination.")
|
||||
Component(statsCtrl, "StatsController", "Spring MVC — /api/stats", "Returns aggregate counts (total persons, total documents) for the UI stats bar.")
|
||||
Component(statsSvc, "StatsService", "Spring Service", "Queries aggregate counts: total persons and total documents.")
|
||||
Component(dashSvc, "DashboardService", "Spring Service", "Assembles the user dashboard: recent document resume (calls DocumentService + TranscriptionService), weekly transcription pulse stats, and activity feed with contributor avatars.")
|
||||
|
||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
|
||||
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by DashboardService to fetch document titles and resume data.")
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by DashboardService to fetch transcription block progress for resume.")
|
||||
|
||||
Rel(frontend, dashCtrl, "Dashboard requests", "HTTP / JSON")
|
||||
Rel(frontend, statsCtrl, "GET /api/stats", "HTTP / JSON")
|
||||
Rel(frontend, notifCtrl, "Notification stream and history", "HTTP / JSON / SSE")
|
||||
Rel(frontend, geschCtrl, "Story requests", "HTTP / JSON")
|
||||
Rel(dashCtrl, dashSvc, "Delegates to", "")
|
||||
Rel(statsCtrl, statsSvc, "Delegates to", "")
|
||||
Rel(statsSvc, db, "Reads aggregate counts", "JDBC")
|
||||
Rel(dashSvc, auditQuery, "Fetches activity feed and pulse stats", "")
|
||||
Rel(dashSvc, documentSvc, "Fetches document titles and resume data", "")
|
||||
Rel(dashSvc, transcriptionSvc, "Fetches transcription block progress for resume", "")
|
||||
Rel(notifCtrl, notifSvc, "Delegates to", "")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection", "")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients", "")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to", "")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
Rel(geschSvc, db, "Reads / writes geschichten", "JDBC")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level 3 — Components: Web Frontend
|
||||
|
||||
The internal structure of the SvelteKit frontend.
|
||||
The internal structure of the SvelteKit frontend, split into four focused views.
|
||||
|
||||
### 3a — Middleware, Auth & Layout
|
||||
|
||||
Per-request middleware: session validation, i18n, auth cookie handling, and auth pages.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend
|
||||
title Component Diagram: Web Frontend — Middleware, Auth & Layout
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
|
||||
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Two responsibilities: (1) userGroup handle — reads auth_token cookie, fetches /api/users/me, stores user in event.locals. (2) handleFetch — intercepts all outgoing fetch() calls, injects Authorization header from cookie. Redirects to /login if token absent.")
|
||||
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Four handle layers: (1) handleAuth — redirects unauthenticated users to /login; (2) userGroup — reads auth_token cookie, fetches /api/users/me, stores user in event.locals; (3) handleFetch — injects Authorization header on all outgoing /api/ calls; (4) handleLocaleDetection — sets language cookie from Accept-Language header.")
|
||||
Component(i18n, "hooks.ts (Paraglide)", "SvelteKit Client Hook", "Client-side i18n middleware. Detects language from URL and sets the active locale for Paraglide.js translation functions.")
|
||||
|
||||
Component(layout, "+layout.server.ts", "SvelteKit Layout Loader", "Passes event.locals.user down to all child pages so every route has access to the authenticated user.")
|
||||
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL search params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons, returns results. Page: renders search form with full-text, date range, sender/receiver typeahead, tag filters. Displays paginated document list.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: fetches /api/documents/{id}. Handles 401 redirect to login, 404 error. Page: shows document metadata, file viewer (PDF/image inline), transcription, tags.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Form with PersonTypeahead for sender/receiver, TagInput for tags, date/location fields. Submits PUT to /api/documents/{id}.")
|
||||
Component(persons, "/persons and /persons/[id]", "SvelteKit Routes", "Lists all persons. Detail page shows person metadata and all documents they sent.")
|
||||
Component(conversations, "/conversations", "SvelteKit Route", "Selects two persons via PersonTypeahead, fetches /api/documents/conversation, displays chronological exchange.")
|
||||
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes username:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
|
||||
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes email:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
|
||||
Component(logoutPage, "/logout", "SvelteKit Route (server-only)", "Clears the auth_token cookie and redirects to /login.")
|
||||
Component(adminPage, "/admin", "SvelteKit Route", "User management UI (create/delete users). Excel import trigger button (calls /api/admin/trigger-import).")
|
||||
|
||||
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend. Used by PersonTypeahead for typeahead suggestions.")
|
||||
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend. Used by TagInput for autocomplete.")
|
||||
|
||||
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
|
||||
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
|
||||
Component(registerPage, "/register", "SvelteKit Route", "Loader validates invite code via GET /api/auth/invite/{code}. Form action: POST /api/auth/register to create the user account.")
|
||||
Component(forgotPw, "/forgot-password", "SvelteKit Route", "Form action: POST /api/auth/forgot-password. Always responds with success to prevent email enumeration.")
|
||||
Component(resetPw, "/reset-password", "SvelteKit Route", "Form action: POST /api/auth/reset-password with the token from the query string.")
|
||||
}
|
||||
|
||||
Rel(user, hooks, "Every browser request", "HTTPS")
|
||||
Rel(hooks, backend, "GET /api/users/me (session check)", "HTTP / Basic Auth")
|
||||
Rel(hooks, loginPage, "Redirect if no token", "")
|
||||
|
||||
Rel(layout, homePage, "Provides user context", "")
|
||||
Rel(layout, docDetail, "Provides user context", "")
|
||||
Rel(layout, adminPage, "Provides user context", "")
|
||||
|
||||
Rel(homePage, backend, "GET /api/documents/search", "HTTP / JSON")
|
||||
Rel(homePage, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}", "HTTP / JSON")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}/file", "HTTP / Binary stream")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
Rel(conversations, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(hooks, layout, "Stores authenticated user in event.locals", "")
|
||||
Rel(loginPage, backend, "POST /api/users/me (auth check)", "HTTP / Basic Auth")
|
||||
Rel(adminPage, backend, "GET/POST/DELETE /api/users", "HTTP / JSON")
|
||||
Rel(adminPage, backend, "POST /api/admin/trigger-import", "HTTP / JSON")
|
||||
Rel(registerPage, backend, "GET /api/auth/invite/{code}, POST /api/auth/register", "HTTP / JSON")
|
||||
Rel(forgotPw, backend, "POST /api/auth/forgot-password", "HTTP / JSON")
|
||||
Rel(resetPw, backend, "POST /api/auth/reset-password", "HTTP / JSON")
|
||||
```
|
||||
|
||||
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
|
||||
### 3b — Document Workflows
|
||||
|
||||
Document search, viewing, editing, enrichment, and the shared components that support them.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend — Document Workflows
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||
Component(docBulkEdit, "/documents/bulk-edit", "SvelteKit Route", "Multi-document metadata editor. Loader: GET /api/documents/incomplete. Requires WRITE_ALL (redirects otherwise). Action: PATCH /api/documents/bulk.")
|
||||
Component(enrichPage, "/enrich/[id]", "SvelteKit Route", "Guided enrichment workflow. Loader: GET /api/documents/{id}. Progressively saves annotations and transcription blocks.")
|
||||
|
||||
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend for PersonTypeahead suggestions.")
|
||||
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend for TagInput autocomplete.")
|
||||
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
|
||||
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
|
||||
}
|
||||
|
||||
Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
Rel(docNew, backend, "GET /api/persons, POST /api/documents", "HTTP / JSON + Multipart")
|
||||
Rel(docBulkEdit, backend, "GET /api/documents/incomplete, PATCH /api/documents/bulk", "HTTP / JSON")
|
||||
Rel(enrichPage, backend, "GET/POST /api/transcription, POST /api/documents/{id}/annotations", "HTTP / JSON")
|
||||
Rel(homePage, typeahead, "Uses for sender/receiver filter", "")
|
||||
Rel(docEdit, typeahead, "Uses for sender/receiver selection", "")
|
||||
Rel(docNew, typeahead, "Uses for sender selection", "")
|
||||
Rel(docEdit, tagInput, "Uses for tag management", "")
|
||||
Rel(typeahead, apiPersons, "Fetches suggestions", "HTTP")
|
||||
Rel(tagInput, apiTags, "Fetches existing tags", "HTTP")
|
||||
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
|
||||
```
|
||||
|
||||
### 3c — People, Stories & Discovery
|
||||
|
||||
Person directory, bilateral conversations, activity feed, stories, family tree, and user profiles.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend — People, Stories & Discovery
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||
}
|
||||
|
||||
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
|
||||
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||
```
|
||||
|
||||
### 3d — Administration & Help
|
||||
|
||||
Admin panel sub-routes and the transcription help guide.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend — Administration & Help
|
||||
|
||||
Person(admin, "Administrator")
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(adminUsers, "/admin/users, /admin/users/[id], /admin/users/new, /admin/invites", "SvelteKit Routes", "User directory, create/update/delete users, and manage invite codes. Requires ADMIN_USER permission.")
|
||||
Component(adminGroups, "/admin/groups, /admin/groups/[id], /admin/groups/new", "SvelteKit Routes", "Permission group management: create/edit groups and their permission sets.")
|
||||
Component(adminTags, "/admin/tags and /admin/tags/[id]", "SvelteKit Routes", "Tag administration: edit tag hierarchy, merge tags, delete subtrees.")
|
||||
Component(adminOcr, "/admin/ocr and /admin/ocr/[personId]", "SvelteKit Routes", "Global and per-person OCR configuration. Manages script types and triggers sender model training.")
|
||||
Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers Excel/ODS mass import (POST /api/admin/trigger-import). Displays import state.")
|
||||
Component(hilfe, "/hilfe/transkription", "SvelteKit Route", "Static transcription style guide for Kurrent and Sütterlin character recognition. No backend calls.")
|
||||
}
|
||||
|
||||
Rel(admin, adminUsers, "Manages users and invites", "HTTPS / Browser")
|
||||
Rel(user, hilfe, "Views transcription style guide", "HTTPS / Browser")
|
||||
Rel(adminUsers, backend, "GET/POST/DELETE /api/users, POST /api/auth/invite", "HTTP / JSON")
|
||||
Rel(adminGroups, backend, "GET/POST/PUT/DELETE /api/groups", "HTTP / JSON")
|
||||
Rel(adminTags, backend, "GET/PUT/DELETE /api/tags", "HTTP / JSON")
|
||||
Rel(adminOcr, backend, "GET/POST /api/ocr (global config and sender training)", "HTTP / JSON")
|
||||
Rel(adminSystem, backend, "POST /api/admin/trigger-import, GET /api/admin/import-status", "HTTP / JSON")
|
||||
```
|
||||
|
||||
---
|
||||
@@ -218,12 +519,12 @@ sequenceDiagram
|
||||
participant Backend as Backend (Spring Boot)
|
||||
participant DB as PostgreSQL
|
||||
|
||||
User->>Browser: Enter username + password
|
||||
User->>Browser: Enter email + password
|
||||
Browser->>Frontend: POST /login (form action)
|
||||
Frontend->>Frontend: Base64 encode "user:password"
|
||||
Frontend->>Frontend: Base64 encode "email:password"
|
||||
Frontend->>Backend: GET /api/users/me<br/>Authorization: Basic <token>
|
||||
Backend->>Backend: Spring Security parses Basic Auth
|
||||
Backend->>DB: SELECT user WHERE username=?
|
||||
Backend->>DB: SELECT user WHERE email=?
|
||||
DB-->>Backend: AppUser + groups + permissions
|
||||
Backend->>Backend: BCrypt.matches(password, hash)
|
||||
Backend-->>Frontend: 200 OK — UserDTO
|
||||
@@ -271,3 +572,14 @@ sequenceDiagram
|
||||
Backend-->>Frontend: 200 OK — Document JSON
|
||||
Frontend-->>User: Refreshed document view
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
Entity-relationship and full column reference for the PostgreSQL schema (30 tables, 7 domain groups). Source files in `docs/architecture/db/`.
|
||||
|
||||
- **[db-relationships.puml](db/db-relationships.puml)** — Entity relationships: all tables and foreign-key connections, grouped by domain. Start here for an overview.
|
||||
- **[db-orm.puml](db/db-orm.puml)** — Full schema reference: all columns and types for all 30 tables. Use this when mapping Java entities to database columns.
|
||||
|
||||
> Schema as of Flyway V60 (2026-05-06). Open in VS Code with the PlantUML extension (server: `http://heim-nas:8500`).
|
||||
|
||||
39
docs/architecture/c4/README.md
Normal file
39
docs/architecture/c4/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# C4-PlantUML Diagrams
|
||||
|
||||
Architecture diagrams in C4-PlantUML format. These are the authoritative source for layout-accurate diagrams. The companion `c4-diagrams.md` in the parent directory keeps Mermaid versions for inline Gitea rendering.
|
||||
|
||||
## Render in Gitea
|
||||
|
||||
Gitea is configured to render `.puml` files as diagrams. Open any `.puml` file in the Gitea UI to see the rendered diagram.
|
||||
|
||||
> **Note:** `plantuml` code fences inside Markdown files do **not** render inline in Gitea — this is a Gitea limitation unrelated to the server configuration. The `.md` files in this repo use Mermaid for that reason.
|
||||
|
||||
## Render in VS Code
|
||||
|
||||
Install the [PlantUML extension](https://marketplace.visualstudio.com/items?itemName=jebbs.plantuml) (`jebbs.plantuml`). The project's `.vscode/settings.json` already points at the shared server:
|
||||
|
||||
```
|
||||
plantuml.server = http://heim-nas:8500
|
||||
```
|
||||
|
||||
Open any `.puml` file and press `Alt+D` to preview.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Diagram |
|
||||
|---|---|
|
||||
| `l1-context.puml` | Level 1 — System Context |
|
||||
| `l2-containers.puml` | Level 2 — Containers |
|
||||
| `l3-backend-3a-security.puml` | L3 Backend: Security & Authentication |
|
||||
| `l3-backend-3b-document-management.puml` | L3 Backend: Document Management & Import |
|
||||
| `l3-backend-3c-transcription.puml` | L3 Backend: Document Transcription Pipeline |
|
||||
| `l3-backend-3d-users-groups.puml` | L3 Backend: Users, Groups & Administration |
|
||||
| `l3-backend-3e-persons.puml` | L3 Backend: Persons & Family Graph |
|
||||
| `l3-backend-3f-ocr.puml` | L3 Backend: OCR Orchestration |
|
||||
| `l3-backend-3g-supporting.puml` | L3 Backend: Supporting Domains |
|
||||
| `l3-frontend-3a-middleware-auth.puml` | L3 Frontend: Middleware, Auth & Layout |
|
||||
| `l3-frontend-3b-document-workflows.puml` | L3 Frontend: Document Workflows |
|
||||
| `l3-frontend-3c-people-stories.puml` | L3 Frontend: People, Stories & Discovery |
|
||||
| `l3-frontend-3d-administration.puml` | L3 Frontend: Administration & Help |
|
||||
| `seq-auth-flow.puml` | Sequence: Authentication Flow |
|
||||
| `seq-document-upload.puml` | Sequence: Document Upload Flow |
|
||||
16
docs/architecture/c4/l1-context.puml
Normal file
16
docs/architecture/c4/l1-context.puml
Normal file
@@ -0,0 +1,16 @@
|
||||
@startuml
|
||||
!include <C4/C4_Context>
|
||||
|
||||
title System Context: Familienarchiv
|
||||
|
||||
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews and transcribes documents")
|
||||
Person(member, "Family Member", "Access by administrator invite. Searches, browses, reads, and transcribes archived documents.")
|
||||
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
|
||||
@enduml
|
||||
28
docs/architecture/c4/l2-containers.puml
Normal file
28
docs/architecture/c4/l2-containers.puml
Normal file
@@ -0,0 +1,28 @@
|
||||
@startuml
|
||||
!include <C4/C4_Container>
|
||||
|
||||
title Container Diagram: Familienarchiv
|
||||
|
||||
Person(user, "User", "Admin or family member")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification and password-reset emails.")
|
||||
|
||||
System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
|
||||
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications.")
|
||||
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
|
||||
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
|
||||
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Objects keyed as documents/{UUID}_{filename}.")
|
||||
Container(mc, "Bucket Init Helper", "MinIO Client (mc)", "One-shot container on startup. Creates the archive bucket with private access policy.")
|
||||
}
|
||||
|
||||
Rel(user, frontend, "Uses", "HTTPS / Browser")
|
||||
Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON")
|
||||
Rel(backend, user, "SSE notifications (server-sent events)", "HTTP / SSE — direct backend-to-browser")
|
||||
Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL")
|
||||
Rel(backend, storage, "Uploads and streams document files", "HTTP / S3 API (AWS SDK v2)")
|
||||
Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JSON")
|
||||
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI")
|
||||
|
||||
@enduml
|
||||
21
docs/architecture/c4/l3-backend-3a-security.puml
Normal file
21
docs/architecture/c4/l3-backend-3a-security.puml
Normal file
@@ -0,0 +1,21 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Security & Authentication
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
|
||||
}
|
||||
|
||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||
Rel(userDetails, db, "Loads user by email", "JDBC")
|
||||
|
||||
@enduml
|
||||
40
docs/architecture/c4/l3-backend-3b-document-management.puml
Normal file
40
docs/architecture/c4/l3-backend-3b-document-management.puml
Normal file
@@ -0,0 +1,40 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Document Management & Import
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
|
||||
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel/ODS files from /import mount. Tracks import state (IDLE/RUNNING/DONE/FAILED) and delegates to ExcelService. Returns immediately; processing runs asynchronously.")
|
||||
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel/ODS workbooks (Apache POI). Column indices configurable via application.properties. Creates/updates document records per row.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
|
||||
}
|
||||
|
||||
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. Called by DocumentService to resolve sender / receiver persons by ID.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "See diagram 3d. Called by DocumentService to find or create tags by name.")
|
||||
|
||||
Rel(frontend, docCtrl, "Document requests", "HTTP / JSON")
|
||||
Rel(frontend, adminCtrl, "Trigger import", "HTTP / JSON")
|
||||
Rel(docCtrl, docSvc, "Delegates to")
|
||||
Rel(adminCtrl, massImport, "Triggers")
|
||||
Rel(docSvc, fileSvc, "Upload / download files")
|
||||
Rel(docSvc, docRepo, "Reads / writes documents")
|
||||
Rel(docSvc, docSpec, "Builds search predicates")
|
||||
Rel(docSvc, personSvc, "Resolves sender / receivers")
|
||||
Rel(docSvc, tagSvc, "Finds or creates tags")
|
||||
Rel(massImport, excelSvc, "Parses Excel/ODS file")
|
||||
Rel(excelSvc, docSvc, "Creates / updates documents")
|
||||
Rel(minioConf, fileSvc, "Provides S3Client and S3Presigner beans")
|
||||
Rel(fileSvc, minio, "PUT / GET / presigned URL objects", "S3 API / HTTP")
|
||||
Rel(docRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
41
docs/architecture/c4/l3-backend-3c-transcription.puml
Normal file
41
docs/architecture/c4/l3-backend-3c-transcription.puml
Normal file
@@ -0,0 +1,41 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Document Transcription Pipeline
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(transcriptionCtrl, "TranscriptionBlockController", "Spring MVC — /api/transcription", "CRUD for transcription text blocks per document page. Manages sort order, review status, and block version history.")
|
||||
Component(annotationCtrl, "AnnotationController", "Spring MVC — /api/documents/{id}/annotations", "CRUD for free-form page annotations with polygon coordinates, colour coding, and file-hash tracking.")
|
||||
Component(commentCtrl, "CommentController", "Spring MVC — /api/documents/{id}/comments", "Threaded comment CRUD on transcription blocks with @mention support and notification triggers.")
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "Creates and updates transcription blocks from annotation regions. Tracks block versions, sanitizes text with an HTML allow-list, and triggers mentions.")
|
||||
Component(transcriptionQueueSvc, "TranscriptionQueueService", "Spring Service", "Assembles segmentation, transcription, and review queue projections by delegating to DocumentService and AuditLogQueryService.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "Manages document page annotations with polygon coordinates. Called by OcrAsyncRunner to persist OCR-generated block boundaries.")
|
||||
Component(commentSvc, "CommentService", "Spring Service", "Creates and manages threaded comments with @mention parsing. Triggers NotificationService for REPLY and MENTION events.")
|
||||
Component(blockRepo, "TranscriptionBlockRepository", "Spring Data JPA", "Reads and writes TranscriptionBlock and TranscriptionBlockVersion records.")
|
||||
Component(annotationRepo, "AnnotationRepository", "Spring Data JPA", "Reads and writes DocumentAnnotation records.")
|
||||
Component(commentRepo, "CommentRepository", "Spring Data JPA", "Reads and writes DocumentComment records.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by TranscriptionQueueService to assemble pipeline queue projections.")
|
||||
Component(auditQuerySvc, "AuditLogQueryService", "Spring Service", "See diagram 3g. Called by TranscriptionQueueService for pipeline activity data.")
|
||||
|
||||
Rel(frontend, transcriptionCtrl, "Transcription block requests", "HTTP / JSON")
|
||||
Rel(frontend, annotationCtrl, "Annotation requests", "HTTP / JSON")
|
||||
Rel(frontend, commentCtrl, "Comment requests", "HTTP / JSON")
|
||||
Rel(transcriptionCtrl, transcriptionSvc, "Delegates to")
|
||||
Rel(transcriptionCtrl, transcriptionQueueSvc, "Queries pipeline queues")
|
||||
Rel(annotationCtrl, annotationSvc, "Delegates to")
|
||||
Rel(commentCtrl, commentSvc, "Delegates to")
|
||||
Rel(transcriptionSvc, blockRepo, "Reads / writes blocks and versions")
|
||||
Rel(annotationSvc, annotationRepo, "Reads / writes annotations")
|
||||
Rel(commentSvc, commentRepo, "Reads / writes comments")
|
||||
Rel(transcriptionQueueSvc, documentSvc, "Queries pipeline document state")
|
||||
Rel(transcriptionQueueSvc, auditQuerySvc, "Queries pipeline activity data")
|
||||
Rel(blockRepo, db, "SQL queries", "JDBC")
|
||||
Rel(annotationRepo, db, "SQL queries", "JDBC")
|
||||
Rel(commentRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
41
docs/architecture/c4/l3-backend-3d-users-groups.puml
Normal file
41
docs/architecture/c4/l3-backend-3d-users-groups.puml
Normal file
@@ -0,0 +1,41 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Users, Groups & Administration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me), creates and deletes users (requires ADMIN_USER), supports user search and profile updates.")
|
||||
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
|
||||
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead, supports tag merge, tree structure, and subtree deletion.")
|
||||
Component(inviteCtrl, "InviteController", "Spring MVC — /api/auth/invite", "Creates invite codes and validates them at registration time. Rate-limited via WebConfig interceptor.")
|
||||
Component(authCtrl, "AuthController", "Spring MVC — /api/auth", "Handles user registration (POST /register) and password reset token endpoints (/forgot-password, /reset-password).")
|
||||
Component(userSvc, "UserService", "Spring Service", "User CRUD with BCrypt password encoding, group assignment, and audit logging. Orchestrates invite-based registration and password reset tokens.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "Tag CRUD with name search, hierarchical tree structure, merge/reparent operations, and recursive subtree deletion.")
|
||||
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data if DB is empty.")
|
||||
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by email. Supports search by email or display name.")
|
||||
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
|
||||
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive). Supports recursive ancestor/descendant CTE queries and merge/reparent helpers.")
|
||||
}
|
||||
|
||||
Rel(frontend, userCtrl, "User requests", "HTTP / JSON")
|
||||
Rel(frontend, groupCtrl, "Group requests", "HTTP / JSON")
|
||||
Rel(frontend, tagCtrl, "Tag requests", "HTTP / JSON")
|
||||
Rel(frontend, inviteCtrl, "Invite validation", "HTTP / JSON")
|
||||
Rel(frontend, authCtrl, "Registration and password reset", "HTTP / JSON")
|
||||
Rel(userCtrl, userSvc, "Delegates to")
|
||||
Rel(groupCtrl, userSvc, "Delegates to")
|
||||
Rel(tagCtrl, tagSvc, "Delegates to")
|
||||
Rel(tagSvc, tagRepo, "Reads / writes tags")
|
||||
Rel(inviteCtrl, userSvc, "Creates and validates invites")
|
||||
Rel(authCtrl, userSvc, "Registers users, resets passwords")
|
||||
Rel(userSvc, userRepo, "Reads / writes users")
|
||||
Rel(userSvc, groupRepo, "Assigns groups")
|
||||
Rel(dataInit, db, "Seeds initial data", "JDBC")
|
||||
Rel(userRepo, db, "SQL queries", "JDBC")
|
||||
Rel(groupRepo, db, "SQL queries", "JDBC")
|
||||
Rel(tagRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
30
docs/architecture/c4/l3-backend-3e-persons.puml
Normal file
30
docs/architecture/c4/l3-backend-3e-persons.puml
Normal file
@@ -0,0 +1,30 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Persons & Family Graph
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Returns documents sent by or received by a person, correspondent suggestions, and person summary with document counts.")
|
||||
Component(relCtrl, "RelationshipController", "Spring MVC — /api/network, /api/persons/{id}/relationships", "CRUD for explicit person relationships and the full family network graph (nodes + edges) used by the Stammbaum view.")
|
||||
Component(personSvc, "PersonService", "Spring Service", "Person CRUD, alias management, and merge operations (reassigns all document sender/receiver references before deleting duplicate persons).")
|
||||
Component(relSvc, "RelationshipService", "Spring Service", "Manages explicit directional family relationships (PARENT_OF, SPOUSE_OF, SIBLING_OF, etc.) with optional date ranges and notes.")
|
||||
Component(relInference, "RelationshipInferenceService", "Spring Service", "Computes transitive family relationships from explicit edges to infer grandparent/grandchild, aunt/uncle, and other extended-family links for the network graph.")
|
||||
Component(personRepo, "PersonRepository", "Spring Data JPA", "Queries persons with name search (including aliases), correspondent discovery, person summaries with document counts, and merge/reassignment helpers.")
|
||||
Component(relRepo, "PersonRelationshipRepository", "Spring Data JPA", "Reads and writes PersonRelationship records. Supports lookup by person ID, by relation type, and existence checks for deduplication.")
|
||||
}
|
||||
|
||||
Rel(frontend, personCtrl, "Person requests", "HTTP / JSON")
|
||||
Rel(frontend, relCtrl, "Relationship and graph requests", "HTTP / JSON")
|
||||
Rel(personCtrl, personSvc, "Delegates to")
|
||||
Rel(relCtrl, relSvc, "Delegates to")
|
||||
Rel(relCtrl, relInference, "Queries inferred graph")
|
||||
Rel(personSvc, personRepo, "Reads / writes persons")
|
||||
Rel(relSvc, relRepo, "Reads / writes relationships")
|
||||
Rel(relInference, relRepo, "Reads relationships for inference")
|
||||
Rel(personRepo, db, "SQL queries", "JDBC")
|
||||
Rel(relRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
41
docs/architecture/c4/l3-backend-3f-ocr.puml
Normal file
41
docs/architecture/c4/l3-backend-3f-ocr.puml
Normal file
@@ -0,0 +1,41 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — OCR Orchestration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
|
||||
Container(ocrPy, "OCR Service", "Python FastAPI")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(ocrCtrl, "OcrController", "Spring MVC — /api/ocr", "REST entry point: trigger single or batch OCR jobs, stream progress via SSE, query job status, and manage training runs and per-sender models.")
|
||||
Component(ocrSvc, "OcrService", "Spring Service", "Creates OcrJob and OcrJobDocument records, checks Python service health, and delegates async execution to OcrAsyncRunner.")
|
||||
Component(ocrBatch, "OcrBatchService", "Spring Service", "Orchestrates multi-document OCR jobs, iterating documents and delegating each to OcrAsyncRunner.")
|
||||
Component(ocrAsync, "OcrAsyncRunner", "Spring Component — @Async", "Async worker that streams OCR results from Python page by page, persists transcription blocks and annotations via domain services, and emits progress via SSE.")
|
||||
Component(ocrClient, "RestClientOcrClient", "Spring Component", "HTTP client wrapping the Python service: POST /ocr/stream (NDJSON), /train, /segtrain, and /train-sender. Falls back from streaming to batch on 404.")
|
||||
Component(ocrTraining, "OcrTrainingService", "Spring Service", "Orchestrates model training: exports training data as ZIP, calls Python /train or /segtrain, persists training metrics in OcrTrainingRunRepository.")
|
||||
Component(ocrJobRepo, "OcrJobRepository, OcrJobDocumentRepository", "Spring Data JPA", "Reads and writes OcrJob and OcrJobDocument records. Tracks job status (RUNNING/DONE/FAILED), per-document progress, page counts, and error messages.")
|
||||
}
|
||||
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist transcription blocks per page.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist OCR-generated annotation regions per page.")
|
||||
|
||||
Rel(frontend, ocrCtrl, "OCR trigger, status, and progress requests", "HTTP / JSON / SSE")
|
||||
Rel(ocrCtrl, ocrSvc, "Single-document jobs")
|
||||
Rel(ocrCtrl, ocrBatch, "Batch jobs")
|
||||
Rel(ocrCtrl, ocrTraining, "Training runs")
|
||||
Rel(ocrSvc, ocrAsync, "Delegates async execution")
|
||||
Rel(ocrBatch, ocrAsync, "Delegates async execution")
|
||||
Rel(ocrAsync, ocrClient, "Streams OCR results page by page", "HTTP / NDJSON")
|
||||
Rel(ocrTraining, ocrClient, "Sends training data ZIP", "HTTP / multipart")
|
||||
Rel(ocrClient, ocrPy, "POST /ocr/stream, /train, /segtrain, /train-sender", "HTTP / REST")
|
||||
Rel(ocrAsync, transcriptionSvc, "Saves transcription blocks per page")
|
||||
Rel(ocrAsync, annotationSvc, "Saves annotation regions per page")
|
||||
Rel(ocrAsync, ocrJobRepo, "Reads / writes OCR job state")
|
||||
Rel(ocrJobRepo, db, "SQL queries", "JDBC")
|
||||
Rel(ocrAsync, minio, "Generates presigned URLs for PDF fetch", "S3 API")
|
||||
Rel(ocrPy, minio, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(ocrTraining, db, "Persists training run metrics", "JDBC")
|
||||
|
||||
@enduml
|
||||
46
docs/architecture/c4/l3-backend-3g-supporting.puml
Normal file
46
docs/architecture/c4/l3-backend-3g-supporting.puml
Normal file
@@ -0,0 +1,46 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Supporting Domains
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(auditSvc, "AuditService", "Spring Service — @Async", "Writes audit log entries asynchronously via a dedicated TaskExecutor, with transaction-aware logging to prevent deadlocks on concurrent saves.")
|
||||
Component(auditQuery, "AuditLogQueryService", "Spring Service", "Queries audit logs for activity feeds, pulse stats, recent contributors, and per-document history. Facade over AuditLogRepository.")
|
||||
Component(dashCtrl, "DashboardController", "Spring MVC — /api/dashboard", "REST endpoints for the user dashboard: recent document resume (/resume), weekly transcription pulse stats (/pulse), and activity feed (/activity) with kind filtering and pagination.")
|
||||
Component(statsCtrl, "StatsController", "Spring MVC — /api/stats", "Returns aggregate counts (total persons, total documents) for the UI stats bar.")
|
||||
Component(statsSvc, "StatsService", "Spring Service", "Queries aggregate counts: total persons and total documents.")
|
||||
Component(dashSvc, "DashboardService", "Spring Service", "Assembles the user dashboard: recent document resume (calls DocumentService + TranscriptionService), weekly transcription pulse stats, and activity feed with contributor avatars.")
|
||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by DashboardService to fetch document titles and resume data.")
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by DashboardService to fetch transcription block progress for resume.")
|
||||
|
||||
Rel(frontend, dashCtrl, "Dashboard requests", "HTTP / JSON")
|
||||
Rel(frontend, statsCtrl, "GET /api/stats", "HTTP / JSON")
|
||||
Rel(frontend, notifCtrl, "Notification stream and history", "HTTP / JSON / SSE")
|
||||
Rel(frontend, geschCtrl, "Story requests", "HTTP / JSON")
|
||||
Rel(dashCtrl, dashSvc, "Delegates to")
|
||||
Rel(statsCtrl, statsSvc, "Delegates to")
|
||||
Rel(statsSvc, db, "Reads aggregate counts", "JDBC")
|
||||
Rel(dashSvc, auditQuery, "Fetches activity feed and pulse stats")
|
||||
Rel(dashSvc, documentSvc, "Fetches document titles and resume data")
|
||||
Rel(dashSvc, transcriptionSvc, "Fetches transcription block progress for resume")
|
||||
Rel(notifCtrl, notifSvc, "Delegates to")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
Rel(geschSvc, db, "Reads / writes geschichten", "JDBC")
|
||||
|
||||
@enduml
|
||||
29
docs/architecture/c4/l3-frontend-3a-middleware-auth.puml
Normal file
29
docs/architecture/c4/l3-frontend-3a-middleware-auth.puml
Normal file
@@ -0,0 +1,29 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — Middleware, Auth & Layout
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Four handle layers: (1) handleAuth — redirects unauthenticated users to /login; (2) userGroup — reads auth_token cookie, fetches /api/users/me, stores user in event.locals; (3) handleFetch — injects Authorization header on all outgoing /api/ calls; (4) handleLocaleDetection — sets language cookie from Accept-Language header.")
|
||||
Component(i18n, "hooks.ts (Paraglide)", "SvelteKit Client Hook", "Client-side i18n middleware. Detects language from URL and sets the active locale for Paraglide.js translation functions.")
|
||||
Component(layout, "+layout.server.ts", "SvelteKit Layout Loader", "Passes event.locals.user down to all child pages so every route has access to the authenticated user.")
|
||||
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes email:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
|
||||
Component(logoutPage, "/logout", "SvelteKit Route (server-only)", "Clears the auth_token cookie and redirects to /login.")
|
||||
Component(registerPage, "/register", "SvelteKit Route", "Loader validates invite code via GET /api/auth/invite/{code}. Form action: POST /api/auth/register to create the user account.")
|
||||
Component(forgotPw, "/forgot-password", "SvelteKit Route", "Form action: POST /api/auth/forgot-password. Always responds with success to prevent email enumeration.")
|
||||
Component(resetPw, "/reset-password", "SvelteKit Route", "Form action: POST /api/auth/reset-password with the token from the query string.")
|
||||
}
|
||||
|
||||
Rel(user, hooks, "Every browser request", "HTTPS")
|
||||
Rel(hooks, backend, "GET /api/users/me (session check)", "HTTP / Basic Auth")
|
||||
Rel(hooks, loginPage, "Redirect if no token")
|
||||
Rel(hooks, layout, "Stores authenticated user in event.locals")
|
||||
Rel(loginPage, backend, "POST /api/users/me (auth check)", "HTTP / Basic Auth")
|
||||
Rel(registerPage, backend, "GET /api/auth/invite/{code}, POST /api/auth/register", "HTTP / JSON")
|
||||
Rel(forgotPw, backend, "POST /api/auth/forgot-password", "HTTP / JSON")
|
||||
Rel(resetPw, backend, "POST /api/auth/reset-password", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
43
docs/architecture/c4/l3-frontend-3b-document-workflows.puml
Normal file
43
docs/architecture/c4/l3-frontend-3b-document-workflows.puml
Normal file
@@ -0,0 +1,43 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — Document Workflows
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
||||
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||
Component(docBulkEdit, "/documents/bulk-edit", "SvelteKit Route", "Multi-document metadata editor. Loader: GET /api/documents/incomplete. Requires WRITE_ALL (redirects otherwise). Action: PATCH /api/documents/bulk.")
|
||||
Component(enrichPage, "/enrich/[id]", "SvelteKit Route", "Guided enrichment workflow. Loader: GET /api/documents/{id}. Progressively saves annotations and transcription blocks.")
|
||||
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend for PersonTypeahead suggestions.")
|
||||
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend for TagInput autocomplete.")
|
||||
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
|
||||
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
|
||||
}
|
||||
|
||||
Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
||||
Rel(homePage, timelineFilter, "Mounts above the result list")
|
||||
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
Rel(docNew, backend, "GET /api/persons, POST /api/documents", "HTTP / JSON + Multipart")
|
||||
Rel(docBulkEdit, backend, "GET /api/documents/incomplete, PATCH /api/documents/bulk", "HTTP / JSON")
|
||||
Rel(enrichPage, backend, "GET/POST /api/transcription, POST /api/documents/{id}/annotations", "HTTP / JSON")
|
||||
Rel(homePage, typeahead, "Uses for sender/receiver filter")
|
||||
Rel(docEdit, typeahead, "Uses for sender/receiver selection")
|
||||
Rel(docNew, typeahead, "Uses for sender selection")
|
||||
Rel(docEdit, tagInput, "Uses for tag management")
|
||||
Rel(typeahead, apiPersons, "Fetches suggestions", "HTTP")
|
||||
Rel(tagInput, apiTags, "Fetches existing tags", "HTTP")
|
||||
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
32
docs/architecture/c4/l3-frontend-3c-people-stories.puml
Normal file
32
docs/architecture/c4/l3-frontend-3c-people-stories.puml
Normal file
@@ -0,0 +1,32 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — People, Stories & Discovery
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||
}
|
||||
|
||||
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
|
||||
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
27
docs/architecture/c4/l3-frontend-3d-administration.puml
Normal file
27
docs/architecture/c4/l3-frontend-3d-administration.puml
Normal file
@@ -0,0 +1,27 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — Administration & Help
|
||||
|
||||
Person(admin, "Administrator")
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(adminUsers, "/admin/users, /admin/users/[id], /admin/users/new, /admin/invites", "SvelteKit Routes", "User directory, create/update/delete users, and manage invite codes. Requires ADMIN_USER permission.")
|
||||
Component(adminGroups, "/admin/groups, /admin/groups/[id], /admin/groups/new", "SvelteKit Routes", "Permission group management: create/edit groups and their permission sets.")
|
||||
Component(adminTags, "/admin/tags and /admin/tags/[id]", "SvelteKit Routes", "Tag administration: edit tag hierarchy, merge tags, delete subtrees.")
|
||||
Component(adminOcr, "/admin/ocr and /admin/ocr/[personId]", "SvelteKit Routes", "Global and per-person OCR configuration. Manages script types and triggers sender model training.")
|
||||
Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers Excel/ODS mass import (POST /api/admin/trigger-import). Displays import state.")
|
||||
Component(hilfe, "/hilfe/transkription", "SvelteKit Route", "Static transcription style guide for Kurrent and Sütterlin character recognition. No backend calls.")
|
||||
}
|
||||
|
||||
Rel(admin, adminUsers, "Manages users and invites", "HTTPS / Browser")
|
||||
Rel(user, hilfe, "Views transcription style guide", "HTTPS / Browser")
|
||||
Rel(adminUsers, backend, "GET/POST/DELETE /api/users, POST /api/auth/invite", "HTTP / JSON")
|
||||
Rel(adminGroups, backend, "GET/POST/PUT/DELETE /api/groups", "HTTP / JSON")
|
||||
Rel(adminTags, backend, "GET/PUT/DELETE /api/tags", "HTTP / JSON")
|
||||
Rel(adminOcr, backend, "GET/POST /api/ocr (global config and sender training)", "HTTP / JSON")
|
||||
Rel(adminSystem, backend, "POST /api/admin/trigger-import, GET /api/admin/import-status", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
26
docs/architecture/c4/seq-auth-flow.puml
Normal file
26
docs/architecture/c4/seq-auth-flow.puml
Normal file
@@ -0,0 +1,26 @@
|
||||
@startuml
|
||||
title Authentication Flow
|
||||
|
||||
actor User
|
||||
participant Browser
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant PostgreSQL as DB
|
||||
|
||||
User -> Browser: Enter email + password
|
||||
Browser -> Frontend: POST /login (form action)
|
||||
Frontend -> Frontend: Base64 encode "email:password"
|
||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>
|
||||
Backend -> Backend: Spring Security parses Basic Auth
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)
|
||||
Backend --> Frontend: 200 OK — UserDTO
|
||||
Frontend -> Browser: Set-Cookie: auth_token=<base64>\n(httpOnly, SameSite=strict, maxAge=86400)
|
||||
Browser -> Frontend: GET / (next request)
|
||||
Frontend -> Frontend: hooks.server.ts reads auth_token cookie
|
||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>
|
||||
Backend --> Frontend: 200 OK — user in event.locals
|
||||
Frontend --> Browser: Render page with user context
|
||||
|
||||
@enduml
|
||||
32
docs/architecture/c4/seq-document-upload.puml
Normal file
32
docs/architecture/c4/seq-document-upload.puml
Normal file
@@ -0,0 +1,32 @@
|
||||
@startuml
|
||||
title Document Upload Flow
|
||||
|
||||
actor User
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant "PermissionAspect (AOP)" as Aspect
|
||||
participant DocumentService as DocSvc
|
||||
participant FileService as FileSvc
|
||||
participant MinIO
|
||||
participant PostgreSQL as DB
|
||||
|
||||
User -> Frontend: Submit edit form (file + metadata)
|
||||
Frontend -> Backend: PUT /api/documents/{id}\nmultipart/form-data + Authorization header
|
||||
Backend -> Aspect: @RequirePermission(WRITE_ALL) check
|
||||
Aspect -> Aspect: Verify user has WRITE_ALL authority
|
||||
Aspect --> Backend: Proceed
|
||||
Backend -> DocSvc: updateDocument(id, dto, file)
|
||||
DocSvc -> DocSvc: Resolve sender Person by ID
|
||||
DocSvc -> DocSvc: Resolve/create Tags
|
||||
DocSvc -> FileSvc: uploadFile(file, filename)
|
||||
FileSvc -> FileSvc: Generate key: documents/{UUID}_{filename}
|
||||
FileSvc -> MinIO: PutObject(bucket, key, stream)
|
||||
MinIO --> FileSvc: Success
|
||||
FileSvc --> DocSvc: S3 key
|
||||
DocSvc -> DB: UPDATE documents SET file_path=?, status='UPLOADED', ...
|
||||
DB --> DocSvc: OK
|
||||
DocSvc --> Backend: Updated Document entity
|
||||
Backend --> Frontend: 200 OK — Document JSON
|
||||
Frontend --> User: Refreshed document view
|
||||
|
||||
@enduml
|
||||
432
docs/architecture/db/db-orm.puml
Normal file
432
docs/architecture/db/db-orm.puml
Normal file
@@ -0,0 +1,432 @@
|
||||
@startuml db-orm
|
||||
' Schema source: Flyway V1–V60 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V60 (2026-05-06)
|
||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||
|
||||
hide circle
|
||||
skinparam linetype ortho
|
||||
|
||||
' ── Auth ──
|
||||
package "Auth" {
|
||||
|
||||
entity app_users {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
email : VARCHAR(255) NOT NULL UNIQUE
|
||||
password : VARCHAR(255) NOT NULL
|
||||
first_name : VARCHAR(100)
|
||||
last_name : VARCHAR(100)
|
||||
birth_date : DATE
|
||||
contact : TEXT
|
||||
enabled : BOOLEAN NOT NULL
|
||||
color : VARCHAR(20) NOT NULL
|
||||
notify_on_reply : BOOLEAN NOT NULL
|
||||
notify_on_mention : BOOLEAN NOT NULL
|
||||
created_at : TIMESTAMP
|
||||
}
|
||||
|
||||
entity user_groups {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
name : VARCHAR(255) NOT NULL UNIQUE
|
||||
}
|
||||
|
||||
entity app_users_groups {
|
||||
app_user_id : UUID <<FK>>
|
||||
group_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity group_permissions {
|
||||
group_id : UUID <<FK>>
|
||||
--
|
||||
permission : VARCHAR(255)
|
||||
}
|
||||
|
||||
entity password_reset_tokens {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
app_user_id : UUID <<FK>>
|
||||
token : VARCHAR(64) NOT NULL UNIQUE
|
||||
expires_at : TIMESTAMP NOT NULL
|
||||
used : BOOLEAN NOT NULL
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
}
|
||||
|
||||
entity invite_tokens {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
code : VARCHAR(10) NOT NULL UNIQUE
|
||||
label : VARCHAR(255)
|
||||
max_uses : INTEGER
|
||||
use_count : INTEGER NOT NULL
|
||||
prefill_first_name : VARCHAR(255)
|
||||
prefill_last_name : VARCHAR(255)
|
||||
prefill_email : VARCHAR(255)
|
||||
expires_at : TIMESTAMP
|
||||
created_by : UUID <<FK>>
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
revoked : BOOLEAN NOT NULL
|
||||
}
|
||||
|
||||
entity invite_token_group_ids {
|
||||
invite_token_id : UUID <<FK>>
|
||||
group_id : UUID <<FK>>
|
||||
}
|
||||
}
|
||||
|
||||
' ── Documents ──
|
||||
package "Documents" {
|
||||
|
||||
entity documents {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
title : VARCHAR(255) NOT NULL
|
||||
original_filename : VARCHAR(255) NOT NULL
|
||||
status : VARCHAR(255) NOT NULL
|
||||
file_path : VARCHAR(255)
|
||||
file_hash : VARCHAR(64)
|
||||
summary : TEXT
|
||||
transcription : TEXT
|
||||
meta_date : DATE
|
||||
meta_location : VARCHAR(255)
|
||||
meta_document_location : VARCHAR(255)
|
||||
archive_box : VARCHAR(255)
|
||||
archive_folder : VARCHAR(255)
|
||||
sender_id : UUID <<FK>>
|
||||
metadata_complete : BOOLEAN NOT NULL
|
||||
script_type : VARCHAR(30) NOT NULL
|
||||
thumbnail_key : VARCHAR(255)
|
||||
thumbnail_generated_at : TIMESTAMP
|
||||
thumbnail_aspect : VARCHAR(16)
|
||||
page_count : INTEGER
|
||||
search_vector : tsvector <<computed>>
|
||||
created_at : TIMESTAMP
|
||||
updated_at : TIMESTAMP
|
||||
}
|
||||
|
||||
entity document_receivers {
|
||||
document_id : UUID <<FK>>
|
||||
person_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity document_tags {
|
||||
document_id : UUID <<FK>>
|
||||
tag_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity document_versions {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
document_id : UUID <<FK>>
|
||||
editor_id : UUID <<FK>>
|
||||
editor_name : VARCHAR(200) NOT NULL
|
||||
saved_at : TIMESTAMP NOT NULL
|
||||
snapshot : JSONB NOT NULL
|
||||
changed_fields : JSONB NOT NULL
|
||||
}
|
||||
|
||||
entity document_annotations {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
document_id : UUID <<FK>>
|
||||
page_number : INTEGER NOT NULL
|
||||
x : DOUBLE PRECISION NOT NULL
|
||||
y : DOUBLE PRECISION NOT NULL
|
||||
width : DOUBLE PRECISION NOT NULL
|
||||
height : DOUBLE PRECISION NOT NULL
|
||||
color : VARCHAR(20) NOT NULL
|
||||
polygon : JSONB
|
||||
file_hash : VARCHAR(64)
|
||||
created_by : UUID <<FK>>
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
}
|
||||
|
||||
entity document_comments {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
document_id : UUID <<FK>>
|
||||
annotation_id : UUID <<FK>>
|
||||
block_id : UUID <<FK>>
|
||||
parent_id : UUID <<FK>>
|
||||
author_id : UUID <<FK>>
|
||||
author_name : VARCHAR(200) NOT NULL
|
||||
content : TEXT NOT NULL
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
updated_at : TIMESTAMP NOT NULL
|
||||
}
|
||||
|
||||
entity document_training_labels {
|
||||
document_id : UUID <<FK>>
|
||||
--
|
||||
label : VARCHAR(50) NOT NULL
|
||||
}
|
||||
|
||||
entity comment_mentions {
|
||||
comment_id : UUID <<FK>>
|
||||
app_user_id : UUID <<FK>>
|
||||
}
|
||||
}
|
||||
|
||||
' ── Persons ──
|
||||
package "Persons" {
|
||||
|
||||
entity persons {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
first_name : VARCHAR(255)
|
||||
last_name : VARCHAR(255) NOT NULL
|
||||
alias : VARCHAR(255)
|
||||
title : VARCHAR(50)
|
||||
person_type : VARCHAR(20) NOT NULL
|
||||
notes : TEXT
|
||||
birth_year : INTEGER
|
||||
death_year : INTEGER
|
||||
family_member : BOOLEAN NOT NULL
|
||||
}
|
||||
|
||||
entity person_name_aliases {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
person_id : UUID <<FK>>
|
||||
last_name : VARCHAR(255) NOT NULL
|
||||
first_name : VARCHAR(255)
|
||||
type : VARCHAR(50) NOT NULL
|
||||
sort_order : INTEGER NOT NULL
|
||||
created_at : TIMESTAMPTZ
|
||||
}
|
||||
|
||||
entity person_relationships {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
person_id : UUID <<FK>>
|
||||
related_person_id : UUID <<FK>>
|
||||
relation_type : VARCHAR(30) NOT NULL
|
||||
from_year : INTEGER
|
||||
to_year : INTEGER
|
||||
notes : VARCHAR(2000)
|
||||
created_at : TIMESTAMPTZ NOT NULL
|
||||
}
|
||||
}
|
||||
|
||||
' ── Tags ──
|
||||
package "Tags" {
|
||||
|
||||
entity tag {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
name : VARCHAR(255) NOT NULL UNIQUE
|
||||
parent_id : UUID <<FK>>
|
||||
color : VARCHAR(20)
|
||||
}
|
||||
}
|
||||
|
||||
' ── Transcription ──
|
||||
package "Transcription" {
|
||||
|
||||
entity transcription_blocks {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
annotation_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
text : TEXT
|
||||
label : VARCHAR(200)
|
||||
sort_order : INTEGER NOT NULL
|
||||
version : INTEGER NOT NULL
|
||||
source : VARCHAR(10) NOT NULL
|
||||
reviewed : BOOLEAN NOT NULL
|
||||
created_by : UUID <<FK>>
|
||||
updated_by : UUID <<FK>>
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
updated_at : TIMESTAMP NOT NULL
|
||||
}
|
||||
|
||||
entity transcription_block_versions {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
block_id : UUID <<FK>>
|
||||
text : TEXT NOT NULL
|
||||
changed_by : UUID <<FK>>
|
||||
changed_at : TIMESTAMP NOT NULL
|
||||
}
|
||||
|
||||
entity transcription_block_mentioned_persons {
|
||||
block_id : UUID <<FK>>
|
||||
person_id : UUID NOT NULL
|
||||
--
|
||||
display_name : VARCHAR(200) NOT NULL
|
||||
}
|
||||
}
|
||||
|
||||
' ── OCR ──
|
||||
package "OCR" {
|
||||
|
||||
entity ocr_jobs {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
status : VARCHAR(20) NOT NULL
|
||||
total_documents : INT NOT NULL
|
||||
processed_documents : INT NOT NULL
|
||||
error_count : INT NOT NULL
|
||||
skipped_count : INT NOT NULL
|
||||
created_by : UUID
|
||||
progress_message : TEXT
|
||||
created_at : TIMESTAMPTZ NOT NULL
|
||||
updated_at : TIMESTAMPTZ NOT NULL
|
||||
}
|
||||
|
||||
entity ocr_job_documents {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
job_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
status : VARCHAR(20) NOT NULL
|
||||
error_message : TEXT
|
||||
current_page : INT
|
||||
total_pages : INT
|
||||
created_at : TIMESTAMPTZ NOT NULL
|
||||
updated_at : TIMESTAMPTZ NOT NULL
|
||||
}
|
||||
|
||||
entity ocr_training_runs {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
status : VARCHAR(20) NOT NULL
|
||||
block_count : INT NOT NULL
|
||||
document_count : INT NOT NULL
|
||||
model_name : VARCHAR(100) NOT NULL
|
||||
error_message : TEXT
|
||||
triggered_by : UUID <<FK>>
|
||||
person_id : UUID <<FK>>
|
||||
cer : DOUBLE PRECISION
|
||||
loss : DOUBLE PRECISION
|
||||
accuracy : DOUBLE PRECISION
|
||||
epochs : INT
|
||||
created_at : TIMESTAMPTZ NOT NULL
|
||||
completed_at : TIMESTAMPTZ
|
||||
}
|
||||
|
||||
entity sender_models {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
person_id : UUID <<FK>> UNIQUE
|
||||
model_path : TEXT NOT NULL
|
||||
accuracy : DOUBLE PRECISION
|
||||
cer : DOUBLE PRECISION
|
||||
corrected_lines_at_training : INT NOT NULL
|
||||
created_at : TIMESTAMPTZ NOT NULL
|
||||
updated_at : TIMESTAMPTZ NOT NULL
|
||||
}
|
||||
}
|
||||
|
||||
' ── Supporting ──
|
||||
package "Supporting" {
|
||||
|
||||
entity notifications {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
recipient_id : UUID <<FK>>
|
||||
type : VARCHAR(32) NOT NULL
|
||||
document_id : UUID
|
||||
reference_id : UUID
|
||||
annotation_id : UUID
|
||||
actor_name : VARCHAR(255)
|
||||
read : BOOLEAN NOT NULL
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
}
|
||||
|
||||
entity audit_log {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
happened_at : TIMESTAMPTZ NOT NULL
|
||||
actor_id : UUID <<FK>>
|
||||
kind : VARCHAR(50) NOT NULL
|
||||
document_id : UUID <<FK>>
|
||||
payload : JSONB
|
||||
}
|
||||
|
||||
entity geschichten {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
title : VARCHAR(255) NOT NULL
|
||||
body : TEXT
|
||||
status : VARCHAR(32) NOT NULL
|
||||
author_id : UUID <<FK>>
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
updated_at : TIMESTAMP NOT NULL
|
||||
published_at : TIMESTAMP
|
||||
}
|
||||
|
||||
entity geschichten_persons {
|
||||
geschichte_id : UUID <<FK>>
|
||||
person_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity geschichten_documents {
|
||||
geschichte_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
}
|
||||
}
|
||||
|
||||
' Auth relationships
|
||||
app_users_groups }o--|| app_users : app_user_id
|
||||
app_users_groups }o--|| user_groups : group_id
|
||||
group_permissions }o--|| user_groups : group_id
|
||||
password_reset_tokens }o--|| app_users : app_user_id
|
||||
invite_tokens }o--|| app_users : created_by
|
||||
invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
||||
invite_token_group_ids }o--|| user_groups : group_id
|
||||
|
||||
' Document relationships
|
||||
documents }o--o| persons : sender_id
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id
|
||||
document_tags }o--|| documents : document_id
|
||||
document_tags }o--|| tag : tag_id
|
||||
document_versions }o--|| documents : document_id
|
||||
document_versions }o--o| app_users : editor_id
|
||||
document_annotations }o--|| documents : document_id
|
||||
document_annotations }o--o| app_users : created_by
|
||||
document_comments }o--|| documents : document_id
|
||||
document_comments }o--o| document_annotations : annotation_id
|
||||
document_comments }o--o| transcription_blocks : block_id
|
||||
document_comments }o--o| app_users : author_id
|
||||
document_comments }o--o| document_comments : parent_id
|
||||
document_training_labels }o--|| documents : document_id
|
||||
comment_mentions }o--|| document_comments : comment_id
|
||||
comment_mentions }o--|| app_users : app_user_id
|
||||
|
||||
' Person relationships
|
||||
person_name_aliases }o--|| persons : person_id
|
||||
person_relationships }o--|| persons : person_id
|
||||
person_relationships }o--|| persons : related_person_id
|
||||
|
||||
' Tag self-reference
|
||||
tag }o--o| tag : parent_id
|
||||
|
||||
' Transcription relationships
|
||||
transcription_blocks }o--|| document_annotations : annotation_id
|
||||
transcription_blocks }o--|| documents : document_id
|
||||
transcription_blocks }o--o| app_users : created_by
|
||||
transcription_blocks }o--o| app_users : updated_by
|
||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||
transcription_block_versions }o--o| app_users : changed_by
|
||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||
|
||||
' OCR relationships
|
||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||
ocr_job_documents }o--|| documents : document_id
|
||||
ocr_training_runs }o--o| app_users : triggered_by
|
||||
ocr_training_runs }o--o| persons : person_id
|
||||
sender_models ||--|| persons : person_id
|
||||
|
||||
' Supporting relationships
|
||||
notifications }o--|| app_users : recipient_id
|
||||
audit_log }o--o| app_users : actor_id
|
||||
audit_log }o--o| documents : document_id
|
||||
geschichten }o--o| app_users : author_id
|
||||
geschichten_persons }o--|| geschichten : geschichte_id
|
||||
geschichten_persons }o--|| persons : person_id
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
|
||||
@enduml
|
||||
132
docs/architecture/db/db-relationships.puml
Normal file
132
docs/architecture/db/db-relationships.puml
Normal file
@@ -0,0 +1,132 @@
|
||||
@startuml db-relationships
|
||||
' Schema source: Flyway V1–V60 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V60 (2026-05-06)
|
||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||
|
||||
hide circle
|
||||
skinparam linetype ortho
|
||||
|
||||
left to right direction
|
||||
|
||||
' ── Auth ──
|
||||
package "Auth" {
|
||||
entity app_users
|
||||
entity user_groups
|
||||
entity app_users_groups
|
||||
entity group_permissions
|
||||
entity password_reset_tokens
|
||||
entity invite_tokens
|
||||
entity invite_token_group_ids
|
||||
}
|
||||
|
||||
' ── Documents ──
|
||||
package "Documents" {
|
||||
entity documents
|
||||
entity document_receivers
|
||||
entity document_tags
|
||||
entity document_versions
|
||||
entity document_annotations
|
||||
entity document_comments
|
||||
entity document_training_labels
|
||||
entity comment_mentions
|
||||
}
|
||||
|
||||
' ── Persons ──
|
||||
package "Persons" {
|
||||
entity persons
|
||||
entity person_name_aliases
|
||||
entity person_relationships
|
||||
}
|
||||
|
||||
' ── Tags ──
|
||||
package "Tags" {
|
||||
entity tag
|
||||
}
|
||||
|
||||
' ── Transcription ──
|
||||
package "Transcription" {
|
||||
entity transcription_blocks
|
||||
entity transcription_block_versions
|
||||
entity transcription_block_mentioned_persons
|
||||
}
|
||||
|
||||
' ── OCR ──
|
||||
package "OCR" {
|
||||
entity ocr_jobs
|
||||
entity ocr_job_documents
|
||||
entity ocr_training_runs
|
||||
entity sender_models
|
||||
}
|
||||
|
||||
' ── Supporting ──
|
||||
package "Supporting" {
|
||||
entity notifications
|
||||
entity audit_log
|
||||
entity geschichten
|
||||
entity geschichten_persons
|
||||
entity geschichten_documents
|
||||
}
|
||||
|
||||
' Auth relationships
|
||||
app_users_groups }o--|| app_users : app_user_id
|
||||
app_users_groups }o--|| user_groups : group_id
|
||||
group_permissions }o--|| user_groups : group_id
|
||||
password_reset_tokens }o--|| app_users : app_user_id
|
||||
invite_tokens }o--|| app_users : created_by
|
||||
invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
||||
invite_token_group_ids }o--|| user_groups : group_id
|
||||
|
||||
' Document relationships
|
||||
documents }o--o| persons : sender_id
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id
|
||||
document_tags }o--|| documents : document_id
|
||||
document_tags }o--|| tag : tag_id
|
||||
document_versions }o--|| documents : document_id
|
||||
document_versions }o--o| app_users : editor_id
|
||||
document_annotations }o--|| documents : document_id
|
||||
document_annotations }o--o| app_users : created_by
|
||||
document_comments }o--|| documents : document_id
|
||||
document_comments }o--o| document_annotations : annotation_id
|
||||
document_comments }o--o| transcription_blocks : block_id
|
||||
document_comments }o--o| app_users : author_id
|
||||
document_comments }o--o| document_comments : parent_id
|
||||
document_training_labels }o--|| documents : document_id
|
||||
comment_mentions }o--|| document_comments : comment_id
|
||||
comment_mentions }o--|| app_users : app_user_id
|
||||
|
||||
' Person relationships
|
||||
person_name_aliases }o--|| persons : person_id
|
||||
person_relationships }o--|| persons : person_id
|
||||
person_relationships }o--|| persons : related_person_id
|
||||
|
||||
' Tag self-reference
|
||||
tag }o--o| tag : parent_id
|
||||
|
||||
' Transcription relationships
|
||||
transcription_blocks }o--|| document_annotations : annotation_id
|
||||
transcription_blocks }o--|| documents : document_id
|
||||
transcription_blocks }o--o| app_users : created_by
|
||||
transcription_blocks }o--o| app_users : updated_by
|
||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||
transcription_block_versions }o--o| app_users : changed_by
|
||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||
|
||||
' OCR relationships
|
||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||
ocr_job_documents }o--|| documents : document_id
|
||||
ocr_training_runs }o--o| app_users : triggered_by
|
||||
ocr_training_runs }o--o| persons : person_id
|
||||
sender_models ||--|| persons : person_id
|
||||
|
||||
' Supporting relationships
|
||||
notifications }o--|| app_users : recipient_id
|
||||
audit_log }o--o| app_users : actor_id
|
||||
audit_log }o--o| documents : document_id
|
||||
geschichten }o--o| app_users : author_id
|
||||
geschichten_persons }o--|| geschichten : geschichte_id
|
||||
geschichten_persons }o--|| persons : person_id
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
|
||||
@enduml
|
||||
@@ -81,7 +81,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.HSTAT:last-child{border-right:none;padding-right:0}
|
||||
.HSTAT a{text-decoration:none;display:block}
|
||||
.HSTAT-NUM{font-size:14px;font-weight:900;color:#002850;line-height:1;display:block}
|
||||
.HSTAT-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#B8B4AE;display:block;margin-top:2px}
|
||||
.HSTAT-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#706C68;display:block;margin-top:2px}
|
||||
|
||||
/* ── Person portrait cards — B.3 avatar, no border line, mint pill ─── */
|
||||
.PERSON-GRID{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
|
||||
@@ -107,7 +107,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
padding:1px 6px;border-radius:10px
|
||||
}
|
||||
.PERSONS-FOOTER{text-align:right;margin-top:3px}
|
||||
.PERSONS-ALL{font-size:6.5px;color:#002850;font-weight:600;text-decoration:none;opacity:.55}
|
||||
.PERSONS-ALL{font-size:6.5px;color:#4A6E8A;font-weight:600;text-decoration:none}
|
||||
|
||||
/* ── Two-column 1:1 — B.1 ─── */
|
||||
.CONTENT-ROW{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
@@ -126,14 +126,14 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.DOC-TITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.DOC-SENDER{font-size:6px;color:#888;margin-top:1px}
|
||||
.DOC-SENDER a{color:#002850;text-decoration:none;opacity:.7}
|
||||
.DOC-DATE{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
|
||||
.DOC-DATE{font-size:6px;color:#706C68;white-space:nowrap;flex-shrink:0}
|
||||
|
||||
/* ── Story rows — B.1: clean 2-line excerpt ─── */
|
||||
.STORY-ROW{padding:7px 10px;border-bottom:1px solid #F0EDE6}
|
||||
.STORY-ROW:last-child{border-bottom:none}
|
||||
.STORY-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850;font-style:italic;margin-bottom:3px;line-height:1.3}
|
||||
.STORY-EXCERPT{font-size:6.5px;color:#888;line-height:1.5;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.STORY-META{font-size:6px;color:#B8B4AE}
|
||||
.STORY-META{font-size:6px;color:#706C68}
|
||||
|
||||
/* ── Drafts card ─── */
|
||||
.DRAFTS-CARD{background:#fff;border:1px solid #E0DDD5;border-left:3px solid #A6DAD8;border-radius:3px;overflow:hidden}
|
||||
@@ -153,8 +153,19 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
Compose alongside light structural classes, e.g. class="CARD DK-CARD"
|
||||
Real tokens live in layout.css / Tailwind dark: variants — these are approximations.
|
||||
Mapping: canvas #0F1219 · surface #161C27 · border rgba(255,255,255,.08)
|
||||
text #D0CCC4 · muted #3A4568 · dim #262E48
|
||||
mint #A6DAD8 (unchanged) · mint-pill rgba(166,218,216,.15)
|
||||
text #D0CCC4 · muted #7080A8 · labels #6070A0 · dim #5A6888
|
||||
mint #A6DAD8 (unchanged) · mint-pill rgba(166,218,216,.14)
|
||||
DK- → Tailwind dark: equivalents (implementation guide)
|
||||
DK-MAIN → dark:bg-canvas
|
||||
DK-HEADER-BAR → dark:bg-surface dark:border-white/8
|
||||
DK-HSTAT-LABEL → dark:text-ink-4 (#6070A0 — WCAG AA ≥ 4.5:1 on #161C27)
|
||||
DK-CARD → dark:bg-surface dark:border-white/8
|
||||
DK-CARD-HEAD h3 → dark:text-ink-4 (#6070A0)
|
||||
DK-DOC-SENDER → dark:text-ink-3 (#7080A8 — WCAG AA ≥ 4.5:1 on #161C27)
|
||||
DK-DOC-DATE → dark:text-ink-5 (#5A6888 — WCAG AA ≥ 4.5:1 on #161C27)
|
||||
DK-STORY-EXCERPT → dark:text-ink-3 (#7080A8)
|
||||
DK-STORY-META → dark:text-ink-5 (#5A6888)
|
||||
DK-DRAFT-META → dark:text-ink-3 (#7080A8)
|
||||
══════════════════════════════════ */
|
||||
.wf-dark .wf-bar{background:#1C1E26;border-bottom-color:#252830}
|
||||
.wf-dark .urlbar{background:#252830}
|
||||
@@ -167,31 +178,31 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.DK-DIVIDER{background:rgba(255,255,255,.08) !important}
|
||||
.DK-HSTAT{border-right-color:rgba(255,255,255,.06) !important}
|
||||
.DK-HSTAT-NUM{color:#E8E4DC !important}
|
||||
.DK-HSTAT-LABEL{color:#323850 !important}
|
||||
.DK-HSTAT-LABEL{color:#6070A0 !important}
|
||||
.DK-PCARD{background:#161C27 !important;border-color:rgba(255,255,255,.08) !important}
|
||||
.DK-PCARD-NAME{color:#C0BDB6 !important}
|
||||
.DK-PCARD-COUNT{color:#A6DAD8 !important;background:rgba(166,218,216,.14) !important}
|
||||
.DK-PERSONS-ALL{color:#A6DAD8 !important;opacity:.65 !important}
|
||||
.DK-CARD{background:#161C27 !important;border-color:rgba(255,255,255,.08) !important}
|
||||
.DK-CARD-HEAD{border-bottom-color:rgba(255,255,255,.06) !important}
|
||||
.DK-CARD-HEAD h3{color:#323850 !important}
|
||||
.DK-CARD-HEAD h3{color:#6070A0 !important}
|
||||
.DK-CARD-HEAD a{color:#A6DAD8 !important;opacity:.5 !important}
|
||||
.DK-DOC-ROW{border-bottom-color:rgba(255,255,255,.04) !important}
|
||||
.DK-DOC-THUMB{background:#1E2638 !important;border-color:rgba(255,255,255,.07) !important}
|
||||
.DK-DOC-TITLE{color:#C0BDB6 !important}
|
||||
.DK-DOC-SENDER{color:#3A4568 !important}
|
||||
.DK-DOC-SENDER{color:#7080A8 !important}
|
||||
.DK-DOC-SENDER a{color:#A6DAD8 !important;opacity:.6 !important}
|
||||
.DK-DOC-DATE{color:#262E48 !important}
|
||||
.DK-DOC-DATE{color:#5A6888 !important}
|
||||
.DK-STORY-ROW{border-bottom-color:rgba(255,255,255,.04) !important}
|
||||
.DK-STORY-TITLE{color:#C0BDB6 !important}
|
||||
.DK-STORY-EXCERPT{color:#3A4568 !important}
|
||||
.DK-STORY-META{color:#262E48 !important}
|
||||
.DK-STORY-EXCERPT{color:#7080A8 !important}
|
||||
.DK-STORY-META{color:#5A6888 !important}
|
||||
.DK-DRAFTS-CARD{background:#161C27 !important;border-color:rgba(255,255,255,.08) !important}
|
||||
.DK-DRAFTS-HEAD{border-bottom-color:rgba(255,255,255,.06) !important}
|
||||
.DK-DRAFTS-HEAD h3{color:#323850 !important}
|
||||
.DK-DRAFTS-HEAD h3{color:#6070A0 !important}
|
||||
.DK-DRAFT-ROW{border-bottom-color:rgba(255,255,255,.04) !important}
|
||||
.DK-DRAFT-TITLE{color:#C0BDB6 !important}
|
||||
.DK-DRAFT-META{color:#3A4568 !important}
|
||||
.DK-DRAFT-META{color:#7080A8 !important}
|
||||
|
||||
/* ══════════════════════════════════
|
||||
MOBILE — phone chrome + stacked layout
|
||||
@@ -233,8 +244,8 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.M-HSTAT a{text-decoration:none;display:block}
|
||||
.M-HSTAT-NUM{font-size:11px;font-weight:900;color:#002850;line-height:1;display:block}
|
||||
.M-HSTAT-NUM.dk{color:#E8E4DC}
|
||||
.M-HSTAT-LBL{font-size:5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#B8B4AE;display:block;margin-top:1px}
|
||||
.M-HSTAT-LBL.dk{color:#323850}
|
||||
.M-HSTAT-LBL{font-size:5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#706C68;display:block;margin-top:1px}
|
||||
.M-HSTAT-LBL.dk{color:#6070A0}
|
||||
|
||||
/* Mobile drafts */
|
||||
.M-DCARD{background:#fff;border:1px solid #E0DDD5;border-left:3px solid #A6DAD8;border-radius:3px;overflow:hidden}
|
||||
@@ -242,14 +253,14 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.M-DCARD-HEAD{padding:5px 9px;border-bottom:1px solid #F0EDE6;display:flex;align-items:center}
|
||||
.M-DCARD-HEAD.dk{border-bottom-color:rgba(255,255,255,.06)}
|
||||
.M-DCARD-HEAD h3{font-size:6px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#999}
|
||||
.M-DCARD-HEAD.dk h3{color:#323850}
|
||||
.M-DCARD-HEAD.dk h3{color:#6070A0}
|
||||
.M-DRAFT-ROW{display:flex;align-items:center;justify-content:space-between;padding:5px 9px;border-bottom:1px solid #F0EDE6}
|
||||
.M-DRAFT-ROW:last-child{border-bottom:none}
|
||||
.M-DRAFT-ROW.dk{border-bottom-color:rgba(255,255,255,.04)}
|
||||
.M-DRAFT-TITLE{font-family:Georgia,serif;font-size:7px;color:#002850}
|
||||
.M-DRAFT-TITLE.dk{color:#C0BDB6}
|
||||
.M-DRAFT-META{font-size:5.5px;color:#AAA;margin-top:1px}
|
||||
.M-DRAFT-META.dk{color:#3A4568}
|
||||
.M-DRAFT-META.dk{color:#7080A8}
|
||||
|
||||
/* Mobile 2×2 person grid */
|
||||
.M-PGRID{display:grid;grid-template-columns:1fr 1fr;gap:5px}
|
||||
@@ -260,7 +271,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.M-PNAME.dk{color:#C0BDB6}
|
||||
.M-PCOUNT{font-size:5px;font-weight:800;color:#002850;background:#D4F0EE;padding:1px 5px;border-radius:10px}
|
||||
.M-PCOUNT.dk{color:#A6DAD8;background:rgba(166,218,216,.14)}
|
||||
.M-PALL{font-size:5.5px;color:#002850;opacity:.55;text-decoration:none;display:block;text-align:right;margin-top:1px}
|
||||
.M-PALL{font-size:5.5px;color:#4A6E8A;text-decoration:none;display:block;text-align:right;margin-top:1px}
|
||||
.M-PALL.dk{color:#A6DAD8;opacity:.65}
|
||||
|
||||
/* Mobile single-column cards */
|
||||
@@ -269,7 +280,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.M-CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:5px 9px;border-bottom:1px solid #E0DDD5}
|
||||
.M-CARD-HEAD.dk{border-bottom-color:rgba(255,255,255,.06)}
|
||||
.M-CARD-HEAD h3{font-size:6px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#999}
|
||||
.M-CARD-HEAD.dk h3{color:#323850}
|
||||
.M-CARD-HEAD.dk h3{color:#6070A0}
|
||||
.M-CARD-HEAD a{font-size:6px;color:#002850;opacity:.4;text-decoration:none;font-weight:600}
|
||||
.M-CARD-HEAD.dk a{color:#A6DAD8;opacity:.5}
|
||||
.M-DROW{display:flex;align-items:center;gap:6px;padding:4px 9px;border-bottom:1px solid #F0EDE6}
|
||||
@@ -279,23 +290,35 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
.M-DTITLE{font-family:Georgia,serif;font-size:7px;color:#002850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.M-DTITLE.dk{color:#C0BDB6}
|
||||
.M-DSENDER{font-size:5.5px;color:#888}
|
||||
.M-DSENDER.dk{color:#3A4568}
|
||||
.M-DDATE{font-size:5.5px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
|
||||
.M-DDATE.dk{color:#262E48}
|
||||
.M-DSENDER.dk{color:#7080A8}
|
||||
.M-DDATE{font-size:5.5px;color:#706C68;white-space:nowrap;flex-shrink:0}
|
||||
.M-DDATE.dk{color:#5A6888}
|
||||
.M-SROW{padding:6px 9px;border-bottom:1px solid #F0EDE6}
|
||||
.M-SROW:last-child{border-bottom:none}
|
||||
.M-SROW.dk{border-bottom-color:rgba(255,255,255,.04)}
|
||||
.M-STITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;font-style:italic;margin-bottom:2px;line-height:1.3}
|
||||
.M-STITLE.dk{color:#C0BDB6}
|
||||
.M-SEXCERPT{font-size:6px;color:#888;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:2px}
|
||||
.M-SEXCERPT.dk{color:#3A4568}
|
||||
.M-SMETA{font-size:5.5px;color:#B8B4AE}
|
||||
.M-SMETA.dk{color:#262E48}
|
||||
.M-SEXCERPT.dk{color:#7080A8}
|
||||
.M-SMETA{font-size:5.5px;color:#706C68}
|
||||
.M-SMETA.dk{color:#5A6888}
|
||||
|
||||
/* Phone layout helper */
|
||||
.phones-row{display:flex;gap:20px;align-items:start;flex-wrap:wrap}
|
||||
.phone-label{font-size:8px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;display:flex;align-items:center;gap:6px}
|
||||
.phone-col{display:flex;flex-direction:column}
|
||||
|
||||
/* ── impl-ref table ─── */
|
||||
.impl-ref-section{margin-top:64px;border-top:2px dashed #C8C4BE;padding-top:56px}
|
||||
.impl-ref-section .sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
|
||||
.impl-ref-section .sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
|
||||
.impl-ref-table{width:100%;border-collapse:collapse;font-size:11px;margin-bottom:24px}
|
||||
.impl-ref-table th{text-align:left;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:6px 12px 6px 0;border-bottom:2px solid #E0DDD5}
|
||||
.impl-ref-table td{padding:7px 12px 7px 0;border-bottom:1px solid #F0EDE6;vertical-align:top;color:#1A1A1A}
|
||||
.impl-ref-table tr:last-child td{border-bottom:none}
|
||||
.impl-ref-table td:first-child{font-weight:700;color:#002850;white-space:nowrap;width:180px}
|
||||
.impl-ref-table td code{font-family:monospace;font-size:10px;background:#F0EDE6;padding:1px 4px;border-radius:3px;white-space:nowrap}
|
||||
.impl-ref-table td .note-cell{font-size:10px;color:#888;font-style:italic}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -372,9 +395,9 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
<div class="DIVIDER"></div>
|
||||
<div class="HEADER-STATS">
|
||||
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
|
||||
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
|
||||
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
|
||||
<div class="HSTAT"><a href="/documents"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
|
||||
<div class="HSTAT"><a href="/persons"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
|
||||
<div class="HSTAT"><a href="/geschichten"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -402,13 +425,13 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="PCARD-COUNT">19 Dok.</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
|
||||
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="/persons">Alle 94 Personen →</a></div>
|
||||
</div>
|
||||
|
||||
<!-- Zone 5: 1:1 split (B.1) -->
|
||||
<div class="CONTENT-ROW">
|
||||
<div class="CARD">
|
||||
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
|
||||
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="/documents">Alle Dokumente</a></div>
|
||||
<div class="DOC-ROW">
|
||||
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
|
||||
<div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div>
|
||||
@@ -436,7 +459,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
</div>
|
||||
<div class="CARD">
|
||||
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle Geschichten</a></div>
|
||||
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="/geschichten">Alle Geschichten</a></div>
|
||||
<div class="STORY-ROW">
|
||||
<div class="STORY-TITLE">Die Reise nach Berlin</div>
|
||||
<div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div>
|
||||
@@ -494,6 +517,18 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<li>Drafts-Card: white mit mint left-border (BLOG_WRITE-Variante)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="note">
|
||||
<strong>Stat-Links — Zielrouten</strong>
|
||||
<ul>
|
||||
<li>Dokumente-Zahl → <code>/documents</code></li>
|
||||
<li>Personen-Zahl → <code>/persons</code></li>
|
||||
<li>Geschichten-Zahl → <code>/geschichten</code></li>
|
||||
<li>„Alle N Personen →" → <code>/persons</code></li>
|
||||
<li>„Alle Dokumente" / „Alle" in Card-Head → <code>/documents</code></li>
|
||||
<li>„Alle Geschichten" / „Alle" in Card-Head → <code>/geschichten</code></li>
|
||||
<li>Personen-Kacheln → <code>/persons/{id}</code> (Instanz-spezifisch)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -515,9 +550,9 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="HEADER-LEFT"><div class="HEADER-TIME">Guten Morgen</div><div class="HEADER-NAME">Herzlich willkommen, Marcel.</div></div>
|
||||
<div class="DIVIDER"></div>
|
||||
<div class="HEADER-STATS">
|
||||
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
|
||||
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
|
||||
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
|
||||
<div class="HSTAT"><a href="/documents"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
|
||||
<div class="HSTAT"><a href="/persons"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
|
||||
<div class="HSTAT"><a href="/geschichten"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,19 +575,19 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#3D5A7A">FM</div><div class="PCARD-NAME">Frieda Müller</div><div class="PCARD-COUNT">28 Dok.</div></a>
|
||||
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#4A7A5A">HW</div><div class="PCARD-NAME">Heinrich Weber</div><div class="PCARD-COUNT">19 Dok.</div></a>
|
||||
</div>
|
||||
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
|
||||
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="/persons">Alle 94 Personen →</a></div>
|
||||
</div>
|
||||
|
||||
<div class="CONTENT-ROW">
|
||||
<div class="CARD">
|
||||
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
|
||||
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="/documents">Alle Dokumente</a></div>
|
||||
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div><div class="DOC-DATE">vor 2 Tagen</div></div>
|
||||
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#E0DDD5">—</div></div><div class="DOC-DATE">vor 4 Tagen</div></div>
|
||||
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><polyline points="22 14 16 9 4 22"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Familienfoto, Sommer 1928</div><div class="DOC-SENDER">von <a href="#">Ernst Raddatz</a></div></div><div class="DOC-DATE">vor 1 Woche</div></div>
|
||||
<div style="padding:5px 10px;font-size:6px;color:#C8C4BE;border-top:1px solid #F0EDE6">+ 2 weitere Dokumente …</div>
|
||||
</div>
|
||||
<div class="CARD">
|
||||
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
|
||||
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="/geschichten">Alle</a></div>
|
||||
<div class="STORY-ROW"><div class="STORY-TITLE">Die Reise nach Berlin</div><div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div><div class="STORY-META">vor 3 Tagen</div></div>
|
||||
<div class="STORY-ROW"><div class="STORY-TITLE">Sommer 1934 in Köln</div><div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten in Köln — die letzte unbeschwerte Reise vor dem Krieg …</div><div class="STORY-META">vor 2 Wochen</div></div>
|
||||
</div>
|
||||
@@ -609,9 +644,9 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
<div class="DIVIDER DK-DIVIDER"></div>
|
||||
<div class="HEADER-STATS">
|
||||
<div class="HSTAT DK-HSTAT"><a href="#"><span class="HSTAT-NUM DK-HSTAT-NUM">847</span><span class="HSTAT-LABEL DK-HSTAT-LABEL">Dokumente</span></a></div>
|
||||
<div class="HSTAT DK-HSTAT"><a href="#"><span class="HSTAT-NUM DK-HSTAT-NUM">94</span><span class="HSTAT-LABEL DK-HSTAT-LABEL">Personen</span></a></div>
|
||||
<div class="HSTAT DK-HSTAT"><a href="#"><span class="HSTAT-NUM DK-HSTAT-NUM">12</span><span class="HSTAT-LABEL DK-HSTAT-LABEL">Geschichten</span></a></div>
|
||||
<div class="HSTAT DK-HSTAT"><a href="/documents"><span class="HSTAT-NUM DK-HSTAT-NUM">847</span><span class="HSTAT-LABEL DK-HSTAT-LABEL">Dokumente</span></a></div>
|
||||
<div class="HSTAT DK-HSTAT"><a href="/persons"><span class="HSTAT-NUM DK-HSTAT-NUM">94</span><span class="HSTAT-LABEL DK-HSTAT-LABEL">Personen</span></a></div>
|
||||
<div class="HSTAT DK-HSTAT"><a href="/geschichten"><span class="HSTAT-NUM DK-HSTAT-NUM">12</span><span class="HSTAT-LABEL DK-HSTAT-LABEL">Geschichten</span></a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -638,12 +673,12 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="PCARD-COUNT DK-PCARD-COUNT">19 Dok.</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL DK-PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
|
||||
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL DK-PERSONS-ALL" href="/persons">Alle 94 Personen →</a></div>
|
||||
</div>
|
||||
|
||||
<div class="CONTENT-ROW">
|
||||
<div class="CARD DK-CARD">
|
||||
<div class="CARD-HEAD DK-CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
|
||||
<div class="CARD-HEAD DK-CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="/documents">Alle Dokumente</a></div>
|
||||
<div class="DOC-ROW DK-DOC-ROW">
|
||||
<div class="DOC-THUMB DK-DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#4A5880" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
|
||||
<div class="DOC-INFO"><div class="DOC-TITLE DK-DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER DK-DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div>
|
||||
@@ -651,7 +686,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
<div class="DOC-ROW DK-DOC-ROW">
|
||||
<div class="DOC-THUMB DK-DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#4A5880" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
|
||||
<div class="DOC-INFO"><div class="DOC-TITLE DK-DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER DK-DOC-SENDER" style="color:#262E48">—</div></div>
|
||||
<div class="DOC-INFO"><div class="DOC-TITLE DK-DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER DK-DOC-SENDER" style="color:#5A6888">—</div></div>
|
||||
<div class="DOC-DATE DK-DOC-DATE">vor 4 Tagen</div>
|
||||
</div>
|
||||
<div class="DOC-ROW DK-DOC-ROW">
|
||||
@@ -661,7 +696,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
<div class="DOC-ROW DK-DOC-ROW">
|
||||
<div class="DOC-THUMB DK-DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#4A5880" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
|
||||
<div class="DOC-INFO"><div class="DOC-TITLE DK-DOC-TITLE">Taufregister Heinrich Weber, 1902</div><div class="DOC-SENDER DK-DOC-SENDER" style="color:#262E48">—</div></div>
|
||||
<div class="DOC-INFO"><div class="DOC-TITLE DK-DOC-TITLE">Taufregister Heinrich Weber, 1902</div><div class="DOC-SENDER DK-DOC-SENDER" style="color:#5A6888">—</div></div>
|
||||
<div class="DOC-DATE DK-DOC-DATE">vor 2 Wo.</div>
|
||||
</div>
|
||||
<div class="DOC-ROW DK-DOC-ROW">
|
||||
@@ -671,7 +706,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
</div>
|
||||
<div class="CARD DK-CARD">
|
||||
<div class="CARD-HEAD DK-CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle Geschichten</a></div>
|
||||
<div class="CARD-HEAD DK-CARD-HEAD"><h3>Geschichten</h3><a href="/geschichten">Alle Geschichten</a></div>
|
||||
<div class="STORY-ROW DK-STORY-ROW">
|
||||
<div class="STORY-TITLE DK-STORY-TITLE">Die Reise nach Berlin</div>
|
||||
<div class="STORY-EXCERPT DK-STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div>
|
||||
@@ -713,11 +748,12 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</ul>
|
||||
</div>
|
||||
<div class="note">
|
||||
<strong>Typografie-Token im Dark Mode</strong>
|
||||
<strong>Typografie-Token im Dark Mode — WCAG AA</strong>
|
||||
<ul>
|
||||
<li>Primär-Text: <code>#D0CCC4</code> / <code>#C0BDB6</code> (warm, kein reines Weiß)</li>
|
||||
<li>Beschriftungen (Labels): <code>#323850</code></li>
|
||||
<li>Datum/Dim: <code>#262E48</code></li>
|
||||
<li>Gedämpft (muted): <code>#7080A8</code> — Absender, Excerpts, Entwurfs-Meta (≥ 4.5:1)</li>
|
||||
<li>Labels/Überschriften: <code>#6070A0</code> — HSTAT-Label, Card-Head h3 (≥ 4.5:1)</li>
|
||||
<li>Dim: <code>#5A6888</code> — Datum-Felder (≥ 4.5:1)</li>
|
||||
<li>Mint: <code>#A6DAD8</code> — unverändert, auch im Dark Mode</li>
|
||||
<li>Mint-Pill Hintergrund: <code>rgba(166,218,216,.14)</code></li>
|
||||
</ul>
|
||||
@@ -774,9 +810,9 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="M-HTIME">Guten Abend</div>
|
||||
<div class="M-HNAME">Herzlich willkommen, Brigitte.</div>
|
||||
<div class="M-HSTATS">
|
||||
<div class="M-HSTAT"><a href="#"><span class="M-HSTAT-NUM">847</span><span class="M-HSTAT-LBL">Dok.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="#"><span class="M-HSTAT-NUM">94</span><span class="M-HSTAT-LBL">Pers.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="#"><span class="M-HSTAT-NUM">12</span><span class="M-HSTAT-LBL">Gesch.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="/documents"><span class="M-HSTAT-NUM">847</span><span class="M-HSTAT-LBL">Dok.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="/persons"><span class="M-HSTAT-NUM">94</span><span class="M-HSTAT-LBL">Pers.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="/geschichten"><span class="M-HSTAT-NUM">12</span><span class="M-HSTAT-LBL">Gesch.</span></a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -787,11 +823,11 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<a class="M-PCARD" href="#"><div class="M-PAV" style="background:#3D5A7A">FM</div><div class="M-PNAME">Frieda Müller</div><div class="M-PCOUNT">28 Dok.</div></a>
|
||||
<a class="M-PCARD" href="#"><div class="M-PAV" style="background:#4A7A5A">HW</div><div class="M-PNAME">Heinrich Weber</div><div class="M-PCOUNT">19 Dok.</div></a>
|
||||
</div>
|
||||
<a class="M-PALL" href="#">Alle 94 Personen →</a>
|
||||
<a class="M-PALL" href="/persons">Alle 94 Personen →</a>
|
||||
</div>
|
||||
|
||||
<div class="M-CARD">
|
||||
<div class="M-CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle</a></div>
|
||||
<div class="M-CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="/documents">Alle</a></div>
|
||||
<div class="M-DROW">
|
||||
<div style="width:15px;height:18px;background:#ECEAE4;border:1px solid #E0DDD5;border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center"><svg width="6" height="8" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
|
||||
<div class="M-DINFO"><div class="M-DTITLE">Brief von Ernst an Käthe, März 1923</div><div class="M-DSENDER">von Käthe Raddatz</div></div>
|
||||
@@ -810,7 +846,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
|
||||
<div class="M-CARD">
|
||||
<div class="M-CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
|
||||
<div class="M-CARD-HEAD"><h3>Geschichten</h3><a href="/geschichten">Alle</a></div>
|
||||
<div class="M-SROW">
|
||||
<div class="M-STITLE">Die Reise nach Berlin</div>
|
||||
<div class="M-SEXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin …</div>
|
||||
@@ -857,9 +893,9 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="M-HTIME">Guten Morgen</div>
|
||||
<div class="M-HNAME">Herzlich willkommen, Marcel.</div>
|
||||
<div class="M-HSTATS">
|
||||
<div class="M-HSTAT"><a href="#"><span class="M-HSTAT-NUM">847</span><span class="M-HSTAT-LBL">Dok.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="#"><span class="M-HSTAT-NUM">94</span><span class="M-HSTAT-LBL">Pers.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="#"><span class="M-HSTAT-NUM">12</span><span class="M-HSTAT-LBL">Gesch.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="/documents"><span class="M-HSTAT-NUM">847</span><span class="M-HSTAT-LBL">Dok.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="/persons"><span class="M-HSTAT-NUM">94</span><span class="M-HSTAT-LBL">Pers.</span></a></div>
|
||||
<div class="M-HSTAT"><a href="/geschichten"><span class="M-HSTAT-NUM">12</span><span class="M-HSTAT-LBL">Gesch.</span></a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -882,11 +918,11 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<a class="M-PCARD" href="#"><div class="M-PAV" style="background:#3D5A7A">FM</div><div class="M-PNAME">Frieda Müller</div><div class="M-PCOUNT">28 Dok.</div></a>
|
||||
<a class="M-PCARD" href="#"><div class="M-PAV" style="background:#4A7A5A">HW</div><div class="M-PNAME">Heinrich Weber</div><div class="M-PCOUNT">19 Dok.</div></a>
|
||||
</div>
|
||||
<a class="M-PALL" href="#">Alle 94 Personen →</a>
|
||||
<a class="M-PALL" href="/persons">Alle 94 Personen →</a>
|
||||
</div>
|
||||
|
||||
<div class="M-CARD">
|
||||
<div class="M-CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle</a></div>
|
||||
<div class="M-CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="/documents">Alle</a></div>
|
||||
<div class="M-DROW">
|
||||
<div style="width:15px;height:18px;background:#ECEAE4;border:1px solid #E0DDD5;border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center"><svg width="6" height="8" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
|
||||
<div class="M-DINFO"><div class="M-DTITLE">Brief von Ernst an Käthe, März 1923</div><div class="M-DSENDER">von Käthe Raddatz</div></div>
|
||||
@@ -900,7 +936,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
|
||||
<div class="M-CARD">
|
||||
<div class="M-CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
|
||||
<div class="M-CARD-HEAD"><h3>Geschichten</h3><a href="/geschichten">Alle</a></div>
|
||||
<div class="M-SROW">
|
||||
<div class="M-STITLE">Die Reise nach Berlin</div>
|
||||
<div class="M-SEXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin …</div>
|
||||
@@ -972,7 +1008,7 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
</div>
|
||||
<div class="M-DROW dk">
|
||||
<div style="width:15px;height:18px;background:#1E2638;border:1px solid rgba(255,255,255,.07);border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center"><svg width="6" height="8" viewBox="0 0 20 26" fill="none" stroke="#4A5880" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
|
||||
<div class="M-DINFO"><div class="M-DTITLE dk">Heiratsurkunde Raddatz-Müller, 1898</div><div class="M-DSENDER dk" style="color:#262E48">—</div></div>
|
||||
<div class="M-DINFO"><div class="M-DTITLE dk">Heiratsurkunde Raddatz-Müller, 1898</div><div class="M-DSENDER dk" style="color:#5A6888">—</div></div>
|
||||
<div class="M-DDATE dk">vor 4 T.</div>
|
||||
</div>
|
||||
<div class="M-DROW dk">
|
||||
@@ -1046,10 +1082,143 @@ body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<li>Avatare behalten ihre Farbe — Eigenfarbe, kein Token</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ann-block">
|
||||
<strong>Neue i18n-Schlüssel — messages/{de,en,es}.json</strong>
|
||||
<ul>
|
||||
<li><code>dashboard.greeting.morning</code> — „Guten Morgen" (vor 12:00)</li>
|
||||
<li><code>dashboard.greeting.afternoon</code> — „Guten Tag" (12:00–18:00)</li>
|
||||
<li><code>dashboard.greeting.evening</code> — „Guten Abend" (ab 18:00)</li>
|
||||
<li><code>dashboard.welcome</code> — „Herzlich willkommen, {name}." (parametrisch)</li>
|
||||
<li><code>dashboard.persons.viewAll</code> — „Alle {count} Personen →" (parametrisch)</li>
|
||||
<li><code>dashboard.recentlyUpdated</code> — „Zuletzt aktualisiert"</li>
|
||||
<li><code>dashboard.myDrafts</code> — „Meine Entwürfe"</li>
|
||||
<li><code>dashboard.stat.documents</code> — „Dokumente" / mobil: „Dok."</li>
|
||||
<li><code>dashboard.stat.persons</code> — „Personen" / mobil: „Pers."</li>
|
||||
<li><code>dashboard.stat.stories</code> — „Geschichten" / mobil: „Gesch."</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ IMPL-REF TABLE ══ -->
|
||||
<div class="impl-ref-section">
|
||||
<div class="sec-h"><span class="sec-num" style="background:#4A6E8A">↗</span> Implementierungsreferenz — Tailwind-Klassen je Region</div>
|
||||
<p style="font-size:11px;color:#888;margin-bottom:20px">Exakte Tailwind CSS 4-Klassen und Pixel-Werte für jeden UI-Bereich. <strong>Diese Werte gelten für die Implementierung</strong>, nicht die skalierten Mockup-Werte.</p>
|
||||
|
||||
<table class="impl-ref-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Region</th>
|
||||
<th>Tailwind-Klassen</th>
|
||||
<th>Hinweise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Seiten-Canvas</td>
|
||||
<td><code>bg-canvas w-full</code></td>
|
||||
<td class="note-cell">Vollbreite Viewport; Farbe: <code>--palette-sand</code> / <code>bg-canvas</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Inhalts-Wrapper</td>
|
||||
<td><code>max-w-7xl mx-auto px-8 py-8</code></td>
|
||||
<td class="note-cell">1280 px max-width, 32 px padding allseitig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Header-Balken</td>
|
||||
<td><code>bg-white border border-line rounded-sm px-4 py-3 flex items-center gap-4</code></td>
|
||||
<td class="note-cell">Explizit <code>bg-white</code>, nicht <code>bg-surface</code> — weißer Hintergrund ist intentionell (B.1-Entscheidung)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Header: Gruß-Zeit</td>
|
||||
<td><code>text-[6px] font-bold uppercase tracking-[.8px] text-[#C8B8A0]</code></td>
|
||||
<td class="note-cell">Warm-beige, kein Design-Token — kandidiert für <code>--color-greeting-time</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Header: Begrüßungsname</td>
|
||||
<td><code>font-serif text-xl text-brand-navy</code></td>
|
||||
<td class="note-cell">Georgia/Tinos; <code>text-xl</code> (20 px real)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Header: Trennlinie</td>
|
||||
<td><code>w-px self-stretch bg-line flex-shrink-0</code></td>
|
||||
<td class="note-cell">1 px vertikal; <code>--palette-line</code> = <code>#E8E4DF</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stat-Zahl</td>
|
||||
<td><code>text-2xl font-black text-brand-navy leading-none block</code></td>
|
||||
<td class="note-cell">Numerischer Wert; Block damit Label darunter sitzt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stat-Label</td>
|
||||
<td><code>text-[11px] font-bold uppercase tracking-[.8px] text-ink-3 block mt-0.5</code></td>
|
||||
<td class="note-cell">Licht: <code>#706C68</code> (WCAG AA); Tailwind-Token <code>text-ink-3</code> TBD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Personen-Grid</td>
|
||||
<td><code>grid grid-cols-4 gap-1.5</code></td>
|
||||
<td class="note-cell">Mobile: <code>grid-cols-2</code> bei <code>sm:</code> (640 px)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Personen-Kachel</td>
|
||||
<td><code>bg-surface border border-line rounded-sm p-2.5 flex flex-col items-center text-center gap-1.5 no-underline</code></td>
|
||||
<td class="note-cell"><code>bg-surface</code> = <code>--palette-surface</code>; kein Bottom-Accent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Personen-Avatar</td>
|
||||
<td><code>w-9 h-9 rounded-full flex items-center justify-center font-black text-white text-[9px] shadow-sm flex-shrink-0</code></td>
|
||||
<td class="note-cell">34 px real ≈ <code>w-9</code> (36 px) — nächster Tailwind-Step; Avatar-Farbe per Person-Eigenfarbe</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mint-Pill Badge</td>
|
||||
<td><code>text-[11px] font-bold text-brand-navy bg-[#D4F0EE] px-1.5 py-px rounded-full</code></td>
|
||||
<td class="note-cell">Token-Kandidat: <code>--color-mint-pill</code> / <code>bg-mint-soft</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>„Alle N Personen →"</td>
|
||||
<td><code>text-xs font-semibold text-[#4A6E8A] no-underline text-right block mt-1</code></td>
|
||||
<td class="note-cell">Direkte Farbe (kein opacity-Trick) — WCAG AA auf <code>bg-canvas</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Content-Card</td>
|
||||
<td><code>bg-surface border border-line rounded-sm overflow-hidden flex flex-col</code></td>
|
||||
<td class="note-cell">Gleiche Card-Sprache wie restliche App</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Card-Kopfzeile</td>
|
||||
<td><code>flex items-center justify-between px-3 py-1.5 border-b border-line</code></td>
|
||||
<td class="note-cell">h3: <code>text-[11px] font-bold uppercase tracking-[.12em] text-ink-3</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dok.-Datum</td>
|
||||
<td><code>text-[12px] text-ink-3 whitespace-nowrap flex-shrink-0</code></td>
|
||||
<td class="note-cell">Licht: <code>#706C68</code> (WCAG AA); nicht <code>#C8C4BE</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Story-Excerpt</td>
|
||||
<td><code>text-xs text-ink-2 leading-relaxed line-clamp-2</code></td>
|
||||
<td class="note-cell">2 Zeilen max via Tailwind <code>line-clamp-2</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Story-Meta</td>
|
||||
<td><code>text-[11px] text-ink-3</code></td>
|
||||
<td class="note-cell">Licht: <code>#706C68</code> (WCAG AA)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Entwürfe-Card</td>
|
||||
<td><code>bg-surface border border-line border-l-[3px] border-l-brand-mint rounded-sm overflow-hidden</code></td>
|
||||
<td class="note-cell">Mint Left-Border als visueller Anker für BLOG_WRITE-Zone</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dark Mode</td>
|
||||
<td><code>dark:bg-surface dark:border-white/8 dark:text-ink</code> (via Tailwind <code>dark:</code>)</td>
|
||||
<td class="note-cell">Nicht <code>!important</code>-Overrides aus Spec — spec-DK- Klassen sind Annäherungen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -36,6 +36,10 @@ src/lib/paraglide_bak*
|
||||
e2e/.auth/
|
||||
|
||||
**/test-results/**
|
||||
**/test-results.locked/
|
||||
|
||||
# Stale SvelteKit build artifacts
|
||||
**/.svelte-kit.old/
|
||||
|
||||
# Proofshot browser verification artifacts
|
||||
proofshot-artifacts/
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type * as Kit from '@sveltejs/kit';
|
||||
|
||||
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
|
||||
type RouteParams = { };
|
||||
type RouteId = '/stammbaum';
|
||||
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||
type EnsureDefined<T> = T extends null | undefined ? {} : T;
|
||||
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
|
||||
export type Snapshot<T = any> = Kit.Snapshot<T>;
|
||||
type PageServerParentData = EnsureDefined<import('../$types.js').LayoutServerData>;
|
||||
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
|
||||
|
||||
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
||||
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
|
||||
export type ActionData = unknown;
|
||||
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('../../../../../src/routes/stammbaum/+page.server.js').load>>>>>>;
|
||||
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
|
||||
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
|
||||
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
|
||||
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
|
||||
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "PARAGLIDE_LOCALE",
|
||||
"value": "de",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1812352142.362504,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "auth_token",
|
||||
"value": "Basic%20YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDphZG1pbjEyMw%3D%3D",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777878542.943668,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
||||
@@ -448,6 +448,28 @@
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_stats_documents": "Dokumente",
|
||||
"dashboard_stats_persons": "Personen",
|
||||
"dashboard_reader_stats_documents": "Dokumente",
|
||||
"dashboard_reader_stats_persons": "Personen",
|
||||
"dashboard_reader_stats_stories": "Geschichten",
|
||||
"dashboard_reader_person_chips_heading": "Personen",
|
||||
"dashboard_reader_no_persons": "Noch keine Personen im Archiv.",
|
||||
"dashboard_reader_all_persons": "Alle Personen →",
|
||||
"dashboard_reader_drafts_heading": "Meine Entwürfe",
|
||||
"dashboard_reader_drafts_empty": "Keine Entwürfe",
|
||||
"dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert",
|
||||
"dashboard_reader_recent_stories_heading": "Neue Geschichten",
|
||||
"dashboard_badge_new": "Neu",
|
||||
"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",
|
||||
@@ -1045,5 +1067,12 @@
|
||||
"relation_form_year_placeholder": "z.B. 1920",
|
||||
|
||||
"person_relationships_heading": "Beziehungen",
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt."
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||
|
||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||
"timeline_clear_selection": "Auswahl zurücksetzen",
|
||||
"timeline_zoom_reset": "Zurück zur Übersicht",
|
||||
"timeline_bar_aria_singular": "{when}, 1 Dokument",
|
||||
"timeline_bar_aria_plural": "{when}, {count} Dokumente",
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt"
|
||||
}
|
||||
|
||||
@@ -448,6 +448,28 @@
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_stats_documents": "Documents",
|
||||
"dashboard_stats_persons": "Persons",
|
||||
"dashboard_reader_stats_documents": "Documents",
|
||||
"dashboard_reader_stats_persons": "Persons",
|
||||
"dashboard_reader_stats_stories": "Stories",
|
||||
"dashboard_reader_person_chips_heading": "Persons",
|
||||
"dashboard_reader_no_persons": "No persons in the archive yet.",
|
||||
"dashboard_reader_all_persons": "All Persons →",
|
||||
"dashboard_reader_drafts_heading": "My Drafts",
|
||||
"dashboard_reader_drafts_empty": "No drafts",
|
||||
"dashboard_reader_recent_docs_heading": "Recently Updated",
|
||||
"dashboard_reader_recent_stories_heading": "New Stories",
|
||||
"dashboard_badge_new": "New",
|
||||
"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",
|
||||
@@ -1045,5 +1067,12 @@
|
||||
"relation_form_year_placeholder": "e.g. 1920",
|
||||
|
||||
"person_relationships_heading": "Relationships",
|
||||
"person_relationships_empty": "No relationships known yet."
|
||||
"person_relationships_empty": "No relationships known yet.",
|
||||
|
||||
"timeline_aria_label": "Document density timeline",
|
||||
"timeline_clear_selection": "Clear selection",
|
||||
"timeline_zoom_reset": "Reset zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 document",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documents",
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected"
|
||||
}
|
||||
|
||||
@@ -448,6 +448,28 @@
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_stats_documents": "Documentos",
|
||||
"dashboard_stats_persons": "Personas",
|
||||
"dashboard_reader_stats_documents": "Documentos",
|
||||
"dashboard_reader_stats_persons": "Personas",
|
||||
"dashboard_reader_stats_stories": "Historias",
|
||||
"dashboard_reader_person_chips_heading": "Personas",
|
||||
"dashboard_reader_no_persons": "Todavía no hay personas en el archivo.",
|
||||
"dashboard_reader_all_persons": "Todas las personas →",
|
||||
"dashboard_reader_drafts_heading": "Mis borradores",
|
||||
"dashboard_reader_drafts_empty": "Sin borradores",
|
||||
"dashboard_reader_recent_docs_heading": "Actualizados recientemente",
|
||||
"dashboard_reader_recent_stories_heading": "Nuevas historias",
|
||||
"dashboard_badge_new": "Nuevo",
|
||||
"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",
|
||||
@@ -1045,5 +1067,12 @@
|
||||
"relation_form_year_placeholder": "ej. 1920",
|
||||
|
||||
"person_relationships_heading": "Relaciones",
|
||||
"person_relationships_empty": "Aún no se conocen relaciones."
|
||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||
|
||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||
"timeline_clear_selection": "Borrar selección",
|
||||
"timeline_zoom_reset": "Restablecer zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 documento",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documentos",
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ChronikErrorCard from './ChronikErrorCard.svelte';
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('ChronikErrorCard', () => {
|
||||
it('renders the default error message', async () => {
|
||||
render(ChronikErrorCard, { onRetry: vi.fn() });
|
||||
await expect
|
||||
.element(page.getByText('Die Chronik konnte nicht geladen werden.'))
|
||||
.element(page.getByText('Die Aktivitäten konnten nicht geladen werden.'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -27,7 +27,8 @@ describe('ChronikErrorCard', () => {
|
||||
it('calls onRetry when the retry button is clicked', async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(ChronikErrorCard, { onRetry });
|
||||
await userEvent.click(page.getByText('Erneut versuchen'));
|
||||
const btn = (await page.getByText('Erneut versuchen').element()) as HTMLElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ const rowHref: string = $derived(
|
||||
<a
|
||||
href={rowHref}
|
||||
data-variant={variant}
|
||||
aria-label={variant === 'comment'
|
||||
? item.commentPreview
|
||||
? `${m.chronik_comment_added({ actor: actorName, doc: docTitle })} — ${item.commentPreview}`
|
||||
: m.chronik_comment_added({ actor: actorName, doc: docTitle })
|
||||
: undefined}
|
||||
class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
|
||||
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
|
||||
>
|
||||
@@ -159,20 +164,11 @@ const rowHref: string = $derived(
|
||||
</p>
|
||||
|
||||
{#if variant === 'comment'}
|
||||
<!--
|
||||
TODO: the backend does not yet expose a comment body preview on
|
||||
ActivityFeedItemDTO. Render an ellipsis placeholder until it does —
|
||||
duplicating the document title here looks like the comment is
|
||||
quoting itself (Leonie, PR #288 review).
|
||||
SECURITY: once item.commentPreview lands, render via {text}, never
|
||||
{@html}. The backend must truncate and strip tags server-side (Nora,
|
||||
issue #285 comment #3552).
|
||||
-->
|
||||
<p
|
||||
data-testid="chronik-comment-preview"
|
||||
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
|
||||
>
|
||||
„…“
|
||||
{item.commentPreview ?? '„…"'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -186,6 +186,61 @@ describe('ChronikRow', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
// --- commentPreview content ---
|
||||
it('renders commentPreview text when variant is comment and commentPreview is present', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: 'Hello family, great letter!'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview?.textContent).toContain('Hello family, great letter!');
|
||||
});
|
||||
|
||||
it('renders placeholder ellipsis when variant is comment and commentPreview is null', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: undefined
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview?.textContent?.trim()).toBe('„…"');
|
||||
});
|
||||
|
||||
it('does not render preview paragraph for non-comment variants', async () => {
|
||||
const item: ActivityFeedItemDTO = { ...baseItem, kind: 'TEXT_SAVED' };
|
||||
render(ChronikRow, { item });
|
||||
expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('link has aria-label containing preview text for comment variant with preview', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: 'A wonderful letter from grandma'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[aria-label]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute('aria-label')).toContain('A wonderful letter from grandma');
|
||||
});
|
||||
|
||||
it('link still has aria-label for comment variant when commentPreview is absent', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: undefined
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[aria-label]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute('aria-label')).not.toBeNull();
|
||||
});
|
||||
|
||||
// --- robustness: title rendering for edge cases ---
|
||||
it('still renders the row link when documentTitle is an empty string', async () => {
|
||||
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
|
||||
|
||||
@@ -34,6 +34,6 @@ describe('BulkDropZone', () => {
|
||||
|
||||
it('shows drop hint text', async () => {
|
||||
render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Dateien ablegen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
128
frontend/src/lib/document/TimelineBars.svelte
Normal file
128
frontend/src/lib/document/TimelineBars.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { formatTickLabel } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
|
||||
|
||||
let {
|
||||
filled,
|
||||
maxCount,
|
||||
barAreaHeight,
|
||||
isSelected,
|
||||
isInDragPreview,
|
||||
isDragging,
|
||||
dragWindowLeftPct,
|
||||
dragWindowRightPct,
|
||||
rowEl = $bindable(),
|
||||
onbarpointerdown,
|
||||
onbarpointerenter,
|
||||
onbarclick
|
||||
}: {
|
||||
filled: MonthBucket[];
|
||||
maxCount: number;
|
||||
barAreaHeight: number;
|
||||
isSelected: (label: string) => boolean;
|
||||
isInDragPreview: (index: number) => boolean;
|
||||
isDragging: boolean;
|
||||
dragWindowLeftPct: number;
|
||||
dragWindowRightPct: number;
|
||||
rowEl?: HTMLDivElement;
|
||||
onbarpointerdown: (event: PointerEvent, index: number) => void;
|
||||
onbarpointerenter: (index: number) => void;
|
||||
onbarclick: (index: number) => void;
|
||||
} = $props();
|
||||
|
||||
function barHeight(count: number): number {
|
||||
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
|
||||
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * barAreaHeight);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={rowEl}
|
||||
class="relative flex items-end border-b border-line"
|
||||
style="height: {barAreaHeight}px;"
|
||||
>
|
||||
{#each filled as bucket, i (bucket.month)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-bar"
|
||||
aria-label={bucket.count === 1
|
||||
? m.timeline_bar_aria_singular({ when: formatTickLabel(bucket.month, getLocale()) })
|
||||
: m.timeline_bar_aria_plural({
|
||||
when: formatTickLabel(bucket.month, getLocale()),
|
||||
count: bucket.count
|
||||
})}
|
||||
aria-pressed={isSelected(bucket.month)}
|
||||
onpointerdown={(e) => onbarpointerdown(e, i)}
|
||||
onpointerenter={() => onbarpointerenter(i)}
|
||||
onclick={() => onbarclick(i)}
|
||||
class="bar group flex h-full min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
class:selected={isSelected(bucket.month)}
|
||||
class:in-drag-preview={isInDragPreview(i)}
|
||||
>
|
||||
<span
|
||||
class="bar-fill block w-full rounded-t-[2px]"
|
||||
style="height: {barHeight(bucket.count)}px;"
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if isDragging}
|
||||
<div
|
||||
class="drag-window"
|
||||
data-testid="timeline-drag-window"
|
||||
style="left: {dragWindowLeftPct}%; right: {dragWindowRightPct}%;"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Timeline-specific tokens (--timeline-bar-idle, --timeline-bar-outside) live
|
||||
in layout.css next to the rest of the design tokens; this <style> only
|
||||
consumes them. */
|
||||
.bar .bar-fill {
|
||||
background-color: var(--timeline-bar-idle);
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar .bar-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bar.selected .bar-fill,
|
||||
.bar.in-drag-preview .bar-fill {
|
||||
background-color: var(--palette-mint, #a1dcd8);
|
||||
}
|
||||
|
||||
.bar.in-drag-preview .bar-fill {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Gate hover under (hover: hover) so emulated mouse events on touch devices
|
||||
don't leave a tapped bar stuck in :hover until the next tap elsewhere. */
|
||||
@media (hover: hover) {
|
||||
.bar:hover .bar-fill {
|
||||
background-color: var(--palette-mint, #a1dcd8);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
/* Graylog-style range selector window: left/right borders mark the dragged
|
||||
range, tinted body fills the area. pointer-events:none keeps the bars below
|
||||
reachable so pointermove still fires their pointerenter. */
|
||||
.drag-window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(161, 220, 216, 0.22);
|
||||
border-left: 2px solid var(--palette-mint, #a1dcd8);
|
||||
border-right: 2px solid var(--palette-mint, #a1dcd8);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/lib/document/TimelineControls.svelte
Normal file
41
frontend/src/lib/document/TimelineControls.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
isZoomed,
|
||||
hasSelection,
|
||||
onresetzoom,
|
||||
onclearselection
|
||||
}: {
|
||||
isZoomed: boolean;
|
||||
hasSelection: boolean;
|
||||
onresetzoom: () => void;
|
||||
onclearselection: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||
{#if isZoomed}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-zoom-reset"
|
||||
aria-label={m.timeline_zoom_reset()}
|
||||
title={m.timeline_zoom_reset()}
|
||||
onclick={onresetzoom}
|
||||
class="hover:text-ink-1 inline-flex h-11 min-w-[44px] items-center justify-center gap-1 rounded-sm px-3 text-xs text-ink-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasSelection}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-clear"
|
||||
aria-label={m.timeline_clear_selection()}
|
||||
onclick={onclearselection}
|
||||
class="hover:text-ink-1 inline-flex h-11 w-11 items-center justify-center rounded-full text-ink-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
204
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal file
204
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import {
|
||||
fillDensityGaps,
|
||||
aggregateToYears,
|
||||
clipBucketsToRange,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
formatTickLabel
|
||||
} from '$lib/document/timeline';
|
||||
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import TimelineBars from '$lib/document/TimelineBars.svelte';
|
||||
import TimelineYAxis from '$lib/document/TimelineYAxis.svelte';
|
||||
import TimelineXAxis from '$lib/document/TimelineXAxis.svelte';
|
||||
import TimelineControls from '$lib/document/TimelineControls.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
// Drag emits filter + zoom atomically (Graylog-style range selector).
|
||||
// Single click and clear emit filter only — zoom fields are absent.
|
||||
type SelectionEvent = {
|
||||
from: string;
|
||||
to: string;
|
||||
zoomFrom?: string | null;
|
||||
zoomTo?: string | null;
|
||||
};
|
||||
type ZoomEvent = { zoomFrom: string; zoomTo: string };
|
||||
|
||||
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
|
||||
// Above this threshold, month bars compress to sub-pixel widths in the flex
|
||||
// row; we collapse to year granularity so each bar stays clickable.
|
||||
const MONTH_GRANULARITY_LIMIT = 240;
|
||||
|
||||
let {
|
||||
density,
|
||||
minDate,
|
||||
maxDate,
|
||||
from,
|
||||
to,
|
||||
zoomFrom = null,
|
||||
zoomTo = null,
|
||||
onchange,
|
||||
onzoomchange
|
||||
}: {
|
||||
density: MonthBucket[] | null;
|
||||
minDate: string | null;
|
||||
maxDate: string | null;
|
||||
from: string;
|
||||
to: string;
|
||||
zoomFrom?: string | null;
|
||||
zoomTo?: string | null;
|
||||
onchange: (event: SelectionEvent) => void;
|
||||
onzoomchange?: (event: ZoomEvent | null) => void;
|
||||
} = $props();
|
||||
|
||||
const monthBuckets = $derived.by(() => {
|
||||
if (density === null) return [];
|
||||
const full = fillDensityGaps(density, minDate, maxDate);
|
||||
return clipBucketsToRange(full, zoomFrom, zoomTo);
|
||||
});
|
||||
|
||||
const filled = $derived(
|
||||
monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
|
||||
);
|
||||
|
||||
const isZoomed = $derived(zoomFrom !== null && zoomTo !== null);
|
||||
|
||||
function resetZoom() {
|
||||
onzoomchange?.(null);
|
||||
}
|
||||
|
||||
const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
|
||||
|
||||
const hasSelection = $derived(from !== '' || to !== '');
|
||||
|
||||
let rowEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function clearSelection() {
|
||||
onchange({ from: '', to: '' });
|
||||
}
|
||||
|
||||
function isSelected(label: string): boolean {
|
||||
if (!hasSelection) return false;
|
||||
const labelFrom = selectionBoundaryFrom(label);
|
||||
return labelFrom >= from && labelFrom <= to;
|
||||
}
|
||||
|
||||
function isYearLabel(label: string): boolean {
|
||||
return label.length === 4;
|
||||
}
|
||||
|
||||
function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) {
|
||||
const lo = Math.min(startIndex, endIndex);
|
||||
const hi = Math.max(startIndex, endIndex);
|
||||
const startLabel = filled[lo]?.month;
|
||||
const endLabel = filled[hi]?.month;
|
||||
if (!startLabel || !endLabel) return;
|
||||
const selFrom = selectionBoundaryFrom(startLabel);
|
||||
const selTo = selectionBoundaryTo(endLabel);
|
||||
if (includeZoom) {
|
||||
onchange({ from: selFrom, to: selTo, zoomFrom: selFrom, zoomTo: selTo });
|
||||
} else {
|
||||
onchange({ from: selFrom, to: selTo });
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a viewport X-coordinate to a bar index by measuring the row, so
|
||||
// pointermove during drag works even when the cursor leaves the original bar
|
||||
// or the graph entirely. Pointer capture isn't usable here because it would
|
||||
// re-target click and suppress pointerenter on sibling bars.
|
||||
function indexFromClientX(clientX: number): number | null {
|
||||
if (!rowEl || filled.length === 0) return null;
|
||||
const rect = rowEl.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
if (x < 0) return 0;
|
||||
if (x >= rect.width) return filled.length - 1;
|
||||
const barWidth = rect.width / filled.length;
|
||||
return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth)));
|
||||
}
|
||||
|
||||
const drag = createTimelineDrag({
|
||||
indexFromClientX,
|
||||
labelAt: (i) => filled[i]?.month,
|
||||
isYearLabel,
|
||||
emit: emitSelection
|
||||
});
|
||||
|
||||
// Strip any in-flight document listeners if the component unmounts mid-drag
|
||||
// (route change, view toggle, breakpoint drop). Without this they survive on
|
||||
// document and keep writing to torn-down state cells.
|
||||
$effect(() => drag.cleanup);
|
||||
|
||||
function isInDragPreview(index: number): boolean {
|
||||
if (!drag.isDragging) return false;
|
||||
if (drag.lowIndex === null || drag.highIndex === null) return false;
|
||||
return index >= drag.lowIndex && index <= drag.highIndex;
|
||||
}
|
||||
|
||||
const dragWindowLeftPct = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.lowIndex === null || filled.length === 0) return 0;
|
||||
return (drag.lowIndex / filled.length) * 100;
|
||||
});
|
||||
const dragWindowRightPct = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.highIndex === null || filled.length === 0) return 100;
|
||||
return ((filled.length - drag.highIndex - 1) / filled.length) * 100;
|
||||
});
|
||||
|
||||
// While dragging, expose the live preview range to assistive tech via a
|
||||
// polite live region. Empty text outside drag avoids announcing residual state.
|
||||
const dragLiveMessage = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.lowIndex === null || drag.highIndex === null) return '';
|
||||
const fromLabel = filled[drag.lowIndex]?.month;
|
||||
const toLabel = filled[drag.highIndex]?.month;
|
||||
if (!fromLabel || !toLabel) return '';
|
||||
return m.timeline_dragging_aria_live({
|
||||
from: formatTickLabel(fromLabel, getLocale()),
|
||||
to: formatTickLabel(toLabel, getLocale())
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if density !== null}
|
||||
<div
|
||||
data-testid="timeline-density-filter"
|
||||
role="group"
|
||||
aria-label={m.timeline_aria_label()}
|
||||
class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
|
||||
>
|
||||
<div class="flex">
|
||||
<TimelineYAxis maxCount={maxCount} barAreaHeight={BAR_AREA_HEIGHT} />
|
||||
|
||||
<div class="flex-1">
|
||||
<TimelineBars
|
||||
filled={filled}
|
||||
maxCount={maxCount}
|
||||
barAreaHeight={BAR_AREA_HEIGHT}
|
||||
isSelected={isSelected}
|
||||
isInDragPreview={isInDragPreview}
|
||||
isDragging={drag.isDragging}
|
||||
dragWindowLeftPct={dragWindowLeftPct}
|
||||
dragWindowRightPct={dragWindowRightPct}
|
||||
bind:rowEl={rowEl}
|
||||
onbarpointerdown={drag.pointerDown}
|
||||
onbarpointerenter={drag.pointerEnter}
|
||||
onbarclick={drag.click}
|
||||
/>
|
||||
|
||||
<TimelineXAxis filled={filled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sr-only" aria-live="polite" data-testid="timeline-aria-live">
|
||||
{dragLiveMessage}
|
||||
</div>
|
||||
|
||||
<TimelineControls
|
||||
isZoomed={isZoomed}
|
||||
hasSelection={hasSelection}
|
||||
onresetzoom={resetZoom}
|
||||
onclearselection={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
659
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal file
659
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
|
||||
import { formatTickLabel } from './timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const NOOP = () => undefined;
|
||||
|
||||
function makeProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
] satisfies MonthBucket[],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
from: '',
|
||||
to: '',
|
||||
onchange: NOOP,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimelineDensityFilter — visibility', () => {
|
||||
it('renders nothing when density is null', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ density: null, minDate: null, maxDate: null }));
|
||||
expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the widget when density is populated', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes an accessible group label on the widget', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const widget = document.querySelector('[data-testid="timeline-density-filter"]') as HTMLElement;
|
||||
expect(widget.getAttribute('role')).toBe('group');
|
||||
expect(widget.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — axes', () => {
|
||||
it('renders a Y-axis showing the maximum bar count and zero', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 12 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(yAxis).not.toBeNull();
|
||||
expect(yAxis.textContent).toContain('12');
|
||||
expect(yAxis.textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('renders X-axis ticks at January boundaries for long month ranges', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let m = 8; m <= 12; m++)
|
||||
buckets.push({ month: `1914-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 12; m++)
|
||||
buckets.push({ month: `1915-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 2; m++)
|
||||
buckets.push({ month: `1916-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1914-08-01', maxDate: '1916-02-29' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
expect(ticks.length).toBe(2);
|
||||
expect(Array.from(ticks).map((t) => t.textContent?.trim())).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('1915'), expect.stringContaining('1916')])
|
||||
);
|
||||
});
|
||||
|
||||
it('renders X-axis ticks for year-aggregated bars (every 10 years for ~50yr range)', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1949; year++) {
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
buckets.push({ month: `${year}-${String(month).padStart(2, '0')}`, count: 1 });
|
||||
}
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1949-12-31' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
const labels = Array.from(ticks).map((t) => t.textContent?.trim());
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — bars', () => {
|
||||
it('renders one bar per month within the range, including zero-count gaps', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
|
||||
it('zero-count months get the minimum visible bar height of 2px', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 4 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-09-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"] .bar-fill'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
expect(bars.length).toBe(2);
|
||||
expect(bars[1].style.height).toBe('2px');
|
||||
});
|
||||
|
||||
it('renders an empty widget without crashing when density is empty array and no range', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ density: [], minDate: null, maxDate: null }));
|
||||
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('[data-testid="timeline-bar"]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — selection', () => {
|
||||
it('clicking a bar emits the boundary dates of that month via onchange', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ onchange }));
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
bars[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-08-31' });
|
||||
});
|
||||
|
||||
it('shows a clear button when from/to are set', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
await expect.element(page.getByTestId('timeline-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the clear button when from/to are empty', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
expect(document.querySelector('[data-testid="timeline-clear"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('clicking the clear button emits empty dates via onchange', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30', onchange }));
|
||||
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLButtonElement;
|
||||
clearBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '', to: '' });
|
||||
});
|
||||
|
||||
it('clear button is a real <button> with aria-label (Nora a11y review)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.tagName).toBe('BUTTON');
|
||||
expect(clearBtn.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — year-granularity fallback', () => {
|
||||
it('collapses to year buckets when the month sequence exceeds the limit', async () => {
|
||||
// 21 years × 12 months = 252 entries — above the 240 month limit.
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: year - 1899 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1920-12-31' })
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(21);
|
||||
const firstLabel = bars[0].getAttribute('aria-label') ?? '';
|
||||
// Localised, not the raw machine string "1900 · 1".
|
||||
expect(firstLabel).not.toMatch(/^\d{4} · \d+$/);
|
||||
expect(firstLabel).toContain('1900');
|
||||
expect(firstLabel).toContain('1');
|
||||
});
|
||||
|
||||
it('clicking a year bar zooms into that year (filter + zoom atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: 5 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: buckets,
|
||||
minDate: '1900-01-01',
|
||||
maxDate: '1920-12-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
bars[5].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1905-01-01',
|
||||
to: '1905-12-31',
|
||||
zoomFrom: '1905-01-01',
|
||||
zoomTo: '1905-12-31'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — zoom', () => {
|
||||
it('does not show the zoom-in button (drag replaces it as the zoom gesture)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the reset-zoom button only when zoomed', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }));
|
||||
await expect.element(page.getByTestId('timeline-zoom-reset')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking reset-zoom emits onzoomchange(null)', async () => {
|
||||
const onzoomchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30', onzoomchange })
|
||||
);
|
||||
|
||||
const resetBtn = document.querySelector(
|
||||
'[data-testid="timeline-zoom-reset"]'
|
||||
) as HTMLButtonElement;
|
||||
resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onzoomchange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('when zoomed, only bars within the zoom range are rendered', async () => {
|
||||
// 21-year span normally collapses to year mode (>240 months handled
|
||||
// elsewhere). Zooming in to a 3-month window should restore month bars.
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
buckets.push({
|
||||
month: `${year}-${String(month).padStart(2, '0')}`,
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: buckets,
|
||||
minDate: '1900-01-01',
|
||||
maxDate: '1920-12-31',
|
||||
zoomFrom: '1910-06-01',
|
||||
zoomTo: '1910-08-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
// 3 months in zoom range
|
||||
expect(bars.length).toBe(3);
|
||||
const firstLabel = bars[0].getAttribute('aria-label') ?? '';
|
||||
const lastLabel = bars[2].getAttribute('aria-label') ?? '';
|
||||
// Localised — must NOT contain the raw "YYYY-MM" machine string.
|
||||
expect(firstLabel).not.toMatch(/\d{4}-\d{2}/);
|
||||
expect(lastLabel).not.toMatch(/\d{4}-\d{2}/);
|
||||
expect(firstLabel).toContain(formatTickLabel('1910-06', getLocale()));
|
||||
expect(lastLabel).toContain(formatTickLabel('1910-08', getLocale()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — touch targets', () => {
|
||||
it('reset-zoom button is at least 44×44 (WCAG 2.5.8)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }));
|
||||
const resetBtn = document.querySelector('[data-testid="timeline-zoom-reset"]') as HTMLElement;
|
||||
expect(resetBtn.classList.contains('h-11')).toBe(true);
|
||||
expect(resetBtn.className).toMatch(/min-w-\[44px\]/);
|
||||
expect(resetBtn.className).not.toMatch(/(?:^|\s)h-6(?:$|\s)/);
|
||||
});
|
||||
|
||||
it('clear-selection button is at least 44×44 (WCAG 2.5.8)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.classList.contains('h-11')).toBe(true);
|
||||
expect(clearBtn.classList.contains('w-11')).toBe(true);
|
||||
expect(clearBtn.className).not.toMatch(/(?:^|\s)h-6(?:$|\s)/);
|
||||
});
|
||||
|
||||
it('bar buttons render a focus-visible ring so keyboard users can see focus', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
expect(bar.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(bar.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(bar.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('reset-zoom button renders the same focus-visible ring as the bars', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }));
|
||||
const resetBtn = document.querySelector('[data-testid="timeline-zoom-reset"]') as HTMLElement;
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('clear-selection button renders the same focus-visible ring as the bars', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('bar hover style is gated by @media (hover: hover) to avoid touch-device hover-stick', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const cssText = Array.from(document.styleSheets)
|
||||
.flatMap((sheet) => {
|
||||
try {
|
||||
return Array.from(sheet.cssRules);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n');
|
||||
expect(cssText).toMatch(/@media \(hover: hover\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — accessibility', () => {
|
||||
it('Y-axis labels meet the 12px minimum font floor (Tailwind text-xs)', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(yAxis.classList.contains('text-xs')).toBe(true);
|
||||
expect(yAxis.className).not.toMatch(/text-\[10px\]/);
|
||||
});
|
||||
|
||||
it('X-axis row uses text-xs and h-4 to fit the 12px line height', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const xAxis = document.querySelector('[data-testid="timeline-x-axis"]') as HTMLElement;
|
||||
expect(xAxis.classList.contains('text-xs')).toBe(true);
|
||||
expect(xAxis.classList.contains('h-4')).toBe(true);
|
||||
expect(xAxis.className).not.toMatch(/text-\[10px\]/);
|
||||
});
|
||||
|
||||
it('bar aria-label uses the singular noun form when count is 1', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 1 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
// "documents", "Dokumente", "documentos" are the plural-only forms in our 3 locales.
|
||||
expect(label).not.toMatch(/\b(?:documents|Dokumente|documentos)\b/);
|
||||
expect(label).toMatch(/\b1 (?:document|Dokument|documento)\b/);
|
||||
});
|
||||
|
||||
it('bar aria-label uses the plural noun form when count is not 1', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 5 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
expect(label).toMatch(/\b5 (?:documents|Dokumente|documentos)\b/);
|
||||
});
|
||||
|
||||
it('bar aria-label is built from a localised template, never the raw YYYY-MM', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 5 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
expect(label).not.toMatch(/1915-08/);
|
||||
expect(label).toContain(formatTickLabel('1915-08', getLocale()));
|
||||
expect(label).toContain('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — aria-live during drag', () => {
|
||||
function pointerDown(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
function pointerEnter(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
|
||||
}
|
||||
function pointerUp(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
|
||||
it('renders a polite aria-live region whose text reflects the dragged range', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const live = document.querySelector('[data-testid="timeline-aria-live"]') as HTMLElement;
|
||||
expect(live).not.toBeNull();
|
||||
expect(live.getAttribute('aria-live')).toBe('polite');
|
||||
expect(live.textContent?.trim() ?? '').toBe('');
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
const text = live.textContent ?? '';
|
||||
expect(text).toContain(formatTickLabel('1915-08', getLocale()));
|
||||
expect(text).toContain(formatTickLabel('1915-10', getLocale()));
|
||||
|
||||
pointerUp(bars[2]);
|
||||
await tick();
|
||||
expect(live.textContent?.trim() ?? '').toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — listener cleanup on unmount', () => {
|
||||
it('removes document pointer listeners when unmounted mid-drag', async () => {
|
||||
const removed: string[] = [];
|
||||
const realRemove = document.removeEventListener.bind(document);
|
||||
const removeSpy = vi
|
||||
.spyOn(document, 'removeEventListener')
|
||||
.mockImplementation((type: string, listener, options) => {
|
||||
removed.push(type);
|
||||
return realRemove(type, listener as EventListener, options);
|
||||
});
|
||||
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
bar.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(removed).toContain('pointermove');
|
||||
expect(removed).toContain('pointerup');
|
||||
expect(removed).toContain('pointercancel');
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||
function pointerDown(el: HTMLElement) {
|
||||
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
||||
// jsdom-style stub for setPointerCapture (the real DOM has it but vitest-browser
|
||||
// uses Playwright-driven Chromium so it works natively too).
|
||||
el.dispatchEvent(event);
|
||||
}
|
||||
function pointerEnter(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
|
||||
}
|
||||
function pointerUp(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
|
||||
it('dragging from bar A to bar B emits a single onchange with filter + zoom (atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
pointerUp(bars[2]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('dragging from a later bar to an earlier bar still emits ascending boundaries', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[2]);
|
||||
pointerEnter(bars[0]);
|
||||
pointerUp(bars[0]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('pressing+releasing on the same bar selects that single month without zoom', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ onchange }));
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[1]);
|
||||
pointerUp(bars[1]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-09-01', to: '1915-09-30' });
|
||||
});
|
||||
|
||||
it('renders a drag-window overlay between drag start and current position', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-11', count: 4 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-11-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
const win = document.querySelector('[data-testid="timeline-drag-window"]') as HTMLElement;
|
||||
expect(win).not.toBeNull();
|
||||
// 4 bars total, drag covers indices 0..2 → left 0%, right 25%.
|
||||
expect(win.style.left).toBe('0%');
|
||||
expect(win.style.right).toBe('25%');
|
||||
|
||||
pointerUp(bars[2]);
|
||||
await tick();
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks bars in the active drag range with the in-drag-preview class', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-11', count: 4 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-11-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
expect(bars[0].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[1].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[2].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[3].classList.contains('in-drag-preview')).toBe(false);
|
||||
|
||||
pointerUp(bars[2]);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/document/TimelineXAxis.svelte
Normal file
40
frontend/src/lib/document/TimelineXAxis.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
let {
|
||||
filled
|
||||
}: {
|
||||
filled: MonthBucket[];
|
||||
} = $props();
|
||||
|
||||
const tickIndices = $derived(tickIndicesFor(filled));
|
||||
|
||||
// When all visible buckets share a year, the X-axis omits the year so a
|
||||
// 12-month zoom reads as "Jan Feb Mär…" without repetition.
|
||||
const omitTickYear = $derived.by(() => {
|
||||
if (filled.length === 0 || filled[0].month.length === 4) return false;
|
||||
const firstYear = filled[0].month.slice(0, 4);
|
||||
return filled.every((b) => b.month.slice(0, 4) === firstYear);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative mt-1 h-4 font-sans text-xs leading-none text-ink-3"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-x-axis"
|
||||
>
|
||||
{#each tickIndices as idx (filled[idx]?.month)}
|
||||
{@const tickLeftPct = ((idx + 0.5) / filled.length) * 100}
|
||||
<span
|
||||
class="absolute -translate-x-1/2 whitespace-nowrap"
|
||||
data-testid="timeline-x-tick"
|
||||
style="left: {tickLeftPct}%;"
|
||||
>
|
||||
{formatTickLabel(filled[idx].month, getLocale(), omitTickYear)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
19
frontend/src/lib/document/TimelineYAxis.svelte
Normal file
19
frontend/src/lib/document/TimelineYAxis.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
maxCount,
|
||||
barAreaHeight
|
||||
}: {
|
||||
maxCount: number;
|
||||
barAreaHeight: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-between pr-1.5 text-right font-sans text-xs leading-none text-ink-3"
|
||||
style="height: {barAreaHeight}px;"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-y-axis"
|
||||
>
|
||||
<span>{maxCount}</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
392
frontend/src/lib/document/timeline.spec.ts
Normal file
392
frontend/src/lib/document/timeline.spec.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
monthBoundaryFrom,
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
fetchDensity,
|
||||
buildDensityUrl,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} from './timeline';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
it('returns the first day of the given month', () => {
|
||||
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
});
|
||||
|
||||
it('handles January', () => {
|
||||
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthBoundaryTo', () => {
|
||||
it('returns the last day of a 31-day month', () => {
|
||||
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('returns the last day of a 30-day month', () => {
|
||||
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
|
||||
});
|
||||
|
||||
it('returns 28 for February in a non-leap year', () => {
|
||||
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
|
||||
});
|
||||
|
||||
it('returns 29 for February in a leap year', () => {
|
||||
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMonthSequence', () => {
|
||||
it('returns a single month when min and max are in the same month', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
|
||||
});
|
||||
|
||||
it('returns months from minDate through maxDate inclusive', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
|
||||
'1915-08',
|
||||
'1915-09',
|
||||
'1915-10',
|
||||
'1915-11'
|
||||
]);
|
||||
});
|
||||
|
||||
it('crosses year boundaries correctly', () => {
|
||||
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
|
||||
'1915-11',
|
||||
'1915-12',
|
||||
'1916-01',
|
||||
'1916-02'
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
|
||||
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
|
||||
expect(buildMonthSequence(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillDensityGaps', () => {
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(fillDensityGaps([], null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all-zero sequence when buckets array is empty', () => {
|
||||
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 0 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-08', count: 1 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
|
||||
|
||||
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipBucketsToRange', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 },
|
||||
{ month: '1915-11', count: 3 }
|
||||
];
|
||||
|
||||
it('returns the original buckets when range bounds are null', () => {
|
||||
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
|
||||
});
|
||||
|
||||
it('keeps only buckets whose month falls within the range', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the range excludes everything', () => {
|
||||
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats partial dates correctly when bounds cross month boundaries', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionBoundaryFrom / To', () => {
|
||||
it('handles month labels (YYYY-MM)', () => {
|
||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDensityUrl', () => {
|
||||
it('returns the bare endpoint when no filters provided', () => {
|
||||
expect(buildDensityUrl()).toBe('/api/documents/density');
|
||||
});
|
||||
|
||||
it('forwards single-value filters as query params', () => {
|
||||
expect(buildDensityUrl({ q: 'Brief', senderId: 's-1' })).toBe(
|
||||
'/api/documents/density?q=Brief&senderId=s-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('repeats the tag param for multi-value tag filters', () => {
|
||||
const url = buildDensityUrl({ tags: ['Familie', 'Urlaub'], tagOp: 'OR' });
|
||||
expect(url).toContain('tag=Familie');
|
||||
expect(url).toContain('tag=Urlaub');
|
||||
expect(url).toContain('tagOp=OR');
|
||||
});
|
||||
|
||||
it('omits tagOp when it is AND (default on backend)', () => {
|
||||
const url = buildDensityUrl({ tags: ['Familie'], tagOp: 'AND' });
|
||||
expect(url).not.toContain('tagOp=');
|
||||
});
|
||||
|
||||
it('does not forward from/to even if a caller mistakenly adds them', () => {
|
||||
// Intentional: density is the surface for picking from/to, so it must always
|
||||
// span the broader space the user is selecting within.
|
||||
// @ts-expect-error - from/to are explicitly absent from DensityFilters
|
||||
const url = buildDensityUrl({ q: 'Brief', from: '1915-01-01', to: '1916-12-31' });
|
||||
expect(url).not.toContain('from=');
|
||||
expect(url).not.toContain('to=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDensity', () => {
|
||||
it('skips fetch and returns null density on mobile', async () => {
|
||||
const fetch = vi.fn();
|
||||
|
||||
const result = await fetchDensity(fetch, null, false);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ density: null, minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('skips fetch when view is calendar', async () => {
|
||||
const fetch = vi.fn();
|
||||
|
||||
const result = await fetchDensity(fetch, 'calendar', true);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ density: null, minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('calls /api/documents/density and returns body on desktop, list view', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
buckets: [{ month: '1915-08', count: 3 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1916-12-31'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/documents/density');
|
||||
expect(result.density).toEqual([{ month: '1915-08', count: 3 }]);
|
||||
expect(result.minDate).toBe('1915-08-01');
|
||||
expect(result.maxDate).toBe('1916-12-31');
|
||||
});
|
||||
|
||||
it('forwards active filters as query params', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ buckets: [], minDate: null, maxDate: null })
|
||||
});
|
||||
|
||||
await fetchDensity(fetch, null, true, { senderId: 's-1', tags: ['Familie'] });
|
||||
|
||||
const calledWith = fetch.mock.calls[0][0] as string;
|
||||
expect(calledWith).toContain('senderId=s-1');
|
||||
expect(calledWith).toContain('tag=Familie');
|
||||
});
|
||||
|
||||
it('returns empty density and null bounds when the API responds non-ok', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const result = await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(result).toEqual({ density: [], minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('treats fetch rejection as a graceful degradation, not an error', async () => {
|
||||
const fetch = vi.fn().mockRejectedValue(new TypeError('Network down'));
|
||||
|
||||
const result = await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(result).toEqual({ density: [], minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('emits console.warn with the status when the response is non-ok', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toContain('503');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('emits console.warn with the caught error when fetch rejects', async () => {
|
||||
const error = new TypeError('Network down');
|
||||
const fetch = vi.fn().mockRejectedValue(error);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]).toContain(error);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickIndicesFor', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
231
frontend/src/lib/document/timeline.ts
Normal file
231
frontend/src/lib/document/timeline.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
type DocumentDensityResult = components['schemas']['DocumentDensityResult'];
|
||||
|
||||
export type DensityState = {
|
||||
density: MonthBucket[] | null;
|
||||
minDate: string | null;
|
||||
maxDate: string | null;
|
||||
};
|
||||
|
||||
const SKIP: DensityState = { density: null, minDate: null, maxDate: null };
|
||||
const EMPTY: DensityState = { density: [], minDate: null, maxDate: null };
|
||||
|
||||
export function monthBoundaryFrom(yearMonth: string): string {
|
||||
return `${yearMonth}-01`;
|
||||
}
|
||||
|
||||
export function monthBoundaryTo(yearMonth: string): string {
|
||||
const [year, month] = yearMonth.split('-').map(Number);
|
||||
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
|
||||
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
|
||||
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
|
||||
if (!minDate || !maxDate) return [];
|
||||
|
||||
const [minY, minM] = minDate.split('-').map(Number);
|
||||
const [maxY, maxM] = maxDate.split('-').map(Number);
|
||||
|
||||
const sequence: string[] = [];
|
||||
let year = minY;
|
||||
let month = minM;
|
||||
|
||||
while (year < maxY || (year === maxY && month <= maxM)) {
|
||||
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
|
||||
month += 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
export function fillDensityGaps(
|
||||
buckets: MonthBucket[],
|
||||
minDate: string | null,
|
||||
maxDate: string | null
|
||||
): MonthBucket[] {
|
||||
const sequence = buildMonthSequence(minDate, maxDate);
|
||||
if (sequence.length === 0) return [];
|
||||
|
||||
const counts = new Map(buckets.map((b) => [b.month, b.count]));
|
||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the month buckets whose YYYY-MM falls inside the provided
|
||||
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
|
||||
* input array is returned unchanged. Used by the timeline's zoom-in tool to
|
||||
* narrow the visible bars without refetching data.
|
||||
*
|
||||
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
|
||||
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
|
||||
*/
|
||||
export function clipBucketsToRange(
|
||||
buckets: MonthBucket[],
|
||||
fromInclusive: string | null,
|
||||
toInclusive: string | null
|
||||
): MonthBucket[] {
|
||||
if (!fromInclusive || !toInclusive) return buckets;
|
||||
const fromMonth = fromInclusive.slice(0, 7);
|
||||
const toMonth = toInclusive.slice(0, 7);
|
||||
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates month-granular buckets into one entry per year. Month strings are
|
||||
* truncated to "YYYY" and counts are summed. Used when the date span is too
|
||||
* long for month-granular bars to render at a clickable size.
|
||||
*/
|
||||
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const b of buckets) {
|
||||
const year = b.month.slice(0, 4);
|
||||
totals.set(year, (totals.get(year) ?? 0) + b.count);
|
||||
}
|
||||
return Array.from(totals.entries())
|
||||
.map(([year, count]) => ({ month: year, count }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
|
||||
* (year) and return the matching LocalDate string.
|
||||
*/
|
||||
export function selectionBoundaryFrom(label: string): string {
|
||||
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
|
||||
}
|
||||
|
||||
export function selectionBoundaryTo(label: string): string {
|
||||
if (label.length === 4) return `${label}-12-31`;
|
||||
return monthBoundaryTo(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
|
||||
* to whether bars are years or months and how many are visible:
|
||||
* - Year bars: pick years divisible by a step that scales with range length
|
||||
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
|
||||
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
|
||||
* one year zoomed in to months), fall back to evenly spaced ticks so we
|
||||
* show ~6 labels even when no January boundary exists.
|
||||
*/
|
||||
export function tickIndicesFor(filled: MonthBucket[]): number[] {
|
||||
if (filled.length === 0) return [];
|
||||
const isYearMode = filled[0].month.length === 4;
|
||||
const indices: number[] = [];
|
||||
|
||||
if (isYearMode) {
|
||||
const years = filled.length;
|
||||
const step =
|
||||
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const year = parseInt(filled[i].month, 10);
|
||||
if (year % step === 0) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
if (filled.length <= 18) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Long month range — pick January boundaries (year breaks).
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
if (filled[i].month.endsWith('-01')) indices.push(i);
|
||||
}
|
||||
// Fallback if there's no January in the visible range (rare): even spacing.
|
||||
if (indices.length === 0) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
|
||||
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
|
||||
* "Jan", "Feb", … without repetition.
|
||||
*/
|
||||
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
|
||||
if (label.length === 4) return label;
|
||||
const [yearStr, monthStr] = label.split('-');
|
||||
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
|
||||
const opts: Intl.DateTimeFormatOptions = omitYear
|
||||
? { month: 'short' }
|
||||
: { month: 'short', year: 'numeric' };
|
||||
return new Intl.DateTimeFormat(locale, opts).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of /documents URL params that should narrow the density chart.
|
||||
* Date bounds (`from`/`to`) are intentionally excluded — see
|
||||
* {@link fetchDensity} for why.
|
||||
*/
|
||||
export type DensityFilters = {
|
||||
q?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tags?: string[];
|
||||
tagQ?: string;
|
||||
status?: string;
|
||||
tagOp?: 'AND' | 'OR';
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the density endpoint URL, including the active non-date filters
|
||||
* so the chart matches the document list it sits above.
|
||||
*/
|
||||
export function buildDensityUrl(filters: DensityFilters = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.q) params.set('q', filters.q);
|
||||
if (filters.senderId) params.set('senderId', filters.senderId);
|
||||
if (filters.receiverId) params.set('receiverId', filters.receiverId);
|
||||
for (const tag of filters.tags ?? []) params.append('tag', tag);
|
||||
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||
const qs = params.toString();
|
||||
return qs ? `/api/documents/density?${qs}` : '/api/documents/density';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the density data for the timeline widget. Tablet and below (lg breakpoint,
|
||||
* <1024px) and calendar view both skip the request entirely — the widget isn't
|
||||
* rendered there. A non-ok response or network failure degrades to an empty
|
||||
* bucket list instead of throwing, so the document list page keeps rendering.
|
||||
*/
|
||||
export async function fetchDensity(
|
||||
fetch: typeof globalThis.fetch,
|
||||
view: string | null,
|
||||
isDesktop: boolean,
|
||||
filters: DensityFilters = {}
|
||||
): Promise<DensityState> {
|
||||
if (!isDesktop || view === 'calendar') return SKIP;
|
||||
|
||||
try {
|
||||
const response = await fetch(buildDensityUrl(filters));
|
||||
if (!response.ok) {
|
||||
console.warn(`[timeline] density fetch responded with ${response.status}`);
|
||||
return EMPTY;
|
||||
}
|
||||
const body = (await response.json()) as DocumentDensityResult;
|
||||
return {
|
||||
density: body.buckets,
|
||||
minDate: body.minDate ?? null,
|
||||
maxDate: body.maxDate ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[timeline] density fetch failed', error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ describe('TranscriptionBlock — interactions', () => {
|
||||
it('calls onFocus when textarea is focused', async () => {
|
||||
const onFocus = vi.fn();
|
||||
renderBlock({ onFocus });
|
||||
const textarea = page.getByRole('textbox');
|
||||
await textarea.click();
|
||||
const textboxEl = (await page.getByRole('textbox').element()) as HTMLElement;
|
||||
textboxEl.dispatchEvent(new FocusEvent('focus', { bubbles: false }));
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -152,16 +152,20 @@ describe('TranscriptionBlock — reorder controls', () => {
|
||||
it('calls onMoveUp when up arrow clicked', async () => {
|
||||
const onMoveUp = vi.fn();
|
||||
renderBlock({ onMoveUp, isFirst: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await btn.click();
|
||||
const btnEl = (await page
|
||||
.getByRole('button', { name: 'Nach oben' })
|
||||
.element()) as HTMLButtonElement;
|
||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(onMoveUp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onMoveDown when down arrow clicked', async () => {
|
||||
const onMoveDown = vi.fn();
|
||||
renderBlock({ onMoveDown, isLast: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await btn.click();
|
||||
const btnEl = (await page
|
||||
.getByRole('button', { name: 'Nach unten' })
|
||||
.element()) as HTMLButtonElement;
|
||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(onMoveDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -227,16 +231,17 @@ describe('TranscriptionBlock — delete confirmation', () => {
|
||||
describe('TranscriptionBlock — quote selection', () => {
|
||||
it('shows quote hint after text is selected in the editor', async () => {
|
||||
renderBlock({ text: 'Breslau, den 12. August' });
|
||||
await page.getByRole('textbox').click();
|
||||
// Select all text in the contenteditable via the native Selection API.
|
||||
// Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
|
||||
const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
|
||||
// Native .focus() activates ProseMirror's DOMObserver so it listens for selectionchange.
|
||||
const editorEl = (await page.getByRole('textbox').element()) as HTMLElement;
|
||||
editorEl.focus();
|
||||
// Let ProseMirror's focus handler complete before we overwrite the selection.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editorEl);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import TranscriptionEditView from './TranscriptionEditView.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||
|
||||
@@ -148,24 +148,28 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
||||
});
|
||||
|
||||
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||
const blockWithMention = {
|
||||
...block1,
|
||||
// text must contain the @displayName token so deserialize() creates a mention node;
|
||||
// fill() replaces the whole content with plain text and would destroy the node
|
||||
text: '@Auguste Raddatz',
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
renderView({ blocks: [blockWithMention], onSaveBlock });
|
||||
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Hallo @Auguste Raddatz');
|
||||
// type() focuses the element (cursor at position 0) then inserts without replacing the
|
||||
// existing mention node. Fake timers interfere with keyboard CDP so use real timers
|
||||
// + vi.waitFor to catch the 1500 ms debounce.
|
||||
await userEvent.type(page.getByRole('textbox').first(), 'Hallo ');
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]),
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('resets debounce timer on rapid successive changes', async () => {
|
||||
@@ -238,8 +242,9 @@ describe('TranscriptionEditView — flush on blur', () => {
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Blur text');
|
||||
|
||||
// Blur before 1500ms debounce fires — locator.blur() not available, use native DOM
|
||||
const el = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
// Blur before 1500ms debounce fires — locator.blur() not available, use native DOM.
|
||||
// PersonMentionEditor uses a contenteditable div (role=textbox), not a <textarea>.
|
||||
const el = document.querySelector('[role="textbox"]') as HTMLElement;
|
||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -335,7 +340,12 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
|
||||
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
|
||||
// handlers when a TipTap editor is mounted in the same component tree.
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||
.element()) as HTMLButtonElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
@@ -349,9 +359,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
|
||||
await btn.click();
|
||||
await expect.element(btn).toBeDisabled();
|
||||
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
|
||||
const btnEl = (await page
|
||||
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||
.element()) as HTMLButtonElement;
|
||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeDisabled();
|
||||
resolveMarkAll();
|
||||
});
|
||||
});
|
||||
|
||||
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
|
||||
const { createTimelineDrag } = await import('./useTimelineDrag.svelte');
|
||||
|
||||
type EmitCall = { start: number; end: number; includeZoom: boolean };
|
||||
|
||||
function makeOpts(overrides: Partial<Parameters<typeof createTimelineDrag>[0]> = {}) {
|
||||
const labels = ['1915-08', '1915-09', '1915-10', '1915']; // last entry is a year label
|
||||
const calls: EmitCall[] = [];
|
||||
const opts = {
|
||||
indexFromClientX: vi.fn(() => 0),
|
||||
labelAt: (i: number) => labels[i],
|
||||
isYearLabel: (l: string) => l.length === 4,
|
||||
emit: (start: number, end: number, includeZoom: boolean) => {
|
||||
calls.push({ start, end, includeZoom });
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
return { opts, calls };
|
||||
}
|
||||
|
||||
function pointerDownEvent(button = 0): PointerEvent {
|
||||
return { button } as unknown as PointerEvent;
|
||||
}
|
||||
|
||||
describe('createTimelineDrag', () => {
|
||||
beforeEach(() => {
|
||||
// Reset listeners attached to the JSDOM document between tests
|
||||
document.removeEventListener('pointermove', () => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts with no drag in progress and null low/high indices', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
expect(drag.lowIndex).toBeNull();
|
||||
expect(drag.highIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('pointerDown on a primary button enters drag state at that index', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 1);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(true);
|
||||
expect(drag.lowIndex).toBe(1);
|
||||
expect(drag.highIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('pointerDown on a non-primary button is ignored', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(2), 1);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('pointerEnter during drag widens the range high boundary', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
drag.pointerEnter(2);
|
||||
flushSync();
|
||||
expect(drag.lowIndex).toBe(0);
|
||||
expect(drag.highIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('pointerEnter outside drag is a no-op', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerEnter(2);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(false);
|
||||
expect(drag.lowIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('click on a month bar emits filter only (no zoom)', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.click(0);
|
||||
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
|
||||
});
|
||||
|
||||
it('click on a year bar emits filter + zoom atomically', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.click(3); // labels[3] = '1915' (year label)
|
||||
expect(calls).toEqual([{ start: 3, end: 3, includeZoom: true }]);
|
||||
});
|
||||
|
||||
it('range drag commits emit with zoom in ascending order', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 2) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 999 }));
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([{ start: 0, end: 2, includeZoom: true }]);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('reverse-direction drag still emits ascending boundaries via emit ordering', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 2);
|
||||
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 }));
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
// emit receives raw start/end — orchestrator's emit() handles ordering
|
||||
expect(calls).toEqual([{ start: 2, end: 0, includeZoom: true }]);
|
||||
});
|
||||
|
||||
it('pointerup on the same bar (no-range) on a month label emits filter without zoom', () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
|
||||
});
|
||||
|
||||
it('pointercancel resets state without emitting', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 1);
|
||||
document.dispatchEvent(new PointerEvent('pointercancel'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([]);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('click after pointerup is suppressed (no double emit)', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
drag.click(0); // synthesized click after pointerup
|
||||
expect(calls.length).toBe(1); // only the pointerup-driven emit, not the click
|
||||
});
|
||||
|
||||
it('cleanup removes document pointer listeners', () => {
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
drag.cleanup();
|
||||
const removed = removeSpy.mock.calls.map((c) => c[0]);
|
||||
expect(removed).toEqual(expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']));
|
||||
});
|
||||
});
|
||||
118
frontend/src/lib/document/useTimelineDrag.svelte.ts
Normal file
118
frontend/src/lib/document/useTimelineDrag.svelte.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
type DragOptions = {
|
||||
/**
|
||||
* Maps a viewport X coordinate to a bar index, or null if outside the row.
|
||||
* The orchestrator owns the row element bound from `TimelineBars`.
|
||||
*/
|
||||
indexFromClientX(clientX: number): number | null;
|
||||
/** Returns the bucket label at index, or undefined if out of range. */
|
||||
labelAt(index: number): string | undefined;
|
||||
/**
|
||||
* True when a label represents an aggregated year bar — controls click-to-zoom
|
||||
* semantics (clicking a year zooms into its 12 months; clicking a month doesn't).
|
||||
*/
|
||||
isYearLabel(label: string): boolean;
|
||||
/**
|
||||
* Emits a selection. `includeZoom` is true for a range drag or a click on a
|
||||
* year bar; false for a click on a month bar.
|
||||
*/
|
||||
emit(startIndex: number, endIndex: number, includeZoom: boolean): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drag state machine for the timeline density widget. Exposes:
|
||||
* - `isDragging`, `lowIndex`, `highIndex` reactive read-onlies for the UI
|
||||
* - `pointerDown` / `pointerEnter` / `click` handlers for bar buttons
|
||||
* - `cleanup()` to drop document-level listeners on component unmount
|
||||
*
|
||||
* Document-level listeners (pointermove, pointerup, pointercancel) keep drag
|
||||
* tracking alive while the cursor leaves the original bar or the timeline row.
|
||||
* `cleanup()` must be called from a Svelte `$effect` teardown so a route change
|
||||
* mid-drag does not leak listeners.
|
||||
*/
|
||||
export function createTimelineDrag(opts: DragOptions) {
|
||||
let startIndex = $state<number | null>(null);
|
||||
let endIndex = $state<number | null>(null);
|
||||
// Set after a pointerup-driven emit so the synthesized click that follows is
|
||||
// suppressed (we'd otherwise emit twice). Keyboard Enter/Space fires click
|
||||
// without preceding pointerdown, so click stays the keyboard surface.
|
||||
let suppressClick = $state(false);
|
||||
|
||||
function handleDocumentMove(e: PointerEvent) {
|
||||
const idx = opts.indexFromClientX(e.clientX);
|
||||
if (idx !== null) endIndex = idx;
|
||||
}
|
||||
|
||||
function handleDocumentUp() {
|
||||
cleanup();
|
||||
finalizeDrag();
|
||||
}
|
||||
|
||||
function handleDocumentCancel() {
|
||||
cleanup();
|
||||
startIndex = null;
|
||||
endIndex = null;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', handleDocumentMove);
|
||||
document.removeEventListener('pointerup', handleDocumentUp);
|
||||
document.removeEventListener('pointercancel', handleDocumentCancel);
|
||||
}
|
||||
|
||||
function finalizeDrag() {
|
||||
if (startIndex === null || endIndex === null) return;
|
||||
const start = startIndex;
|
||||
const end = endIndex;
|
||||
startIndex = null;
|
||||
endIndex = null;
|
||||
const isRangeDrag = start !== end;
|
||||
const startLabel = opts.labelAt(start);
|
||||
// Range drag → atomic zoom + filter. Same-bar release on a year bar →
|
||||
// zoom into that year's months. Same-bar release on a month bar →
|
||||
// filter only.
|
||||
const includeZoom = isRangeDrag || (!!startLabel && opts.isYearLabel(startLabel));
|
||||
opts.emit(start, end, includeZoom);
|
||||
suppressClick = true;
|
||||
queueMicrotask(() => {
|
||||
suppressClick = false;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get isDragging() {
|
||||
return startIndex !== null;
|
||||
},
|
||||
get lowIndex() {
|
||||
if (startIndex === null) return null;
|
||||
if (endIndex === null) return startIndex;
|
||||
return Math.min(startIndex, endIndex);
|
||||
},
|
||||
get highIndex() {
|
||||
if (startIndex === null) return null;
|
||||
if (endIndex === null) return startIndex;
|
||||
return Math.max(startIndex, endIndex);
|
||||
},
|
||||
pointerDown(event: PointerEvent, index: number) {
|
||||
if (event.button !== 0) return;
|
||||
startIndex = index;
|
||||
endIndex = index;
|
||||
document.addEventListener('pointermove', handleDocumentMove);
|
||||
document.addEventListener('pointerup', handleDocumentUp);
|
||||
document.addEventListener('pointercancel', handleDocumentCancel);
|
||||
},
|
||||
pointerEnter(index: number) {
|
||||
if (startIndex === null) return;
|
||||
endIndex = index;
|
||||
},
|
||||
click(index: number) {
|
||||
if (suppressClick) {
|
||||
suppressClick = false;
|
||||
return;
|
||||
}
|
||||
const label = opts.labelAt(index);
|
||||
const includeZoom = !!label && opts.isYearLabel(label);
|
||||
opts.emit(index, index, includeZoom);
|
||||
},
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
@@ -1332,6 +1332,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/density": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["density"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/conversation": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2122,9 +2138,11 @@ export interface components {
|
||||
};
|
||||
StatsDTO: {
|
||||
/** Format: int64 */
|
||||
totalPersons?: number;
|
||||
totalPersons: number;
|
||||
/** Format: int64 */
|
||||
totalDocuments?: number;
|
||||
totalDocuments: number;
|
||||
/** Format: int64 */
|
||||
totalStories: number;
|
||||
};
|
||||
PersonSummaryDTO: {
|
||||
title?: string;
|
||||
@@ -2133,16 +2151,16 @@ export interface components {
|
||||
displayName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
personType?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
familyMember?: boolean;
|
||||
notes?: string;
|
||||
alias?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
familyMember?: boolean;
|
||||
};
|
||||
InferredRelationshipWithPersonDTO: {
|
||||
person: components["schemas"]["PersonNodeDTO"];
|
||||
@@ -2351,6 +2369,19 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
uploadedAt: string;
|
||||
};
|
||||
DocumentDensityResult: {
|
||||
buckets: components["schemas"]["MonthBucket"][];
|
||||
/** Format: date */
|
||||
minDate?: string;
|
||||
/** Format: date */
|
||||
maxDate?: string;
|
||||
};
|
||||
MonthBucket: {
|
||||
/** @example 1915-08 */
|
||||
month: string;
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
};
|
||||
DashboardResumeDTO: {
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
@@ -2402,6 +2433,8 @@ export interface components {
|
||||
* @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds.
|
||||
*/
|
||||
annotationId?: string;
|
||||
/** @description Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments. */
|
||||
commentPreview?: string;
|
||||
};
|
||||
InvitePrefillDTO: {
|
||||
firstName: string;
|
||||
@@ -2973,6 +3006,8 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -4801,7 +4836,7 @@ export interface operations {
|
||||
/** @description Filter by document status */
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
/** @description Sort field */
|
||||
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE";
|
||||
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "UPDATED_AT" | "RELEVANCE";
|
||||
/** @description Sort direction: ASC or DESC */
|
||||
dir?: string;
|
||||
/** @description Tag operator: AND (default) or OR */
|
||||
@@ -4925,6 +4960,36 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
density: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tag?: string[];
|
||||
tagQ?: string;
|
||||
/** @description Filter by document status */
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
/** @description Tag operator: AND (default) or OR */
|
||||
tagOp?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentDensityResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -53,7 +53,12 @@ describe('GeschichteEditor — title-required guard', () => {
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
|
||||
await userEvent.tab(); // blur
|
||||
// userEvent.tab() / keyboard('{Tab}') do not reliably fire the blur event on
|
||||
// inputs inside Playwright's test iframe. .blur() is a no-op when the element
|
||||
// has lost focus to TipTap's onMount initialisation. Dispatching the FocusEvent
|
||||
// directly fires Svelte's onblur listener regardless of the current focus owner.
|
||||
const input = await page.getByPlaceholder('Titel der Geschichte').element();
|
||||
input.dispatchEvent(new FocusEvent('blur'));
|
||||
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -111,8 +116,23 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
// userEvent.fill() with trailing whitespace does not fire the input event chain
|
||||
// that Svelte's bind:value requires (CDP limitation). Setting .value directly
|
||||
// and dispatching an input event works around this while preserving the trailing
|
||||
// space needed to verify the trim() contract.
|
||||
const input = (await page
|
||||
.getByPlaceholder('Titel der Geschichte')
|
||||
.element()) as HTMLInputElement;
|
||||
input.value = 'My title ';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// userEvent.click() via Playwright CDP does not reliably trigger Svelte 5 onclick
|
||||
// handlers when a TipTap editor is mounted in the same component. Dispatching
|
||||
// the MouseEvent directly from the browser JS context bypasses this issue.
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: 'Entwurf speichern' })
|
||||
.element()) as HTMLButtonElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
@@ -125,7 +145,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: 'Veröffentlichen' })
|
||||
.element()) as HTMLButtonElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
||||
@@ -140,7 +163,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
||||
});
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: 'Entwurf speichern' })
|
||||
.element()) as HTMLButtonElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
((await page.getByRole('button', { name: 'Max Mustermann' }).element()) as HTMLElement).click();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({
|
||||
@@ -158,11 +158,13 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
((await page.getByRole('button', { name: 'Max Mustermann' }).element()) as HTMLElement).click();
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Anna Musterfrau').click();
|
||||
(
|
||||
(await page.getByRole('button', { name: 'Anna Musterfrau' }).element()) as HTMLElement
|
||||
).click();
|
||||
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
@@ -210,7 +212,13 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
const resultEl = (await page
|
||||
.getByRole('button', { name: 'Max Mustermann' })
|
||||
.element()) as HTMLElement;
|
||||
resultEl.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||
);
|
||||
await tick();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -240,8 +248,11 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
]
|
||||
});
|
||||
// Buttons have aria-label="Entfernen"
|
||||
const removeButtons = page.getByRole('button', { name: 'Entfernen' });
|
||||
await removeButtons.first().click();
|
||||
const removeBtn = (await page
|
||||
.getByRole('button', { name: 'Entfernen' })
|
||||
.first()
|
||||
.element()) as HTMLElement;
|
||||
removeBtn.click();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
});
|
||||
@@ -267,7 +278,9 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
}
|
||||
]
|
||||
});
|
||||
await page.getByRole('button', { name: 'Entfernen' }).first().click();
|
||||
(
|
||||
(await page.getByRole('button', { name: 'Entfernen' }).first().element()) as HTMLElement
|
||||
).click();
|
||||
await tick();
|
||||
const inputs = receiverInputs();
|
||||
expect(inputs).toHaveLength(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
@@ -346,6 +346,14 @@ describe('PersonTypeahead – ARIA roles', () => {
|
||||
|
||||
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||
|
||||
// CDP-based userEvent.keyboard does not reliably trigger Svelte 5 onkeydown
|
||||
// handlers. Dispatch native KeyboardEvent directly on the DOM element instead.
|
||||
async function pressKey(input: ReturnType<typeof page.getByPlaceholder>, key: string) {
|
||||
const el = (await input.element()) as HTMLInputElement;
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe('PersonTypeahead – keyboard navigation', () => {
|
||||
it('ArrowDown moves highlight to the first option', async () => {
|
||||
mockFetchWithPersons();
|
||||
@@ -354,9 +362,7 @@ describe('PersonTypeahead – keyboard navigation', () => {
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await pressKey(input, 'ArrowDown');
|
||||
|
||||
// First option should be highlighted (aria-selected="true")
|
||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||
@@ -370,11 +376,8 @@ describe('PersonTypeahead – keyboard navigation', () => {
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await pressKey(input, 'ArrowDown');
|
||||
await pressKey(input, 'ArrowDown');
|
||||
|
||||
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
|
||||
@@ -387,11 +390,8 @@ describe('PersonTypeahead – keyboard navigation', () => {
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight first
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowUp}'); // wrap to last
|
||||
await tick();
|
||||
await pressKey(input, 'ArrowDown'); // highlight first
|
||||
await pressKey(input, 'ArrowUp'); // wrap to last
|
||||
|
||||
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
|
||||
@@ -404,13 +404,9 @@ describe('PersonTypeahead – keyboard navigation', () => {
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
|
||||
await tick();
|
||||
await pressKey(input, 'ArrowDown'); // highlight first (index 0)
|
||||
await pressKey(input, 'ArrowDown'); // highlight second (index 1 = last)
|
||||
await pressKey(input, 'ArrowDown'); // wrap to first (index 0)
|
||||
|
||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||
@@ -431,11 +427,8 @@ describe('PersonTypeahead – keyboard navigation', () => {
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
await pressKey(input, 'ArrowDown');
|
||||
await pressKey(input, 'Enter');
|
||||
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
@@ -449,9 +442,8 @@ describe('PersonTypeahead – keyboard navigation', () => {
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await tick();
|
||||
await pressKey(input, 'Escape');
|
||||
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
// Value unchanged — nothing was selected
|
||||
await expect.element(input).toHaveValue('Mu');
|
||||
@@ -468,9 +460,7 @@ describe('PersonTypeahead – keyboard navigation', () => {
|
||||
const beforeNav = await input.element().getAttribute('aria-activedescendant');
|
||||
expect(beforeNav).toBeFalsy();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await pressKey(input, 'ArrowDown');
|
||||
|
||||
const afterNav = await input.element().getAttribute('aria-activedescendant');
|
||||
expect(afterNav).toBeTruthy();
|
||||
|
||||
61
frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
Normal file
61
frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
interface Props {
|
||||
drafts: Geschichte[];
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{#if drafts.length === 0}
|
||||
<p class="px-3 py-3 font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col">
|
||||
{#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"
|
||||
>
|
||||
<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>
|
||||
<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}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderDraftsModule from './ReaderDraftsModule.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const draft1: Geschichte = {
|
||||
id: 'g1',
|
||||
title: 'Mein erster Entwurf',
|
||||
status: 'DRAFT',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-02T00:00:00Z'
|
||||
};
|
||||
|
||||
const draft2: Geschichte = {
|
||||
id: 'g2',
|
||||
title: 'Zweiter Entwurf',
|
||||
status: 'DRAFT',
|
||||
createdAt: '2025-02-01T00:00:00Z',
|
||||
updatedAt: '2025-02-01T00:00:00Z'
|
||||
};
|
||||
|
||||
describe('ReaderDraftsModule', () => {
|
||||
it('renders a link to /geschichten/{id}/edit for each draft', async () => {
|
||||
render(ReaderDraftsModule, { drafts: [draft1, draft2] });
|
||||
const link1 = page.getByRole('link', { name: /Mein erster Entwurf/ });
|
||||
await expect.element(link1).toHaveAttribute('href', '/geschichten/g1/edit');
|
||||
const link2 = page.getByRole('link', { name: /Zweiter Entwurf/ });
|
||||
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
|
||||
});
|
||||
|
||||
it('shows heading as h3 (not h2)', 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();
|
||||
});
|
||||
|
||||
it('shows empty state when drafts is empty', async () => {
|
||||
render(ReaderDraftsModule, { drafts: [] });
|
||||
const emptyText = page.getByText(/Keine Entwürfe/i);
|
||||
await expect.element(emptyText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show empty state when drafts are present', async () => {
|
||||
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||
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();
|
||||
});
|
||||
});
|
||||
87
frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte
Normal file
87
frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<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>
|
||||
@@ -0,0 +1,99 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
64
frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
Normal file
64
frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const AVATAR_PALETTE = ['#012851', '#5A3080', '#005F74', '#2A6040', '#803020'] as const;
|
||||
function djb2(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
return Math.abs(hash);
|
||||
}
|
||||
function personAvatarColor(id: string): string {
|
||||
return AVATAR_PALETTE[djb2(id) % AVATAR_PALETTE.length];
|
||||
}
|
||||
function getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '';
|
||||
if (words.length === 1) return words[0].charAt(0).toUpperCase();
|
||||
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
|
||||
interface Props {
|
||||
persons: PersonSummaryDTO[];
|
||||
}
|
||||
|
||||
const { persons }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section aria-label={m.dashboard_reader_person_chips_heading()}>
|
||||
{#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">
|
||||
{#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"
|
||||
>
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
{p.documentCount}
|
||||
</span>
|
||||
{/if}
|
||||
</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"
|
||||
>{m.dashboard_reader_all_persons()}</a
|
||||
>
|
||||
</section>
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderPersonChips from './ReaderPersonChips.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const person1: PersonSummaryDTO = {
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Anna Müller',
|
||||
documentCount: 23,
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
};
|
||||
|
||||
const person2: PersonSummaryDTO = {
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000002',
|
||||
firstName: 'Karl',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Karl Schmidt',
|
||||
documentCount: 5,
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
};
|
||||
|
||||
describe('ReaderPersonChips', () => {
|
||||
it('renders a card for each person with correct href', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1, person2] });
|
||||
const link1 = page.getByRole('link', { name: /Anna Müller/ });
|
||||
await expect
|
||||
.element(link1)
|
||||
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000001');
|
||||
const link2 = page.getByRole('link', { name: /Karl Schmidt/ });
|
||||
await expect
|
||||
.element(link2)
|
||||
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
|
||||
});
|
||||
|
||||
it('person card has min-h-[44px] touch target', 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();
|
||||
});
|
||||
|
||||
it('renders an "Alle Personen" link to /persons', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Personen/i });
|
||||
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 });
|
||||
const cls = ((await allLink.element()) as HTMLElement).className;
|
||||
expect(cls).toMatch(/focus-visible:ring-2/);
|
||||
expect(cls).toMatch(/focus-visible:ring-brand-navy/);
|
||||
});
|
||||
|
||||
it('meets the 44px touch target on the "Alle Personen" link', 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(/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 () => {
|
||||
render(ReaderPersonChips, { persons: [] });
|
||||
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
|
||||
await expect.element(chips).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an empty-state message when persons array is empty', async () => {
|
||||
render(ReaderPersonChips, { persons: [] });
|
||||
const message = page.getByText(/Noch keine Personen im Archiv/i);
|
||||
await expect.element(message).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
87
frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
Normal file
87
frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
interface Props {
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
const { documents }: Props = $props();
|
||||
|
||||
function isNew(doc: Document): boolean {
|
||||
return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
|
||||
}
|
||||
</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">
|
||||
{#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>
|
||||
{#if isNew(doc)}
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-accent-bg px-1.5 py-px text-[11px] font-bold text-ink"
|
||||
>
|
||||
{m.dashboard_badge_new()}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-xs text-ink-3">
|
||||
{#if doc.sender}
|
||||
{doc.sender.displayName ?? doc.sender.lastName}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Date -->
|
||||
<span class="shrink-0 text-[11px] text-ink-3">
|
||||
{relativeTimeDe(new Date(doc.updatedAt))}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderRecentDocs from './ReaderRecentDocs.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseDoc: Document = {
|
||||
id: 'doc1',
|
||||
title: 'Brief an Hans',
|
||||
originalFilename: 'brief.pdf',
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: true,
|
||||
scriptType: 'HANDWRITING_KURRENT',
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
updatedAt: '2025-01-01T12:00:00Z'
|
||||
};
|
||||
|
||||
const updatedDoc: Document = {
|
||||
...baseDoc,
|
||||
id: 'doc2',
|
||||
title: 'Urkunde 1920',
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
updatedAt: '2025-03-01T12:00:00Z'
|
||||
};
|
||||
|
||||
describe('ReaderRecentDocs', () => {
|
||||
it('renders a link to /documents/{id} for each document', async () => {
|
||||
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||
const link = page.getByRole('link', { name: /Brief an Hans/ });
|
||||
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 () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
const sameInstantDoc: Document = {
|
||||
...baseDoc,
|
||||
id: 'doc-same-instant',
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
updatedAt: '2025-01-01T12:00:00.000Z'
|
||||
};
|
||||
render(ReaderRecentDocs, { documents: [sameInstantDoc] });
|
||||
const badge = page.getByText(/^Neu$/i);
|
||||
await expect.element(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sender name text when sender is present', async () => {
|
||||
const docWithSender: Document = {
|
||||
...baseDoc,
|
||||
sender: {
|
||||
id: 'p1',
|
||||
lastName: 'Müller',
|
||||
firstName: 'Anna',
|
||||
displayName: 'Anna Müller',
|
||||
personType: 'PERSON' as const,
|
||||
familyMember: false
|
||||
}
|
||||
};
|
||||
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('—');
|
||||
});
|
||||
});
|
||||
61
frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
Normal file
61
frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
interface Props {
|
||||
stories: Geschichte[];
|
||||
}
|
||||
|
||||
const { stories }: Props = $props();
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
function excerpt(body: string | undefined): string {
|
||||
if (!body) return '';
|
||||
const text = stripHtml(body);
|
||||
if (text.length <= 150) return text;
|
||||
return text.slice(0, 150) + '…';
|
||||
}
|
||||
</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">
|
||||
{#each stories as story (story.id)}
|
||||
<li>
|
||||
<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"
|
||||
>
|
||||
<span class="font-serif text-base text-ink italic">{story.title}</span>
|
||||
{#if story.body}
|
||||
<p class="line-clamp-2 text-xs leading-relaxed text-ink-2">{excerpt(story.body)}</p>
|
||||
{/if}
|
||||
<span class="text-[11px] text-ink-3">
|
||||
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderRecentStories from './ReaderRecentStories.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const story1: Geschichte = {
|
||||
id: 'g1',
|
||||
title: 'Die Familie Müller',
|
||||
body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>',
|
||||
status: 'PUBLISHED',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
publishedAt: '2025-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const longBodyStory: Geschichte = {
|
||||
id: 'g2',
|
||||
title: 'Sehr lange Geschichte',
|
||||
body: '<p>' + 'A'.repeat(200) + '</p>',
|
||||
status: 'PUBLISHED',
|
||||
createdAt: '2025-02-01T00:00:00Z',
|
||||
updatedAt: '2025-02-01T00:00:00Z',
|
||||
publishedAt: '2025-02-01T00:00:00Z'
|
||||
};
|
||||
|
||||
describe('ReaderRecentStories', () => {
|
||||
it('renders a link to /geschichten/{id} for each story', async () => {
|
||||
render(ReaderRecentStories, { stories: [story1] });
|
||||
const link = page.getByRole('link', { name: /Die Familie Müller/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/geschichten/g1');
|
||||
});
|
||||
|
||||
it('truncates body excerpt to 150 characters and strips HTML', async () => {
|
||||
render(ReaderRecentStories, { stories: [longBodyStory] });
|
||||
const excerpt = page.getByText(/A{100,150}/);
|
||||
await expect.element(excerpt).toBeInTheDocument();
|
||||
const text = ((await excerpt.element()) as HTMLElement).textContent;
|
||||
expect(text!.replace(/…$/, '').length).toBeLessThanOrEqual(150);
|
||||
});
|
||||
|
||||
it('shows empty state when stories array is empty', async () => {
|
||||
render(ReaderRecentStories, { stories: [] });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Alle Geschichten" link pointing to /geschichten', async () => {
|
||||
render(ReaderRecentStories, { stories: [story1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
|
||||
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
|
||||
});
|
||||
|
||||
it('exposes a focus-visible ring on the "Alle Geschichten" link', async () => {
|
||||
render(ReaderRecentStories, { stories: [story1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
|
||||
const cls = ((await allLink.element()) as HTMLElement).className;
|
||||
expect(cls).toMatch(/focus-visible:ring-2/);
|
||||
expect(cls).toMatch(/focus-visible:ring-brand-navy/);
|
||||
});
|
||||
|
||||
it('meets the 44px touch target on the "Alle Geschichten" link', async () => {
|
||||
render(ReaderRecentStories, { stories: [story1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -174,7 +174,14 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
// MentionDropdown handles selection via onmousedown (not onclick) to prevent
|
||||
// blurring the editor before the selection fires. userEvent.click() via CDP
|
||||
// does not reliably trigger Svelte 5's onmousedown handler when TipTap is
|
||||
// mounted — dispatch the MouseEvent directly from browser JS instead.
|
||||
const option = (await page
|
||||
.getByRole('option', { name: /Auguste Raddatz/ })
|
||||
.element()) as HTMLElement;
|
||||
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||
@@ -212,7 +219,10 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
const option = (await page
|
||||
.getByRole('option', { name: /Auguste Raddatz/ })
|
||||
.element()) as HTMLElement;
|
||||
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||||
|
||||
@@ -9,6 +9,8 @@ const defaultProps = {
|
||||
icon: '✍',
|
||||
title: 'Unleserliche Wörter',
|
||||
body: 'Schreiben Sie [unleserlich].',
|
||||
// beispielInput is required for the → arrow to render (component: {#if beispielInput !== undefined})
|
||||
beispielInput: '[Original]',
|
||||
beispielOutput: '[unleserlich]'
|
||||
};
|
||||
|
||||
@@ -34,9 +36,12 @@ describe('RichtlinienRuleCard', () => {
|
||||
|
||||
it('renders beispielOutput in monospace with → arrow', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
const mono = document.querySelector('code, [class*="font-mono"]');
|
||||
expect(mono).not.toBeNull();
|
||||
expect(mono!.textContent).toContain('[unleserlich]');
|
||||
// With both beispielInput and beispielOutput, the component renders two code elements:
|
||||
// [Input] → [Output]. querySelectorAll gets both; last one is the output.
|
||||
const codes = document.querySelectorAll('code, [class*="font-mono"]');
|
||||
expect(codes.length).toBeGreaterThanOrEqual(1);
|
||||
const outputCode = codes[codes.length - 1];
|
||||
expect(outputCode.textContent).toContain('[unleserlich]');
|
||||
await expect.element(page.getByText(/→/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -369,8 +369,7 @@ describe('TagInput – onTextInput callback', () => {
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Ka');
|
||||
await waitForFetch();
|
||||
const option = page.getByRole('option', { name: 'Kaufvertrag' });
|
||||
await option.click();
|
||||
((await page.getByRole('option', { name: 'Kaufvertrag' }).element()) as HTMLElement).click();
|
||||
await expect.poll(() => onTextInput.mock.calls.at(-1)).toEqual(['']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,21 @@ type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||
type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
export async function load({ fetch }) {
|
||||
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
|
||||
if (res?.status !== 'fulfilled') return null;
|
||||
const v = res.value as { response: Response; data: unknown };
|
||||
return v.response.ok ? ((v.data as T) ?? null) : null;
|
||||
}
|
||||
|
||||
export async function load({ fetch, parent }) {
|
||||
const { canWrite, canAnnotate, canBlogWrite } = await parent();
|
||||
// READ_ALL without WRITE_ALL or ANNOTATE_ALL — see ADR-007.
|
||||
// BLOG_WRITE-only users land here too and see the drafts module on top.
|
||||
const isReader = !canWrite && !canAnnotate;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
try {
|
||||
@@ -20,6 +33,43 @@ export async function load({ fetch }) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
if (isReader) {
|
||||
const readerFetches: Promise<unknown>[] = [
|
||||
api.GET('/api/stats'),
|
||||
api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }),
|
||||
api.GET('/api/documents/search', {
|
||||
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
|
||||
}),
|
||||
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } })
|
||||
];
|
||||
if (canBlogWrite) {
|
||||
readerFetches.push(
|
||||
api.GET('/api/geschichten', { params: { query: { status: 'DRAFT', limit: 10 } } })
|
||||
);
|
||||
}
|
||||
|
||||
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] =
|
||||
await Promise.allSettled(readerFetches);
|
||||
|
||||
const readerStats = settled<StatsDTO>(statsRes);
|
||||
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
|
||||
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
|
||||
const recentDocs = searchData?.items.map((i) => i.document) ?? [];
|
||||
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
||||
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
|
||||
|
||||
return {
|
||||
isReader: true as const,
|
||||
canBlogWrite,
|
||||
readerStats,
|
||||
topPersons,
|
||||
recentDocs,
|
||||
recentStories,
|
||||
drafts,
|
||||
error: null as string | null
|
||||
};
|
||||
}
|
||||
|
||||
const [
|
||||
statsResult,
|
||||
resumeResult,
|
||||
@@ -87,6 +137,7 @@ export async function load({ fetch }) {
|
||||
}
|
||||
|
||||
return {
|
||||
isReader: false as const,
|
||||
stats,
|
||||
resumeDoc,
|
||||
pulse,
|
||||
@@ -103,6 +154,7 @@ export async function load({ fetch }) {
|
||||
if ((e as { status?: number }).status) throw e;
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
isReader,
|
||||
stats: null,
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
@@ -113,6 +165,11 @@ export async function load({ fetch }) {
|
||||
weeklyStats: null,
|
||||
incompleteDocs: [] as IncompleteDocumentDTO[],
|
||||
incompleteTotal: 0,
|
||||
readerStats: null,
|
||||
topPersons: [] as PersonSummaryDTO[],
|
||||
recentDocs: [] as Document[],
|
||||
recentStories: [] as Geschichte[],
|
||||
drafts: [] as Geschichte[],
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ 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 ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
|
||||
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
|
||||
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
|
||||
import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -25,42 +30,63 @@ 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}
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
{#if data.isReader}
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||
|
||||
<EnrichmentBlock
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
bannerCount={bannerCount}
|
||||
onBannerClose={() => (bannerCount = 0)}
|
||||
<ReaderHeaderBar
|
||||
name={data.user?.firstName ?? ''}
|
||||
documents={data.readerStats?.totalDocuments ?? null}
|
||||
persons={data.readerStats?.totalPersons ?? null}
|
||||
stories={data.readerStats?.totalStories ?? null}
|
||||
/>
|
||||
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
</h2>
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{#if data.canBlogWrite}
|
||||
<ReaderDraftsModule drafts={data.drafts ?? []} />
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
</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} />
|
||||
|
||||
<EnrichmentBlock
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
bannerCount={bannerCount}
|
||||
onBannerClose={() => (bannerCount = 0)}
|
||||
/>
|
||||
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
</h2>
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -13,12 +13,14 @@ let {
|
||||
items,
|
||||
canWrite,
|
||||
error,
|
||||
total = 0,
|
||||
q = '',
|
||||
sort = 'DATE'
|
||||
}: {
|
||||
items: DocumentSearchItem[];
|
||||
canWrite: boolean;
|
||||
error?: string | null;
|
||||
total?: number;
|
||||
q?: string;
|
||||
sort?: SortMode;
|
||||
} = $props();
|
||||
@@ -77,6 +79,10 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
|
||||
</div>
|
||||
</div>
|
||||
{:else if items.length > 0}
|
||||
<!-- RESULT COUNT -->
|
||||
{#if total > 0}
|
||||
<p class="mb-2 text-sm text-ink-2">{total} Dokumente</p>
|
||||
{/if}
|
||||
<!-- GROUP CARDS -->
|
||||
{#each groups as group (group.label)}
|
||||
<div
|
||||
|
||||
@@ -26,8 +26,7 @@ export async function load({ fetch, locals }) {
|
||||
const api = createApiClient(fetch);
|
||||
const canManageUsers = hasPerm(user, 'ADMIN_USER');
|
||||
|
||||
// TODO: replace with a dedicated /api/admin/stats endpoint that returns counts only,
|
||||
// so the System page does not load full entity lists it does not render.
|
||||
// TODO(#453): replace with dedicated /api/admin/stats endpoint returning counts only
|
||||
const [usersResult, groupsResult, tagsResult] = await Promise.all([
|
||||
api.GET('/api/users'),
|
||||
api.GET('/api/groups'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user