diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionQueueController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionQueueController.java new file mode 100644 index 00000000..59591795 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionQueueController.java @@ -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> getSegmentationQueue() { + return ResponseEntity.ok(transcriptionQueueService.getSegmentationQueue()); + } + + @GetMapping("/transcription-queue") + public ResponseEntity> getTranscriptionQueue() { + return ResponseEntity.ok(transcriptionQueueService.getTranscriptionQueue()); + } + + @GetMapping("/ready-to-read") + public ResponseEntity> getReadyToRead() { + return ResponseEntity.ok(transcriptionQueueService.getReadyToReadQueue()); + } + + @GetMapping("/weekly-stats") + public ResponseEntity getWeeklyStats() { + return ResponseEntity.ok(transcriptionQueueService.getWeeklyStats()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java new file mode 100644 index 00000000..833c047b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java @@ -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 +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionWeeklyStatsDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionWeeklyStatsDTO.java new file mode 100644 index 00000000..5cbbe923 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionWeeklyStatsDTO.java @@ -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 +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index 022a2ebb..92517f4b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -167,4 +167,73 @@ public interface DocumentRepository extends JpaRepository, JpaSp """) List findEnrichmentData(@Param("ids") Collection 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 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 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 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(); + } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionQueueProjection.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionQueueProjection.java new file mode 100644 index 00000000..ff23d43e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionQueueProjection.java @@ -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(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionWeeklyStatsProjection.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionWeeklyStatsProjection.java new file mode 100644 index 00000000..0b1e6f14 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionWeeklyStatsProjection.java @@ -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(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java new file mode 100644 index 00000000..bc0118fa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java @@ -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 getSegmentationQueue() { + return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE) + .stream() + .map(this::toDTO) + .toList(); + } + + public List getTranscriptionQueue() { + return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE) + .stream() + .map(this::toDTO) + .toList(); + } + + public List 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() + ); + } +} diff --git a/backend/src/main/resources/db/migration/V38__add_transcription_queue_indexes.sql b/backend/src/main/resources/db/migration/V38__add_transcription_queue_indexes.sql new file mode 100644 index 00000000..48e142bd --- /dev/null +++ b/backend/src/main/resources/db/migration/V38__add_transcription_queue_indexes.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java new file mode 100644 index 00000000..8b6f9fab --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java @@ -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)); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java index 51ab05b4..80f1fd00 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -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 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 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 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 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 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(); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index e4baab52..93fddb8f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -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 rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null}); + List 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 rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null}); + List 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))) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java new file mode 100644 index 00000000..0a76114b --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java @@ -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 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 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 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 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; + } +} diff --git a/docs/specs/dashboard-expansion-patterns.html b/docs/specs/dashboard-expansion-patterns.html new file mode 100644 index 00000000..0907ca9e --- /dev/null +++ b/docs/specs/dashboard-expansion-patterns.html @@ -0,0 +1,1122 @@ + + + + + +Dashboard Expansion — 4 Layout-Muster (Issue #240) + + + +
+ + +
+

Dashboard Expansion — 4 Layout-Muster

+
+ Issue #240 + Leonie Voss — UX & Accessibility + 15. April 2026 +
+
src/routes/+page.svelte  ·  src/lib/components/Dashboard*.svelte  ·  Muster A Tabs · B Accordion · C Mission Control · D Priority Queue
+
+ +
+

Das Problem

+

+ Die rechte Spalte der Startseite ist mit DropZone + „Metadaten fehlen" bereits ausgelastet. + Issue #240 möchte zwei weitere Karten ergänzen: Transkription ausstehend und Lesefertig. + Direkt gestapelt scrollt die Spalte sofort aus dem sichtbaren Bereich — ein 67-jähriger Nutzer auf einem kleinen + Display sieht die unteren Karten nie. +

+

+ Der Nutzer schlug Tabs vor (Neueste Aktivität / Transkription fehlt / Metadaten fehlen / Lesefertig). + Diese Spec bewertet diesen Vorschlag und zeigt drei weitere Muster im Vergleich. + Alle Muster werden bei 320 px (Mobiltelefon) und Desktop (~55 % Skalierung) gezeigt. +

+
+ + +
+ +
+
+
+
+ + +
MR
+
+
+ +
+
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
Karl Raddatz
12. Apr
+
Taufurkunde Karl Raddatz
Standesamt
9. Apr
+
Postkarte aus Breslau
Martha Raddatz
7. Apr
+
Familienfoto Sommer 1952
Unbekannt
3. Apr
+
47 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop oder klicken
+
+
Metadaten fehlen
+
Familienfoto 1952
+
Standesamtsurkunde
+
Reisepass Opa Heinrich
+ Alle anzeigen → +
+
▼ TRANSKRIPTION FEHLT (neu) — scrollt aus dem Sichtfeld
+
▼ LESEFERTIG (neu) — nie sichtbar ohne Scrollen
+
+
+
+
+ Desktop (55 % Skalierung) — zwei neue Karten würden die rechte Spalte sprengen +
+
+
+ +
+ + + + +
+ + +
Aufgaben-Karte mit Tab-Strip
+
DropZone bleibt oben. Darunter: eine Karte mit drei Tabs (Metadaten / Transkription / Lesefertig).
+ +
+ + Kein zusätzlicher Scroll in der rechten Spalte + + Bekanntes Muster für jüngere Nutzer + + Platzsparend in 300 px + − Inaktive Kategorien unsichtbar — Senioren übersehen sie + − Drei kurze Labels kaum lesbar bei 300 px / 320 px + − JS-Zustand nötig (aktiver Tab) +
+ +

+ Der ursprüngliche Vorschlag nannte vier Tabs für die gesamte Seite (Neueste Aktivität / Transkription / Metadaten / Lesefertig). + Das ist ein UX-Antipattern: „Neueste Aktivität" ist der häufigste Anwendungsfall — ihn hinter einem Klick zu verstecken erhöht den Aufwand für jeden Dashboard-Besuch. + Tabs sollten ausschließlich auf die drei To-do-Widget-Kategorien in der rechten Spalte angewendet werden, nicht auf die gesamte Seite. +

+

+ Accessibility: Tab-Elemente müssen role="tablist", role="tab", aria-selected und + role="tabpanel" tragen. Jeder Tab braucht min-h-[44px] (WCAG 2.2 Touchziel). + Drei Tabs in einer 300-px-Spalte = ~100 px pro Tab — grenzwertig auf Deutsch mit langen Wörtern. +

+ +
+ +
+
+
+
+ +
Hochladen
+ +
+
+
Metadaten
+
Transkr.
+
Lesefertig
+
+
Familienfoto 1952
Titel fehlt
+
Standesamtsurkunde
Datum fehlt
+
Reisepass Opa
Absender fehlt
+ Alle anzeigen → +
+ +
+
Neueste Aktivität
+
Brief von Oma Martha
+
Taufurkunde Karl R.
+
47 Dok. · 12 Pers.
+
+
+
+ Mobil 320 px +
+ + +
+
+
+ + +
MR
+
+
+ +
+
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
12. Apr
+
Taufurkunde Karl Raddatz
9. Apr
+
Postkarte aus Breslau
7. Apr
+
Familienfoto Sommer 1952
3. Apr
+
47 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop
+
+
+
Metadaten
+
Transkr.
+
Lesefertig
+
+
Familienfoto 1952
Titel fehlt
+
Standesamtsurkunde
Datum fehlt
+
Reisepass Opa Heinrich
Absender fehlt
+ Alle anzeigen → +
+
+
+
+
+ Desktop (55 %) — DropZone + Tabbed-Karte; zwei Kategorien versteckt hinter Klick +
+
+ +
+ + + + + + + + + + +
ElementTailwind-KlassenWertHinweis
Tab-Stripflex border-b border-lineARIA: role="tablist"
Tab inaktivpx-3 py-3 text-xs font-medium text-gray-500 border-b-2 border-transparent whitespace-nowrap -mb-pxmin-h 44 px ✓role="tab" aria-selected="false"
Tab aktivpx-3 py-3 text-xs font-semibold text-ink border-b-2 border-ink -mb-px2 px navy Unterliniearia-selected="true"
Tab-Panelpt-3 focus:outline-nonerole="tabpanel" tabindex="0"
Aufgaben-Karterounded-sm border border-line bg-white p-4 flex-1padding 16 pxErsetzt 3 separate Karten
Zeile mit Kontextflex flex-col py-2 border-b border-line last:border-0min-h ~44 pxz. B. „3 von 8 Blöcken geprüft"
+
+
+ +
+ + + + +
+ + +
Aufklappbare Kategorien
+
DropZone oben. Darunter: drei Accordion-Sektionen, standardmäßig nur die dringlichste offen.
+ +
+ + Alle drei Kategorien-Überschriften immer sichtbar + + Kein JS — implementierbar mit nativem <details> + + Vertraut für 60+ (wie FAQ) + − Inhalt zu 2/3 verborgen (nur Überschriften sichtbar) + − „Lesefertig" wird meist zugeklappt bleiben und übersehen +
+ +

+ Jede Sektion zeigt eine klickbare Kopfzeile (Pfeil + Label + Anzahl). Server-seitig wird die Sektion mit der + höchsten Anzahl als <details open> gerendert. Kein Client-JS nötig — native + <details>/<summary>-Elemente liefern ARIA-Accessibility gratis. + Sortierung der offenen Sektion nach Dringlichkeit: Metadaten → Transkription → Lesefertig. +

+ +
+ +
+
+
+
+ +
Hochladen
+
+
+
▼ Metadaten fehlen (5)
+
+
Familienfoto 1952
Titel fehlt
+
Standesamtsurkunde
Datum fehlt
+ Alle 5 anzeigen → +
+
+
▶ Transkription fehlt (8)
+
▶ Lesefertig ✓ (3)
+
+
+
Neueste Aktivität
+
Brief von Oma Martha
+
Taufurkunde Karl R.
+
+
+
+ Mobil 320 px +
+ + +
+
+
+ + +
MR
+
+
+ +
+
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
12. Apr
+
Taufurkunde Karl Raddatz
9. Apr
+
Postkarte aus Breslau
7. Apr
+
Familienfoto Sommer 1952
3. Apr
+
47 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop
+
+
+
▼ Metadaten fehlen (5)
+
+
Familienfoto 1952
Titel fehlt
+
Standesamtsurkunde
Datum fehlt
+ Alle 5 anzeigen → +
+
+
▶ Transkription fehlt (8)
+
▶ Lesefertig ✓ (3)
+
+
+
+
+
+ Desktop (55 %) — Metadaten-Sektion offen, Transkription und Lesefertig zugeklappt +
+
+ +
+ + + + + + + + + + +
ElementHTML / Tailwind-KlassenWertHinweis
Accordion-Wrapper<details> nativAccessibility gratis, kein JS
Accordion-Header<summary class="flex items-center justify-between min-h-[44px] cursor-pointer list-none">min-h 44 px ✓WCAG 2.2 Touchziel
Pfeil-Icontransition-transform group-open:rotate-90w-4 h-4CSS-only; kein JS
Zähler-Badgeml-auto font-mono text-xs text-gray-400z. B. „(5)"
Dringlichste Sektion<details open>Server-seitig rendern: if incompleteDocs.length >= needsTrans.length
Accordion-Inhaltpt-1 pb-2 direkt nach <summary>Keine overflow:hidden-Animation nötig
+
+
+ +
+ + + + +
+ + +
Volle Breite unterhalb des Hauptgitters
+
Rechte Spalte bleibt unverändert. Unterhalb des Gitters: drei gleichwertige Spalten als neuer horizontaler Aktionsbereich.
+ +
+ + Alle drei Kategorien gleichzeitig sichtbar — kein Klick nötig + + „Neueste Aktivität" bleibt primärer Inhalt, nichts rückt dahinter + + Kein JS-Zustand + + „Lesefertig" bekommt eigene mint-Karte als visuellen Applaus + + Mobil stapeln die drei Spalten natürlich + − Leichtes Scrollen auf Desktop erforderlich + − Sechster API-Aufruf beim Dashboard-Load (via Promise.allSettled isoliert) +
+ +

+ Der Ist-Zustand der rechten Spalte bleibt vollständig erhalten. Die zwei neuen Karten des Issue #240 werden + nicht in die rechte Spalte gepackt, sondern in einen neuen vollbreiten Abschnitt direkt unterhalb des + bestehenden Zwei-Spalten-Gitters. Der Abschnitt ist nur sichtbar, wenn mindestens eine der beiden Kategorien + Einträge hat ({#if needsTranscription.length > 0 || readyToRead.length > 0}). +

+

+ Die „Lesefertig"-Spalte erhält einen mint-gefärbten Hintergrund (bg-mint/10 border-mint) als positives Signal — kein + neutrales To-do, sondern eine Einladung zum Lesen. Leere Zustände zeigen eine kurze Erfolgsmeldung in + bg-mint/5, nicht eine tote weiße Box. +

+ +
+ +
+
+
+
+ + +
Hochladen
+
+
Metadaten fehlen
+
Familienfoto 1952
+
Standesamtsurkunde
+ Alle anzeigen → +
+ +
+
Neueste Aktivität
+
Brief von Oma Martha
+
Taufurkunde Karl R.
+
47 Dok. · 12 Pers.
+
+ +
+
Was braucht Aufmerksamkeit?
+
+ +
Taufurkunde Karl R.
Noch nicht begonnen
+
Reisepass Opa Heinrich
3 von 8 Blöcken geprüft
+ Alle 8 anzeigen → +
+
+
Lesefertig ✓
+
Postkarte aus Breslau
100 % geprüft
+
Brief Oma Martha 1938
95 % geprüft
+ Alle 3 anzeigen → +
+
+
+
+ Mobil 320 px — Streifen stapelt vertikal +
+ + +
+
+
+ + +
MR
+
+
+ +
+ +
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
12. Apr
+
Taufurkunde Karl Raddatz
9. Apr
+
Postkarte aus Breslau
7. Apr
+
Familienfoto Sommer 1952
3. Apr
+
47 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop
+
+
Metadaten fehlen
+
Familienfoto 1952
Titel fehlt
+
Standesamtsurkunde
Datum fehlt
+
Reisepass Opa Heinrich
Absender fehlt
+ Alle anzeigen → +
+
+
+ + +
+
Was braucht Aufmerksamkeit?
+
+ +
+ +
Taufurkunde Karl R.
Noch nicht begonnen
+
Reisepass Opa Heinrich
3 von 8 Blöcken geprüft
+
Standesamt 1889
Noch nicht begonnen
+ Alle 8 anzeigen → +
+ +
+
Lesefertig ✓
+
Postkarte aus Breslau
100 % geprüft
+
Brief Oma Martha 1938
95 % geprüft
+
Heiratsurkunde 1921
91 % geprüft
+ Alle 3 lesen → +
+ +
+
+
Alle Dokumente transkribiert
+
Keine offenen Aufgaben
+
+
+
+
+
+ Desktop (55 %) — Hauptgitter unberührt; Streifen darunter zeigt leeren Zustand in Spalte 3 +
+
+ +
+ + + + + + + + + + + + +
ElementTailwind-KlassenWertHinweis
Streifen-Wrappermt-4 bg-white border border-line rounded-sm p-6padding 24 pxDirekt nach bestehendem div.mt-4.grid
Streifen-Titeltext-xs font-bold uppercase tracking-widest text-gray-400 mb-412 px / 700Standard-Section-Title-Muster
3-Spalten-Gridgrid grid-cols-1 gap-4 sm:grid-cols-3gap 16 pxMobil: 1 Spalte, sm+: 3
Transkription-Spaltebg-surface rounded-sm border border-line p-4Neutral — es ist eine Aufgabe
Lesefertig-Spaltebg-mint/10 rounded-sm border border-mint p-4Mint-Ton = positives Signal
Leerer Zustandflex flex-col items-center justify-center text-center bg-mint/5 border border-dashed border-mint rounded-sm p-6 min-h-[80px]min-h 80 pxNiemals leere graue Box
Untertext-Zeiletext-xs text-gray-400 mt-0.512 pxz. B. „3 von 8 Blöcken geprüft"
Sichtbarkeit{#if needsTranscription.length > 0 || readyToRead.length > 0}Streifen komplett ausgeblendet wenn leer
+
+
+ +
+ + + + +
+ + +
„Was als nächstes?" — zusammengeführte Liste
+
Alle drei Kategorien in einer sortierten Liste mit Typ-Badge. Ersetzt die separaten drei Karten in der rechten Spalte.
+ +
+ + Keine mentale Auswahl zwischen Kategorien + + Eine einzelne Entscheidungsfläche + − „Lesefertig" verliert seinen Belohnungscharakter (gemischt mit Aufgaben) + − Merge-Logik auf dem Server ist komplexer als zwei separate Queries + − Farb-Badge allein reicht nicht — Icon + Label immer nötig (WCAG 1.4.1) +
+ +

+ Alle drei Kategorien werden in einer einzigen sortierten Liste zusammengeführt. Sortierung: + Metadaten fehlen (blockiert Suche) → Transkription fehlt → Lesefertig. Jede Zeile trägt ein farbkodiertes Label; + Farbe darf niemals der einzige Indikator sein — Icon und Text sind Pflicht (WCAG 1.4.1). +

+

+ Kontrast-Check: Orange-700 auf Weiß = 5,4:1 ✓ AA. Navy auf Weiß = 14,5:1 ✓ AAA. Green-800 auf Weiß = 9,7:1 ✓ AAA. +

+ +
+ +
+
+
+
+ +
Hochladen
+
+
Was als nächstes?
+
Familienfoto 1952
⚠ Metadaten fehlen
+
Taufurkunde Karl R.
✏ Noch nicht begonnen
+
Reisepass Opa
✏ 3 von 8 Blöcken
+
Postkarte 1943
✓ Lesefertig
+
+
+
Neueste Aktivität
+
Brief von Oma Martha
+
Taufurkunde Karl R.
+
+
+
+ Mobil 320 px +
+ + +
+
+
+ + +
MR
+
+
+ +
+
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
12. Apr
+
Taufurkunde Karl Raddatz
9. Apr
+
Postkarte aus Breslau
7. Apr
+
Familienfoto Sommer 1952
3. Apr
+
47 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop
+
+
Was als nächstes?
+
Familienfoto 1952
⚠ Metadaten fehlen
+
Standesamtsurkunde
⚠ Datum fehlt
+
Taufurkunde Karl R.
✏ Noch nicht begonnen
+
Reisepass Opa Heinrich
✏ 3 von 8 Blöcken
+
Postkarte aus Breslau
✓ Lesefertig
+
+
+
+
+
+ Desktop (55 %) — eine Liste, drei Typen durch Farbe + Icon + Label unterschieden +
+
+ +
+ + + + + + + + + + + +
ElementTailwind-KlassenWertHinweis
Listen-Wrapperrounded-sm border border-line bg-white p-4 flex-1Ersetzt separate 3 Karten
Prioritäts-Zeileflex items-start gap-3 py-2 border-b border-line last:border-0 min-h-[44px]min-h 44 px ✓WCAG touch target
Typ-Punktw-2 h-2 rounded-full mt-1.5 shrink-08 × 8 pxNie allein — Label ist Pflicht
Label orangetext-xs text-orange-70012 pxKontrast 5,4:1 ✓ AA
Label navytext-xs text-ink12 pxKontrast 14,5:1 ✓ AAA
Label grüntext-xs text-green-80012 pxKontrast 9,7:1 ✓ AAA
Merge-ServicefindWhatsNext(int size) auf DashboardControllerSortierung: Metadaten → Trans → Lesefertig; per Markus: Threshold als @Param
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KriteriumA — TabsB — AccordionC — Mission Control ★D — Priority Queue
Alle 3 Kategorien sofort sichtbarNeinNur ÜberschriftenJaNein (gemischt)
Neueste Aktivität bleibt PrimärinhaltJaJaJaJa
60+ Usability (Discovery ohne Klick)MittelÜberschriften sichtbarSehr gutMittel — gemischte Liste
JS-Zustand nötigJa (aktiver Tab)NeinNeinNein
WCAG 2.2 compliance (out of the box)tablist + aria-selected nötigdetails/summary nativKeine neuen AnforderungenFarbe + Icon + Label alle Pflicht
Mobile 320 px3 Tabs zu schmalGutSehr gut — stapelt natürlichGut
„Lesefertig" als visueller ApplausNur wenn Tab aktivNur wenn offenJa — eigene mint-KarteNein — gleichwertig mit Aufgaben
Backend-Merge-KomplexitätGeringGeringGering (2 separate Queries)Mittel (Merge + Sortierung)
Implementierungsaufwand FrontendMittelGeringGeringGering
+
+ + +
+

Empfehlung: Muster C — Mission-Control-Streifen

+

+ Der Mission-Control-Streifen ist das einzige Muster, das alle drei Kategorien gleichzeitig sichtbar macht, + ohne den Primärinhalt zu verstecken oder JS-Zustand zu erzeugen. Scrollen nach unten ist kein Fehler — + versteckter Inhalt schon. +

+
    +
  • Tabs (A) — Gut für kompakten Platz, aber inaktive Kategorien werden von 60+-Nutzern übersehen. Tabs für die gesamte Seite (wie ursprünglich vorgeschlagen) wäre ein Antipattern — Neueste Aktivität wäre hinter einem Klick.
  • +
  • Accordion (B) — Solide Fallback-Option ohne JS, wenn der Streifen aus Platzgründen abgelehnt wird. Alle Kategorien-Überschriften bleiben sichtbar.
  • +
  • Priority Queue (D) — Elegant, aber „Lesefertig" verliert seinen Belohnungscharakter. Die Merge-Logik ist auch komplexer als zwei separate Queries.
  • +
  • Mission Control (C) — Keine versteckten Inhalte. Kein JS. „Lesefertig" bekommt eine eigene mint-getönte Spalte als visuellen Applaus. Mobil stapeln die Spalten ohne weiteren Code. Der einzige Trade-off ist ein leichtes Scrollen auf Desktop.
  • +
+

+ Quick win: Wenn C abgelehnt wird — Muster B (Accordion) als Zweitstimme. Kein Refactoring der rechten Spalte, kein JS, alle Kategorien-Überschriften immer sichtbar. +

+
+ + +
+ + + + +
+ + +
Beitragspyramide: Skill-basierte Aufgabentrennung
+
+ Die ursprüngliche „Transkription fehlt"-Spalte wird in zwei klar getrennte Aufgaben aufgeteilt, die + unterschiedliche Fähigkeiten erfordern. Das löst gleichzeitig das Problem der leeren dritten Spalte. +
+ + +
+
+
Kein Segment
+
0 Annotationen
+
+
+
+
Segmentiert
+
Rahmen vorhanden, kein Text
+
+
+
+
Transkribiert
+
Text vorhanden, review < 90 %
+
+
+
+
Lesefertig ✓
+
review ≥ 90 %
+
+
+ Jede Stufe landet in einer eigenen Spalte. Die dritte Spalte ist strukturell nie leer — + Lesefertig-Dokumente erscheinen sobald auch nur eines fertig ist. +
+
+ + +
+
+
Spalte 1 — Segmentierung
+
+ Dokumente ohne Annotationsrahmen. Keine Vorkenntnisse nötig — Rahmen um Textblöcke einzeichnen. Niedrigste Einstiegshürde, breiteste Zielgruppe. +
+
Query: SELECT … WHERE annotation_count = 0
+
+
+
Spalte 2 — Transkription
+
+ Dokumente mit Rahmen, aber wenig/kein Text (text IS NULL OR LENGTH(text) < threshold). Kurrent-Kenntnisse empfohlen. +
+
Query: annotation_count > 0 AND reviewed < 75 %
+
+
+
Spalte 3 — Lesefertig ✓
+
+ Dokumente mit reviewed ≥ 90 %. Belohnungsbereich — kein Auftrag, sondern eine Einladung zum Lesen. +
+
Query: reviewed_pct >= 0.90 (bestehend)
+
+
+ + +
+
Engagement-Elemente — 5 Ideen
+ +
+ +
+
① Fortschritt — drei verschiedene Granularitäten
+
+ Kein globaler Balken bei 1 500 Dokumenten. + Ein Balken bei 0,8 % Füllstand ist psychologisch demotivierender als gar kein Balken + (endowed-progress-Effekt: Menschen brechen auf, wenn das Ziel unerreichbar wirkt). +

+ Stattdessen drei zielgruppengerechte Ansätze: +
    +
  • Segmentierung-Spalte: Wochenpuls — „Diese Woche: +5 Dokumente". Zeigt Schwung, nicht den Berg. Query: COUNT(*) WHERE created_at > NOW() - INTERVAL '7 days'
  • +
  • Transkription-Zeilen: Balkensegment pro Dokument — „3 von 8 Blöcken". Hier ist der Maßstab korrekt: 8 Blöcke sind in einer Sitzung erreichbar. Nur sichtbar wenn annotation_count > 0.
  • +
  • Lesefertig-Zeilen: Prozentzahl als Text — „94 % geprüft". Kein Balken nötig — der Erfolg ist bereits kommuniziert durch die mint-Spalte selbst.
  • +
+
+
+ +
+
② Skill-Label als Zugangsfilter
+
+ Unter dem Spaltentitel: ein kleines Pill-Label mit der Anforderung. „Ohne Vorkenntnisse" (grün) vs. + „Kurrent-Kenntnisse" (neutral). Senkt die Hemmschwelle für Neueinsteiger drastisch — sie sehen sofort, + was sie tun können. +
+
+ +
+
③ Contributor-Avatare (Social Proof)
+
+ Unter dem Titel: kleine Initialen-Bubbles der letzten 3 Beitragenden. Kein Leaderboard (erzeugt + Wettbewerb), aber soziale Sichtbarkeit (erzeugt Zugehörigkeit). „MR, TG und 2 weitere haben hier + mitgemacht." +
+
+ +
+
④ „Starte hier →" CTA-Button
+
+ Jede Aufgaben-Spalte endet mit einem einzelnen, klaren CTA, der direkt zum nächsten zu bearbeitenden + Dokument springt. Kein Auswahlprozess, kein Überlegen — ein Klick, sofort im Dokument. Entscheidungslähmung + ist der Hauptgrund für Non-Participation. +
+
+ +
+
⑤ Lesefertig-Leerstand → Cross-Column-Redirect
+
+ Wenn Lesefertig leer ist (frühe Projektphase), zeigt die Spalte nicht „Noch nichts fertig" als + stille Sackgasse. Stattdessen: „Dokumente erscheinen hier, wenn die Transkription abgeschlossen ist — + jetzt mithelfen →". Der Link springt direkt zur Segmentierungs-Spalte. Leerer Zustand = aktive + Einladung, kein toter Endpunkt. +
+
+ +
+
+ + +
+
+
+
+ + +
MR
+
+
+ +
+
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
12. Apr
+
Taufurkunde Karl Raddatz
9. Apr
+
Postkarte aus Breslau
7. Apr
+
47 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop
+
+
Metadaten fehlen
+
Familienfoto 1952
Titel fehlt
+
Standesamtsurkunde
Datum fehlt
+ Alle 5 anzeigen → +
+
+
+ + +
+
Was braucht Aufmerksamkeit?
+
+ + +
+
+ +
✓ Ohne Vorkenntnisse
+ +
+
↑ +5 diese Woche
+
· 1 480 offen
+
+
+
MR
+
TG
+
AS
+
+ 2
+
+
+
Taufurkunde Karl R.
Noch keine Rahmen
+
Standesamt 1889
Noch keine Rahmen
+
Heiratsurkunde 1921
Noch keine Rahmen
+ Jetzt einzeichnen → +
+ + +
+
+ +
Kurrent hilfreich
+
+
↑ +2 diese Woche
+
· 8 offen
+
+
+
MR
+
1 Person
+
+
+ +
+
Brief v. Oma Martha 1943
+
+
+
0 / 6 Blöcke
+
+
+
+
Reisepass Opa Heinrich
+
+
+
3 / 8 Blöcke
+
+
+
+
Postkarte aus Breslau
+
+
+
0 / 4 Blöcke
+
+
+ Jetzt tippen → +
+ + +
+
+
Lesefertig ✓
+
3 Dokumente bereit
+
+
MR
+
TG
+
+
+ +
+
Postkarte aus Breslau 1943
+
100 % geprüft
+
+
+
Brief Oma Martha 1938
+
95 % geprüft
+
+
+
Heiratsurkunde 1921
+
91 % geprüft
+
+ Alle 3 lesen → +
+ +
+
+
+
+ Desktop (55 %) — v2: Spalten 1+2 aufgeteilt, Lesefertig-Leerstand mit aktivem Cross-Column-CTA +
+
+ + +
+ + + + + + + + + + + + + + + + +
ElementTailwind-Klassen / LogikWertHinweis
Skill-Pill „Ohne Vorkenntnisse"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-800Kontrast 9,7:1 ✓Klärung für 60+ und Neueinsteiger
Skill-Pill „Kurrent hilfreich"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-surface border border-line text-inkneutral — kein SchreckpunktNicht „Experten nötig" sondern „hilfreich"
Wochenpuls (Segmentierung + Transkription)text-xs font-semibold text-green-700 (Seg.) / text-ink (Trans.)12 pxQuery: COUNT WHERE created_at > NOW() - INTERVAL '7 days'; kein globaler Balken
Per-Dokument-Balken Trackflex-1 h-1 bg-navy/20 rounded-full overflow-hiddenh: 4 pxNur in Transkription-Spalte, nur wenn annotation_count > 0
Per-Dokument-Balken Füllstandh-full bg-navy rounded-full + style="width:{pct}%"pct = textedBlocks / totalBlocks * 100; Guard: totalBlocks = 0 → width 0
Lesefertig-Prozentzahltext-xs font-semibold text-green-80012 pxKein Balken — die mint-Spalte selbst ist das Erfolgssignal
Contributor-Avatarw-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white24 × 24 pxFarbe per User-ID deterministisch (kein API-Feld nötig)
„Starte hier"-CTAblock w-full text-center text-xs font-semibold text-white bg-ink rounded-sm py-1.5 mt-2 hover:bg-ink-2 transition-colors focus-visible:ring-2 focus-visible:ring-inkmin-h 36 pxLink: /enrich?filter=NEEDS_SEGMENTATION&next=1
Lesefertig-Leerstand CTAinline-flex items-center text-xs font-semibold text-ink border border-ink rounded-sm px-3 py-1 hover:bg-ink hover:text-white transition-colorsLink springt zur Segmentierungs-Ansicht
Contributor-API-FeldGET /api/documents/needs-segmentation → DTO enthält lastContributors: [{initials, colorSeed}]max 3 AvatareNeues DTO-Feld — beachte Nora: nur Initialen, keine Namen
Segmentierung-QueryWHERE NOT EXISTS (SELECT 1 FROM document_annotations WHERE document_id = d.id)Index auf document_annotations.document_id prüfen (Tobias)
Transkription-QueryEXISTS annotation AND (no blocks OR reviewed_pct < 0.75)Guard gegen Division durch 0 (Sara)
+
+ + +
+ Datenschutz-Hinweis (Nora): Contributor-Avatare zeigen nur Initialen, niemals volle Namen im DOM. + Das DTO liefert initials + einen deterministischen colorSeed (z. B. Hash der User-ID mod 6 Farben), + keine E-Mail-Adressen oder echten Namen. Das @RequirePermission(READ_ALL) auf den neuen Endpoints gilt auch hier. +
+
+ +
+ + diff --git a/docs/specs/mission-control-strip-final.html b/docs/specs/mission-control-strip-final.html new file mode 100644 index 00000000..3b8e0138 --- /dev/null +++ b/docs/specs/mission-control-strip-final.html @@ -0,0 +1,814 @@ + + + + + +Mission-Control-Streifen — Finale Spec (Issue #240) + + + +
+ + +
+

Mission-Control-Streifen — Finale Spec

+
+ Issue #240 + Leonie Voss — UX & Accessibility + 15. April 2026 + v3 — Final +
+
src/routes/+page.svelte · src/lib/components/DashboardMissionControl.svelte · +page.server.ts
+
+
+

Entscheidung

+

+ Der bestehende Dashboard-Aufbau (Neueste Aktivität links, DropZone + Metadaten-Widget rechts) bleibt unverändert. + Unterhalb des Zwei-Spalten-Gitters erscheint ein neuer vollbreiter Mission-Control-Streifen mit drei + gleichwertigen Spalten: Rahmen einzeichnen (Segmentierung, kein Vorwissen nötig), + Text eintippen (Transkription, Kurrent hilfreich), Lesefertig ✓ (Belohnungsbereich). +

+

+ 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 Experten-gesucht-Escape-Hatch verhindert, dass schwer lesbare + Dokumente dauerhaft die Spalte blockieren. +

+
+ + +
+
Dokument-Lebenszyklus
+
+
+
Kein Segment
+
0 Annotationen
+
→ Spalte 1
+
+
+
+
Segmentiert
+
Rahmen da, wenig Text
+
→ Spalte 2
+
+
+
+
In Review
+
Text da, reviewed < 90 %
+
→ Spalte 2
+
+
+
+
Lesefertig ✓
+
reviewed ≥ 90 %
+
→ Spalte 3
+
+
+ „Segmentiert" und „In Review" landen beide in Spalte 2 — + unterschieden durch den per-Dokument-Balken (0 Blöcke vs. N Blöcke). +
+
+ + +
+
+
Spalte 1 — Rahmen einzeichnen
+

Dokumente ohne Annotationsrahmen. Kein Kurrent nötig — Textblöcke markieren reicht.

+

Bedingung: annotation_count = 0

+

Sort: Wöchentliche Rotation (seeded shuffle, s. u.)

+

Fortschritt: Wochenpuls „↑ +5 diese Woche", kein globaler Balken

+
+
+
Spalte 2 — Text eintippen
+

Annotationen vorhanden, aber Text fehlt oder reviewed < 90 %. Kurrent-Kenntnisse hilfreich.

+

Bedingung: annotation_count > 0 AND reviewed_pct < 0.90

+

Sort: Teilfortschritt zuerst, dann wöchentliche Rotation; needsExpert-Flagge schiebt nach hinten

+

Fortschritt: Per-Dokument-Balken „3 / 8 Blöcke"

+
+
+
Spalte 3 — Lesefertig ✓
+

Reviewed ≥ 90 %. Keine Aufgabe — Einladung zum Lesen.

+

Bedingung: reviewed_pct >= 0.90

+

Sort: Neueste zuerst

+

Fortschritt: „94 % geprüft" als Text — kein Balken, die mint-Spalte ist das Signal

+

Leerstand: Cross-Column-Redirect zu Spalte 1

+
+
+
+ +
+ + +
+
Sortierstrategie — Das „zu schwer"-Problem
+
Schwer lesbare Dokumente blockieren die Spalte
+
Wenn dieselben 3 Dokumente immer oben stehen und niemand sie lesen kann, stoppt die Transkription komplett.
+ +
+
Problem: Bei 1 500 Dokumenten ohne Transkription und sortiert nach updated_at + können dieselben 3 besonders schwer lesbaren Dokumente dauerhaft die Spalte blockieren. + Jeder öffnet sie, gibt auf, und die Spalte wird zur Sackgasse.
+
+ +
+ +
+

Option 1 — Zufällig pro Seitenaufruf

+

ORDER BY RANDOM()

+

Jeder Besuch zeigt andere Dokumente. Kein Aufwand, aber chaotisch — kein Nutzer sieht ein Dokument zweimal, + kann nicht gezielt zurückkehren.

+
+ Null Aufwand− Chaotisch− Kein stabiles Lesezeichen
+
+ +
+
★ Empfohlen
+

Option 2 — Teilfortschritt + wöchentliche Rotation

+

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.

+ ORDER BY textedBlocks DESC, + HASHTEXT(id || EXTRACT(WEEK FROM NOW())::text) +
+ Konsistent innerhalb einer Woche+ Bringt leichte Dokumente an die Oberfläche+ Kein neues Datenbankfeld
+
+ +
+

Option 3 — Manuelle Schwierigkeitsbewertung

+

Beitragende bewerten Dokumente 1–3 nach Versuch. Einfache Dokumente erscheinen zuerst.

+

Beste Langzeitlösung — braucht aber Bewertungs-UI auf der Enrich-Seite und Signalakkumulation.

+
+ Selbstverbessernd− UI-Aufwand− Braucht Zeit bis Signal
+
+
+ + + + + +
+
Mockup: Experten-gesucht-Badge in der Transkriptions-Zeile
+
+ +
+
Reisepass Opa Heinrich 3 / 8 Blöcke
+
+
+
37 %
+
+
+ +
+
Standesamt Breslau 1872 + Experten gesucht +
+
Schrift besonders schwer lesbar — Hilfe willkommen
+
+
+
+ +
+ + + + + + + + + +
ElementSQL / TailwindWertHinweis
Sort TranskriptionORDER BY textedBlocks DESC, HASHTEXT(id::text || EXTRACT(WEEK FROM NOW())::int::text)Kein neues Feld nötig; ändert sich automatisch jede Woche
needsExpert-FlagALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSEFlyway V{n}__add_needs_expert.sqlFlagged Docs ans Ende: ORDER BY needs_expert ASC, ...
Experten-Badgeinline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700Kontrast 6,8:1 ✓Nur wenn doc.needsExpert === true
„Zu schwer"-Button (Enrich)text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2Unscheinbar — kein roter Knopf, keine Scham
Endpoint (Flagge setzen)PATCH /api/documents/{id}/needs-expert@RequirePermission(READ_ALL)Jeder angemeldete Nutzer darf flaggen
+
+
+ +
+ + +
+
Mockup — Desktop, normaler Zustand
+ +
+
+
+
+ + +
MR
+
+
+ +
+ + +
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
Karl Raddatz
12. Apr
+
Taufurkunde Karl Raddatz
Standesamt
9. Apr
+
Postkarte aus Breslau
Martha Raddatz
7. Apr
+
Familienfoto Sommer 1952
Unbekannt
3. Apr
+
47 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop
+
+
Metadaten fehlen
+
Familienfoto 1952
Titel fehlt
+
Standesamtsurkunde
Datum fehlt
+ Alle 5 anzeigen → +
+
+
+ + +
+
Was braucht Aufmerksamkeit?
+
+ + +
+
+
Rahmen einzeichnen
+
✓ Ohne Vorkenntnisse
+
↑ +5 diese Woche· 1 480 offen
+
+
MR
+
TG
+
AS
+
+ 2
+
+
+
Taufurkunde Karl R.
Noch keine Rahmen
+
Standesamt 1889
Noch keine Rahmen
+
Heiratsurkunde 1921
Noch keine Rahmen
+ Jetzt einzeichnen → +
+ + +
+
+
Text eintippen
+
Kurrent hilfreich
+
↑ +2 diese Woche· 8 offen
+
+
MR
+
1 Person
+
+
+ +
+
Reisepass Opa Heinrich
+
3 / 8 Blöcke
+
+
+
Brief v. Oma Martha 1943
+
0 / 6 Blöcke
+
+ +
+
Standesamt Breslau 1872
Experten gesucht
+
Schrift besonders schwer lesbar
+
+ Jetzt tippen → +
+ + +
+
+
Lesefertig ✓
+
3 Dokumente bereit
+
+
MR
+
TG
+
+
+
+
Postkarte aus Breslau 1943
+
100 % geprüft
+
+
+
Brief Oma Martha 1938
+
95 % geprüft
+
+
+
Heiratsurkunde 1921
+
91 % geprüft
+
+ Alle 3 lesen → +
+ +
+
+
+
+ Desktop (55 %) — normaler Zustand: Teilfortschritt oben, Experten-gesucht-Dokument unten in Spalte 2 +
+
+
+ + +
+
Mockup — Desktop, frühe Projektphase (Lesefertig leer)
+ +
+
+
+
+ + +
MR
+
+
+ +
+
+
+
Neueste Aktivität
+
Brief von Oma Martha, 1943
12. Apr
+
Taufurkunde Karl Raddatz
9. Apr
+
1 500 Dokumente · 12 Personen
+
+
+
Datei hochladen
Drag & Drop
+
+
Metadaten fehlen
+
Familienfoto 1952
+
Standesamtsurkunde
+ Alle anzeigen → +
+
+
+
+
Was braucht Aufmerksamkeit?
+
+
+
+
Rahmen einzeichnen
+
✓ Ohne Vorkenntnisse
+
↑ +3 diese Woche· 1 498 offen
+
MR
1 Person
+
+
Taufurkunde Karl R.
+
Standesamt 1889
+
Heiratsurkunde 1921
+ Jetzt einzeichnen → +
+
+
+
Text eintippen
+
Kurrent hilfreich
+
↑ +1 diese Woche· 2 offen
+
MR
1 Person
+
+
+
Brief v. Oma Martha 1943
+
0 / 6 Blöcke
+
+
+
Reisepass Opa Heinrich
+
0 / 4 Blöcke
+
+ Jetzt tippen → +
+ +
+
+
Noch kein Dokument lesefertig
+
Erscheint hier sobald die Transkription abgeschlossen ist.
+ Jetzt mithelfen → +
+
+
+
+
+ Desktop (55 %) — frühe Phase: 1 500 Dokumente ohne Transkription, Wochenpuls zeigt Schwung statt Berg +
+
+
+ +
+ + +
+
Mockup — Mobil 320 px
+

+ Die rechte Spalte (DropZone + Metadaten) erscheint auf Mobil zuerst im DOM (lg:order-last schiebt sie auf Desktop nach rechts). + Der Streifen stapelt seine drei Spalten vertikal. Jede Spalte hat volle Breite — keine Overflow-Probleme. +

+ +
+ +
+
+
+
+ + +
Hochladen
+
+
Metadaten fehlen
+
Familienfoto 1952
+
Standesamtsurkunde
+
+ +
+
Neueste Aktivität
+
Brief von Oma Martha
+
Taufurkunde Karl R.
+
1 500 Dok. · 12 Pers.
+
+ +
+
Was braucht Aufmerksamkeit?
+ +
+
Rahmen einzeichnen
+
✓ Ohne Vorkenntnisse
+
↑ +5 diese Woche· 1 480 offen
+
Taufurkunde Karl R.
+
Standesamt 1889
+ Jetzt einzeichnen → +
+ +
+
Text eintippen
+
Kurrent hilfreich
+
↑ +2 diese Woche· 8 offen
+
+
Reisepass Opa Heinrich
+
3 / 8 Blöcke
+
+
+
Brief v. Oma Martha 1943
+
0 / 6 Blöcke
+
+ Jetzt tippen → +
+ +
+
Lesefertig ✓
+
3 bereit
+
Postkarte 1943
100 %
+
Brief Oma 1938
95 %
+ Alle lesen → +
+
+
+
+ Mobil 320 px — Streifen stapelt vertikal, volle Breite je Spalte +
+ + +
+
+
Mobile-Reihenfolge (DOM)
+
    +
  1. Suchleiste
  2. +
  3. DropZone (write users only)
  4. +
  5. Metadaten fehlen
  6. +
  7. Neueste Aktivität
  8. +
  9. Was braucht Aufmerksamkeit? +
      +
    1. Rahmen einzeichnen
    2. +
    3. Text eintippen
    4. +
    5. Lesefertig ✓
    6. +
    +
  10. +
+
+ +
+
+
+ +
+ + +
+
Engagement-Elemente — Zusammenfassung
+
+
+

① Skill-Pill

+

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.

+

bg-green-50 border-green-200 text-green-800 / bg-surface border-line text-ink

+
+
+

② Wochenpuls

+

„↑ +5 diese Woche · 1 480 offen" statt globalem Fortschrittsbalken. + Zeigt Schwung, nicht den Berg. Psychologisch: 0,8 %-Balken ist demotivierender als kein Balken.

+

SELECT COUNT(*) WHERE created_at > NOW() - INTERVAL '7 days'

+
+
+

③ Per-Dokument-Balken

+

Nur in Spalte 2, nur wenn annotation_count > 0. Richtiger Maßstab: + 8 Blöcke sind in einer Sitzung abschließbar. Zeigt auch, welche Dokumente „fast fertig" sind.

+

width: {textedBlocks / totalBlocks * 100}%; Guard: totalBlocks === 0 → width: 0

+
+
+

④ Contributor-Avatare

+

Max. 3 Initialen-Bubbles der letzten Beitragenden pro Spalte. Kein Leaderboard (Wettbewerb) — + soziale Sichtbarkeit (Zugehörigkeit). Farbe deterministisch aus User-ID-Hash.

+

DTO: lastContributors: [{initials, colorIndex}] — nur Initialen, keine Namen (Nora)

+
+
+

⑤ „Starte hier →"-CTA

+

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.

+

/enrich?filter=NEEDS_SEGMENTATION&next=1 (Segmentierung)
/enrich?filter=NEEDS_TRANSCRIPTION&next=1 (Transkription)

+
+
+

⑥ Lesefertig-Leerstand → Redirect

+

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.

+

{#if readyToRead.length === 0}DashboardReadyToReadEmpty.svelte

+
+
+
+ +
+ + +
+
Implementation Reference
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind-KlassenPixel / WertHinweis
Streifen-Wrappermt-4 bg-white border border-line rounded-sm p-6padding 24 pxDirekt nach bestehendem div.mt-4.grid
Streifen-Titeltext-xs font-bold uppercase tracking-widest text-gray-400 mb-412 px / 700Standard-Section-Title-Muster
3-Spalten-Gridgrid grid-cols-1 gap-4 sm:grid-cols-3gap 16 pxsm = 640 px; darunter stapeln
Segmentierung-Spaltebg-surface rounded-sm border border-line p-4 flex flex-col gap-3Neutral
Transkription-Spaltebg-surface rounded-sm border border-line p-4 flex flex-col gap-3Neutral — es ist eine Aufgabe
Lesefertig-Spalte (gefüllt)bg-mint/10 rounded-sm border border-mint p-4 flex flex-col gap-3Mint-Ton = Erfolg
Lesefertig-Spalte (leer)flex flex-col items-center justify-center text-center bg-mint/5 border border-dashed border-mint rounded-sm p-6 min-h-[120px]min-h 120 pxKein toter Endpunkt
Skill-Pill easyinline-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-800Kontrast 9,7:1 ✓ AAA
Skill-Pill kurrentinline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-surface border border-line text-inkKontrast 14,5:1 ✓ AAANeutral — kein Abschreck-Signal
Wochenpuls-Zahltext-xs font-semibold text-green-700 (Seg.) / text-ink (Trans.)12 pxKein globaler Balken
Per-Dokument-Trackflex-1 h-1 bg-navy/20 rounded-full overflow-hiddenh 4 pxNur wenn annotation_count > 0
Per-Dokument-Fillh-full bg-ink rounded-full transition-all + style="width:{pct}%"Guard: totalBlocks === 0 → 0%
Lesefertig-Prozenttext-xs font-semibold text-green-80012 pxKein Balken — mint-Spalte ist das Signal
Contributor-Avatarw-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-024 × 24 pxFarbe: 6 Werte, Index = userIdHash % 6
CTA-Button (primär)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-1min-h 36 pxaria-label mit Dokumenttitel falls nötig
CTA-Button (ghost, Leerstand)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-colorsmin-h 36 px
Experten-gesucht-Badgeinline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700Kontrast 6,8:1 ✓ AANur wenn doc.needsExpert === true
Sichtbarkeit Streifen{#if needsSegmentation.length > 0 || needsTranscription.length > 0 || readyToRead.length > 0}Streifen verschwindet wenn alle drei Buckets leer
Dokument-Zeile Mindesthöhemin-h-[44px] flex items-start py-244 px ✓ WCAG 2.2Gilt für alle klickbaren Zeilen
+
+
+ +
+ + +
+
Backend — neue Endpoints & Queries
+
+ + + + + + + + + + + + +
Endpoint / QueryBedingungSortAuth
GET /api/documents/needs-segmentation?size=3NOT EXISTS (SELECT 1 FROM document_annotations WHERE document_id = d.id)HASHTEXT(id::text || week::text)READ_ALL
GET /api/documents/needs-transcription?size=3EXISTS annotation AND (no blocks OR reviewed_pct < 0.90)textedBlocks DESC, needs_expert ASC, HASHTEXT(...)READ_ALL
GET /api/documents/ready-to-read?size=3reviewed_pct >= 0.90updated_at DESCREAD_ALL
PATCH /api/documents/{id}/needs-expertSetzt needs_expert = trueREAD_ALL (jeder Nutzer darf flaggen)
GET /api/stats/strip-activityWochenpuls: COUNT(*) WHERE created_at > NOW() - INTERVAL '7 days' pro BucketREAD_ALL
Flyway-MigrationALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSEV{n}__add_needs_expert_flag.sql
Index prüfen (Tobias)document_annotations(document_id), transcription_blocks(document_id, reviewed)EXPLAIN ANALYZE vor Merge
Division durch 0 (Sara)Alle reviewed_pct-Queries: CASE WHEN COUNT(*) = 0 THEN 0 ELSE SUM(...)::float / COUNT(*) END
+
+
+ +
+ + +
+
Neue Svelte-Komponenten
+
+
+

DashboardMissionControl.svelte

+

Wrapper für den vollbreiten Streifen. Props: needsSegmentation, needsTranscription, + readyToRead, weeklyActivity. Rendert die drei Spalten und ist komplett unsichtbar wenn alle Arrays leer sind.

+
+
+

DashboardSegmentationCol.svelte

+

Spalte 1: Skill-Pill, Wochenpuls, Avatare, Dokumentliste, CTA. Keine Balken — keine Dokument-Metadaten vorhanden.

+
+
+

DashboardTranscriptionCol.svelte

+

Spalte 2: Skill-Pill, Wochenpuls, Avatare, per-Dokument-Balken, Experten-Badge bei needsExpert, CTA.

+
+
+

DashboardReadyToReadCol.svelte

+

Spalte 3: Zeigt gefüllten Zustand (Liste mit %-Text) oder leeren Zustand (Cross-Column-Redirect zu Segmentierung).

+
+
+
+
+ Bestehende Komponente bleibt: DashboardNeedsMetadata.svelte ist unverändert — + sie lebt weiterhin in der rechten Spalte. Der Mission-Control-Streifen ist vollständig additiv und ändert nichts am bestehenden Layout. +
+
+
+ +
+ + diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 81350a62..da516d83 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index bbbd0f07..86c3dd90 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2d7aba00..07924e14 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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" } diff --git a/frontend/src/lib/components/MissionControlStrip.svelte b/frontend/src/lib/components/MissionControlStrip.svelte new file mode 100644 index 00000000..2a026877 --- /dev/null +++ b/frontend/src/lib/components/MissionControlStrip.svelte @@ -0,0 +1,33 @@ + + +
+

+ {m.mission_control_heading()} +

+
+ + + +
+
diff --git a/frontend/src/lib/components/MissionControlStrip.svelte.spec.ts b/frontend/src/lib/components/MissionControlStrip.svelte.spec.ts new file mode 100644 index 00000000..8bcd25b0 --- /dev/null +++ b/frontend/src/lib/components/MissionControlStrip.svelte.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/frontend/src/lib/components/ReadyColumn.svelte b/frontend/src/lib/components/ReadyColumn.svelte new file mode 100644 index 00000000..20fc91d4 --- /dev/null +++ b/frontend/src/lib/components/ReadyColumn.svelte @@ -0,0 +1,71 @@ + + +{#if docs.length > 0} +
+
+
+

+ {m.mission_control_ready_heading()} +

+
+

+ {m.mission_control_ready_subtitle({ count: docs.length })} +

+
+ +
+{:else} +
+

{m.mission_control_ready_empty()}

+ + {m.mission_control_ready_empty_cta()} + +
+{/if} diff --git a/frontend/src/lib/components/ReadyColumn.svelte.spec.ts b/frontend/src/lib/components/ReadyColumn.svelte.spec.ts new file mode 100644 index 00000000..1674e44a --- /dev/null +++ b/frontend/src/lib/components/ReadyColumn.svelte.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/frontend/src/lib/components/SegmentationColumn.svelte b/frontend/src/lib/components/SegmentationColumn.svelte new file mode 100644 index 00000000..f7361444 --- /dev/null +++ b/frontend/src/lib/components/SegmentationColumn.svelte @@ -0,0 +1,58 @@ + + +{#if docs.length > 0} +
+
+

+ {m.mission_control_segmentation_heading()} +

+ + {m.mission_control_seg_skill_pill()} + + {#if weeklyCount > 0} +

+ {m.mission_control_weekly_pulse({ count: weeklyCount })} +

+ {/if} +
+ +
+{:else} +
+

{m.mission_control_segmentation_empty()}

+
+{/if} diff --git a/frontend/src/lib/components/SegmentationColumn.svelte.spec.ts b/frontend/src/lib/components/SegmentationColumn.svelte.spec.ts new file mode 100644 index 00000000..52de56af --- /dev/null +++ b/frontend/src/lib/components/SegmentationColumn.svelte.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionColumn.svelte b/frontend/src/lib/components/TranscriptionColumn.svelte new file mode 100644 index 00000000..ef4b742b --- /dev/null +++ b/frontend/src/lib/components/TranscriptionColumn.svelte @@ -0,0 +1,81 @@ + + +{#if docs.length > 0} +
+
+

+ {m.mission_control_transcription_heading()} +

+ + {m.mission_control_trans_skill_pill()} + + {#if weeklyCount > 0} +

+ {m.mission_control_weekly_pulse({ count: weeklyCount })} +

+ {/if} +
+ +
+{:else} +
+

{m.mission_control_transcription_empty()}

+
+{/if} diff --git a/frontend/src/lib/components/TranscriptionColumn.svelte.spec.ts b/frontend/src/lib/components/TranscriptionColumn.svelte.spec.ts new file mode 100644 index 00000000..170671db --- /dev/null +++ b/frontend/src/lib/components/TranscriptionColumn.svelte.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 5d17827c..dc00b483 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -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?: { diff --git a/frontend/src/lib/utils/date.ts b/frontend/src/lib/utils/date.ts index 5a469f39..18e3ed69 100644 --- a/frontend/src/lib/utils/date.ts +++ b/frontend/src/lib/utils/date.ts @@ -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. diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index e1386db8..6a430477 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -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 diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index bbdfae44..4bf13282 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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 ? + + {:else} = {}) => ({ 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: [], diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 3e8f2dcb..ab335199 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -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 >); diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index 9b577a12..a31a7e4b 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -44,6 +44,10 @@ const emptyData = { stats: null, incompleteCount: 0, initialValues: { senderName: '', receiverName: '' }, + segmentationDocs: [], + transcriptionDocs: [], + readyDocs: [], + weeklyStats: null, error: null };