Compare commits
1 Commits
e15867e47d
...
docs/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd7f0d486 |
@@ -212,14 +212,6 @@ public class DocumentController {
|
||||
return ResponseEntity.ok(DocumentSearchResult.of(results));
|
||||
}
|
||||
|
||||
// --- EXPERT FLAG ---
|
||||
|
||||
@PatchMapping("/{id}/needs-expert")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Document toggleNeedsExpert(@PathVariable UUID id) {
|
||||
return documentService.toggleNeedsExpert(id);
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
public record TrainingLabelRequest(String label, boolean enrolled) {}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionQueueService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Serves the three Mission Control Strip columns for the dashboard.
|
||||
* All endpoints require READ_ALL — same guard as the rest of the archive.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/transcription")
|
||||
@RequiredArgsConstructor
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public class TranscriptionQueueController {
|
||||
|
||||
private final TranscriptionQueueService transcriptionQueueService;
|
||||
|
||||
@GetMapping("/segmentation-queue")
|
||||
public ResponseEntity<List<TranscriptionQueueItemDTO>> getSegmentationQueue() {
|
||||
return ResponseEntity.ok(transcriptionQueueService.getSegmentationQueue());
|
||||
}
|
||||
|
||||
@GetMapping("/transcription-queue")
|
||||
public ResponseEntity<List<TranscriptionQueueItemDTO>> getTranscriptionQueue() {
|
||||
return ResponseEntity.ok(transcriptionQueueService.getTranscriptionQueue());
|
||||
}
|
||||
|
||||
@GetMapping("/ready-to-read")
|
||||
public ResponseEntity<List<TranscriptionQueueItemDTO>> getReadyToRead() {
|
||||
return ResponseEntity.ok(transcriptionQueueService.getReadyToReadQueue());
|
||||
}
|
||||
|
||||
@GetMapping("/weekly-stats")
|
||||
public ResponseEntity<TranscriptionWeeklyStatsDTO> getWeeklyStats() {
|
||||
return ResponseEntity.ok(transcriptionQueueService.getWeeklyStats());
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A single row in one of the three Mission Control Strip queues.
|
||||
* Annotation/block counts drive the per-document mini progress bar
|
||||
* in the Transkription column and the percentage label in Lesefertig.
|
||||
*/
|
||||
public record TranscriptionQueueItemDTO(
|
||||
UUID id,
|
||||
String title,
|
||||
LocalDate documentDate,
|
||||
boolean needsExpert,
|
||||
int annotationCount,
|
||||
int textedBlockCount,
|
||||
int reviewedBlockCount
|
||||
) {}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
/**
|
||||
* Weekly activity pulse for the Mission Control Strip column headers.
|
||||
* Counts documents that received new work in each pipeline stage
|
||||
* during the last 7 days.
|
||||
*/
|
||||
public record TranscriptionWeeklyStatsDTO(
|
||||
long segmentationCount,
|
||||
long transcriptionCount,
|
||||
long readyCount
|
||||
) {}
|
||||
@@ -97,11 +97,6 @@ public class Document {
|
||||
@Builder.Default
|
||||
private ScriptType scriptType = ScriptType.UNKNOWN;
|
||||
|
||||
@Column(name = "needs_expert", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private boolean needsExpert = false;
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||
@Builder.Default
|
||||
|
||||
@@ -89,82 +89,4 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
""")
|
||||
List<UUID> findRankedIdsByFts(@Param("query") String query);
|
||||
|
||||
// --- Mission Control Strip queues ---
|
||||
|
||||
/** Documents with no annotations — Segmentierung column. */
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
|
||||
0 AS annotationCount, 0 AS textedBlockCount, 0 AS reviewedBlockCount
|
||||
FROM documents d
|
||||
WHERE d.status NOT IN ('PLACEHOLDER')
|
||||
AND NOT EXISTS (SELECT 1 FROM document_annotations da WHERE da.document_id = d.id)
|
||||
ORDER BY d.needs_expert ASC,
|
||||
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
|
||||
LIMIT :limit
|
||||
""")
|
||||
List<Object[]> findSegmentationQueue(@Param("limit") int limit);
|
||||
|
||||
/** Documents with annotations but not yet fully reviewed — Transkription column. */
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
|
||||
COUNT(DISTINCT da.id) AS annotationCount,
|
||||
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
|
||||
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
|
||||
FROM documents d
|
||||
JOIN document_annotations da ON da.document_id = d.id
|
||||
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
|
||||
GROUP BY d.id, d.title, d.meta_date, d.needs_expert
|
||||
HAVING COUNT(DISTINCT da.id) > 0
|
||||
AND (
|
||||
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) = 0
|
||||
OR (
|
||||
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
|
||||
NULLIF(COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END), 0)
|
||||
) < 0.90
|
||||
)
|
||||
ORDER BY d.needs_expert ASC,
|
||||
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) DESC,
|
||||
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
|
||||
LIMIT :limit
|
||||
""")
|
||||
List<Object[]> findTranscriptionQueue(@Param("limit") int limit);
|
||||
|
||||
/** Documents with reviewed_pct >= 90 % — Lesefertig column. */
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
|
||||
COUNT(DISTINCT da.id) AS annotationCount,
|
||||
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
|
||||
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
|
||||
FROM documents d
|
||||
JOIN document_annotations da ON da.document_id = d.id
|
||||
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
|
||||
GROUP BY d.id, d.title, d.meta_date, d.needs_expert
|
||||
HAVING COUNT(DISTINCT da.id) > 0
|
||||
AND COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) > 0
|
||||
AND (
|
||||
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
|
||||
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END)
|
||||
) >= 0.90
|
||||
ORDER BY (
|
||||
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
|
||||
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END)
|
||||
) DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
List<Object[]> findReadyToReadQueue(@Param("limit") int limit);
|
||||
|
||||
/** Weekly pulse: distinct documents that received new work in each pipeline stage. */
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT da.document_id) FROM document_annotations da
|
||||
WHERE da.created_at >= NOW() - INTERVAL '7 days') AS segmentationCount,
|
||||
(SELECT COUNT(DISTINCT tb.document_id) FROM transcription_blocks tb
|
||||
WHERE tb.created_at >= NOW() - INTERVAL '7 days'
|
||||
AND tb.text IS NOT NULL AND tb.text <> '') AS transcriptionCount,
|
||||
(SELECT COUNT(DISTINCT tb.document_id) FROM transcription_blocks tb
|
||||
WHERE tb.updated_at >= NOW() - INTERVAL '7 days'
|
||||
AND tb.reviewed = true) AS readyCount
|
||||
""")
|
||||
Object[] findWeeklyStats();
|
||||
|
||||
}
|
||||
@@ -570,13 +570,6 @@ public class DocumentService {
|
||||
return parsed != null ? parsed.title() : stripExtension(filename);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document toggleNeedsExpert(UUID documentId) {
|
||||
Document doc = getDocumentById(documentId);
|
||||
doc.setNeedsExpert(!doc.isNeedsExpert());
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
private static String tryParseDate(String s) {
|
||||
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
|
||||
int m = Integer.parseInt(s.substring(5, 7));
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
|
||||
* and the weekly activity pulse used by the column headers.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TranscriptionQueueService {
|
||||
|
||||
private static final int DEFAULT_QUEUE_SIZE = 5;
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
|
||||
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
|
||||
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
|
||||
.stream()
|
||||
.map(this::mapRow)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
|
||||
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
|
||||
.stream()
|
||||
.map(this::mapRow)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
|
||||
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
|
||||
.stream()
|
||||
.map(this::mapRow)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
|
||||
Object[] row = documentRepository.findWeeklyStats();
|
||||
return new TranscriptionWeeklyStatsDTO(
|
||||
toLong(row[0]),
|
||||
toLong(row[1]),
|
||||
toLong(row[2])
|
||||
);
|
||||
}
|
||||
|
||||
// --- mapping helpers ---
|
||||
|
||||
private TranscriptionQueueItemDTO mapRow(Object[] row) {
|
||||
UUID id = toUUID(row[0]);
|
||||
String title = (String) row[1];
|
||||
LocalDate documentDate = toLocalDate(row[2]);
|
||||
boolean needsExpert = toBoolean(row[3]);
|
||||
int annotationCount = toInt(row[4]);
|
||||
int textedBlockCount = toInt(row[5]);
|
||||
int reviewedBlockCount = toInt(row[6]);
|
||||
return new TranscriptionQueueItemDTO(id, title, documentDate, needsExpert,
|
||||
annotationCount, textedBlockCount, reviewedBlockCount);
|
||||
}
|
||||
|
||||
private UUID toUUID(Object o) {
|
||||
if (o instanceof UUID u) return u;
|
||||
return UUID.fromString(o.toString());
|
||||
}
|
||||
|
||||
private LocalDate toLocalDate(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof LocalDate d) return d;
|
||||
if (o instanceof java.sql.Date d) return d.toLocalDate();
|
||||
return LocalDate.parse(o.toString());
|
||||
}
|
||||
|
||||
private boolean toBoolean(Object o) {
|
||||
if (o instanceof Boolean b) return b;
|
||||
return Boolean.parseBoolean(o.toString());
|
||||
}
|
||||
|
||||
private int toInt(Object o) {
|
||||
if (o == null) return 0;
|
||||
if (o instanceof Number n) return n.intValue();
|
||||
if (o instanceof BigDecimal bd) return bd.intValue();
|
||||
return Integer.parseInt(o.toString());
|
||||
}
|
||||
|
||||
private long toLong(Object o) {
|
||||
if (o == null) return 0L;
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
if (o instanceof BigDecimal bd) return bd.longValue();
|
||||
return Long.parseLong(o.toString());
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
CREATE INDEX idx_transcription_blocks_document_id ON transcription_blocks(document_id);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -555,23 +555,5 @@
|
||||
"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",
|
||||
"mission_control_heading": "Was braucht Aufmerksamkeit?",
|
||||
"mission_control_segmentation_heading": "Rahmen einzeichnen",
|
||||
"mission_control_segmentation_description": "Textbereiche markieren — keine Vorkenntnisse nötig",
|
||||
"mission_control_seg_skill_pill": "✓ Ohne Vorkenntnisse",
|
||||
"mission_control_segmentation_empty": "Alle Dokumente haben bereits Segmentierungsblöcke.",
|
||||
"mission_control_transcription_heading": "Text eintippen",
|
||||
"mission_control_transcription_description": "Text abschreiben — Kurrent-Kenntnisse hilfreich",
|
||||
"mission_control_trans_skill_pill": "Kurrent hilfreich",
|
||||
"mission_control_transcription_empty": "Keine Dokumente warten auf Transkription.",
|
||||
"mission_control_ready_heading": "Lesefertig ✓",
|
||||
"mission_control_ready_description": "Vollständig transkribiert und geprüft",
|
||||
"mission_control_ready_subtitle": "{count} Dokumente bereit",
|
||||
"mission_control_ready_empty": "Noch keine Dokumente vollständig transkribiert.",
|
||||
"mission_control_ready_empty_cta": "Jetzt mitmachen",
|
||||
"mission_control_weekly_pulse": "↑ +{count} diese Woche",
|
||||
"mission_control_expert_badge": "Experten gesucht",
|
||||
"mission_control_blocks_progress": "{texted} / {total} Blöcke",
|
||||
"mission_control_reviewed_pct": "{pct}% geprüft"
|
||||
"training_chip_segmentation": "Segmentierung"
|
||||
}
|
||||
|
||||
@@ -555,23 +555,5 @@
|
||||
"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",
|
||||
"mission_control_heading": "What needs attention?",
|
||||
"mission_control_segmentation_heading": "Draw regions",
|
||||
"mission_control_segmentation_description": "Mark text areas — no prior knowledge needed",
|
||||
"mission_control_seg_skill_pill": "✓ No prior knowledge",
|
||||
"mission_control_segmentation_empty": "All documents already have segmentation blocks.",
|
||||
"mission_control_transcription_heading": "Type the text",
|
||||
"mission_control_transcription_description": "Type out text — Kurrent knowledge helpful",
|
||||
"mission_control_trans_skill_pill": "Kurrent helpful",
|
||||
"mission_control_transcription_empty": "No documents waiting for transcription.",
|
||||
"mission_control_ready_heading": "Ready to read ✓",
|
||||
"mission_control_ready_description": "Fully transcribed and reviewed",
|
||||
"mission_control_ready_subtitle": "{count} documents ready",
|
||||
"mission_control_ready_empty": "No documents fully transcribed yet.",
|
||||
"mission_control_ready_empty_cta": "Start contributing",
|
||||
"mission_control_weekly_pulse": "↑ +{count} this week",
|
||||
"mission_control_expert_badge": "Expert needed",
|
||||
"mission_control_blocks_progress": "{texted} / {total} blocks",
|
||||
"mission_control_reviewed_pct": "{pct}% reviewed"
|
||||
"training_chip_segmentation": "Segmentation"
|
||||
}
|
||||
|
||||
@@ -555,23 +555,5 @@
|
||||
"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",
|
||||
"mission_control_heading": "¿Qué necesita atención?",
|
||||
"mission_control_segmentation_heading": "Marcar regiones",
|
||||
"mission_control_segmentation_description": "Marcar áreas de texto — sin conocimientos previos",
|
||||
"mission_control_seg_skill_pill": "✓ Sin conocimientos previos",
|
||||
"mission_control_segmentation_empty": "Todos los documentos ya tienen bloques de segmentación.",
|
||||
"mission_control_transcription_heading": "Escribir el texto",
|
||||
"mission_control_transcription_description": "Escribir el texto — conocimiento de Kurrent útil",
|
||||
"mission_control_trans_skill_pill": "Kurrent útil",
|
||||
"mission_control_transcription_empty": "No hay documentos esperando transcripción.",
|
||||
"mission_control_ready_heading": "Listo para leer ✓",
|
||||
"mission_control_ready_description": "Completamente transcrito y revisado",
|
||||
"mission_control_ready_subtitle": "{count} documentos listos",
|
||||
"mission_control_ready_empty": "Aún no hay documentos completamente transcritos.",
|
||||
"mission_control_ready_empty_cta": "Empezar a colaborar",
|
||||
"mission_control_weekly_pulse": "↑ +{count} esta semana",
|
||||
"mission_control_expert_badge": "Se busca experto",
|
||||
"mission_control_blocks_progress": "{texted} / {total} bloques",
|
||||
"mission_control_reviewed_pct": "{pct}% revisado"
|
||||
"training_chip_segmentation": "Segmentación"
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded border border-purple-200 bg-purple-50 px-2 py-0.5 text-xs font-semibold text-purple-700"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1.5L1.5 13.5h13L8 1.5z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path d="M8 6v3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
|
||||
</svg>
|
||||
{m.mission_control_expert_badge()}
|
||||
</span>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import SegmentationColumn from './SegmentationColumn.svelte';
|
||||
import TranscriptionColumn from './TranscriptionColumn.svelte';
|
||||
import ReadyColumn from './ReadyColumn.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
needsExpert: boolean;
|
||||
annotationCount: number;
|
||||
textedBlockCount: number;
|
||||
reviewedBlockCount: number;
|
||||
};
|
||||
|
||||
type TranscriptionWeeklyStatsDTO = {
|
||||
segmentationCount: number;
|
||||
transcriptionCount: number;
|
||||
readyCount: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
segmentationDocs: TranscriptionQueueItemDTO[];
|
||||
transcriptionDocs: TranscriptionQueueItemDTO[];
|
||||
readyDocs: TranscriptionQueueItemDTO[];
|
||||
weeklyStats: TranscriptionWeeklyStatsDTO | null;
|
||||
}
|
||||
|
||||
let { segmentationDocs, transcriptionDocs, readyDocs, weeklyStats }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if segmentationDocs.length > 0 || transcriptionDocs.length > 0 || readyDocs.length > 0}
|
||||
<section class="mt-4 rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.mission_control_heading()}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<SegmentationColumn
|
||||
docs={segmentationDocs}
|
||||
weeklyCount={weeklyStats?.segmentationCount ?? 0}
|
||||
/>
|
||||
<TranscriptionColumn
|
||||
docs={transcriptionDocs}
|
||||
weeklyCount={weeklyStats?.transcriptionCount ?? 0}
|
||||
/>
|
||||
<ReadyColumn docs={readyDocs} weeklyCount={weeklyStats?.readyCount ?? 0} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,90 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type TranscriptionQueueItemDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
needsExpert: boolean;
|
||||
annotationCount: number;
|
||||
textedBlockCount: number;
|
||||
reviewedBlockCount: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
docs: TranscriptionQueueItemDTO[];
|
||||
weeklyCount: number;
|
||||
}
|
||||
|
||||
let { docs, weeklyCount }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Intl.DateTimeFormat(getLocale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(dateStr + 'T12:00:00'));
|
||||
}
|
||||
|
||||
function reviewedPct(doc: TranscriptionQueueItemDTO): number {
|
||||
if (doc.textedBlockCount === 0) return 0;
|
||||
return Math.round((doc.reviewedBlockCount / doc.textedBlockCount) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if docs.length > 0}
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-sm border border-brand-mint bg-brand-mint/10 p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.mission_control_ready_heading()}
|
||||
</h3>
|
||||
{#if weeklyCount > 0}
|
||||
<span class="rounded-full bg-accent-bg px-2 py-0.5 text-xs font-semibold text-ink-2">
|
||||
{m.mission_control_weekly_pulse({ count: weeklyCount })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs font-semibold text-ink-2">
|
||||
{m.mission_control_ready_subtitle({ count: docs.length })}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each docs as doc (doc.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-brand-mint/20 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
{#if doc.documentDate}
|
||||
<span class="text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
|
||||
{/if}
|
||||
{#if doc.textedBlockCount > 0}
|
||||
<span class="text-xs font-semibold text-ink">
|
||||
{m.mission_control_reviewed_pct({ pct: reviewedPct(doc) })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-brand-mint bg-brand-mint/5 p-6 text-center"
|
||||
>
|
||||
<p class="text-xs text-ink-3">{m.mission_control_ready_empty()}</p>
|
||||
<a
|
||||
href="/enrich?filter=NEEDS_SEGMENTATION&next=1"
|
||||
class="mt-2 inline-flex items-center rounded-sm border border-ink px-3 py-2 text-xs font-semibold text-ink transition-colors hover:bg-ink hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
{m.mission_control_ready_empty_cta()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ExpertBadge from './ExpertBadge.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
needsExpert: boolean;
|
||||
annotationCount: number;
|
||||
textedBlockCount: number;
|
||||
reviewedBlockCount: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
docs: TranscriptionQueueItemDTO[];
|
||||
weeklyCount: number;
|
||||
}
|
||||
|
||||
let { docs, weeklyCount }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Intl.DateTimeFormat(getLocale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(dateStr + 'T12:00:00'));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if docs.length > 0}
|
||||
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
|
||||
<div>
|
||||
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.mission_control_segmentation_heading()}
|
||||
</h3>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-line bg-accent-bg px-2 py-0.5 text-xs font-semibold text-ink"
|
||||
>
|
||||
{m.mission_control_seg_skill_pill()}
|
||||
</span>
|
||||
{#if weeklyCount > 0}
|
||||
<p class="mt-1 text-xs font-semibold text-ink-2">
|
||||
{m.mission_control_weekly_pulse({ count: weeklyCount })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each docs as doc (doc.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
||||
{#if doc.needsExpert}
|
||||
<ExpertBadge />
|
||||
{/if}
|
||||
</div>
|
||||
{#if doc.documentDate}
|
||||
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ExpertBadge from './ExpertBadge.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
needsExpert: boolean;
|
||||
annotationCount: number;
|
||||
textedBlockCount: number;
|
||||
reviewedBlockCount: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
docs: TranscriptionQueueItemDTO[];
|
||||
weeklyCount: number;
|
||||
}
|
||||
|
||||
let { docs, weeklyCount }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Intl.DateTimeFormat(getLocale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(dateStr + 'T12:00:00'));
|
||||
}
|
||||
|
||||
function blockProgress(doc: TranscriptionQueueItemDTO): number {
|
||||
if (doc.annotationCount === 0) return 0;
|
||||
return (doc.textedBlockCount / doc.annotationCount) * 100;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if docs.length > 0}
|
||||
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
|
||||
<div>
|
||||
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.mission_control_transcription_heading()}
|
||||
</h3>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-line bg-surface px-2 py-0.5 text-xs font-semibold text-ink"
|
||||
>
|
||||
{m.mission_control_trans_skill_pill()}
|
||||
</span>
|
||||
{#if weeklyCount > 0}
|
||||
<p class="mt-1 text-xs font-semibold text-ink">
|
||||
{m.mission_control_weekly_pulse({ count: weeklyCount })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each docs as doc (doc.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
||||
{#if doc.needsExpert}
|
||||
<ExpertBadge />
|
||||
{/if}
|
||||
</div>
|
||||
{#if doc.documentDate}
|
||||
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
|
||||
{/if}
|
||||
{#if doc.textedBlockCount > 0}
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<span class="shrink-0 text-xs text-ink-3">
|
||||
{m.mission_control_blocks_progress({
|
||||
texted: doc.textedBlockCount,
|
||||
total: doc.annotationCount
|
||||
})}
|
||||
</span>
|
||||
<div class="h-1 flex-1 overflow-hidden rounded-full bg-ink/20">
|
||||
<div
|
||||
class="h-full rounded-full bg-ink transition-all"
|
||||
style="width: {blockProgress(doc).toFixed(0)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="mt-0.5 text-xs text-ink-3 italic">—</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -660,32 +660,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/transcription/segmentation-queue": {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
get: operations["getSegmentationQueue"];
|
||||
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
|
||||
};
|
||||
"/api/transcription/transcription-queue": {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
get: operations["getTranscriptionQueue"];
|
||||
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
|
||||
};
|
||||
"/api/transcription/ready-to-read": {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
get: operations["getReadyToRead"];
|
||||
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
|
||||
};
|
||||
"/api/transcription/weekly-stats": {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
get: operations["getTranscriptionWeeklyStats"];
|
||||
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/needs-expert": {
|
||||
parameters: { query?: never; header?: never; path: { id: string; }; cookie?: never; };
|
||||
get?: never; put?: never; post?: never; delete?: never; options?: never; head?: never;
|
||||
patch: operations["toggleNeedsExpert"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1225,7 +1199,6 @@ export interface components {
|
||||
metadataComplete: boolean;
|
||||
/** @enum {string} */
|
||||
scriptType: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT";
|
||||
needsExpert: boolean;
|
||||
receivers?: components["schemas"]["Person"][];
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
@@ -1473,28 +1446,6 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
totalDocuments?: number;
|
||||
};
|
||||
TranscriptionQueueItemDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
needsExpert: boolean;
|
||||
/** Format: int32 */
|
||||
annotationCount: number;
|
||||
/** Format: int32 */
|
||||
textedBlockCount: number;
|
||||
/** Format: int32 */
|
||||
reviewedBlockCount: number;
|
||||
};
|
||||
TranscriptionWeeklyStatsDTO: {
|
||||
/** Format: int64 */
|
||||
segmentationCount: number;
|
||||
/** Format: int64 */
|
||||
transcriptionCount: number;
|
||||
/** Format: int64 */
|
||||
readyCount: number;
|
||||
};
|
||||
PersonSummaryDTO: {
|
||||
title?: string;
|
||||
/** Format: uuid */
|
||||
@@ -3088,46 +3039,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getSegmentationQueue: {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
|
||||
};
|
||||
};
|
||||
getTranscriptionQueue: {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
|
||||
};
|
||||
};
|
||||
getReadyToRead: {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
|
||||
};
|
||||
};
|
||||
getTranscriptionWeeklyStats: {
|
||||
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionWeeklyStatsDTO"]; }; };
|
||||
};
|
||||
};
|
||||
toggleNeedsExpert: {
|
||||
parameters: { query?: never; header?: never; path: { id: string; }; cookie?: never; };
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["Document"]; }; };
|
||||
};
|
||||
};
|
||||
getStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -5,8 +5,6 @@ import type { components } from '$lib/generated/api';
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
@@ -78,28 +76,12 @@ 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,
|
||||
segmentationResult,
|
||||
transcriptionResult,
|
||||
readyResult,
|
||||
weeklyStatsResult
|
||||
] = await Promise.allSettled([
|
||||
const [statsResult, incompleteResult, recentResult] = 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/transcription/segmentation-queue'),
|
||||
api.GET('/api/transcription/transcription-queue'),
|
||||
api.GET('/api/transcription/ready-to-read'),
|
||||
api.GET('/api/transcription/weekly-stats')
|
||||
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
|
||||
]);
|
||||
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
@@ -111,18 +93,6 @@ 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 {
|
||||
@@ -132,10 +102,6 @@ export async function load({ url, fetch }) {
|
||||
stats,
|
||||
incompleteDocs,
|
||||
recentDocs,
|
||||
segmentationDocs,
|
||||
transcriptionDocs,
|
||||
readyDocs,
|
||||
weeklyStats,
|
||||
initialValues: {
|
||||
senderName: senderObj?.displayName ?? '',
|
||||
receiverName: receiverObj?.displayName ?? ''
|
||||
@@ -153,10 +119,6 @@ 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
|
||||
|
||||
@@ -9,7 +9,6 @@ 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();
|
||||
@@ -133,13 +132,6 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
||||
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||
</div>
|
||||
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
{:else}
|
||||
<DocumentList
|
||||
documents={data.documents ?? []}
|
||||
|
||||
@@ -38,8 +38,6 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
documentDate: '1923-04-12',
|
||||
location: 'Berlin',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
needsExpert: false,
|
||||
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
|
||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
|
||||
tags: [],
|
||||
|
||||
Reference in New Issue
Block a user