Compare commits

...

11 Commits

Author SHA1 Message Date
Marcel
e15867e47d fix(#240): remove CTA buttons and dead i18n keys from Mission Control Strip
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
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:34:47 +02:00
Marcel
7b32b686bd 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:31:20 +02:00
Marcel
f00dd675e7 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:22:06 +02:00
Marcel
d0b178af7a 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:19:39 +02:00
Marcel
9e7960371f 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:15:21 +02:00
Marcel
86151082f4 fix(#240): redirect Mission Control Strip links to document detail page
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m30s
CI / Backend Unit Tests (push) Failing after 2m46s
CI / Unit & Component Tests (pull_request) Failing after 2m30s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
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 09:11:21 +02:00
Marcel
d30ea1d67c fix(#240): add missing V36 index migration and rename needs_expert to V37
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m28s
CI / Backend Unit Tests (push) Failing after 2m43s
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / Backend Unit Tests (pull_request) Failing after 2m41s
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 08:10:44 +02:00
Marcel
1af74364db docs(#240): add Mission Control Strip spec and pattern alternatives
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / Backend Unit Tests (push) Failing after 2m38s
CI / Unit & Component Tests (pull_request) Failing after 2m27s
CI / Backend Unit Tests (pull_request) Failing after 2m42s
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 08:03:38 +02:00
Marcel
00e0d41201 feat(#240): Mission Control Strip frontend — 5 components + dashboard wiring
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m53s
CI / Backend Unit Tests (push) Failing after 3m7s
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / Backend Unit Tests (pull_request) Failing after 2m43s
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-15 23:14:43 +02:00
Marcel
8d59209251 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-15 23:06:04 +02:00
Marcel
1177de2f95 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-15 22:59:17 +02:00
24 changed files with 2739 additions and 5 deletions

View File

@@ -212,6 +212,14 @@ public class DocumentController {
return ResponseEntity.ok(DocumentSearchResult.of(results));
}
// --- EXPERT FLAG ---
@PatchMapping("/{id}/needs-expert")
@RequirePermission(Permission.WRITE_ALL)
public Document toggleNeedsExpert(@PathVariable UUID id) {
return documentService.toggleNeedsExpert(id);
}
// --- TRAINING LABELS ---
public record TrainingLabelRequest(String label, boolean enrolled) {}

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

@@ -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

@@ -89,4 +89,82 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
""")
List<UUID> findRankedIdsByFts(@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

@@ -570,6 +570,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));

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 @@
CREATE INDEX 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;

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

@@ -555,5 +555,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

@@ -555,5 +555,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

@@ -555,5 +555,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

@@ -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

@@ -660,6 +660,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;
@@ -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"][];
@@ -1446,6 +1473,28 @@ export interface components {
/** 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 */
@@ -3039,6 +3088,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;

View File

@@ -5,6 +5,8 @@ import type { components } from '$lib/generated/api';
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type StatsDTO = components['schemas']['StatsDTO'];
type Document = components['schemas']['Document'];
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
@@ -76,12 +78,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,6 +111,18 @@ 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 {
@@ -102,6 +132,10 @@ export async function load({ url, fetch }) {
stats,
incompleteDocs,
recentDocs,
segmentationDocs,
transcriptionDocs,
readyDocs,
weeklyStats,
initialValues: {
senderName: senderObj?.displayName ?? '',
receiverName: receiverObj?.displayName ?? ''
@@ -119,6 +153,10 @@ export async function load({ url, fetch }) {
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 ?? []}

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: [],