Compare commits

...

32 Commits

Author SHA1 Message Date
Marcel
9fb1821db5 fix(#240): remove CTA buttons and dead i18n keys from Mission Control Strip
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m29s
CI / Backend Unit Tests (pull_request) Failing after 2m41s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
The enrich page already handles task routing; the buttons in the
segmentation and transcription columns were redundant. Removes the
unused mission_control_segmentation_cta, mission_control_transcription_cta,
and mission_control_ready_all_cta keys from all three locale files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:18 +02:00
Marcel
86a216918f fix(#240): make Mission Control Strip dark-mode compatible
Replace all hardcoded Tailwind colours with semantic tokens:
- bg-white → bg-surface (outer strip container)
- text-gray-400 → text-ink-3 (dates, meta text, empty-state copy)
- text-green-800 / text-green-700 → text-ink / text-ink-2 (headings, pulse, reviewed %)
- bg-green-50 / border-green-200 → bg-accent-bg / border-line (skill pill, weekly pulse badge)
- bg-ink text-white → bg-primary text-primary-fg (CTA buttons; dark: mint bg + navy text)
- hover:text-white → hover:text-primary-fg (ghost CTA hover text)
- focus-visible:ring-brand-navy → focus-visible:ring-focus-ring (all doc links)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:18 +02:00
Marcel
48152517aa fix(#240): fix invisible hover on column 1 & 2 doc links
brand-sand/30 on white background is near-invisible; use full
hover:bg-brand-sand instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:18 +02:00
Marcel
4af2e4ad17 fix(#240): remove dead "Alle lesen" link and add hover shadow to ReadyColumn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:18 +02:00
Marcel
94b5d1a5a8 fix(#240): align Mission Control Strip UI with final spec
- Strip heading: "Mitarbeiten" → "Was braucht Aufmerksamkeit?"
- Column 1 heading: "Segmentierung" → "Rahmen einzeichnen"; add green
  skill pill "✓ Ohne Vorkenntnisse"; heading color gray → ink (navy)
- Column 2 heading: "Transkription" → "Text eintippen"; add navy skill
  pill "Kurrent hilfreich"; heading color gray → ink; weekly pulse
  color green → ink (task, not achievement); progress bar track
  bg-gray-200/h-1.5 → bg-ink/20/h-1; add transition-all to fill
- Column 3 heading: "Lesefertig" → "Lesefertig ✓"; heading color
  gray → green-800; add "N Dokumente bereit" subtitle in green; add
  "Alle N lesen →" link at bottom; reviewed % color gray → green-800
- All columns: add CTA buttons at bottom (Jetzt einzeichnen /
  Jetzt tippen); empty state removed from cols 1 & 2 (columns
  hide when empty); empty-state ghost CTA in col 3 restyled as
  bordered button with hover:bg-ink
- Strip: add visibility guard — hides when all three lists are empty
- i18n: add mission_control_seg_skill_pill, mission_control_trans_skill_pill,
  mission_control_ready_subtitle, mission_control_ready_all_cta in
  de/en/es; update heading and CTA copy in all three locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:18 +02:00
Marcel
aa8fb70d10 fix(#240): redirect Mission Control Strip links to document detail page
The /enrich route is for metadata (title, date, sender/receiver).
Segmentation and transcription work happens on the document detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:18 +02:00
Marcel
9404ec34ce fix(#240): add missing V36 index migration and rename needs_expert to V37
V36 (add_index_transcription_blocks_document_id) was applied to the dev
database during a previous local session but never committed to git.
Flyway checksum mismatch prevented the backend from starting.

- V36__add_index_transcription_blocks_document_id.sql: restored from the
  index that already exists in the database (idx_transcription_blocks_document_id)
- V36__add_needs_expert_to_documents.sql → V37__add_needs_expert_to_documents.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:18 +02:00
Marcel
78abc7f726 docs(#240): add Mission Control Strip spec and pattern alternatives
Adds the design decision record for how to expand the dashboard without
pushing content below the fold: a full-width 3-column strip (Segmentierung /
Transkription / Lesefertig) below the existing grid.

- dashboard-expansion-patterns.html — four pattern alternatives evaluated
  (Tabs, Accordion, Mission Control, Priority Queue) with annotated mockups,
  engagement feature proposal, and final recommendation.
- mission-control-strip-final.html — clean implementation blueprint with
  pipeline diagram, column definitions, seeded-weekly-shuffle sorting,
  expert-flag escape hatch, all Tailwind impl-ref values, and backend
  contracts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:07 +02:00
Marcel
f36bebd1a8 feat(#240): Mission Control Strip frontend — 5 components + dashboard wiring
Adds the full-width 3-column collaboration widget below the existing
dashboard grid. Renders without the backend running (Promise.allSettled
isolation keeps failures silent).

Components (src/lib/components/):
- ExpertBadge.svelte — purple pill with icon, no props
- SegmentationColumn.svelte — col 1: links to /enrich/{id}, weekly pulse
- TranscriptionColumn.svelte — col 2: per-doc progress bar when blocks exist
- ReadyColumn.svelte — col 3: mint border when filled, dashed empty state
- MissionControlStrip.svelte — strip wrapper, 1-col mobile / 3-col sm+

i18n: 19 new keys added to de/en/es (mission_control_*)

Page wiring:
- +page.server.ts: 4 new Promise.allSettled calls for segmentation-queue,
  transcription-queue, ready-to-read, weekly-stats; all failures silent
- +page.svelte: MissionControlStrip rendered below the grid in isDashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:42:07 +02:00
Marcel
53c5d90340 feat(#240): update generated API types for Mission Control Strip
Manually adds the new types to src/lib/generated/api.ts:
- Document.needsExpert: boolean (required field)
- TranscriptionQueueItemDTO schema
- TranscriptionWeeklyStatsDTO schema
- Paths: /api/transcription/{segmentation-queue, transcription-queue,
         ready-to-read, weekly-stats} and /api/documents/{id}/needs-expert
- Operations: matching typed request/response shapes

Fixes briefwechsel spec fixtures to include scriptType and needsExpert
so the Document type shape is satisfied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:41:55 +02:00
Marcel
2ea603a3bf feat(#240): backend for Mission Control Strip — queue endpoints + expert flag
Adds the server-side foundation for the dashboard transcription widget:

- V36 migration: needs_expert BOOLEAN NOT NULL DEFAULT FALSE on documents
- Document entity: needsExpert field (@Schema required)
- DocumentRepository: 4 native queries — segmentation queue, transcription
  queue, ready-to-read queue (seeded weekly shuffle sort), weekly pulse stats
- TranscriptionQueueService: maps Object[] rows to typed DTOs, handles
  PostgreSQL type variations (UUID/String, Date/LocalDate, Number/BigDecimal)
- TranscriptionQueueController: GET /api/transcription/{segmentation-queue,
  transcription-queue, ready-to-read, weekly-stats} — all guarded by READ_ALL
- DocumentService + DocumentController: PATCH /api/documents/{id}/needs-expert
  toggles the expert flag (WRITE_ALL required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:41:55 +02:00
Marcel
d7b2357834 feat(search): surface summary snippet when summary matched the query
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / Backend Unit Tests (push) Failing after 2m44s
Add a summary_snippet column to findEnrichmentData using ts_headline on
documents.summary, only when the summary's tsvector matches the query.
Expose it via SearchMatchData.summarySnippet / summaryOffsets and render
a "Zusammenfassung" / "Summary" / "Resumen" labelled row in the document
list — identical treatment to the transcription snippet row.

Fixes the case where a document appeared in search results with no
visible match explanation (e.g. searching "frucht" found a document
whose summary mentioned "Früchte").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
eb18d4f568 feat(search): restyle highlights to navy underline and add snippet labels
Switch search match highlights from bordered mint chips to a plain navy
underline (decoration-brand-navy). Add visible "Inhalt" / "Content" /
"Contenido" label before the transcription snippet, matching the style
of the Von/An sender-receiver labels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
091f7e5d25 feat(search): partial-word matching via to_tsquery prefix queries
Replace websearch_to_tsquery with a CROSS JOIN LATERAL subquery that
appends :* to each lexeme so prefix matches work (e.g. "furchtb" finds
"furchtbar"). websearch_to_tsquery still handles the safe tokenisation
of user input (stop words, special chars, operators); regexp_replace
then adds :* before to_tsquery re-parses the result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
32f151ff31 feat(search): add snippetOffsets to SearchMatchData and use ts_headline for highlighted snippets
- SearchMatchData gains a 6th field snippetOffsets: List<MatchOffset> so the frontend
  can render highlighted terms inside the transcription snippet without {#html}.
- DocumentRepository.findEnrichmentData now calls ts_headline() with chr(1)/chr(2)
  sentinels instead of returning raw block text; parseHighlight() strips the sentinels
  and produces clean text + MatchOffset list in one pass.
- DocumentService exposes ParsedHighlight and parseHighlight() as public so they can be
  called from cross-package integration tests.
- All related tests updated to the new 6-argument SearchMatchData constructor and
  to call parseHighlight() for asserting the snippet clean text and offsets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
9ff8423da6 feat(search): highlight snippet terms and mark sender/receiver/tag matches in document list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
162397d4eb fix(search): make ParsedHighlight and parseHighlight public for cross-package test access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
fabab6b502 fix(pdf): merge setElements and render effects so canvas remount triggers re-render
The refactor made pdfDoc a plain variable so renderer.isLoaded was not
reactive. Svelte only tracked currentPage and scale — but when the canvas
reappeared after loading, neither changed, so the PDF stayed blank.

Fix: merge the two effects into one that reads canvasEl synchronously.
Svelte now tracks canvasEl as a dependency; when the canvas remounts
(loading spinner → false), the effect re-fires and renders the
already-loaded PDF document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
bcb2898e5f perf(search): add index on transcription_blocks.document_id for lateral join
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
2c64a6d8a4 style(search): improve mark hover contrast, remove no-op class, italicize snippet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
b74ae27171 test(search): add applyOffsets coverage for negative start offsets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
2817410f94 test(search): assert matchData key and snippet in controller search response
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
63d1a2e1ff fix(search): mark documents and total as required in OpenAPI schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
bb29cac496 feat(search): pass matchData from server load to DocumentList
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
60dc73ba04 feat(search): render title highlights and transcription snippets in DocumentList
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
6cffd36b22 feat(search): add applyOffsets utility and regenerate API types with MatchOffset/SearchMatchData
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
f723a83011 feat(search): enrich searchDocuments with per-document match data
DocumentService.searchDocuments now returns DocumentSearchResult with matchData
populated from findEnrichmentData. Title highlights are parsed from chr(1)/chr(2)
delimiters into MatchOffset lists; transcription snippet and sender/receiver/tag
match flags are extracted from the same native SQL row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
c235151075 test(search): add DocumentSearchEnrichmentTest for findEnrichmentData native query
Tests lateral join best-block selection, chr(1)/chr(2) headline delimiters,
sender/receiver/tag match flags, and null cases for missing relations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
741eebc276 feat(search): add DocumentSearchResult.withMatchData() factory with match overlay map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
8a5ca6868f feat(search): add SearchMatchData record for per-document match signals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
a15b5ebf17 feat(search): add MatchOffset record for character-level highlight positions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:10:10 +02:00
Marcel
ed12a54339 fix(fileloader): use untrack to prevent infinite reload loop
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m26s
CI / Backend Unit Tests (pull_request) Failing after 2m43s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
loadFile() reads fileUrl synchronously before its first await. When
called from a \$effect, Svelte tracks that read and re-runs the effect
every time fileUrl changes — i.e. after every successful load — causing
an infinite cycle of file fetches and PdfViewer remounts.

Fix: wrap the fileUrl read in untrack() so callers never accidentally
subscribe to fileUrl changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:26:04 +02:00
42 changed files with 4295 additions and 107 deletions

View File

@@ -208,8 +208,15 @@ public class DocumentController {
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
}
List<Document> results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir);
return ResponseEntity.ok(DocumentSearchResult.of(results));
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir));
}
// --- EXPERT FLAG ---
@PatchMapping("/{id}/needs-expert")
@RequirePermission(Permission.WRITE_ALL)
public Document toggleNeedsExpert(@PathVariable UUID id) {
return documentService.toggleNeedsExpert(id);
}
// --- TRAINING LABELS ---

View File

@@ -0,0 +1,47 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.TranscriptionQueueService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Serves the three Mission Control Strip columns for the dashboard.
* All endpoints require READ_ALL — same guard as the rest of the archive.
*/
@RestController
@RequestMapping("/api/transcription")
@RequiredArgsConstructor
@RequirePermission(Permission.READ_ALL)
public class TranscriptionQueueController {
private final TranscriptionQueueService transcriptionQueueService;
@GetMapping("/segmentation-queue")
public ResponseEntity<List<TranscriptionQueueItemDTO>> getSegmentationQueue() {
return ResponseEntity.ok(transcriptionQueueService.getSegmentationQueue());
}
@GetMapping("/transcription-queue")
public ResponseEntity<List<TranscriptionQueueItemDTO>> getTranscriptionQueue() {
return ResponseEntity.ok(transcriptionQueueService.getTranscriptionQueue());
}
@GetMapping("/ready-to-read")
public ResponseEntity<List<TranscriptionQueueItemDTO>> getReadyToRead() {
return ResponseEntity.ok(transcriptionQueueService.getReadyToReadQueue());
}
@GetMapping("/weekly-stats")
public ResponseEntity<TranscriptionWeeklyStatsDTO> getWeeklyStats() {
return ResponseEntity.ok(transcriptionQueueService.getWeeklyStats());
}
}

View File

@@ -1,16 +1,35 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.model.Document;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record DocumentSearchResult(List<Document> documents, long total) {
public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Document> documents,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long total,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Map<UUID, SearchMatchData> matchData
) {
/**
* Creates a result where total equals the list size.
* Creates a fully-enriched result from documents and their match overlay data.
* Absent map entries (e.g. document deleted between FTS and enrichment) are safe —
* the frontend treats a missing entry as "no match data".
*/
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
return new DocumentSearchResult(documents, documents.size(), matchData);
}
/**
* Creates a result without match data — used for filter-only searches (no text query).
* No pagination yet — the full matched set is always returned.
* When pagination is added, total must come from a DB COUNT query, not list.size().
*/
public static DocumentSearchResult of(List<Document> documents) {
return new DocumentSearchResult(documents, documents.size());
return withMatchData(documents, Map.of());
}
}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Character-level offset of a highlighted term within a text field.
* Offsets are Java {@code String} character positions (UTF-16 code units),
* which are identical to JavaScript string positions — consistent end-to-end
* for all German BMP characters (ä, ö, ü, ß, etc.).
*/
public record MatchOffset(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int start,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int length
) {}

View File

@@ -0,0 +1,67 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.UUID;
/**
* Match signals for a single document in a full-text search result.
* All fields are non-null except {@code transcriptionSnippet} and {@code summarySnippet},
* which are null when the respective field did not match the query.
*/
public record SearchMatchData(
/**
* Best-ranked matching transcription line, or null if no block matched.
*/
String transcriptionSnippet,
/**
* Character offsets of highlighted terms within the document title.
* Empty when the title did not contribute to the match.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<MatchOffset> titleOffsets,
/**
* True when the sender's name matched the query.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
boolean senderMatched,
/**
* IDs of receiver persons whose names matched the query.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<UUID> matchedReceiverIds,
/**
* IDs of tags whose names matched the query.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<UUID> matchedTagIds,
/**
* Character offsets of highlighted terms within the transcription snippet.
* Empty when no transcription block matched or the snippet has no highlights.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<MatchOffset> snippetOffsets,
/**
* Highlighted summary excerpt, or null if the summary did not match the query.
*/
String summarySnippet,
/**
* Character offsets of highlighted terms within the summary snippet.
* Empty when the summary did not match or has no highlights.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<MatchOffset> summaryOffsets
) {
/** Canonical "no match data" value for a single document. */
public static SearchMatchData empty() {
return new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
}
}

View File

@@ -0,0 +1,19 @@
package org.raddatz.familienarchiv.dto;
import java.time.LocalDate;
import java.util.UUID;
/**
* A single row in one of the three Mission Control Strip queues.
* Annotation/block counts drive the per-document mini progress bar
* in the Transkription column and the percentage label in Lesefertig.
*/
public record TranscriptionQueueItemDTO(
UUID id,
String title,
LocalDate documentDate,
boolean needsExpert,
int annotationCount,
int textedBlockCount,
int reviewedBlockCount
) {}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.dto;
/**
* Weekly activity pulse for the Mission Control Strip column headers.
* Counts documents that received new work in each pipeline stage
* during the last 7 days.
*/
public record TranscriptionWeeklyStatsDTO(
long segmentationCount,
long transcriptionCount,
long readyCount
) {}

View File

@@ -97,6 +97,11 @@ public class Document {
@Builder.Default
private ScriptType scriptType = ScriptType.UNKNOWN;
@Column(name = "needs_expert", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private boolean needsExpert = false;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
@Builder.Default

View File

@@ -83,10 +83,166 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Query(nativeQuery = true, value = """
SELECT d.id FROM documents d
WHERE d.search_vector @@ websearch_to_tsquery('german', :query)
ORDER BY ts_rank(d.search_vector, websearch_to_tsquery('german', :query)) DESC,
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
'g'))
END AS pq
) q
WHERE d.search_vector @@ q.pq
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
d.meta_date DESC NULLS LAST
""")
List<UUID> findRankedIdsByFts(@Param("query") String query);
/**
* Returns match-enrichment data for a set of documents identified by their IDs.
* Each row contains (in column order):
* <ol>
* <li>UUID — document id</li>
* <li>String — title headline with \x01/\x02 delimiters around matched terms</li>
* <li>String — best-ranked transcription snippet with \x01/\x02 delimiters, or null</li>
* <li>Boolean — whether the sender's name matched the query</li>
* <li>String — comma-separated matched receiver UUIDs, or null</li>
* <li>String — comma-separated matched tag UUIDs, or null</li>
* <li>String — summary snippet with \x01/\x02 delimiters, or null if summary didn't match</li>
* </ol>
* Short-circuit before calling this method when {@code ids} is empty or {@code query} is blank.
*/
@Query(nativeQuery = true, value = """
SELECT
d.id,
ts_headline('german', d.title, q.pq,
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',HighlightAll=true')
AS title_headline,
CASE WHEN best_block.text IS NOT NULL THEN
ts_headline('german', best_block.text, q.pq,
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',MaxWords=50,MinWords=20')
END AS transcription_snippet,
(s.id IS NOT NULL AND
to_tsvector('german', COALESCE(s.first_name, '') || ' ' || COALESCE(s.last_name, ''))
@@ q.pq)
AS sender_matched,
(SELECT string_agg(r.id::text, ',')
FROM document_receivers dr
JOIN persons r ON r.id = dr.person_id
WHERE dr.document_id = d.id
AND to_tsvector('german', COALESCE(r.first_name, '') || ' ' || r.last_name)
@@ q.pq
) AS matched_receiver_ids,
(SELECT string_agg(t.id::text, ',')
FROM document_tags dt
JOIN tag t ON t.id = dt.tag_id
WHERE dt.document_id = d.id
AND to_tsvector('german', t.name) @@ q.pq
) AS matched_tag_ids,
CASE WHEN d.summary IS NOT NULL AND d.summary <> ''
AND to_tsvector('german', d.summary) @@ q.pq
THEN ts_headline('german', d.summary, q.pq,
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',MaxWords=50,MinWords=20')
END AS summary_snippet
FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
'g'))
END AS pq
) q
LEFT JOIN persons s ON s.id = d.sender_id
LEFT JOIN LATERAL (
SELECT tb.text
FROM transcription_blocks tb
WHERE tb.document_id = d.id
AND to_tsvector('german', tb.text) @@ q.pq
ORDER BY ts_rank(to_tsvector('german', tb.text), q.pq) DESC
LIMIT 1
) best_block ON true
WHERE d.id IN :ids
""")
List<Object[]> findEnrichmentData(@Param("ids") Collection<UUID> ids, @Param("query") String query);
// --- Mission Control Strip queues ---
/** Documents with no annotations — Segmentierung column. */
@Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
0 AS annotationCount, 0 AS textedBlockCount, 0 AS reviewedBlockCount
FROM documents d
WHERE d.status NOT IN ('PLACEHOLDER')
AND NOT EXISTS (SELECT 1 FROM document_annotations da WHERE da.document_id = d.id)
ORDER BY d.needs_expert ASC,
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
LIMIT :limit
""")
List<Object[]> findSegmentationQueue(@Param("limit") int limit);
/** Documents with annotations but not yet fully reviewed — Transkription column. */
@Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
COUNT(DISTINCT da.id) AS annotationCount,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
FROM documents d
JOIN document_annotations da ON da.document_id = d.id
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
GROUP BY d.id, d.title, d.meta_date, d.needs_expert
HAVING COUNT(DISTINCT da.id) > 0
AND (
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) = 0
OR (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
NULLIF(COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END), 0)
) < 0.90
)
ORDER BY d.needs_expert ASC,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) DESC,
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
LIMIT :limit
""")
List<Object[]> findTranscriptionQueue(@Param("limit") int limit);
/** Documents with reviewed_pct >= 90 % — Lesefertig column. */
@Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
COUNT(DISTINCT da.id) AS annotationCount,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
FROM documents d
JOIN document_annotations da ON da.document_id = d.id
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
GROUP BY d.id, d.title, d.meta_date, d.needs_expert
HAVING COUNT(DISTINCT da.id) > 0
AND COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) > 0
AND (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END)
) >= 0.90
ORDER BY (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END)
) DESC
LIMIT :limit
""")
List<Object[]> findReadyToReadQueue(@Param("limit") int limit);
/** Weekly pulse: distinct documents that received new work in each pipeline stage. */
@Query(nativeQuery = true, value = """
SELECT
(SELECT COUNT(DISTINCT da.document_id) FROM document_annotations da
WHERE da.created_at >= NOW() - INTERVAL '7 days') AS segmentationCount,
(SELECT COUNT(DISTINCT tb.document_id) FROM transcription_blocks tb
WHERE tb.created_at >= NOW() - INTERVAL '7 days'
AND tb.text IS NOT NULL AND tb.text <> '') AS transcriptionCount,
(SELECT COUNT(DISTINCT tb.document_id) FROM transcription_blocks tb
WHERE tb.updated_at >= NOW() - INTERVAL '7 days'
AND tb.reviewed = true) AS readyCount
""")
Object[] findWeeklyStats();
}

View File

@@ -3,10 +3,13 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.ScriptType;
import org.raddatz.familienarchiv.model.TrainingLabel;
@@ -290,13 +293,13 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
}
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
@@ -312,11 +315,13 @@ public class DocumentService {
// generates an INNER JOIN that silently drops documents with null sender/receivers.
if (sort == DocumentSort.RECEIVER) {
List<Document> results = documentRepository.findAll(spec);
return sortByFirstReceiver(results, dir);
List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
}
if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec);
return sortBySender(results, dir);
List<Document> sorted = sortBySender(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
}
// RELEVANCE: default when text present and no explicit sort given
@@ -325,14 +330,16 @@ public class DocumentService {
List<Document> results = documentRepository.findAll(spec);
Map<UUID, Integer> rankMap = new HashMap<>();
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
return results.stream()
List<Document> sorted = results.stream()
.sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList();
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
}
Sort springSort = resolveSort(sort, dir);
return documentRepository.findAll(spec, springSort);
List<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(results, enrichWithMatchData(results, text));
}
private Sort resolveSort(DocumentSort sort, String dir) {
@@ -570,6 +577,13 @@ public class DocumentService {
return parsed != null ? parsed.title() : stripExtension(filename);
}
@Transactional
public Document toggleNeedsExpert(UUID documentId) {
Document doc = getDocumentById(documentId);
doc.setNeedsExpert(!doc.isNeedsExpert());
return documentRepository.save(doc);
}
private static String tryParseDate(String s) {
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
int m = Integer.parseInt(s.substring(5, 7));
@@ -584,6 +598,93 @@ public class DocumentService {
return null;
}
/**
* Calls {@code findEnrichmentData} and converts the raw Object[] rows into a
* {@link SearchMatchData} per document. Short-circuits when the list is empty or
* the query is blank (no text search active).
*/
private Map<UUID, SearchMatchData> enrichWithMatchData(List<Document> docs, String query) {
if (docs.isEmpty() || !StringUtils.hasText(query)) return Map.of();
List<UUID> ids = docs.stream().map(Document::getId).toList();
Map<UUID, SearchMatchData> result = new HashMap<>();
for (Object[] row : documentRepository.findEnrichmentData(ids, query)) {
UUID docId = (UUID) row[0];
String titleHeadline = (String) row[1];
String snippetHeadline = (String) row[2];
Boolean senderMatched = (Boolean) row[3];
String receiverIdsStr = (String) row[4];
String tagIdsStr = (String) row[5];
String summaryHeadline = (String) row[6];
ParsedHighlight snippet = parseHighlight(snippetHeadline);
ParsedHighlight summary = parseHighlight(summaryHeadline);
result.put(docId, new SearchMatchData(
snippet != null ? snippet.cleanText() : null,
parseTitleOffsets(titleHeadline),
senderMatched != null && senderMatched,
parseUUIDs(receiverIdsStr),
parseUUIDs(tagIdsStr),
snippet != null ? snippet.offsets() : List.of(),
summary != null ? summary.cleanText() : null,
summary != null ? summary.offsets() : List.of()
));
}
return result;
}
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}
/**
* Parses a {@code ts_headline} result that uses {@code chr(1)}/{@code chr(2)} as
* start/stop delimiters. Returns the clean text (delimiters stripped) together with
* the character offsets of each highlighted span. Returns {@code null} when
* {@code headline} is {@code null}.
*/
public static ParsedHighlight parseHighlight(String headline) {
if (headline == null) return null;
StringBuilder clean = new StringBuilder(headline.length());
List<MatchOffset> offsets = new ArrayList<>();
int i = 0;
int pos = 0; // position in the clean string (no delimiters)
while (i < headline.length()) {
char c = headline.charAt(i);
if (c == '\u0001') {
int start = pos;
i++;
while (i < headline.length() && headline.charAt(i) != '\u0002') {
clean.append(headline.charAt(i));
i++;
pos++;
}
offsets.add(new MatchOffset(start, pos - start));
i++; // skip \u0002
} else {
clean.append(c);
i++;
pos++;
}
}
return new ParsedHighlight(clean.toString(), offsets);
}
/**
* Extracts only the {@link MatchOffset} list from a title headline.
* The clean title text comes from the {@link Document} entity itself.
*/
private static List<MatchOffset> parseTitleOffsets(String headline) {
ParsedHighlight parsed = parseHighlight(headline);
return parsed != null ? parsed.offsets() : List.of();
}
private static List<UUID> parseUUIDs(String csv) {
if (csv == null || csv.isBlank()) return List.of();
return Arrays.stream(csv.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(UUID::fromString)
.toList();
}
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");

View File

@@ -0,0 +1,100 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
* and the weekly activity pulse used by the column headers.
*/
@Service
@RequiredArgsConstructor
public class TranscriptionQueueService {
private static final int DEFAULT_QUEUE_SIZE = 5;
private final DocumentRepository documentRepository;
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.toList();
}
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.toList();
}
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.toList();
}
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
Object[] row = documentRepository.findWeeklyStats();
return new TranscriptionWeeklyStatsDTO(
toLong(row[0]),
toLong(row[1]),
toLong(row[2])
);
}
// --- mapping helpers ---
private TranscriptionQueueItemDTO mapRow(Object[] row) {
UUID id = toUUID(row[0]);
String title = (String) row[1];
LocalDate documentDate = toLocalDate(row[2]);
boolean needsExpert = toBoolean(row[3]);
int annotationCount = toInt(row[4]);
int textedBlockCount = toInt(row[5]);
int reviewedBlockCount = toInt(row[6]);
return new TranscriptionQueueItemDTO(id, title, documentDate, needsExpert,
annotationCount, textedBlockCount, reviewedBlockCount);
}
private UUID toUUID(Object o) {
if (o instanceof UUID u) return u;
return UUID.fromString(o.toString());
}
private LocalDate toLocalDate(Object o) {
if (o == null) return null;
if (o instanceof LocalDate d) return d;
if (o instanceof java.sql.Date d) return d.toLocalDate();
return LocalDate.parse(o.toString());
}
private boolean toBoolean(Object o) {
if (o instanceof Boolean b) return b;
return Boolean.parseBoolean(o.toString());
}
private int toInt(Object o) {
if (o == null) return 0;
if (o instanceof Number n) return n.intValue();
if (o instanceof BigDecimal bd) return bd.intValue();
return Integer.parseInt(o.toString());
}
private long toLong(Object o) {
if (o == null) return 0L;
if (o instanceof Number n) return n.longValue();
if (o instanceof BigDecimal bd) return bd.longValue();
return Long.parseLong(o.toString());
}
}

View File

@@ -0,0 +1,4 @@
-- Index on transcription_blocks.document_id to speed up the LATERAL join
-- used in DocumentService.findEnrichmentData (FTS match enrichment).
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_id
ON transcription_blocks (document_id);

View File

@@ -0,0 +1 @@
ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document;
@@ -24,6 +25,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -61,7 +63,7 @@ class DocumentControllerTest {
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList());
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk());
@@ -71,7 +73,7 @@ class DocumentControllerTest {
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()))
.thenReturn(Collections.emptyList());
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
@@ -104,7 +106,7 @@ class DocumentControllerTest {
@WithMockUser
void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList());
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
@@ -112,6 +114,28 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.documents").isArray());
}
@Test
@WithMockUser
void search_responseBodyContainsMatchDataKey() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId)
.title("Brief an Anna")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.build();
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.matchData").isMap())
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
.value("Er schrieb einen langen Brief"));
}
// ─── POST /api/documents ─────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,68 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DocumentSearchResultTest {
private Document doc(UUID id) {
return Document.builder()
.id(id)
.title("Test")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
@Test
void withMatchData_total_equals_list_size() {
UUID id = UUID.randomUUID();
List<Document> docs = List.of(doc(id));
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
assertThat(result.total()).isEqualTo(1L);
}
@Test
void withMatchData_exposes_match_data_map() {
UUID id = UUID.randomUUID();
SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data));
assertThat(result.matchData()).containsKey(id);
assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet");
}
@Test
void of_factory_returns_empty_match_data() {
UUID id = UUID.randomUUID();
DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id)));
assertThat(result.matchData()).isEmpty();
assertThat(result.total()).isEqualTo(1L);
}
@Test
void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
@Test
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
}

View File

@@ -0,0 +1,22 @@
package org.raddatz.familienarchiv.dto;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MatchOffsetTest {
@Test
void should_hold_start_and_length() {
MatchOffset offset = new MatchOffset(6, 5);
assertThat(offset.start()).isEqualTo(6);
assertThat(offset.length()).isEqualTo(5);
}
@Test
void should_implement_value_equality() {
assertThat(new MatchOffset(0, 3)).isEqualTo(new MatchOffset(0, 3));
assertThat(new MatchOffset(0, 3)).isNotEqualTo(new MatchOffset(0, 4));
}
}

View File

@@ -0,0 +1,69 @@
package org.raddatz.familienarchiv.dto;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class SearchMatchDataTest {
@Test
void transcription_snippet_is_nullable() {
SearchMatchData data = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
assertThat(data.transcriptionSnippet()).isNull();
}
@Test
void non_null_list_fields_are_empty_by_default_in_empty_factory() {
SearchMatchData data = SearchMatchData.empty();
assertThat(data.transcriptionSnippet()).isNull();
assertThat(data.titleOffsets()).isEmpty();
assertThat(data.matchedReceiverIds()).isEmpty();
assertThat(data.matchedTagIds()).isEmpty();
assertThat(data.senderMatched()).isFalse();
}
@Test
void holds_all_field_values() {
MatchOffset offset = new MatchOffset(0, 4);
SearchMatchData data = new SearchMatchData(
"schreibt dir aus dem Feld",
List.of(offset),
true,
List.of(),
List.of(),
List.of(),
null,
List.of()
);
assertThat(data.transcriptionSnippet()).isEqualTo("schreibt dir aus dem Feld");
assertThat(data.titleOffsets()).containsExactly(offset);
assertThat(data.senderMatched()).isTrue();
}
@Test
void snippet_offsets_are_empty_in_empty_factory() {
SearchMatchData data = SearchMatchData.empty();
assertThat(data.snippetOffsets()).isEmpty();
}
@Test
void snippet_offsets_carry_through_constructor() {
MatchOffset offset = new MatchOffset(5, 3);
SearchMatchData data = new SearchMatchData(
"Das ist ein furchtbares Bild",
List.of(),
false,
List.of(),
List.of(),
List.of(offset),
null,
List.of()
);
assertThat(data.snippetOffsets()).containsExactly(offset);
}
}

View File

@@ -79,6 +79,16 @@ class DocumentFtsTest {
assertThat(ids).hasSize(1);
}
@Test
void should_find_document_by_partial_word_prefix() {
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb");
assertThat(ids).hasSize(1);
}
@Test
void should_not_find_document_when_term_absent() {
documentRepository.saveAndFlush(document("Familienfoto"));

View File

@@ -0,0 +1,307 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class DocumentSearchEnrichmentTest {
@Autowired DocumentRepository documentRepository;
@Autowired PersonRepository personRepository;
@Autowired TagRepository tagRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired EntityManager em;
@BeforeEach
void setUp() {
blockRepository.deleteAll();
documentRepository.deleteAll();
personRepository.deleteAll();
tagRepository.deleteAll();
}
// ─── Lateral join: best transcription snippet ──────────────────────────────
@Test
void lateral_join_returns_highest_ranked_transcription_block() {
Document doc = documentRepository.saveAndFlush(document("Brief an Anna"));
UUID annotId = annotation(doc.getId());
// Three blocks — the one with three occurrences has highest rank
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Das Wetter war schön", 0));
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Brief Brief Brief", 1)); // highest rank for "Brief"
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Ein Brief liegt vor", 2)); // one occurrence
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
// row[2] is now a ts_headline result with sentinel chars — parse it for clean text
DocumentService.ParsedHighlight parsed = DocumentService.parseHighlight((String) rows.get(0)[2]);
assertThat(parsed).isNotNull();
assertThat(parsed.cleanText()).isEqualTo("Brief Brief Brief");
assertThat(parsed.offsets()).isNotEmpty(); // at least one "Brief" is highlighted
}
@Test
void document_with_no_transcription_blocks_has_null_snippet() {
Document doc = documentRepository.saveAndFlush(document("Foto ohne Text"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Foto");
assertThat(rows).hasSize(1);
Object snippet = rows.get(0)[2];
assertThat(snippet).isNull();
}
@Test
void document_with_non_matching_blocks_has_null_snippet() {
Document doc = documentRepository.saveAndFlush(document("Dok"));
UUID annotId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Kein Match hier", 0));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[2]).isNull();
}
// ─── Title headline: delimiter-based offset detection ─────────────────────
@Test
void title_headline_contains_delimiters_when_title_matches() {
Document doc = documentRepository.saveAndFlush(document("Brief an die Familie"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
// chr(1) marks the start of the highlighted term
assertThat(headline).contains("\u0001");
assertThat(headline).contains("\u0002");
}
@Test
void title_headline_has_no_delimiters_when_title_does_not_match() {
Document doc = documentRepository.saveAndFlush(document("Familienfoto"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
assertThat(headline).doesNotContain("\u0001");
assertThat(headline).doesNotContain("\u0002");
}
@Test
void title_headline_matches_stemmed_form() {
// "Brief" (singular, query) should match "Briefe" (plural, in title) via German FTS stemming.
// Both reduce to the stem "brief" under the Snowball German algorithm — verified by the
// existing should_find_document_by_stemmed_inflected_form test in DocumentFtsTest.
Document doc = documentRepository.saveAndFlush(document("Alte Briefe aus Berlin"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
assertThat(headline).contains("\u0001");
}
// ─── Sender match ──────────────────────────────────────────────────────────
@Test
void sender_matched_is_true_when_sender_last_name_matches_query() {
Person sender = personRepository.saveAndFlush(
Person.builder().firstName("Walter").lastName("Raddatz").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isTrue();
}
@Test
void sender_matched_is_false_when_sender_name_does_not_match() {
Person sender = personRepository.saveAndFlush(
Person.builder().firstName("Walter").lastName("Raddatz").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isFalse();
}
@Test
void sender_matched_is_false_when_document_has_no_sender() {
Document doc = documentRepository.saveAndFlush(document("Brief von unbekannt"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isFalse();
}
// ─── Receiver match ────────────────────────────────────────────────────────
@Test
void matched_receiver_ids_contains_uuid_of_matching_receiver() {
Person receiver = personRepository.saveAndFlush(
Person.builder().firstName("Anna").lastName("Schmidt").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(Set.of(receiver))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
assertThat(rows).hasSize(1);
String receiverIds = (String) rows.get(0)[4];
assertThat(receiverIds).contains(receiver.getId().toString());
}
@Test
void matched_receiver_ids_is_null_when_no_receiver_matches() {
Person receiver = personRepository.saveAndFlush(
Person.builder().firstName("Anna").lastName("Schmidt").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(Set.of(receiver))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[4]).isNull();
}
// ─── Tag match ─────────────────────────────────────────────────────────────
@Test
void matched_tag_ids_contains_uuid_of_matching_tag() {
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Dokument")
.originalFilename("dok.pdf")
.status(DocumentStatus.UPLOADED)
.tags(Set.of(tag))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Familiengeschichte");
assertThat(rows).hasSize(1);
String tagIds = (String) rows.get(0)[5];
assertThat(tagIds).contains(tag.getId().toString());
}
@Test
void matched_tag_ids_is_null_when_no_tag_matches() {
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Dokument")
.originalFilename("dok.pdf")
.status(DocumentStatus.UPLOADED)
.tags(Set.of(tag))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[5]).isNull();
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private Document document(String title) {
return Document.builder()
.title(title)
.originalFilename(title.replace(" ", "_") + ".pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
private UUID annotation(UUID documentId) {
DocumentAnnotation ann = annotationRepository.save(DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1")
.build());
em.flush();
return ann.getId();
}
private TranscriptionBlock block(UUID documentId, UUID annotationId, String text, int order) {
return TranscriptionBlock.builder()
.documentId(documentId)
.annotationId(annotationId)
.text(text)
.sortOrder(order)
.build();
}
}

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
@@ -51,12 +52,12 @@ class DocumentServiceSortTest {
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(newer, older));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC");
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result).hasSize(2);
assertThat(result.get(0).getId()).isEqualTo(id2); // newer doc first
assertThat(result.documents()).hasSize(2);
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
}
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
@@ -73,11 +74,11 @@ class DocumentServiceSortTest {
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1)); // unordered from DB
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
// Expect: rank order restored (id1 first)
assertThat(result.get(0).getId()).isEqualTo(id1);
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
}
@Test
@@ -92,9 +93,9 @@ class DocumentServiceSortTest {
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null);
assertThat(result.get(0).getId()).isEqualTo(id1);
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
}
}

View File

@@ -6,11 +6,14 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
@@ -22,6 +25,7 @@ import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDate;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -1287,11 +1291,11 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(withSender, noSender));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
assertThat(result).hasSize(2);
assertThat(result).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
assertThat(result.documents()).hasSize(2);
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
}
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
@@ -1307,10 +1311,10 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(noReceivers, withReceiver));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc");
assertThat(result).extracting(Document::getTitle)
assertThat(result.documents()).extracting(Document::getTitle)
.containsExactly("Has Receiver", "No Receivers");
}
@@ -1329,11 +1333,99 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(docNullName, docSmith));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result).extracting(Document::getTitle)
assertThat(result.documents()).extracting(Document::getTitle)
.containsExactly("smith doc", "Null lastname doc");
}
// ─── searchDocuments — match data enrichment ──────────────────────────────
@Test
void searchDocuments_withTextQuery_includesMatchDataWithTitleOffsets() {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).title("Brief an Anna").build();
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
assertThat(result.matchData()).containsKey(docId);
SearchMatchData md = result.matchData().get(docId);
assertThat(md.titleOffsets()).hasSize(1);
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
}
@Test
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null);
assertThat(result.matchData()).isEmpty();
}
@Test
void searchDocuments_withTextQuery_includesTranscriptionSnippetWhenPresent() {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).title("Dok").build();
// Simulate ts_headline output with sentinel markers around the matched word
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
SearchMatchData md = result.matchData().get(docId);
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
}
// ─── parseHighlight unit tests ────────────────────────────────────────────
@Test
void parseHighlight_returnsNull_whenInputIsNull() {
assertThat(DocumentService.parseHighlight(null)).isNull();
}
@Test
void parseHighlight_returnsCleanTextAndEmptyOffsets_whenNoSentinels() {
DocumentService.ParsedHighlight result = DocumentService.parseHighlight("plain text");
assertThat(result.cleanText()).isEqualTo("plain text");
assertThat(result.offsets()).isEmpty();
}
@Test
void parseHighlight_extractsOffsetAndStripsDelimiters() {
// \u0001 = start sentinel, \u0002 = stop sentinel
DocumentService.ParsedHighlight result = DocumentService.parseHighlight("Das \u0001furchtbare\u0002 Wort");
assertThat(result.cleanText()).isEqualTo("Das furchtbare Wort");
assertThat(result.offsets()).containsExactly(new MatchOffset(4, 10)); // "furchtbare" at pos 4, len 10
}
@Test
void parseHighlight_handlesMultipleHighlightedTerms() {
DocumentService.ParsedHighlight result =
DocumentService.parseHighlight("\u0001Hallo\u0002 und \u0001Welt\u0002");
assertThat(result.cleanText()).isEqualTo("Hallo und Welt");
assertThat(result.offsets()).containsExactly(
new MatchOffset(0, 5), // "Hallo"
new MatchOffset(10, 4) // "Welt"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,814 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Mission-Control-Streifen — Finale Spec (Issue #240)</title>
<style>
:root{
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
--orange:#C26A00;--orange-bg:#FEF4E2;
--green:#2E6E39;--green-bg:#EAF5EA;
--purple:#5B5EA6;--purple-bg:#EEEDFE;
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
/* Header */
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
.prose:last-child{margin-bottom:0;}
/* Sections */
.sec{margin-bottom:52px;}
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:16px;}
/* Tags */
.tag-list{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;}
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.04em;}
.t-g{background:var(--green-bg);color:var(--green);}
.t-o{background:var(--orange-bg);color:var(--orange);}
.t-n{background:rgba(0,40,80,.08);color:var(--navy);}
.t-p{background:var(--purple-bg);color:var(--purple);}
/* Pipeline diagram */
.pipeline{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:14px 18px;background:#fff;border:1px solid var(--border);border-radius:6px;margin-bottom:24px;}
.pipe-node{text-align:center;}
.pipe-badge{display:inline-block;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;margin-bottom:4px;}
.pipe-badge.n1{background:rgba(0,40,80,.08);color:var(--navy);}
.pipe-badge.n2{background:rgba(0,40,80,.08);color:var(--navy);}
.pipe-badge.n3{background:rgba(0,40,80,.08);color:var(--navy);}
.pipe-badge.done{background:var(--green-bg);color:var(--green);}
.pipe-sub{font-size:10px;color:var(--muted);}
.pipe-arrow{font-size:16px;color:var(--border);flex-shrink:0;}
.pipe-col-label{font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-top:4px;}
.pipe-col-label.s{color:var(--navy);}
.pipe-col-label.t{color:var(--navy);}
.pipe-col-label.l{color:var(--green);}
/* Column definition grid */
.col-defs{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:28px;}
.col-def{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;}
.col-def-title{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px;}
.col-def-title.n{color:var(--navy);}
.col-def-title.g{color:var(--green);}
.col-def p{font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:8px;}
.col-def code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
/* Callout */
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
.callout.purple{background:var(--purple-bg);border-left:3px solid var(--purple);}
.callout strong{font-weight:700;}
.callout strong.o{color:var(--orange);}
.callout strong.g{color:var(--green);}
.callout strong.n{color:var(--navy);}
.callout strong.p{color:var(--purple);}
/* Sorting options */
.sort-options{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:20px;}
.sort-opt{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;position:relative;}
.sort-opt.rec{border-color:var(--navy);box-shadow:0 0 0 1px var(--navy);}
.sort-opt-rec-badge{position:absolute;top:-8px;right:10px;background:var(--navy);color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:4px;letter-spacing:.05em;}
.sort-opt h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;}
.sort-opt p{font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:8px;}
.sort-opt code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;display:block;margin-top:6px;line-height:1.6;}
/* Frames */
.frames-row{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;margin-bottom:16px;}
.caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:6px;}
/* Desktop frame */
.frame-desktop{background:var(--surface);border-radius:8px;overflow:hidden;border:1px solid var(--border);box-shadow:0 4px 16px rgba(0,0,0,.08);}
.f-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 8px;gap:5px;}
.f-logo{font-size:6.5px;font-weight:700;color:#fff;letter-spacing:.7px;border-bottom:1px solid var(--mint);padding-bottom:1px;}
.f-navlinks{display:flex;gap:5px;margin-left:8px;}
.f-navlink{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:600;text-transform:uppercase;}
.f-navlink.on{color:rgba(255,255,255,.9);}
.f-navr{margin-left:auto;}
.f-av{width:14px;height:14px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.f-body{padding:10px;}
.f-search{background:#fff;border:1px solid var(--border);border-radius:4px;height:24px;display:flex;align-items:center;padding:0 8px;gap:5px;margin-bottom:5px;}
.f-si{font-size:9px;color:var(--muted);}
.f-st{font-size:7.5px;color:var(--subtle);flex:1;}
.f-resume{background:var(--mint);opacity:.2;height:7px;border-radius:3px;margin-bottom:8px;}
.f-grid-2{display:grid;grid-template-columns:1fr 155px;gap:7px;margin-bottom:7px;}
.f-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;}
.f-card{background:#fff;border:1px solid var(--sand);border-radius:3px;padding:7px;}
.f-ht{font-size:6px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:5px;}
.f-ht.o{color:var(--orange);}
.f-ht.g{color:var(--green);}
.f-ht.n{color:var(--navy);}
.f-row{border-bottom:1px solid var(--sand);padding:3px 0;}
.f-row:last-of-type{border-bottom:none;}
.f-dn{font-family:Georgia,serif;font-size:7.5px;color:var(--navy);line-height:1.3;}
.f-ds{font-size:6px;color:var(--muted);margin-top:1px;}
.f-dd{font-size:5.5px;color:var(--subtle);margin-left:auto;white-space:nowrap;flex-shrink:0;padding-top:1px;}
.f-lnk{font-size:6px;color:var(--navy);display:block;margin-top:5px;}
.f-lnk.g{color:var(--green);}
.f-stat{font-size:5.5px;color:var(--muted);margin-top:5px;}
.f-dz{border:1.5px dashed var(--mint);background:rgba(166,218,216,.07);border-radius:3px;padding:7px;text-align:center;}
.f-dz-i{font-size:12px;color:var(--navy);opacity:.35;margin-bottom:2px;}
.f-dz-t{font-size:6px;font-weight:700;color:var(--navy);}
.f-dz-s{font-size:5px;color:var(--muted);}
.rhs{display:flex;flex-direction:column;gap:6px;}
/* Strip columns */
.strip-col{border-radius:3px;padding:6px;display:flex;flex-direction:column;gap:4px;}
.strip-col.seg{background:rgba(0,40,80,.03);border:1px solid var(--sand);}
.strip-col.trans{background:rgba(0,40,80,.03);border:1px solid var(--sand);}
.strip-col.done{background:rgba(166,218,216,.10);border:1px solid var(--mint);}
.strip-col.done-empty{background:rgba(166,218,216,.06);border:1.5px dashed var(--mint);align-items:center;justify-content:center;text-align:center;min-height:100px;}
/* Skill pill */
.skill-pill{display:inline-flex;align-items:center;padding:1px 5px;border-radius:8px;font-size:5px;font-weight:700;margin-bottom:3px;}
.skill-pill.easy{background:var(--green-bg);border:1px solid rgba(46,110,57,.2);color:var(--green);}
.skill-pill.kurrent{background:rgba(0,40,80,.08);border:1px solid rgba(0,40,80,.15);color:var(--navy);}
/* Pulse */
.pulse{display:flex;align-items:center;gap:4px;margin-bottom:3px;}
.pulse-num{font-size:5.5px;font-weight:700;}
.pulse-num.g{color:var(--green);}
.pulse-num.n{color:var(--navy);}
.pulse-open{font-size:5px;color:var(--muted);}
/* Avatars */
.avatars{display:flex;gap:2px;margin-bottom:4px;}
.av-sm{width:10px;height:10px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:700;color:#fff;}
.av-more{font-size:5px;color:var(--muted);line-height:10px;margin-left:2px;}
/* Per-doc bar */
.doc-bar-row{display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--sand);padding-bottom:4px;}
.doc-bar-row:last-child{border-bottom:none;}
.bar-track{flex:1;height:3px;background:rgba(0,40,80,.12);border-radius:2px;overflow:hidden;}
.bar-fill{height:100%;background:var(--navy);border-radius:2px;}
.bar-label{font-size:5px;color:var(--muted);white-space:nowrap;}
/* CTA button */
.cta-btn{display:block;font-size:6px;font-weight:700;color:#fff;background:var(--navy);border-radius:2px;padding:3px 6px;text-align:center;margin-top:3px;}
.cta-btn.ghost{background:transparent;color:var(--navy);border:1px solid var(--navy);}
/* Expert badge */
.expert-badge{display:inline-flex;align-items:center;gap:2px;padding:1px 4px;border-radius:3px;font-size:5px;font-weight:700;background:var(--purple-bg);color:var(--purple);border:1px solid rgba(91,94,166,.2);margin-left:3px;}
/* Phone frame */
.frame-phone{width:200px;flex-shrink:0;background:var(--surface);border-radius:24px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.12),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;border:4px solid #1C1C18;}
.ph-nav{height:20px;background:var(--navy);display:flex;align-items:center;padding:0 6px;}
.ph-logo{font-size:5.5px;font-weight:700;color:#fff;letter-spacing:.6px;border-bottom:1px solid var(--mint);padding-bottom:1px;}
.ph-body{flex:1;overflow:hidden;padding:6px;display:flex;flex-direction:column;gap:4px;}
.ph-search{background:#fff;border:1px solid var(--border);border-radius:3px;height:18px;display:flex;align-items:center;padding:0 6px;}
.ph-st{font-size:6.5px;color:var(--subtle);flex:1;}
/* impl-ref */
.impl-ref{margin-top:20px;}
.impl-ref table{width:100%;border-collapse:collapse;font-size:12px;}
.impl-ref th{background:var(--navy);color:#fff;padding:6px 10px;text-align:left;font-size:10px;font-weight:600;letter-spacing:.06em;}
.impl-ref td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;}
.impl-ref tr:nth-child(even) td{background:var(--surface);}
.impl-ref code{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
/* Component list */
.comp-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;}
.comp-card{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;}
.comp-card h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:4px;}
.comp-card p{font-size:11px;color:var(--muted);line-height:1.5;}
.comp-card code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
</style>
</head>
<body>
<div class="doc">
<!-- ── HEADER ───────────────────────────────────────────────────────── -->
<div class="hdr">
<h1>Mission-Control-Streifen — Finale Spec</h1>
<div class="badges">
<span class="badge">Issue #240</span>
<span class="badge badge-g">Leonie Voss — UX &amp; Accessibility</span>
<span class="badge badge-g">15. April 2026</span>
<span class="badge badge-g">v3 — Final</span>
</div>
<div class="hdr-meta">src/routes/+page.svelte · src/lib/components/DashboardMissionControl.svelte · +page.server.ts</div>
</div>
<div class="decision-box">
<h2>Entscheidung</h2>
<p class="prose">
Der bestehende Dashboard-Aufbau (Neueste Aktivität links, DropZone + Metadaten-Widget rechts) bleibt unverändert.
Unterhalb des Zwei-Spalten-Gitters erscheint ein neuer vollbreiter <strong>Mission-Control-Streifen</strong> mit drei
gleichwertigen Spalten: <em>Rahmen einzeichnen</em> (Segmentierung, kein Vorwissen nötig),
<em>Text eintippen</em> (Transkription, Kurrent hilfreich), <em>Lesefertig ✓</em> (Belohnungsbereich).
</p>
<p class="prose">
Die „Transkription fehlt"-Spalte aus Issue #240 wird in Segmentierung + Transkription aufgeteilt, um
eine klare Beitragspyramide zu schaffen: Jeder kann Rahmen einzeichnen — nicht jeder kann Kurrent lesen.
Ein wöchentlich rotierender Sort mit <em>Experten-gesucht</em>-Escape-Hatch verhindert, dass schwer lesbare
Dokumente dauerhaft die Spalte blockieren.
</p>
</div>
<!-- ── PIPELINE ─────────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Dokument-Lebenszyklus</div>
<div class="pipeline">
<div class="pipe-node">
<div class="pipe-badge n1">Kein Segment</div>
<div class="pipe-sub">0 Annotationen</div>
<div class="pipe-col-label s">→ Spalte 1</div>
</div>
<div class="pipe-arrow"></div>
<div class="pipe-node">
<div class="pipe-badge n2">Segmentiert</div>
<div class="pipe-sub">Rahmen da, wenig Text</div>
<div class="pipe-col-label t">→ Spalte 2</div>
</div>
<div class="pipe-arrow"></div>
<div class="pipe-node">
<div class="pipe-badge n3">In Review</div>
<div class="pipe-sub">Text da, reviewed &lt; 90 %</div>
<div class="pipe-col-label t">→ Spalte 2</div>
</div>
<div class="pipe-arrow"></div>
<div class="pipe-node">
<div class="pipe-badge done">Lesefertig ✓</div>
<div class="pipe-sub">reviewed ≥ 90 %</div>
<div class="pipe-col-label l">→ Spalte 3</div>
</div>
<div style="margin-left:auto;font-size:11px;color:var(--muted);max-width:200px;line-height:1.4;">
„Segmentiert" und „In Review" landen beide in Spalte 2 —
unterschieden durch den per-Dokument-Balken (0 Blöcke vs. N Blöcke).
</div>
</div>
<!-- Column definitions -->
<div class="col-defs">
<div class="col-def">
<div class="col-def-title n">Spalte 1 — Rahmen einzeichnen</div>
<p>Dokumente ohne Annotationsrahmen. Kein Kurrent nötig — Textblöcke markieren reicht.</p>
<p><strong>Bedingung:</strong> <code>annotation_count = 0</code></p>
<p><strong>Sort:</strong> Wöchentliche Rotation (seeded shuffle, s. u.)</p>
<p><strong>Fortschritt:</strong> Wochenpuls „↑ +5 diese Woche", kein globaler Balken</p>
</div>
<div class="col-def">
<div class="col-def-title n">Spalte 2 — Text eintippen</div>
<p>Annotationen vorhanden, aber Text fehlt oder reviewed &lt; 90 %. Kurrent-Kenntnisse hilfreich.</p>
<p><strong>Bedingung:</strong> <code>annotation_count &gt; 0 AND reviewed_pct &lt; 0.90</code></p>
<p><strong>Sort:</strong> Teilfortschritt zuerst, dann wöchentliche Rotation; <code>needsExpert</code>-Flagge schiebt nach hinten</p>
<p><strong>Fortschritt:</strong> Per-Dokument-Balken „3 / 8 Blöcke"</p>
</div>
<div class="col-def" style="background:rgba(166,218,216,.06);border-color:var(--mint);">
<div class="col-def-title g">Spalte 3 — Lesefertig ✓</div>
<p>Reviewed ≥ 90 %. Keine Aufgabe — Einladung zum Lesen.</p>
<p><strong>Bedingung:</strong> <code>reviewed_pct &gt;= 0.90</code></p>
<p><strong>Sort:</strong> Neueste zuerst</p>
<p><strong>Fortschritt:</strong> „94 % geprüft" als Text — kein Balken, die mint-Spalte ist das Signal</p>
<p><strong>Leerstand:</strong> Cross-Column-Redirect zu Spalte 1</p>
</div>
</div>
</div>
<hr/>
<!-- ── HARD DOCUMENTS PROBLEM ─────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Sortierstrategie — Das „zu schwer"-Problem</div>
<div class="sec-title">Schwer lesbare Dokumente blockieren die Spalte</div>
<div class="sec-sub">Wenn dieselben 3 Dokumente immer oben stehen und niemand sie lesen kann, stoppt die Transkription komplett.</div>
<div class="callout orange">
<div><strong class="o">Problem:</strong> Bei 1 500 Dokumenten ohne Transkription und sortiert nach <code>updated_at</code>
können dieselben 3 besonders schwer lesbaren Dokumente dauerhaft die Spalte blockieren.
Jeder öffnet sie, gibt auf, und die Spalte wird zur Sackgasse.</div>
</div>
<div class="sort-options">
<!-- Option 1 -->
<div class="sort-opt">
<h4>Option 1 — Zufällig pro Seitenaufruf</h4>
<p><code>ORDER BY RANDOM()</code></p>
<p>Jeder Besuch zeigt andere Dokumente. Kein Aufwand, aber chaotisch — kein Nutzer sieht ein Dokument zweimal,
kann nicht gezielt zurückkehren.</p>
<div class="tag-list"><span class="tag t-g">+ Null Aufwand</span><span class="tag t-o"> Chaotisch</span><span class="tag t-o"> Kein stabiles Lesezeichen</span></div>
</div>
<!-- Option 2 — RECOMMENDED -->
<div class="sort-opt rec">
<div class="sort-opt-rec-badge">★ Empfohlen</div>
<h4>Option 2 — Teilfortschritt + wöchentliche Rotation</h4>
<p>Dokumente mit Teilfortschritt (3/8 Blöcke) erscheinen zuerst — am ehesten abschließbar. Dokumente mit 0 Blöcken rotieren wöchentlich durch einen deterministischen Shuffle.</p>
<code>ORDER BY textedBlocks DESC,
HASHTEXT(id || EXTRACT(WEEK FROM NOW())::text)</code>
<div class="tag-list" style="margin-top:8px;"><span class="tag t-g">+ Konsistent innerhalb einer Woche</span><span class="tag t-g">+ Bringt leichte Dokumente an die Oberfläche</span><span class="tag t-g">+ Kein neues Datenbankfeld</span></div>
</div>
<!-- Option 3 -->
<div class="sort-opt">
<h4>Option 3 — Manuelle Schwierigkeitsbewertung</h4>
<p>Beitragende bewerten Dokumente 13 nach Versuch. Einfache Dokumente erscheinen zuerst.</p>
<p>Beste Langzeitlösung — braucht aber Bewertungs-UI auf der Enrich-Seite und Signalakkumulation.</p>
<div class="tag-list"><span class="tag t-g">+ Selbstverbessernd</span><span class="tag t-o"> UI-Aufwand</span><span class="tag t-o"> Braucht Zeit bis Signal</span></div>
</div>
</div>
<!-- Escape hatch -->
<div class="callout navy">
<div>
<strong class="n">Escape-Hatch: „Experten gesucht"-Flagge (Option 2 ergänzen)</strong><br/>
Im Enrich-Bereich: ein einzelner Button „Zu schwer — Hilfe gesucht".
Setzt <code>Document.needsExpert = true</code> (1 Boolean, keine Migration wenn Flyway-Migration V{n} hinzugefügt wird).
In der Transkriptions-Spalte zeigen flagged Dokumente einen lila Badge und werden hinter unflagged Dokumenten einsortiert.
Kein Leaderboard, keine Scham — nur ein ehrliches Signal an die Community.
</div>
</div>
<!-- Expert badge mockup -->
<div style="background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:16px;">
<div style="font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:10px;">Mockup: Experten-gesucht-Badge in der Transkriptions-Zeile</div>
<div style="display:flex;flex-direction:column;gap:4px;max-width:380px;">
<!-- Normal doc -->
<div style="display:flex;flex-direction:column;gap:3px;padding:8px;border:1px solid var(--sand);border-radius:3px;">
<div style="font-family:Georgia,serif;font-size:13px;color:var(--navy);">Reisepass Opa Heinrich <span style="font-family:system-ui;font-size:10px;font-weight:600;background:rgba(0,40,80,.07);color:var(--navy);padding:1px 6px;border-radius:4px;">3 / 8 Blöcke</span></div>
<div style="display:flex;align-items:center;gap:6px;">
<div style="flex:1;height:4px;background:rgba(0,40,80,.12);border-radius:2px;overflow:hidden;"><div style="width:37%;height:100%;background:var(--navy);border-radius:2px;"></div></div>
<div style="font-size:11px;color:var(--muted);">37 %</div>
</div>
</div>
<!-- Expert-needed doc -->
<div style="display:flex;flex-direction:column;gap:3px;padding:8px;border:1px solid rgba(91,94,166,.25);background:rgba(91,94,166,.03);border-radius:3px;">
<div style="font-family:Georgia,serif;font-size:13px;color:var(--navy);">Standesamt Breslau 1872
<span style="font-family:system-ui;font-size:10px;font-weight:600;background:var(--purple-bg);color:var(--purple);padding:1px 6px;border-radius:4px;border:1px solid rgba(91,94,166,.2);">Experten gesucht</span>
</div>
<div style="font-size:11px;color:var(--muted);">Schrift besonders schwer lesbar — Hilfe willkommen</div>
</div>
</div>
</div>
<div class="impl-ref">
<table>
<thead><tr><th>Element</th><th>SQL / Tailwind</th><th>Wert</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td>Sort Transkription</td><td><code>ORDER BY textedBlocks DESC, HASHTEXT(id::text || EXTRACT(WEEK FROM NOW())::int::text)</code></td><td></td><td>Kein neues Feld nötig; ändert sich automatisch jede Woche</td></tr>
<tr><td><code>needsExpert</code>-Flag</td><td><code>ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE</code></td><td>Flyway <code>V{n}__add_needs_expert.sql</code></td><td>Flagged Docs ans Ende: <code>ORDER BY needs_expert ASC, ...</code></td></tr>
<tr><td>Experten-Badge</td><td><code>inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700</code></td><td>Kontrast 6,8:1 ✓</td><td>Nur wenn <code>doc.needsExpert === true</code></td></tr>
<tr><td>„Zu schwer"-Button (Enrich)</td><td><code>text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2</code></td><td></td><td>Unscheinbar — kein roter Knopf, keine Scham</td></tr>
<tr><td>Endpoint (Flagge setzen)</td><td><code>PATCH /api/documents/{id}/needs-expert</code></td><td><code>@RequirePermission(READ_ALL)</code></td><td>Jeder angemeldete Nutzer darf flaggen</td></tr>
</tbody>
</table>
</div>
</div>
<hr/>
<!-- ── DESKTOP MOCKUP — FILLED STATE ─────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Mockup — Desktop, normaler Zustand</div>
<div class="frames-row">
<div style="flex:1;min-width:0;">
<div class="frame-desktop">
<div class="f-nav">
<div class="f-logo">FAMILIENARCHIV</div>
<div class="f-navlinks"><div class="f-navlink on">Archiv</div><div class="f-navlink">Personen</div><div class="f-navlink">Gespräche</div></div>
<div class="f-navr"><div class="f-av">MR</div></div>
</div>
<div class="f-body">
<div class="f-search"><div class="f-si"></div><div class="f-st">Dokumente durchsuchen…</div></div>
<div class="f-resume"></div>
<!-- Existing grid — unchanged -->
<div class="f-grid-2">
<div class="f-card">
<div class="f-ht">Neueste Aktivität</div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Brief von Oma Martha, 1943</div><div class="f-ds">Karl Raddatz</div></div><div class="f-dd">12. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Taufurkunde Karl Raddatz</div><div class="f-ds">Standesamt</div></div><div class="f-dd">9. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Postkarte aus Breslau</div><div class="f-ds">Martha Raddatz</div></div><div class="f-dd">7. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Familienfoto Sommer 1952</div><div class="f-ds">Unbekannt</div></div><div class="f-dd">3. Apr</div></div>
<div class="f-stat">47 Dokumente · 12 Personen</div>
</div>
<div class="rhs">
<div class="f-dz"><div class="f-dz-i"></div><div class="f-dz-t">Datei hochladen</div><div class="f-dz-s">Drag &amp; Drop</div></div>
<div class="f-card" style="flex:1;">
<div class="f-ht o">Metadaten fehlen</div>
<div class="f-row"><div class="f-dn">Familienfoto 1952</div><div class="f-ds">Titel fehlt</div></div>
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div><div class="f-ds">Datum fehlt</div></div>
<a class="f-lnk">Alle 5 anzeigen →</a>
</div>
</div>
</div>
<!-- ★ Mission Control Strip -->
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:8px;">
<div class="f-ht" style="margin-bottom:7px;">Was braucht Aufmerksamkeit?</div>
<div class="f-grid-3">
<!-- Col 1: SEGMENTIERUNG -->
<div class="strip-col seg">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Rahmen einzeichnen</div>
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
<div class="pulse"><span class="pulse-num g">↑ +5 diese Woche</span><span class="pulse-open">· 1 480 offen</span></div>
<div class="avatars">
<div class="av-sm" style="background:var(--navy);">MR</div>
<div class="av-sm" style="background:var(--purple);">TG</div>
<div class="av-sm" style="background:#8C6E3F;">AS</div>
<div class="av-more">+ 2</div>
</div>
</div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div><div class="f-ds">Noch keine Rahmen</div></div>
<div class="f-row"><div class="f-dn">Standesamt 1889</div><div class="f-ds">Noch keine Rahmen</div></div>
<div class="f-row"><div class="f-dn">Heiratsurkunde 1921</div><div class="f-ds">Noch keine Rahmen</div></div>
<a class="cta-btn">Jetzt einzeichnen →</a>
</div>
<!-- Col 2: TRANSKRIPTION with per-doc bar + expert badge -->
<div class="strip-col trans">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Text eintippen</div>
<div class="skill-pill kurrent">Kurrent hilfreich</div>
<div class="pulse"><span class="pulse-num n">↑ +2 diese Woche</span><span class="pulse-open">· 8 offen</span></div>
<div class="avatars">
<div class="av-sm" style="background:var(--navy);">MR</div>
<div class="av-more">1 Person</div>
</div>
</div>
<!-- Per-document bar — partial progress first -->
<div class="doc-bar-row">
<div class="f-dn">Reisepass Opa Heinrich</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:37%;"></div></div><div class="bar-label">3 / 8 Blöcke</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Brief v. Oma Martha 1943</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
</div>
<!-- Expert-needed doc — sorted last -->
<div class="doc-bar-row" style="border-color:rgba(91,94,166,.2);background:rgba(91,94,166,.03);padding:2px 3px;">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;"><div class="f-dn">Standesamt Breslau 1872</div><span class="expert-badge">Experten gesucht</span></div>
<div class="f-ds">Schrift besonders schwer lesbar</div>
</div>
<a class="cta-btn">Jetzt tippen →</a>
</div>
<!-- Col 3: LESEFERTIG — filled -->
<div class="strip-col done">
<div>
<div class="f-ht g" style="margin-bottom:2px;">Lesefertig ✓</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;margin-bottom:4px;">3 Dokumente bereit</div>
<div class="avatars">
<div class="av-sm" style="background:var(--green);">MR</div>
<div class="av-sm" style="background:var(--purple);">TG</div>
</div>
</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
<div class="f-dn">Postkarte aus Breslau 1943</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;">100 % geprüft</div>
</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
<div class="f-dn">Brief Oma Martha 1938</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;">95 % geprüft</div>
</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
<div class="f-dn">Heiratsurkunde 1921</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;">91 % geprüft</div>
</div>
<a class="f-lnk g" style="margin-top:3px;">Alle 3 lesen →</a>
</div>
</div>
</div>
</div>
</div>
<span class="caption">Desktop (55 %) — normaler Zustand: Teilfortschritt oben, Experten-gesucht-Dokument unten in Spalte 2</span>
</div>
</div>
</div>
<!-- ── DESKTOP MOCKUP — EARLY STATE (Lesefertig leer) ───────────────── -->
<div class="sec">
<div class="sec-label">Mockup — Desktop, frühe Projektphase (Lesefertig leer)</div>
<div class="frames-row">
<div style="flex:1;min-width:0;">
<div class="frame-desktop">
<div class="f-nav">
<div class="f-logo">FAMILIENARCHIV</div>
<div class="f-navlinks"><div class="f-navlink on">Archiv</div><div class="f-navlink">Personen</div></div>
<div class="f-navr"><div class="f-av">MR</div></div>
</div>
<div class="f-body">
<div class="f-search"><div class="f-si"></div><div class="f-st">Dokumente durchsuchen…</div></div>
<div class="f-resume"></div>
<div class="f-grid-2">
<div class="f-card">
<div class="f-ht">Neueste Aktivität</div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Brief von Oma Martha, 1943</div></div><div class="f-dd">12. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Taufurkunde Karl Raddatz</div></div><div class="f-dd">9. Apr</div></div>
<div class="f-stat">1 500 Dokumente · 12 Personen</div>
</div>
<div class="rhs">
<div class="f-dz"><div class="f-dz-i"></div><div class="f-dz-t">Datei hochladen</div><div class="f-dz-s">Drag &amp; Drop</div></div>
<div class="f-card" style="flex:1;">
<div class="f-ht o">Metadaten fehlen</div>
<div class="f-row"><div class="f-dn">Familienfoto 1952</div></div>
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div></div>
<a class="f-lnk">Alle anzeigen →</a>
</div>
</div>
</div>
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:8px;">
<div class="f-ht" style="margin-bottom:7px;">Was braucht Aufmerksamkeit?</div>
<div class="f-grid-3">
<div class="strip-col seg">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Rahmen einzeichnen</div>
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
<div class="pulse"><span class="pulse-num g">↑ +3 diese Woche</span><span class="pulse-open">· 1 498 offen</span></div>
<div class="avatars"><div class="av-sm" style="background:var(--navy);">MR</div><div class="av-more">1 Person</div></div>
</div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
<div class="f-row"><div class="f-dn">Standesamt 1889</div></div>
<div class="f-row"><div class="f-dn">Heiratsurkunde 1921</div></div>
<a class="cta-btn">Jetzt einzeichnen →</a>
</div>
<div class="strip-col trans">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Text eintippen</div>
<div class="skill-pill kurrent">Kurrent hilfreich</div>
<div class="pulse"><span class="pulse-num n">↑ +1 diese Woche</span><span class="pulse-open">· 2 offen</span></div>
<div class="avatars"><div class="av-sm" style="background:var(--navy);">MR</div><div class="av-more">1 Person</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Brief v. Oma Martha 1943</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Reisepass Opa Heinrich</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 4 Blöcke</div></div>
</div>
<a class="cta-btn">Jetzt tippen →</a>
</div>
<!-- Lesefertig EMPTY — cross-column redirect -->
<div class="strip-col done-empty">
<div style="font-size:11px;color:var(--mint);margin-bottom:3px;"></div>
<div style="font-size:6.5px;font-weight:700;color:var(--navy);margin-bottom:3px;">Noch kein Dokument lesefertig</div>
<div style="font-size:5.5px;color:var(--muted);line-height:1.5;max-width:105px;margin-bottom:5px;">Erscheint hier sobald die Transkription abgeschlossen ist.</div>
<a class="cta-btn ghost" style="font-size:5.5px;padding:2px 7px;">Jetzt mithelfen →</a>
</div>
</div>
</div>
</div>
</div>
<span class="caption">Desktop (55 %) — frühe Phase: 1 500 Dokumente ohne Transkription, Wochenpuls zeigt Schwung statt Berg</span>
</div>
</div>
</div>
<hr/>
<!-- ── MOBILE MOCKUP ─────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Mockup — Mobil 320 px</div>
<p class="prose" style="margin-bottom:16px;">
Die rechte Spalte (DropZone + Metadaten) erscheint auf Mobil zuerst im DOM (<code>lg:order-last</code> schiebt sie auf Desktop nach rechts).
Der Streifen stapelt seine drei Spalten vertikal. Jede Spalte hat volle Breite — keine Overflow-Probleme.
</p>
<div class="frames-row">
<!-- Phone: filled state -->
<div>
<div class="frame-phone" style="height:620px;">
<div class="ph-nav"><div class="ph-logo">FAMILIENARCHIV</div></div>
<div class="ph-body" style="overflow-y:auto;">
<div class="ph-search"><div class="ph-st">⌕ Dokumente…</div></div>
<!-- Right col first on mobile -->
<div class="f-dz" style="padding:5px;"><div class="f-dz-i" style="font-size:10px;"></div><div class="f-dz-t">Hochladen</div></div>
<div class="f-card" style="padding:5px;">
<div class="f-ht o">Metadaten fehlen</div>
<div class="f-row"><div class="f-dn">Familienfoto 1952</div></div>
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div></div>
</div>
<!-- Left col (recent) -->
<div class="f-card" style="padding:5px;">
<div class="f-ht">Neueste Aktivität</div>
<div class="f-row"><div class="f-dn">Brief von Oma Martha</div></div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
<div class="f-stat">1 500 Dok. · 12 Pers.</div>
</div>
<!-- Strip — stacked on mobile -->
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:5px;display:flex;flex-direction:column;gap:4px;">
<div class="f-ht" style="margin-bottom:3px;">Was braucht Aufmerksamkeit?</div>
<!-- Seg -->
<div class="strip-col seg" style="padding:5px;">
<div class="f-ht n" style="margin-bottom:1px;">Rahmen einzeichnen</div>
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
<div class="pulse" style="margin-bottom:2px;"><span class="pulse-num g">↑ +5 diese Woche</span><span class="pulse-open">· 1 480 offen</span></div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
<div class="f-row"><div class="f-dn">Standesamt 1889</div></div>
<a class="cta-btn" style="font-size:6px;">Jetzt einzeichnen →</a>
</div>
<!-- Trans -->
<div class="strip-col trans" style="padding:5px;">
<div class="f-ht n" style="margin-bottom:1px;">Text eintippen</div>
<div class="skill-pill kurrent">Kurrent hilfreich</div>
<div class="pulse" style="margin-bottom:2px;"><span class="pulse-num n">↑ +2 diese Woche</span><span class="pulse-open">· 8 offen</span></div>
<div class="doc-bar-row">
<div class="f-dn">Reisepass Opa Heinrich</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:37%;"></div></div><div class="bar-label">3 / 8 Blöcke</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Brief v. Oma Martha 1943</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
</div>
<a class="cta-btn" style="font-size:6px;">Jetzt tippen →</a>
</div>
<!-- Lesefertig -->
<div class="strip-col done" style="padding:5px;">
<div class="f-ht g" style="margin-bottom:1px;">Lesefertig ✓</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;margin-bottom:3px;">3 bereit</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);"><div class="f-dn">Postkarte 1943</div><div style="font-size:5.5px;color:var(--green);font-weight:600;">100 %</div></div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);"><div class="f-dn">Brief Oma 1938</div><div style="font-size:5.5px;color:var(--green);font-weight:600;">95 %</div></div>
<a class="f-lnk g">Alle lesen →</a>
</div>
</div>
</div>
</div>
<span class="caption">Mobil 320 px — Streifen stapelt vertikal, volle Breite je Spalte</span>
</div>
<!-- Mobile layout notes -->
<div style="flex:1;min-width:220px;">
<div style="background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:12px;">
<div style="font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--navy);margin-bottom:8px;">Mobile-Reihenfolge (DOM)</div>
<ol style="font-size:12px;color:var(--muted);line-height:1.8;margin-left:16px;">
<li>Suchleiste</li>
<li>DropZone (write users only)</li>
<li>Metadaten fehlen</li>
<li>Neueste Aktivität</li>
<li>Was braucht Aufmerksamkeit?
<ol style="margin-left:16px;">
<li>Rahmen einzeichnen</li>
<li>Text eintippen</li>
<li>Lesefertig ✓</li>
</ol>
</li>
</ol>
</div>
<div class="callout navy">
<div>
<strong class="n">Touch targets:</strong> Alle CTA-Buttons: <code>min-h-[44px]</code> (WCAG 2.2).
Dokument-Zeilen in den Spalten: <code>min-h-[44px] py-2</code>.
Der „Zu schwer"-Button auf der Enrich-Seite: <code>min-h-[44px]</code> als Icon-Button mit <code>aria-label</code>.
</div>
</div>
</div>
</div>
</div>
<hr/>
<!-- ── ENGAGEMENT FEATURES SUMMARY ──────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Engagement-Elemente — Zusammenfassung</div>
<div class="comp-grid">
<div class="comp-card">
<h4>① Skill-Pill</h4>
<p>Unter jedem Spaltentitel. „Ohne Vorkenntnisse" (grün) vs. „Kurrent hilfreich" (navy-neutral).
Senkt die Hemmschwelle — Neueinsteiger sehen sofort, was ohne Kurrent-Kenntnisse möglich ist.</p>
<p style="margin-top:6px;"><code>bg-green-50 border-green-200 text-green-800</code> / <code>bg-surface border-line text-ink</code></p>
</div>
<div class="comp-card">
<h4>② Wochenpuls</h4>
<p>„↑ +5 diese Woche · 1 480 offen" statt globalem Fortschrittsbalken.
Zeigt Schwung, nicht den Berg. Psychologisch: 0,8 %-Balken ist demotivierender als kein Balken.</p>
<p style="margin-top:6px;"><code>SELECT COUNT(*) WHERE created_at &gt; NOW() - INTERVAL '7 days'</code></p>
</div>
<div class="comp-card">
<h4>③ Per-Dokument-Balken</h4>
<p>Nur in Spalte 2, nur wenn <code>annotation_count &gt; 0</code>. Richtiger Maßstab:
8 Blöcke sind in einer Sitzung abschließbar. Zeigt auch, welche Dokumente „fast fertig" sind.</p>
<p style="margin-top:6px;"><code>width: {textedBlocks / totalBlocks * 100}%</code>; Guard: <code>totalBlocks === 0 → width: 0</code></p>
</div>
<div class="comp-card">
<h4>④ Contributor-Avatare</h4>
<p>Max. 3 Initialen-Bubbles der letzten Beitragenden pro Spalte. Kein Leaderboard (Wettbewerb) —
soziale Sichtbarkeit (Zugehörigkeit). Farbe deterministisch aus User-ID-Hash.</p>
<p style="margin-top:6px;">DTO: <code>lastContributors: [{initials, colorIndex}]</code> — nur Initialen, keine Namen (Nora)</p>
</div>
<div class="comp-card">
<h4>⑤ „Starte hier →"-CTA</h4>
<p>Ein einziger opinionated Button je Aufgaben-Spalte, der direkt zum nächsten Dokument springt.
Entscheidungslähmung ist der Hauptgrund für Non-Participation bei Familienprojekten.</p>
<p style="margin-top:6px;"><code>/enrich?filter=NEEDS_SEGMENTATION&amp;next=1</code> (Segmentierung)<br/><code>/enrich?filter=NEEDS_TRANSCRIPTION&amp;next=1</code> (Transkription)</p>
</div>
<div class="comp-card">
<h4>⑥ Lesefertig-Leerstand → Redirect</h4>
<p>Wenn Spalte 3 leer ist (frühe Phase), erscheint kein toter Endpunkt sondern:
„Erscheint hier, sobald die Transkription abgeschlossen ist — jetzt mithelfen →".
Der Link springt zu Spalte 1.</p>
<p style="margin-top:6px;"><code>{#if readyToRead.length === 0}</code><code>DashboardReadyToReadEmpty.svelte</code></p>
</div>
</div>
</div>
<hr/>
<!-- ── IMPL-REF TABLE ────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Implementation Reference</div>
<div class="impl-ref">
<table>
<thead><tr><th>Element</th><th>Tailwind-Klassen</th><th>Pixel / Wert</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td><strong>Streifen-Wrapper</strong></td><td><code>mt-4 bg-white border border-line rounded-sm p-6</code></td><td>padding 24 px</td><td>Direkt nach bestehendem <code>div.mt-4.grid</code></td></tr>
<tr><td>Streifen-Titel</td><td><code>text-xs font-bold uppercase tracking-widest text-gray-400 mb-4</code></td><td>12 px / 700</td><td>Standard-Section-Title-Muster</td></tr>
<tr><td>3-Spalten-Grid</td><td><code>grid grid-cols-1 gap-4 sm:grid-cols-3</code></td><td>gap 16 px</td><td>sm = 640 px; darunter stapeln</td></tr>
<tr><td>Segmentierung-Spalte</td><td><code>bg-surface rounded-sm border border-line p-4 flex flex-col gap-3</code></td><td></td><td>Neutral</td></tr>
<tr><td>Transkription-Spalte</td><td><code>bg-surface rounded-sm border border-line p-4 flex flex-col gap-3</code></td><td></td><td>Neutral — es ist eine Aufgabe</td></tr>
<tr><td>Lesefertig-Spalte (gefüllt)</td><td><code>bg-mint/10 rounded-sm border border-mint p-4 flex flex-col gap-3</code></td><td></td><td>Mint-Ton = Erfolg</td></tr>
<tr><td>Lesefertig-Spalte (leer)</td><td><code>flex flex-col items-center justify-center text-center bg-mint/5 border border-dashed border-mint rounded-sm p-6 min-h-[120px]</code></td><td>min-h 120 px</td><td>Kein toter Endpunkt</td></tr>
<tr><td>Skill-Pill easy</td><td><code>inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-50 border border-green-200 text-green-800</code></td><td>Kontrast 9,7:1 ✓ AAA</td><td></td></tr>
<tr><td>Skill-Pill kurrent</td><td><code>inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-surface border border-line text-ink</code></td><td>Kontrast 14,5:1 ✓ AAA</td><td>Neutral — kein Abschreck-Signal</td></tr>
<tr><td>Wochenpuls-Zahl</td><td><code>text-xs font-semibold text-green-700</code> (Seg.) / <code>text-ink</code> (Trans.)</td><td>12 px</td><td>Kein globaler Balken</td></tr>
<tr><td>Per-Dokument-Track</td><td><code>flex-1 h-1 bg-navy/20 rounded-full overflow-hidden</code></td><td>h 4 px</td><td>Nur wenn <code>annotation_count &gt; 0</code></td></tr>
<tr><td>Per-Dokument-Fill</td><td><code>h-full bg-ink rounded-full transition-all</code> + <code>style="width:{pct}%"</code></td><td></td><td>Guard: <code>totalBlocks === 0 → 0%</code></td></tr>
<tr><td>Lesefertig-Prozent</td><td><code>text-xs font-semibold text-green-800</code></td><td>12 px</td><td>Kein Balken — mint-Spalte ist das Signal</td></tr>
<tr><td>Contributor-Avatar</td><td><code>w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0</code></td><td>24 × 24 px</td><td>Farbe: 6 Werte, Index = <code>userIdHash % 6</code></td></tr>
<tr><td>CTA-Button (primär)</td><td><code>block w-full text-center text-xs font-semibold text-white bg-ink rounded-sm py-2 mt-2 hover:bg-ink-2 transition-colors focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-1</code></td><td>min-h 36 px</td><td><code>aria-label</code> mit Dokumenttitel falls nötig</td></tr>
<tr><td>CTA-Button (ghost, Leerstand)</td><td><code>inline-flex items-center text-xs font-semibold text-ink border border-ink rounded-sm px-3 py-2 hover:bg-ink hover:text-white transition-colors</code></td><td>min-h 36 px</td><td></td></tr>
<tr><td>Experten-gesucht-Badge</td><td><code>inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700</code></td><td>Kontrast 6,8:1 ✓ AA</td><td>Nur wenn <code>doc.needsExpert === true</code></td></tr>
<tr><td>Sichtbarkeit Streifen</td><td><code>{#if needsSegmentation.length &gt; 0 || needsTranscription.length &gt; 0 || readyToRead.length &gt; 0}</code></td><td></td><td>Streifen verschwindet wenn alle drei Buckets leer</td></tr>
<tr><td>Dokument-Zeile Mindesthöhe</td><td><code>min-h-[44px] flex items-start py-2</code></td><td>44 px ✓ WCAG 2.2</td><td>Gilt für alle klickbaren Zeilen</td></tr>
</tbody>
</table>
</div>
</div>
<hr/>
<!-- ── BACKEND CONTRACTS ─────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Backend — neue Endpoints &amp; Queries</div>
<div class="impl-ref">
<table>
<thead><tr><th>Endpoint / Query</th><th>Bedingung</th><th>Sort</th><th>Auth</th></tr></thead>
<tbody>
<tr><td><code>GET /api/documents/needs-segmentation?size=3</code></td><td><code>NOT EXISTS (SELECT 1 FROM document_annotations WHERE document_id = d.id)</code></td><td><code>HASHTEXT(id::text || week::text)</code></td><td><code>READ_ALL</code></td></tr>
<tr><td><code>GET /api/documents/needs-transcription?size=3</code></td><td><code>EXISTS annotation AND (no blocks OR reviewed_pct &lt; 0.90)</code></td><td><code>textedBlocks DESC, needs_expert ASC, HASHTEXT(...)</code></td><td><code>READ_ALL</code></td></tr>
<tr><td><code>GET /api/documents/ready-to-read?size=3</code></td><td><code>reviewed_pct &gt;= 0.90</code></td><td><code>updated_at DESC</code></td><td><code>READ_ALL</code></td></tr>
<tr><td><code>PATCH /api/documents/{id}/needs-expert</code></td><td>Setzt <code>needs_expert = true</code></td><td></td><td><code>READ_ALL</code> (jeder Nutzer darf flaggen)</td></tr>
<tr><td><code>GET /api/stats/strip-activity</code></td><td>Wochenpuls: <code>COUNT(*) WHERE created_at &gt; NOW() - INTERVAL '7 days'</code> pro Bucket</td><td></td><td><code>READ_ALL</code></td></tr>
<tr><td>Flyway-Migration</td><td><code>ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE</code></td><td></td><td>V{n}__add_needs_expert_flag.sql</td></tr>
<tr><td>Index prüfen (Tobias)</td><td><code>document_annotations(document_id)</code>, <code>transcription_blocks(document_id, reviewed)</code></td><td></td><td>EXPLAIN ANALYZE vor Merge</td></tr>
<tr><td>Division durch 0 (Sara)</td><td>Alle reviewed_pct-Queries: <code>CASE WHEN COUNT(*) = 0 THEN 0 ELSE SUM(...)::float / COUNT(*) END</code></td><td></td><td></td></tr>
</tbody>
</table>
</div>
</div>
<hr/>
<!-- ── NEW COMPONENTS ────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Neue Svelte-Komponenten</div>
<div class="comp-grid">
<div class="comp-card">
<h4><code>DashboardMissionControl.svelte</code></h4>
<p>Wrapper für den vollbreiten Streifen. Props: <code>needsSegmentation</code>, <code>needsTranscription</code>,
<code>readyToRead</code>, <code>weeklyActivity</code>. Rendert die drei Spalten und ist komplett unsichtbar wenn alle Arrays leer sind.</p>
</div>
<div class="comp-card">
<h4><code>DashboardSegmentationCol.svelte</code></h4>
<p>Spalte 1: Skill-Pill, Wochenpuls, Avatare, Dokumentliste, CTA. Keine Balken — keine Dokument-Metadaten vorhanden.</p>
</div>
<div class="comp-card">
<h4><code>DashboardTranscriptionCol.svelte</code></h4>
<p>Spalte 2: Skill-Pill, Wochenpuls, Avatare, per-Dokument-Balken, Experten-Badge bei <code>needsExpert</code>, CTA.</p>
</div>
<div class="comp-card">
<h4><code>DashboardReadyToReadCol.svelte</code></h4>
<p>Spalte 3: Zeigt gefüllten Zustand (Liste mit %-Text) oder leeren Zustand (Cross-Column-Redirect zu Segmentierung).</p>
</div>
</div>
<div class="callout green">
<div>
<strong class="g">Bestehende Komponente bleibt:</strong> <code>DashboardNeedsMetadata.svelte</code> ist unverändert —
sie lebt weiterhin in der rechten Spalte. Der Mission-Control-Streifen ist vollständig additiv und ändert nichts am bestehenden Layout.
</div>
</div>
</div>
</div><!-- /doc -->
</body>
</html>

View File

@@ -78,6 +78,8 @@
"docs_empty_btn_clear": "Alle Filter löschen",
"docs_list_from": "Von",
"docs_list_to": "An",
"docs_list_content": "Inhalt",
"docs_list_summary": "Zusammenfassung",
"docs_list_unknown": "Unbekannt",
"docs_group_undated": "Undatiert",
"docs_group_unknown": "Unbekannt",
@@ -555,5 +557,23 @@
"training_seg_too_few_blocks": "Mindestens 5 Segmentierungsblöcke erforderlich (aktuell: {available}).",
"transcription_block_segmentation_only": "Nur Segmentierung",
"training_chip_kurrent": "Kurrent-Erkennung",
"training_chip_segmentation": "Segmentierung"
"training_chip_segmentation": "Segmentierung",
"mission_control_heading": "Was braucht Aufmerksamkeit?",
"mission_control_segmentation_heading": "Rahmen einzeichnen",
"mission_control_segmentation_description": "Textbereiche markieren — keine Vorkenntnisse nötig",
"mission_control_seg_skill_pill": "✓ Ohne Vorkenntnisse",
"mission_control_segmentation_empty": "Alle Dokumente haben bereits Segmentierungsblöcke.",
"mission_control_transcription_heading": "Text eintippen",
"mission_control_transcription_description": "Text abschreiben — Kurrent-Kenntnisse hilfreich",
"mission_control_trans_skill_pill": "Kurrent hilfreich",
"mission_control_transcription_empty": "Keine Dokumente warten auf Transkription.",
"mission_control_ready_heading": "Lesefertig ✓",
"mission_control_ready_description": "Vollständig transkribiert und geprüft",
"mission_control_ready_subtitle": "{count} Dokumente bereit",
"mission_control_ready_empty": "Noch keine Dokumente vollständig transkribiert.",
"mission_control_ready_empty_cta": "Jetzt mitmachen",
"mission_control_weekly_pulse": "↑ +{count} diese Woche",
"mission_control_expert_badge": "Experten gesucht",
"mission_control_blocks_progress": "{texted} / {total} Blöcke",
"mission_control_reviewed_pct": "{pct}% geprüft"
}

View File

@@ -78,6 +78,8 @@
"docs_empty_btn_clear": "Clear all filters",
"docs_list_from": "From",
"docs_list_to": "To",
"docs_list_content": "Content",
"docs_list_summary": "Summary",
"docs_list_unknown": "Unknown",
"docs_group_undated": "Undated",
"docs_group_unknown": "Unknown",
@@ -555,5 +557,23 @@
"training_seg_too_few_blocks": "At least 5 segmentation blocks required (currently: {available}).",
"transcription_block_segmentation_only": "Segmentation only",
"training_chip_kurrent": "Kurrent recognition",
"training_chip_segmentation": "Segmentation"
"training_chip_segmentation": "Segmentation",
"mission_control_heading": "What needs attention?",
"mission_control_segmentation_heading": "Draw regions",
"mission_control_segmentation_description": "Mark text areas — no prior knowledge needed",
"mission_control_seg_skill_pill": "✓ No prior knowledge",
"mission_control_segmentation_empty": "All documents already have segmentation blocks.",
"mission_control_transcription_heading": "Type the text",
"mission_control_transcription_description": "Type out text — Kurrent knowledge helpful",
"mission_control_trans_skill_pill": "Kurrent helpful",
"mission_control_transcription_empty": "No documents waiting for transcription.",
"mission_control_ready_heading": "Ready to read ✓",
"mission_control_ready_description": "Fully transcribed and reviewed",
"mission_control_ready_subtitle": "{count} documents ready",
"mission_control_ready_empty": "No documents fully transcribed yet.",
"mission_control_ready_empty_cta": "Start contributing",
"mission_control_weekly_pulse": "↑ +{count} this week",
"mission_control_expert_badge": "Expert needed",
"mission_control_blocks_progress": "{texted} / {total} blocks",
"mission_control_reviewed_pct": "{pct}% reviewed"
}

View File

@@ -78,6 +78,8 @@
"docs_empty_btn_clear": "Borrar todos los filtros",
"docs_list_from": "De",
"docs_list_to": "Para",
"docs_list_content": "Contenido",
"docs_list_summary": "Resumen",
"docs_list_unknown": "Desconocido",
"docs_group_undated": "Sin fecha",
"docs_group_unknown": "Desconocido",
@@ -555,5 +557,23 @@
"training_seg_too_few_blocks": "Se requieren al menos 5 bloques de segmentación (actualmente: {available}).",
"transcription_block_segmentation_only": "Solo segmentación",
"training_chip_kurrent": "Reconocimiento Kurrent",
"training_chip_segmentation": "Segmentación"
"training_chip_segmentation": "Segmentación",
"mission_control_heading": "¿Qué necesita atención?",
"mission_control_segmentation_heading": "Marcar regiones",
"mission_control_segmentation_description": "Marcar áreas de texto — sin conocimientos previos",
"mission_control_seg_skill_pill": "✓ Sin conocimientos previos",
"mission_control_segmentation_empty": "Todos los documentos ya tienen bloques de segmentación.",
"mission_control_transcription_heading": "Escribir el texto",
"mission_control_transcription_description": "Escribir el texto — conocimiento de Kurrent útil",
"mission_control_trans_skill_pill": "Kurrent útil",
"mission_control_transcription_empty": "No hay documentos esperando transcripción.",
"mission_control_ready_heading": "Listo para leer ✓",
"mission_control_ready_description": "Completamente transcrito y revisado",
"mission_control_ready_subtitle": "{count} documentos listos",
"mission_control_ready_empty": "Aún no hay documentos completamente transcritos.",
"mission_control_ready_empty_cta": "Empezar a colaborar",
"mission_control_weekly_pulse": "↑ +{count} esta semana",
"mission_control_expert_badge": "Se busca experto",
"mission_control_blocks_progress": "{texted} / {total} bloques",
"mission_control_reviewed_pct": "{pct}% revisado"
}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
</script>
<span
class="inline-flex items-center gap-1 rounded border border-purple-200 bg-purple-50 px-2 py-0.5 text-xs font-semibold text-purple-700"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 1.5L1.5 13.5h13L8 1.5z"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
fill="none"
/>
<path d="M8 6v3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
</svg>
{m.mission_control_expert_badge()}
</span>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import SegmentationColumn from './SegmentationColumn.svelte';
import TranscriptionColumn from './TranscriptionColumn.svelte';
import ReadyColumn from './ReadyColumn.svelte';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
type TranscriptionWeeklyStatsDTO = {
segmentationCount: number;
transcriptionCount: number;
readyCount: number;
};
interface Props {
segmentationDocs: TranscriptionQueueItemDTO[];
transcriptionDocs: TranscriptionQueueItemDTO[];
readyDocs: TranscriptionQueueItemDTO[];
weeklyStats: TranscriptionWeeklyStatsDTO | null;
}
let { segmentationDocs, transcriptionDocs, readyDocs, weeklyStats }: Props = $props();
</script>
{#if segmentationDocs.length > 0 || transcriptionDocs.length > 0 || readyDocs.length > 0}
<section class="mt-4 rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.mission_control_heading()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<SegmentationColumn
docs={segmentationDocs}
weeklyCount={weeklyStats?.segmentationCount ?? 0}
/>
<TranscriptionColumn
docs={transcriptionDocs}
weeklyCount={weeklyStats?.transcriptionCount ?? 0}
/>
<ReadyColumn docs={readyDocs} weeklyCount={weeklyStats?.readyCount ?? 0} />
</div>
</section>
{/if}

View File

@@ -56,21 +56,20 @@ onMount(async () => {
await renderer.init();
});
// Wire DOM elements to the renderer after they mount
$effect(() => {
if (canvasEl && textLayerEl) {
renderer.setElements(canvasEl, textLayerEl);
}
});
$effect(() => {
if (renderer.pdfjsReady && url) {
renderer.loadDocument(url);
}
});
// Wire DOM elements to the renderer and trigger rendering.
// canvasEl is read synchronously so Svelte tracks it as a dependency:
// when the canvas reappears after the loading spinner (loading → false),
// this effect re-fires and renders the already-loaded PDF.
$effect(() => {
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
if (!canvasEl || !textLayerEl) return;
renderer.setElements(canvasEl, textLayerEl);
// Also track currentPage and scale so page-nav / zoom re-renders work.
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
renderer.renderCurrentPage().then(() => renderer.prerender());
}

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}
function reviewedPct(doc: TranscriptionQueueItemDTO): number {
if (doc.textedBlockCount === 0) return 0;
return Math.round((doc.reviewedBlockCount / doc.textedBlockCount) * 100);
}
</script>
{#if docs.length > 0}
<div
class="flex flex-col gap-3 rounded-sm border border-brand-mint bg-brand-mint/10 p-4 transition-shadow hover:shadow-sm"
>
<div>
<div class="mb-1 flex items-center gap-2">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_ready_heading()}
</h3>
{#if weeklyCount > 0}
<span class="rounded-full bg-accent-bg px-2 py-0.5 text-xs font-semibold text-ink-2">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</span>
{/if}
</div>
<p class="text-xs font-semibold text-ink-2">
{m.mission_control_ready_subtitle({ count: docs.length })}
</p>
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-brand-mint/20 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span class="font-serif text-sm text-ink">{doc.title}</span>
<div class="mt-0.5 flex items-center gap-2">
{#if doc.documentDate}
<span class="text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if}
{#if doc.textedBlockCount > 0}
<span class="text-xs font-semibold text-ink">
{m.mission_control_reviewed_pct({ pct: reviewedPct(doc) })}
</span>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</div>
{:else}
<div
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-brand-mint bg-brand-mint/5 p-6 text-center"
>
<p class="text-xs text-ink-3">{m.mission_control_ready_empty()}</p>
<a
href="/enrich?filter=NEEDS_SEGMENTATION&next=1"
class="mt-2 inline-flex items-center rounded-sm border border-ink px-3 py-2 text-xs font-semibold text-ink transition-colors hover:bg-ink hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
{m.mission_control_ready_empty_cta()}
</a>
</div>
{/if}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import ExpertBadge from './ExpertBadge.svelte';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}
</script>
{#if docs.length > 0}
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
<div>
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_segmentation_heading()}
</h3>
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-accent-bg px-2 py-0.5 text-xs font-semibold text-ink"
>
{m.mission_control_seg_skill_pill()}
</span>
{#if weeklyCount > 0}
<p class="mt-1 text-xs font-semibold text-ink-2">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</p>
{/if}
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.needsExpert}
<ExpertBadge />
{/if}
</div>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import ExpertBadge from './ExpertBadge.svelte';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}
function blockProgress(doc: TranscriptionQueueItemDTO): number {
if (doc.annotationCount === 0) return 0;
return (doc.textedBlockCount / doc.annotationCount) * 100;
}
</script>
{#if docs.length > 0}
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
<div>
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_transcription_heading()}
</h3>
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-surface px-2 py-0.5 text-xs font-semibold text-ink"
>
{m.mission_control_trans_skill_pill()}
</span>
{#if weeklyCount > 0}
<p class="mt-1 text-xs font-semibold text-ink">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</p>
{/if}
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.needsExpert}
<ExpertBadge />
{/if}
</div>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if}
{#if doc.textedBlockCount > 0}
<div class="mt-1.5 flex items-center gap-2">
<span class="shrink-0 text-xs text-ink-3">
{m.mission_control_blocks_progress({
texted: doc.textedBlockCount,
total: doc.annotationCount
})}
</span>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-ink/20">
<div
class="h-full rounded-full bg-ink transition-all"
style="width: {blockProgress(doc).toFixed(0)}%"
></div>
</div>
</div>
{:else}
<span class="mt-0.5 text-xs text-ink-3 italic"></span>
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}

View File

@@ -628,6 +628,22 @@ export interface paths {
patch: operations["editComment"];
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteAnnotation"];
options?: never;
head?: never;
patch: operations["updateAnnotation"];
trace?: never;
};
"/api/users/search": {
parameters: {
query?: never;
@@ -660,6 +676,32 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/transcription/segmentation-queue": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getSegmentationQueue"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/transcription/transcription-queue": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getTranscriptionQueue"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/transcription/ready-to-read": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getReadyToRead"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/transcription/weekly-stats": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getTranscriptionWeeklyStats"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/documents/{id}/needs-expert": {
parameters: { query?: never; header?: never; path: { id: string; }; cookie?: never; };
get?: never; put?: never; post?: never; delete?: never; options?: never; head?: never;
patch: operations["toggleNeedsExpert"];
trace?: never;
};
"/api/stats": {
parameters: {
query?: never;
@@ -1060,22 +1102,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteAnnotation"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1199,6 +1225,7 @@ export interface components {
metadataComplete: boolean;
/** @enum {string} */
scriptType: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT";
needsExpert: boolean;
receivers?: components["schemas"]["Person"][];
sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][];
@@ -1440,28 +1467,60 @@ export interface components {
label?: string;
enrolled?: boolean;
};
UpdateAnnotationDTO: {
/** Format: double */
x?: number;
/** Format: double */
y?: number;
/** Format: double */
width?: number;
/** Format: double */
height?: number;
};
StatsDTO: {
/** Format: int64 */
totalPersons?: number;
/** Format: int64 */
totalDocuments?: number;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
needsExpert: boolean;
/** Format: int32 */
annotationCount: number;
/** Format: int32 */
textedBlockCount: number;
/** Format: int32 */
reviewedBlockCount: number;
};
TranscriptionWeeklyStatsDTO: {
/** Format: int64 */
segmentationCount: number;
/** Format: int64 */
transcriptionCount: number;
/** Format: int64 */
readyCount: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
personType?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
alias?: string;
notes?: string;
/** Format: int64 */
documentCount?: number;
personType?: string;
};
TrainingInfoResponse: {
/** Format: int32 */
@@ -1508,6 +1567,8 @@ export interface components {
/** Format: int64 */
totalElements?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -1516,8 +1577,6 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -1578,9 +1637,28 @@ export interface components {
totalPages?: number;
};
DocumentSearchResult: {
documents?: components["schemas"]["Document"][];
documents: components["schemas"]["Document"][];
/** Format: int64 */
total?: number;
total: number;
matchData: {
[key: string]: components["schemas"]["SearchMatchData"];
};
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
IncompleteDocumentDTO: {
/** Format: uuid */
@@ -2938,8 +3016,8 @@ export interface operations {
};
};
responses: {
/** @description OK */
200: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
@@ -2995,6 +3073,54 @@ export interface operations {
};
};
};
deleteAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateAnnotationDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"];
};
};
};
};
search: {
parameters: {
query?: {
@@ -3039,6 +3165,46 @@ export interface operations {
};
};
};
getSegmentationQueue: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
};
};
getTranscriptionQueue: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
};
};
getReadyToRead: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
};
};
getTranscriptionWeeklyStats: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionWeeklyStatsDTO"]; }; };
};
};
toggleNeedsExpert: {
parameters: { query?: never; header?: never; path: { id: string; }; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["Document"]; }; };
};
};
getStats: {
parameters: {
query?: never;
@@ -3425,7 +3591,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";
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE";
/** @description Sort direction: ASC or DESC */
dir?: string;
};
@@ -3602,25 +3768,4 @@ export interface operations {
};
};
};
deleteAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@@ -1,3 +1,5 @@
import { untrack } from 'svelte';
export function createFileLoader() {
let fileUrl = $state('');
let isLoading = $state(false);
@@ -6,7 +8,11 @@ export function createFileLoader() {
async function loadFile(url: string): Promise<void> {
isLoading = true;
fileError = '';
if (fileUrl) URL.revokeObjectURL(fileUrl);
// untrack prevents callers ($effect) from accidentally subscribing to fileUrl.
// Without it, the calling effect would re-run every time fileUrl changes (i.e.
// on every successful load), creating an infinite load loop.
const prev = untrack(() => fileUrl);
if (prev) URL.revokeObjectURL(prev);
fileUrl = '';
try {

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { applyOffsets } from './search';
describe('applyOffsets', () => {
it('returns single plain segment when offsets is empty', () => {
expect(applyOffsets('Hallo Welt', [])).toEqual([{ text: 'Hallo Welt', highlight: false }]);
});
it('highlights a single term at the start', () => {
expect(applyOffsets('Brief an Anna', [{ start: 0, length: 5 }])).toEqual([
{ text: 'Brief', highlight: true },
{ text: ' an Anna', highlight: false }
]);
});
it('highlights a term in the middle', () => {
expect(applyOffsets('Der Brief von Anna', [{ start: 4, length: 5 }])).toEqual([
{ text: 'Der ', highlight: false },
{ text: 'Brief', highlight: true },
{ text: ' von Anna', highlight: false }
]);
});
it('highlights a term at the end', () => {
expect(applyOffsets('Brief an Anna', [{ start: 9, length: 4 }])).toEqual([
{ text: 'Brief an ', highlight: false },
{ text: 'Anna', highlight: true }
]);
});
it('handles two non-overlapping offsets in order', () => {
expect(
applyOffsets('Anna und Brief', [
{ start: 0, length: 4 },
{ start: 9, length: 5 }
])
).toEqual([
{ text: 'Anna', highlight: true },
{ text: ' und ', highlight: false },
{ text: 'Brief', highlight: true }
]);
});
it('merges overlapping offsets into the longest span', () => {
// [0,7) and [3,9) overlap → merged [0,max(7,9)) = [0,9) = "Hello wor"
expect(
applyOffsets('Hello world', [
{ start: 0, length: 7 },
{ start: 3, length: 6 }
])
).toEqual([
{ text: 'Hello wor', highlight: true },
{ text: 'ld', highlight: false }
]);
});
it('merges adjacent (touching) offsets', () => {
// [0,3) and [3,6) are adjacent → merged [0,6)
expect(
applyOffsets('Hallo Welt', [
{ start: 0, length: 3 },
{ start: 3, length: 3 }
])
).toEqual([
{ text: 'Hallo ', highlight: true },
{ text: 'Welt', highlight: false }
]);
});
it('clamps offset that extends beyond text length', () => {
expect(applyOffsets('Hi', [{ start: 0, length: 100 }])).toEqual([
{ text: 'Hi', highlight: true }
]);
});
it('ignores a completely out-of-bounds offset', () => {
expect(applyOffsets('Hi', [{ start: 10, length: 5 }])).toEqual([
{ text: 'Hi', highlight: false }
]);
});
it('sorts unsorted offsets correctly', () => {
// Offsets provided in reverse order: second term first
expect(
applyOffsets('Anna und Brief', [
{ start: 9, length: 5 },
{ start: 0, length: 4 }
])
).toEqual([
{ text: 'Anna', highlight: true },
{ text: ' und ', highlight: false },
{ text: 'Brief', highlight: true }
]);
});
it('clamps negative start to 0 and highlights from the beginning', () => {
// start = -2, length = 5 → effective range [-2, 3) → clamped to [0, 3)
expect(applyOffsets('Hello', [{ start: -2, length: 5 }])).toEqual([
{ text: 'Hel', highlight: true },
{ text: 'lo', highlight: false }
]);
});
it('ignores offset whose end is also negative', () => {
// start = -5, length = 2 → end = -3, completely before text
expect(applyOffsets('Hi', [{ start: -5, length: 2 }])).toEqual([
{ text: 'Hi', highlight: false }
]);
});
});

View File

@@ -0,0 +1,46 @@
export type TextSegment = { text: string; highlight: boolean };
export type MatchOffset = { start: number; length: number };
/**
* Converts a flat string and a list of character-level highlight offsets into
* an array of text segments that can be rendered without {@html}.
*
* Offsets are sorted and merged (overlapping spans become the longest enclosing
* span) before processing. Out-of-bounds offsets are clamped or dropped.
*
* @param text The display text (no delimiter characters).
* @param offsets Character offsets produced by the backend (Java char positions,
* compatible with JavaScript String indexing).
*/
export function applyOffsets(text: string, offsets: MatchOffset[]): TextSegment[] {
if (!offsets.length) return [{ text, highlight: false }];
// Sort by start position and merge overlapping / adjacent spans
const sorted = [...offsets].sort((a, b) => a.start - b.start);
const merged: { start: number; end: number }[] = [];
for (const { start, length } of sorted) {
const end = start + length;
if (end <= 0 || start >= text.length) continue; // completely out of bounds
const clampedStart = Math.max(0, start);
const clampedEnd = Math.min(text.length, end);
const last = merged[merged.length - 1];
if (!last || clampedStart > last.end) {
merged.push({ start: clampedStart, end: clampedEnd });
} else {
last.end = Math.max(last.end, clampedEnd);
}
}
if (!merged.length) return [{ text, highlight: false }];
const segments: TextSegment[] = [];
let pos = 0;
for (const { start, end } of merged) {
if (pos < start) segments.push({ text: text.slice(pos, start), highlight: false });
segments.push({ text: text.slice(start, end), highlight: true });
pos = end;
}
if (pos < text.length) segments.push({ text: text.slice(pos), highlight: false });
return segments;
}

View File

@@ -5,6 +5,9 @@ import type { components } from '$lib/generated/api';
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type StatsDTO = components['schemas']['StatsDTO'];
type Document = components['schemas']['Document'];
type SearchMatchData = components['schemas']['SearchMatchData'];
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
@@ -60,9 +63,14 @@ export async function load({ url, fetch }) {
throw redirect(302, '/login');
}
const searchResult = docsResult?.data as { documents?: Document[]; total?: number } | null;
const searchResult = docsResult?.data as {
documents?: Document[];
total?: number;
matchData?: Record<string, SearchMatchData>;
} | null;
const documents: Document[] = searchResult?.documents ?? [];
const total: number = searchResult?.total ?? 0;
const matchData: Record<string, SearchMatchData> = searchResult?.matchData ?? {};
const allPersons = (personsResult.data ?? []) as {
id: string;
firstName: string;
@@ -76,12 +84,28 @@ export async function load({ url, fetch }) {
let stats: StatsDTO | null = null;
let incompleteDocs: IncompleteDocumentDTO[] = [];
let recentDocs: Document[] = [];
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
let readyDocs: TranscriptionQueueItemDTO[] = [];
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
if (isDashboard) {
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
const [
statsResult,
incompleteResult,
recentResult,
segmentationResult,
transcriptionResult,
readyResult,
weeklyStatsResult
] = await Promise.allSettled([
api.GET('/api/stats'),
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } }),
api.GET('/api/transcription/segmentation-queue'),
api.GET('/api/transcription/transcription-queue'),
api.GET('/api/transcription/ready-to-read'),
api.GET('/api/transcription/weekly-stats')
]);
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
@@ -93,15 +117,32 @@ export async function load({ url, fetch }) {
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
recentDocs = recentResult.value.data ?? [];
}
if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) {
segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[];
}
if (transcriptionResult.status === 'fulfilled' && transcriptionResult.value.response.ok) {
transcriptionDocs = (transcriptionResult.value.data ?? []) as TranscriptionQueueItemDTO[];
}
if (readyResult.status === 'fulfilled' && readyResult.value.response.ok) {
readyDocs = (readyResult.value.data ?? []) as TranscriptionQueueItemDTO[];
}
if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) {
weeklyStats = weeklyStatsResult.value.data ?? null;
}
}
return {
isDashboard,
documents,
total,
matchData,
stats,
incompleteDocs,
recentDocs,
segmentationDocs,
transcriptionDocs,
readyDocs,
weeklyStats,
initialValues: {
senderName: senderObj?.displayName ?? '',
receiverName: receiverObj?.displayName ?? ''
@@ -116,9 +157,14 @@ export async function load({ url, fetch }) {
isDashboard,
documents: [],
total: 0,
matchData: {} as Record<string, SearchMatchData>,
stats: null,
incompleteDocs: [],
recentDocs: [],
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null,
initialValues: { senderName: '', receiverName: '' },
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
error: 'Daten konnten nicht geladen werden.' as string | null

View File

@@ -9,6 +9,7 @@ import DocumentList from './DocumentList.svelte';
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -132,6 +133,13 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
</div>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
{:else}
<DocumentList
documents={data.documents ?? []}
@@ -140,6 +148,7 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
total={data.total ?? 0}
q={q}
sort={sort}
matchData={data.matchData ?? {}}
/>
{/if}
</main>

View File

@@ -4,6 +4,8 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { groupDocuments } from '$lib/utils/groupDocuments';
import GroupDivider from '$lib/components/GroupDivider.svelte';
import { applyOffsets } from '$lib/search';
import type { components } from '$lib/generated/api';
let {
documents,
@@ -11,7 +13,8 @@ let {
error,
total = 0,
q = '',
sort
sort,
matchData = {}
}: {
documents: {
id: string;
@@ -19,8 +22,13 @@ let {
originalFilename: string;
documentDate?: string | null;
location?: string | null;
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
sender?: {
id?: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
tags?: { id: string; name: string }[];
}[];
canWrite: boolean;
@@ -28,6 +36,7 @@ let {
total?: number;
q?: string;
sort?: string;
matchData?: Record<string, components['schemas']['SearchMatchData']>;
} = $props();
const fallbackLabel = $derived(
@@ -75,6 +84,17 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{/if}
<ul class="divide-y divide-line-2">
{#each group.documents as doc (doc.id)}
{@const titleText = doc.title || doc.originalFilename}
{@const match = matchData?.[doc.id]}
{@const titleOffsets = match?.titleOffsets ?? []}
{@const titleSegments = applyOffsets(titleText, titleOffsets)}
{@const snippet = match?.transcriptionSnippet}
{@const snippetSegments = snippet ? applyOffsets(snippet, match?.snippetOffsets ?? []) : null}
{@const summary = match?.summarySnippet}
{@const summarySegments = summary ? applyOffsets(summary, match?.summaryOffsets ?? []) : null}
{@const senderMatched = match?.senderMatched ?? false}
{@const matchedReceiverIds = new Set(match?.matchedReceiverIds ?? [])}
{@const matchedTagIds = new Set(match?.matchedTagIds ?? [])}
<li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row">
@@ -82,7 +102,12 @@ const showDividers = $derived(groupedDocuments.length >= 2);
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
{doc.title || doc.originalFilename}
{#each titleSegments as seg, i (i)}
{#if seg.highlight}<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}
{/each}
</h3>
</div>
@@ -110,6 +135,42 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{/if}
</div>
{#if snippetSegments}
<div class="mb-4 flex items-baseline gap-2">
<span
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_content()}</span
>
<p
data-testid="search-snippet"
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each snippetSegments as seg, i (i)}{#if seg.highlight}<mark
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}{/each}
</p>
</div>
{/if}
{#if summarySegments}
<div class="mb-4 flex items-baseline gap-2">
<span
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_summary()}</span
>
<p
data-testid="search-summary"
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each summarySegments as seg, i (i)}{#if seg.highlight}<mark
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}{/each}
</p>
</div>
{/if}
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
<div class="flex items-baseline">
@@ -118,7 +179,15 @@ const showDividers = $derived(groupedDocuments.length >= 2);
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-ink">{doc.sender.displayName}</span>
{#if senderMatched}
<mark
data-testid="sender-match"
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{doc.sender.displayName}</mark
>
{:else}
<span class="text-ink">{doc.sender.displayName}</span>
{/if}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
@@ -130,7 +199,18 @@ const showDividers = $derived(groupedDocuments.length >= 2);
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-ink">
{doc.receivers.map((p) => p.displayName).join(', ')}
{#each doc.receivers as receiver, ri (receiver.id ?? ri)}
{#if ri > 0}<span>, </span>{/if}
{#if receiver.id && matchedReceiverIds.has(receiver.id)}
<mark
data-testid="receiver-match"
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{receiver.displayName}</mark
>
{:else}
{receiver.displayName}
{/if}
{/each}
</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
@@ -144,14 +224,18 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
class="relative z-10 inline-flex cursor-pointer items-center rounded px-2 py-1 text-[10px] font-bold tracking-widest uppercase transition-colors hover:bg-primary hover:text-primary-fg {matchedTagIds.has(tag.id) ? 'bg-muted text-ink underline decoration-brand-navy decoration-2 underline-offset-2' : 'bg-muted text-ink'}"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
goto(`/?tag=${encodeURIComponent(tag.name)}`);
}}
>
{tag.name}
{#if matchedTagIds.has(tag.id)}
<span data-testid="tag-match">{tag.name}</span>
{:else}
{tag.name}
{/if}
</button>
{/each}
</div>

View File

@@ -12,14 +12,20 @@ const baseProps = {
canWrite: false,
error: null,
total: 0,
q: ''
q: '',
matchData: {} as Record<
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>
};
type DocOverrides = {
id?: string;
title?: string;
documentDate?: string | null;
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
sender?: { id?: string; firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
tags?: { id: string; name: string }[];
};
const makeDoc = (overrides: DocOverrides = {}) => ({
@@ -30,7 +36,12 @@ const makeDoc = (overrides: DocOverrides = {}) => ({
documentDate: '2024-03-15',
location: null,
sender: null,
receivers: [] as { firstName?: string | null; lastName: string; displayName: string }[],
receivers: [] as {
id?: string;
firstName?: string | null;
lastName: string;
displayName: string;
}[],
tags: [],
...overrides
});
@@ -115,3 +126,248 @@ describe('DocumentList group headers', () => {
await expect.element(links.nth(1)).toBeInTheDocument();
});
});
// ─── Match data: snippet and title highlighting ───────────────────────────────
describe('DocumentList match snippets and highlights', () => {
it('shows transcription snippet when matchData has one for the document', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument();
});
it('does not show snippet section when matchData has no entry for the document', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, { ...baseProps, documents: [doc], total: 1, matchData: {} });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
});
it('renders a <mark> element when titleOffsets are present', async () => {
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
// The word "Brief" should be inside a <mark> element
const mark = page.getByRole('mark');
await expect.element(mark).toBeInTheDocument();
await expect.element(mark).toHaveTextContent('Brief');
});
it('renders title as plain text when titleOffsets is empty', async () => {
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
await expect.element(page.getByRole('mark')).not.toBeInTheDocument();
await expect.element(page.getByText('Brief an Anna')).toBeInTheDocument();
});
it('renders <mark> inside snippet when snippetOffsets are present', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: 'Er schrieb einen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [{ start: 17, length: 5 }], // "Brief"
summaryOffsets: []
}
}
});
const snippet = page.getByTestId('search-snippet');
await expect.element(snippet).toBeInTheDocument();
const mark = snippet.getByRole('mark');
await expect.element(mark).toBeInTheDocument();
await expect.element(mark).toHaveTextContent('Brief');
});
it('renders snippet as plain text when snippetOffsets is empty', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: 'Er schrieb einen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
const snippet = page.getByTestId('search-snippet');
await expect.element(snippet).toBeInTheDocument();
// No mark elements inside the snippet when offsets is empty
await expect.element(snippet.getByRole('mark')).not.toBeInTheDocument();
});
it('visually marks sender when senderMatched is true', async () => {
const doc = makeDoc({
id: 'doc1',
sender: {
id: 'sender-1',
firstName: 'Walter',
lastName: 'Raddatz',
displayName: 'Walter Raddatz'
}
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: true,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
const senderMark = page.getByTestId('sender-match');
await expect.element(senderMark).toBeInTheDocument();
await expect.element(senderMark).toHaveTextContent('Walter Raddatz');
});
it('does not mark sender when senderMatched is false', async () => {
const doc = makeDoc({
id: 'doc1',
sender: {
id: 'sender-1',
firstName: 'Walter',
lastName: 'Raddatz',
displayName: 'Walter Raddatz'
}
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
await expect.element(page.getByTestId('sender-match')).not.toBeInTheDocument();
});
it('visually marks matched receiver when their id is in matchedReceiverIds', async () => {
const doc = makeDoc({
id: 'doc1',
receivers: [
{ id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
{ id: 'p-2', firstName: 'Karl', lastName: 'Bauer', displayName: 'Karl Bauer' }
]
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: ['p-1'],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
// Only Anna Schmidt should be marked
const receiverMark = page.getByTestId('receiver-match');
await expect.element(receiverMark).toBeInTheDocument();
await expect.element(receiverMark).toHaveTextContent('Anna Schmidt');
});
it('visually marks matched tag when its id is in matchedTagIds', async () => {
const doc = makeDoc({
id: 'doc1',
tags: [
{ id: 'tag-1', name: 'Familiengeschichte' },
{ id: 'tag-2', name: 'Reise' }
]
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: ['tag-1'],
snippetOffsets: [],
summaryOffsets: []
}
}
});
const tagMark = page.getByTestId('tag-match');
await expect.element(tagMark).toBeInTheDocument();
await expect.element(tagMark).toHaveTextContent('Familiengeschichte');
});
});

View File

@@ -38,6 +38,8 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
documentDate: '1923-04-12',
location: 'Berlin',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
needsExpert: false,
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],

View File

@@ -22,8 +22,23 @@ const emptyData = {
canWrite: true,
canAnnotate: false,
isDashboard: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
filters: {
q: '',
from: '',
to: '',
senderId: '',
receiverId: '',
tags: [],
sort: 'DATE' as const,
dir: 'desc' as const,
tagQ: ''
},
documents: [],
total: 0,
matchData: {} as Record<
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>,
incompleteDocs: [],
recentDocs: [],
stats: null,