feat(#240): Mission Control Strip — backend + frontend implementation #245

Merged
marcel merged 26 commits from feat/issue-240-mission-control-strip into main 2026-04-16 13:41:34 +02:00
32 changed files with 3462 additions and 14 deletions

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,20 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
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(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
LocalDate documentDate,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotationCount,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int textedBlockCount,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount
) {}

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* 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(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long segmentationCount,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long transcriptionCount
) {}

View File

@@ -167,4 +167,73 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
""")
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,
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 HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
LIMIT :limit
""")
List<TranscriptionQueueProjection> 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,
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
HAVING COUNT(DISTINCT da.id) > 0
AND (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
COUNT(DISTINCT da.id)
) < 0.90
ORDER BY 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<TranscriptionQueueProjection> 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,
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
HAVING COUNT(DISTINCT da.id) > 0
AND (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
COUNT(DISTINCT da.id)
) >= 0.90
ORDER BY (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
COUNT(DISTINCT da.id)
) DESC
LIMIT :limit
""")
List<TranscriptionQueueProjection> 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
""")
TranscriptionWeeklyStatsProjection findWeeklyStats();
}

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.repository;
import java.time.LocalDate;
import java.util.UUID;
/**
* Spring Data projection for a single row in one of the three Mission Control Strip queues.
* Column aliases in the native SQL queries must match these getter names exactly.
*/
public interface TranscriptionQueueProjection {
UUID getId();
String getTitle();
LocalDate getDocumentDate();
int getAnnotationCount();
int getTextedBlockCount();
int getReviewedBlockCount();
}

View File

@@ -0,0 +1,10 @@
package org.raddatz.familienarchiv.repository;
/**
* Spring Data projection for the weekly activity pulse stats.
* Column aliases in the native SQL query must match these getter names exactly.
*/
public interface TranscriptionWeeklyStatsProjection {
long getSegmentationCount();
long getTranscriptionCount();
}

View File

@@ -0,0 +1,63 @@
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.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 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::toDTO)
.toList();
}
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::toDTO)
.toList();
}
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::toDTO)
.toList();
}
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
var stats = documentRepository.findWeeklyStats();
return new TranscriptionWeeklyStatsDTO(
stats.getSegmentationCount(),
stats.getTranscriptionCount()
);
}
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p) {
return new TranscriptionQueueItemDTO(
p.getId(),
p.getTitle(),
p.getDocumentDate(),
p.getAnnotationCount(),
p.getTextedBlockCount(),
p.getReviewedBlockCount()
);
}
}

View File

@@ -0,0 +1,6 @@
-- Indexes to support the weekly stats correlated subqueries in findWeeklyStats().
-- Without these, COUNT(DISTINCT ...) with a date range filter performs a full table scan
-- on every dashboard load.
CREATE INDEX IF NOT EXISTS idx_document_annotations_created_at ON document_annotations(created_at);
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_created_at ON transcription_blocks(created_at);
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_updated_at ON transcription_blocks(updated_at);

View File

@@ -0,0 +1,150 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.TranscriptionQueueService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TranscriptionQueueController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TranscriptionQueueControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TranscriptionQueueService transcriptionQueueService;
@MockitoBean DocumentRepository documentRepository;
@MockitoBean PersonRepository personRepository;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final TranscriptionQueueItemDTO ITEM = new TranscriptionQueueItemDTO(
UUID.fromString("00000000-0000-0000-0000-000000000001"),
"Testbrief",
LocalDate.of(1920, 6, 15),
3, 1, 0
);
private static final TranscriptionWeeklyStatsDTO STATS = new TranscriptionWeeklyStatsDTO(2L, 5L);
// ─── segmentation-queue ───────────────────────────────────────────────────
@Test
void getSegmentationQueue_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/transcription/segmentation-queue"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getSegmentationQueue_returns403_whenNoReadAllPermission() throws Exception {
mockMvc.perform(get("/api/transcription/segmentation-queue"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getSegmentationQueue_returns200_withItems() throws Exception {
when(transcriptionQueueService.getSegmentationQueue()).thenReturn(List.of(ITEM));
mockMvc.perform(get("/api/transcription/segmentation-queue"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Testbrief"))
.andExpect(jsonPath("$[0].annotationCount").value(3));
}
// ─── transcription-queue ──────────────────────────────────────────────────
@Test
void getTranscriptionQueue_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/transcription/transcription-queue"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getTranscriptionQueue_returns403_whenNoReadAllPermission() throws Exception {
mockMvc.perform(get("/api/transcription/transcription-queue"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getTranscriptionQueue_returns200_withItems() throws Exception {
when(transcriptionQueueService.getTranscriptionQueue()).thenReturn(List.of(ITEM));
mockMvc.perform(get("/api/transcription/transcription-queue"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Testbrief"));
}
// ─── ready-to-read ────────────────────────────────────────────────────────
@Test
void getReadyToRead_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/transcription/ready-to-read"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getReadyToRead_returns403_whenNoReadAllPermission() throws Exception {
mockMvc.perform(get("/api/transcription/ready-to-read"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getReadyToRead_returns200_withItems() throws Exception {
when(transcriptionQueueService.getReadyToReadQueue()).thenReturn(List.of(ITEM));
mockMvc.perform(get("/api/transcription/ready-to-read"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].reviewedBlockCount").value(0));
}
// ─── weekly-stats ─────────────────────────────────────────────────────────
@Test
void getWeeklyStats_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/transcription/weekly-stats"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getWeeklyStats_returns403_whenNoReadAllPermission() throws Exception {
mockMvc.perform(get("/api/transcription/weekly-stats"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getWeeklyStats_returns200_withStats() throws Exception {
when(transcriptionQueueService.getWeeklyStats()).thenReturn(STATS);
mockMvc.perform(get("/api/transcription/weekly-stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.segmentationCount").value(2))
.andExpect(jsonPath("$.transcriptionCount").value(5));
}
}

View File

@@ -4,8 +4,10 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
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.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
@@ -20,6 +22,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -34,6 +37,12 @@ class DocumentRepositoryTest {
@Autowired
private PersonRepository personRepository;
@Autowired
private AnnotationRepository annotationRepository;
@Autowired
private TranscriptionBlockRepository transcriptionBlockRepository;
// ─── save and findById ────────────────────────────────────────────────────
@Test
@@ -253,4 +262,115 @@ class DocumentRepositoryTest {
assertThat(results).hasSize(1);
}
// ─── findSegmentationQueue ────────────────────────────────────────────────
@Test
void findSegmentationQueue_excludes_PLACEHOLDER_documents() {
documentRepository.save(uploaded("Hochgeladener Brief"));
documentRepository.save(Document.builder()
.title("Platzhalter").originalFilename("placeholder.pdf")
.status(DocumentStatus.PLACEHOLDER).build());
List<TranscriptionQueueProjection> result = documentRepository.findSegmentationQueue(10);
assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
.containsExactly("Hochgeladener Brief")
.doesNotContain("Platzhalter");
}
@Test
void findSegmentationQueue_excludes_documents_that_already_have_annotations() {
Document withAnnotation = documentRepository.save(uploaded("Mit Annotation"));
documentRepository.save(uploaded("Ohne Annotation"));
annotationRepository.save(annotation(withAnnotation.getId()));
List<TranscriptionQueueProjection> result = documentRepository.findSegmentationQueue(10);
assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
.containsExactly("Ohne Annotation")
.doesNotContain("Mit Annotation");
}
// ─── findTranscriptionQueue ───────────────────────────────────────────────
@Test
void findTranscriptionQueue_returns_documents_with_annotations_below_90pct_reviewed() {
Document doc = documentRepository.save(uploaded("Tagebuch"));
DocumentAnnotation ann = annotationRepository.save(annotation(doc.getId()));
// One block, not reviewed → 0 / 1 = 0% < 90%
transcriptionBlockRepository.save(block(doc.getId(), ann.getId(), "Text", false));
List<TranscriptionQueueProjection> result = documentRepository.findTranscriptionQueue(10);
assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
.contains("Tagebuch");
}
@Test
void findTranscriptionQueue_returns_zero_textedBlockCount_when_no_transcription_blocks() {
Document doc = documentRepository.save(uploaded("Nur Annotation"));
annotationRepository.save(annotation(doc.getId()));
// No transcription blocks at all — annotationCount=1, textedBlockCount=0
List<TranscriptionQueueProjection> result = documentRepository.findTranscriptionQueue(10);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTextedBlockCount()).isEqualTo(0);
assertThat(result.get(0).getAnnotationCount()).isEqualTo(1);
}
// ─── findReadyToReadQueue ─────────────────────────────────────────────────
@Test
void findReadyToReadQueue_returns_documents_with_at_least_90pct_reviewed() {
Document doc = documentRepository.save(uploaded("Urkunde"));
DocumentAnnotation ann = annotationRepository.save(annotation(doc.getId()));
// One block, reviewed → 1 / 1 = 100% >= 90%
transcriptionBlockRepository.save(block(doc.getId(), ann.getId(), "Text", true));
List<TranscriptionQueueProjection> result = documentRepository.findReadyToReadQueue(10);
assertThat(result).extracting(TranscriptionQueueProjection::getTitle)
.contains("Urkunde");
}
// ─── findWeeklyStats ──────────────────────────────────────────────────────
@Test
void findWeeklyStats_returns_zeros_when_database_is_empty() {
TranscriptionWeeklyStatsProjection stats = documentRepository.findWeeklyStats();
assertThat(stats.getSegmentationCount()).isEqualTo(0L);
assertThat(stats.getTranscriptionCount()).isEqualTo(0L);
}
// ─── seeding helpers ─────────────────────────────────────────────────────
private Document uploaded(String title) {
return Document.builder()
.title(title)
.originalFilename(title.toLowerCase().replace(" ", "_") + ".pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
private DocumentAnnotation annotation(UUID documentId) {
return DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(1)
.x(0.1).y(0.1).width(0.5).height(0.3)
.color("#ff0000")
.build();
}
private TranscriptionBlock block(UUID documentId, UUID annotationId, String text, boolean reviewed) {
return TranscriptionBlock.builder()
.documentId(documentId)
.annotationId(annotationId)
.text(text)
.sortOrder(0)
.reviewed(reviewed)
.build();
}
}

View File

@@ -1348,7 +1348,7 @@ class DocumentServiceTest {
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});
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
@@ -1381,7 +1381,7 @@ class DocumentServiceTest {
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});
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))

View File

@@ -0,0 +1,133 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
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.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TranscriptionQueueServiceTest {
@Mock DocumentRepository documentRepository;
@InjectMocks TranscriptionQueueService service;
// ─── getSegmentationQueue ─────────────────────────────────────────────────
@Test
void getSegmentationQueue_delegatesToRepositoryWithDefaultSize() {
UUID id = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(id, "Brief von 1920", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
verify(documentRepository).findSegmentationQueue(5);
assertThat(result).hasSize(1);
assertThat(result.get(0).id()).isEqualTo(id);
assertThat(result.get(0).title()).isEqualTo("Brief von 1920");
assertThat(result.get(0).documentDate()).isNull();
assertThat(result.get(0).annotationCount()).isEqualTo(0);
}
@Test
void getSegmentationQueue_mapsDocumentDateWhenPresent() {
LocalDate date = LocalDate.of(1920, 6, 15);
TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Brief", date, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
assertThat(result.get(0).documentDate()).isEqualTo(date);
}
// ─── getTranscriptionQueue ────────────────────────────────────────────────
@Test
void getTranscriptionQueue_delegatesToRepositoryWithDefaultSize() {
UUID id = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(id, "Tagebuch", LocalDate.of(1943, 1, 1), 3, 1, 0);
when(documentRepository.findTranscriptionQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getTranscriptionQueue();
verify(documentRepository).findTranscriptionQueue(5);
assertThat(result).hasSize(1);
assertThat(result.get(0).annotationCount()).isEqualTo(3);
assertThat(result.get(0).textedBlockCount()).isEqualTo(1);
assertThat(result.get(0).reviewedBlockCount()).isEqualTo(0);
}
// ─── getReadyToReadQueue ──────────────────────────────────────────────────
@Test
void getReadyToReadQueue_delegatesToRepositoryWithDefaultSize() {
TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Urkunde", null, 4, 4, 4);
when(documentRepository.findReadyToReadQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getReadyToReadQueue();
verify(documentRepository).findReadyToReadQueue(5);
assertThat(result).hasSize(1);
assertThat(result.get(0).reviewedBlockCount()).isEqualTo(4);
}
// ─── getWeeklyStats ───────────────────────────────────────────────────────
@Test
void getWeeklyStats_mapsProjectionToDTO() {
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(3L, 7L);
when(documentRepository.findWeeklyStats()).thenReturn(proj);
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
assertThat(result.segmentationCount()).isEqualTo(3L);
assertThat(result.transcriptionCount()).isEqualTo(7L);
}
@Test
void getWeeklyStats_returnsZeros_whenAllCountsAreZero() {
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(0L, 0L);
when(documentRepository.findWeeklyStats()).thenReturn(proj);
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
assertThat(result.segmentationCount()).isEqualTo(0L);
assertThat(result.transcriptionCount()).isEqualTo(0L);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private TranscriptionQueueProjection mockQueueProjection(
UUID id, String title, LocalDate documentDate,
int annotationCount, int textedBlockCount, int reviewedBlockCount) {
TranscriptionQueueProjection proj = mock(TranscriptionQueueProjection.class);
when(proj.getId()).thenReturn(id);
when(proj.getTitle()).thenReturn(title);
when(proj.getDocumentDate()).thenReturn(documentDate);
when(proj.getAnnotationCount()).thenReturn(annotationCount);
when(proj.getTextedBlockCount()).thenReturn(textedBlockCount);
when(proj.getReviewedBlockCount()).thenReturn(reviewedBlockCount);
return proj;
}
private TranscriptionWeeklyStatsProjection mockStatsProjection(
long segmentationCount, long transcriptionCount) {
TranscriptionWeeklyStatsProjection proj = mock(TranscriptionWeeklyStatsProjection.class);
when(proj.getSegmentationCount()).thenReturn(segmentationCount);
when(proj.getTranscriptionCount()).thenReturn(transcriptionCount);
return proj;
}
}

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

@@ -557,5 +557,22 @@
"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": "Text markieren",
"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 transkribieren",
"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_blocks_progress": "{texted} / {total} Blöcke",
"mission_control_reviewed_pct": "{pct}% geprüft"
}

View File

@@ -557,5 +557,22 @@
"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": "Mark text",
"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": "Transcribe 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_blocks_progress": "{texted} / {total} blocks",
"mission_control_reviewed_pct": "{pct}% reviewed"
}

View File

@@ -557,5 +557,22 @@
"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 texto",
"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": "Transcribir 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_blocks_progress": "{texted} / {total} bloques",
"mission_control_reviewed_pct": "{pct}% revisado"
}

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import SegmentationColumn from './SegmentationColumn.svelte';
import TranscriptionColumn from './TranscriptionColumn.svelte';
import ReadyColumn from './ReadyColumn.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
interface Props {
segmentationDocs: TranscriptionQueueItemDTO[];
transcriptionDocs: TranscriptionQueueItemDTO[];
readyDocs: TranscriptionQueueItemDTO[];
weeklyStats: TranscriptionWeeklyStatsDTO | null;
}
let { segmentationDocs, transcriptionDocs, readyDocs, weeklyStats }: Props = $props();
</script>
<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} />
</div>
</section>

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import MissionControlStrip from './MissionControlStrip.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
afterEach(cleanup);
function makeDoc(
id: string,
title: string,
overrides: Partial<TranscriptionQueueItemDTO> = {}
): TranscriptionQueueItemDTO {
return {
id,
title,
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
...overrides
};
}
const emptyStats: TranscriptionWeeklyStatsDTO = {
segmentationCount: 0,
transcriptionCount: 0,
readyCount: 0
};
describe('MissionControlStrip', () => {
it('renders section heading always', async () => {
render(MissionControlStrip, {
props: {
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null
}
});
await expect.element(page.getByText('Was braucht Aufmerksamkeit?')).toBeInTheDocument();
});
it('renders all three column headings', async () => {
render(MissionControlStrip, {
props: {
segmentationDocs: [makeDoc('s1', 'Seg Dok')],
transcriptionDocs: [makeDoc('t1', 'Trans Dok')],
readyDocs: [makeDoc('r1', 'Ready Dok')],
weeklyStats: emptyStats
}
});
await expect.element(page.getByText('Text markieren')).toBeInTheDocument();
await expect.element(page.getByText('Text transkribieren')).toBeInTheDocument();
await expect.element(page.getByText(/Lesefertig/)).toBeInTheDocument();
});
it('renders document titles in correct columns', async () => {
const segDoc = makeDoc('seg-1', 'Segmentierungs Brief');
const transDoc = makeDoc('trans-1', 'Transkriptions Postkarte');
const readyDoc = makeDoc('ready-1', 'Fertiger Tagebucheintrag');
render(MissionControlStrip, {
props: {
segmentationDocs: [segDoc],
transcriptionDocs: [transDoc],
readyDocs: [readyDoc],
weeklyStats: emptyStats
}
});
await expect.element(page.getByText('Segmentierungs Brief')).toBeInTheDocument();
await expect.element(page.getByText('Transkriptions Postkarte')).toBeInTheDocument();
await expect.element(page.getByText('Fertiger Tagebucheintrag')).toBeInTheDocument();
});
it('renders section heading even when all arrays are empty and weeklyStats is null', async () => {
render(MissionControlStrip, {
props: {
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null
}
});
// Heading always visible
await expect.element(page.getByText('Was braucht Aufmerksamkeit?')).toBeInTheDocument();
// All three empty states should also be visible
await expect
.element(page.getByText('Alle Dokumente haben bereits Segmentierungsblöcke.'))
.toBeInTheDocument();
await expect
.element(page.getByText('Keine Dokumente warten auf Transkription.'))
.toBeInTheDocument();
await expect
.element(page.getByText('Noch keine Dokumente vollständig transkribiert.'))
.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
interface Props {
docs: TranscriptionQueueItemDTO[];
}
let { docs }: Props = $props();
function reviewedPct(doc: TranscriptionQueueItemDTO): number {
if (doc.annotationCount === 0) return 0;
return Math.round((doc.reviewedBlockCount / doc.annotationCount) * 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">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_ready_heading()}
</h3>
</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">{formatMCDate(doc.documentDate, getLocale())}</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,76 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReadyColumn from './ReadyColumn.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
afterEach(cleanup);
function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): TranscriptionQueueItemDTO {
return {
id: 'doc-1',
title: 'Test Dokument',
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
...overrides
};
}
describe('ReadyColumn', () => {
it('renders mint-themed list when docs are provided', async () => {
const doc1 = makeDoc({ id: 'doc-1', title: 'Leseферtig Brief' });
const doc2 = makeDoc({ id: 'doc-2', title: 'Archiv Dokument' });
render(ReadyColumn, { props: { docs: [doc1, doc2] } });
await expect.element(page.getByText('Leseферtig Brief')).toBeInTheDocument();
await expect.element(page.getByText('Archiv Dokument')).toBeInTheDocument();
// Mint-themed container should exist
const mintContainer = document.querySelector('.border-brand-mint');
expect(mintContainer).not.toBeNull();
});
it('renders dashed empty state with CTA link when docs array is empty', async () => {
render(ReadyColumn, { props: { docs: [] } });
await expect
.element(page.getByText('Noch keine Dokumente vollständig transkribiert.'))
.toBeInTheDocument();
const ctaLink = page.getByRole('link', { name: 'Jetzt mitmachen' });
await expect.element(ctaLink).toBeInTheDocument();
await expect
.element(ctaLink)
.toHaveAttribute('href', '/enrich?filter=NEEDS_SEGMENTATION&next=1');
});
it('shows reviewedPct using annotationCount as denominator', async () => {
// annotationCount=4, reviewedBlockCount=4, textedBlockCount=2
// reviewedPct = Math.round(4 / 4 * 100) = 100, NOT Math.round(4/2*100) = 200
const doc = makeDoc({
id: 'doc-1',
title: 'Geprüftes Dokument',
annotationCount: 4,
reviewedBlockCount: 4,
textedBlockCount: 2
});
render(ReadyColumn, { props: { docs: [doc] } });
// Should show 100% (using annotationCount=4 as denominator)
await expect.element(page.getByText('100% geprüft')).toBeInTheDocument();
});
it('links to /documents/{id}', async () => {
const doc = makeDoc({ id: 'ready-789', title: 'Fertiges Dokument' });
render(ReadyColumn, { props: { docs: [doc] } });
const link = page.getByRole('link', { name: /Fertiges Dokument/ });
await expect.element(link).toHaveAttribute('href', '/documents/ready-789');
});
});

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
</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"
>
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3"
>{formatMCDate(doc.documentDate, getLocale())}</span
>
{/if}
</a>
</li>
{/each}
</ul>
</div>
{:else}
<div
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface/50 p-6 text-center"
>
<p class="text-xs text-ink-3">{m.mission_control_segmentation_empty()}</p>
</div>
{/if}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import SegmentationColumn from './SegmentationColumn.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
afterEach(cleanup);
function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): TranscriptionQueueItemDTO {
return {
id: 'doc-1',
title: 'Test Dokument',
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
...overrides
};
}
describe('SegmentationColumn', () => {
it('renders document list when docs are provided', async () => {
const doc1 = makeDoc({ id: 'doc-1', title: 'Brief an Maria' });
const doc2 = makeDoc({ id: 'doc-2', title: 'Postkarte 1923' });
render(SegmentationColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } });
await expect.element(page.getByText('Brief an Maria')).toBeInTheDocument();
await expect.element(page.getByText('Postkarte 1923')).toBeInTheDocument();
});
it('renders dashed empty state when docs array is empty', async () => {
render(SegmentationColumn, { props: { docs: [], weeklyCount: 0 } });
await expect
.element(page.getByText('Alle Dokumente haben bereits Segmentierungsblöcke.'))
.toBeInTheDocument();
});
it('shows weekly pulse when weeklyCount > 0', async () => {
const doc = makeDoc({ id: 'doc-1', title: 'Brief' });
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 3 } });
await expect.element(page.getByText(/\+3 diese Woche/)).toBeInTheDocument();
});
it('does not show weekly pulse when weeklyCount is 0', async () => {
const doc = makeDoc({ id: 'doc-1', title: 'Brief' });
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
await expect.element(page.getByText(/diese Woche/)).not.toBeInTheDocument();
});
it('links to /documents/{id}', async () => {
const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' });
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
const link = page.getByRole('link', { name: /Verlinktes Dokument/ });
await expect.element(link).toHaveAttribute('href', '/documents/abc-123');
});
});

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
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-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"
>
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3"
>{formatMCDate(doc.documentDate, getLocale())}</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" aria-hidden="true">
<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>
{:else}
<div
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface/50 p-6 text-center"
>
<p class="text-xs text-ink-3">{m.mission_control_transcription_empty()}</p>
</div>
{/if}

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionColumn from './TranscriptionColumn.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
afterEach(cleanup);
function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): TranscriptionQueueItemDTO {
return {
id: 'doc-1',
title: 'Test Dokument',
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
...overrides
};
}
describe('TranscriptionColumn', () => {
it('renders document list when docs are provided', async () => {
const doc1 = makeDoc({ id: 'doc-1', title: 'Familienbrief' });
const doc2 = makeDoc({ id: 'doc-2', title: 'Tagebuch Eintrag' });
render(TranscriptionColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } });
await expect.element(page.getByText('Familienbrief')).toBeInTheDocument();
await expect.element(page.getByText('Tagebuch Eintrag')).toBeInTheDocument();
});
it('renders dashed empty state when docs array is empty', async () => {
render(TranscriptionColumn, { props: { docs: [], weeklyCount: 0 } });
await expect
.element(page.getByText('Keine Dokumente warten auf Transkription.'))
.toBeInTheDocument();
});
it('renders progress bar when textedBlockCount > 0', async () => {
const doc = makeDoc({
id: 'doc-1',
title: 'Brief mit Blöcken',
annotationCount: 4,
textedBlockCount: 2
});
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
// The progress text should show "2 / 4 Blöcke"
await expect.element(page.getByText('2 / 4 Blöcke')).toBeInTheDocument();
// A progress bar div should exist (the visual bar)
const progressBar = document.querySelector('.h-1.flex-1');
expect(progressBar).not.toBeNull();
});
it('renders dash placeholder when textedBlockCount is 0', async () => {
const doc = makeDoc({
id: 'doc-1',
title: 'Brief ohne Blöcke',
annotationCount: 3,
textedBlockCount: 0
});
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
// The italic em-dash placeholder should render
const dashEl = document.querySelector('span.italic');
expect(dashEl).not.toBeNull();
expect(dashEl?.textContent?.trim()).toBe('—');
});
it('links to /documents/{id}', async () => {
const doc = makeDoc({ id: 'xyz-456', title: 'Transkriptions Dokument' });
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
const link = page.getByRole('link', { name: /Transkriptions Dokument/ });
await expect.element(link).toHaveAttribute('href', '/documents/xyz-456');
});
});

View File

@@ -660,6 +660,70 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/transcription/weekly-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getWeeklyStats"];
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/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/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/tags": {
parameters: {
query?: never;
@@ -1450,6 +1514,25 @@ export interface components {
/** Format: double */
height?: number;
};
TranscriptionWeeklyStatsDTO: {
/** Format: int64 */
segmentationCount: number;
/** Format: int64 */
transcriptionCount: number;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
/** Format: int32 */
annotationCount: number;
/** Format: int32 */
textedBlockCount: number;
/** Format: int32 */
reviewedBlockCount: number;
};
StatsDTO: {
/** Format: int64 */
totalPersons?: number;
@@ -1461,17 +1544,17 @@ export interface components {
/** 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 */
@@ -1513,10 +1596,10 @@ export interface components {
timeout?: number;
};
PageNotificationDTO: {
/** Format: int32 */
totalPages?: number;
/** Format: int64 */
totalElements?: number;
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
@@ -3094,6 +3177,86 @@ export interface operations {
};
};
};
getWeeklyStats: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TranscriptionWeeklyStatsDTO"];
};
};
};
};
getTranscriptionQueue: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TranscriptionQueueItemDTO"][];
};
};
};
};
getSegmentationQueue: {
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"][];
};
};
};
};
searchTags: {
parameters: {
query?: {

View File

@@ -19,6 +19,19 @@ export function formatDate(isoDate: string, format: 'short' | 'long' = 'long'):
}).format(date);
}
/**
* Format an ISO date string for medium-length display (e.g. "15. Jun. 1920").
* Uses T12:00:00 to avoid UTC timezone off-by-one.
* Pass an explicit BCP 47 locale tag to respect the app locale; defaults to 'de-DE'.
*/
export function formatMCDate(isoDate: string, locale: string = 'de-DE'): string {
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(isoDate + 'T12:00:00'));
}
/**
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
* Returns an empty string for invalid or empty input.

View File

@@ -6,6 +6,8 @@ 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') || '';
@@ -82,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) {
@@ -99,6 +117,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 {
@@ -109,6 +139,10 @@ export async function load({ url, fetch }) {
stats,
incompleteDocs,
recentDocs,
segmentationDocs,
transcriptionDocs,
readyDocs,
weeklyStats,
initialValues: {
senderName: senderObj?.displayName ?? '',
receiverName: receiverObj?.displayName ?? ''
@@ -127,6 +161,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,7 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
documentDate: '1923-04-12',
location: 'Berlin',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],

View File

@@ -31,7 +31,14 @@ describe('home page load — dashboard mode', () => {
data: { totalDocuments: 42, totalPersons: 7 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }) // recent
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // segmentation-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // transcription-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // ready-to-read
.mockResolvedValueOnce({
response: { ok: true },
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}); // weekly-stats
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
@@ -54,7 +61,14 @@ describe('home page load — dashboard mode', () => {
data: { totalDocuments: 248, totalPersons: 34 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // recent
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // segmentation-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // transcription-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // ready-to-read
.mockResolvedValueOnce({
response: { ok: true },
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}); // weekly-stats
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);

View File

@@ -44,6 +44,10 @@ const emptyData = {
stats: null,
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null,
error: null
};