feat(#240): Mission Control Strip — backend + frontend implementation #245
Reference in New Issue
Block a user
Delete Branch "feat/issue-240-mission-control-strip"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements the Mission Control Strip from the spec in PR #244 — a full-width 3-column widget below the existing dashboard grid that surfaces transcription work without touching the right column.
Backend (commits 1–6)
TranscriptionQueueProjectionandTranscriptionWeeklyStatsProjectioninterfaces replace fragileObject[]positional mapping inTranscriptionQueueService. Type-coercion helpers (toUUID,toLocalDate,toInt,toLong) removed.CREATE INDEX IF NOT EXISTSstatements ondocument_annotations(created_at),transcription_blocks(created_at),transcription_blocks(updated_at)— prevents full table scans in the weekly stats correlated subqueries.@Schema(requiredMode = REQUIRED)on all non-null fields ofTranscriptionQueueItemDTOandTranscriptionWeeklyStatsDTO.GET /api/transcription/{segmentation-queue, transcription-queue, ready-to-read, weekly-stats}— all guarded by@RequirePermission(READ_ALL)at class level.Frontend (commits 7–9)
api.tsregenerated — DTO fields now required (non-optional) in TypeScript.formatMCDate()extracted to$lib/utils/date.ts; duplicate inline implementations removed from all three column components.type TranscriptionQueueItemDTO = {...}declarations replaced withimport type { components } from '$lib/generated/api'in all four components.MissionControlStripis now always visible — the outer{#if}was removed.TranscriptionColumnmarkedaria-hidden="true"(block count text above already communicates the value).TranscriptionColumnweekly pulse changed fromtext-inktotext-ink-2to matchSegmentationColumn.reviewedPctdenominator: aligned toannotationCount(matches the SQL thresholdreviewedBlockCount / annotationCount >= 0.90).mission_control_*keys in de/en/es (unchanged from original).+page.server.ts: 4Promise.allSettledcalls — dashboard never breaks on partial API failure.+page.svelte:MissionControlStriprendered below the existing grid inisDashboard.Tests (commits 2, 3, 5, 10)
TranscriptionQueueServiceTest(6 tests) — projection-based mapping, delegation withDEFAULT_QUEUE_SIZE=5TranscriptionQueueControllerTest(12 tests) — 401/403/200 for all 4 endpointsDocumentRepositoryTestadditions (6 tests) — Testcontainers/PostgreSQL:findSegmentationQueueexcludes PLACEHOLDERs and annotated docs;findTranscriptionQueuebelow-90% logic;findReadyToReadQueue≥90% logic;findWeeklyStatsreturns zeros on empty DBSegmentationColumn.svelte.spec.ts,TranscriptionColumn.svelte.spec.ts,ReadyColumn.svelte.spec.ts,MissionControlStrip.svelte.spec.ts(17 tests total)Test plan
/documents/{id}, weekly pulse appears if activityaria-hidden="true"on decorative barCloses #240
🤖 Generated with Claude Code
Adds the server-side foundation for the dashboard transcription widget: - V36 migration: needs_expert BOOLEAN NOT NULL DEFAULT FALSE on documents - Document entity: needsExpert field (@Schema required) - DocumentRepository: 4 native queries — segmentation queue, transcription queue, ready-to-read queue (seeded weekly shuffle sort), weekly pulse stats - TranscriptionQueueService: maps Object[] rows to typed DTOs, handles PostgreSQL type variations (UUID/String, Date/LocalDate, Number/BigDecimal) - TranscriptionQueueController: GET /api/transcription/{segmentation-queue, transcription-queue, ready-to-read, weekly-stats} — all guarded by READ_ALL - DocumentService + DocumentController: PATCH /api/documents/{id}/needs-expert toggles the expert flag (WRITE_ALL required) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Manually adds the new types to src/lib/generated/api.ts: - Document.needsExpert: boolean (required field) - TranscriptionQueueItemDTO schema - TranscriptionWeeklyStatsDTO schema - Paths: /api/transcription/{segmentation-queue, transcription-queue, ready-to-read, weekly-stats} and /api/documents/{id}/needs-expert - Operations: matching typed request/response shapes Fixes briefwechsel spec fixtures to include scriptType and needsExpert so the Document type shape is satisfied. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Adds the full-width 3-column collaboration widget below the existing dashboard grid. Renders without the backend running (Promise.allSettled isolation keeps failures silent). Components (src/lib/components/): - ExpertBadge.svelte — purple pill with icon, no props - SegmentationColumn.svelte — col 1: links to /enrich/{id}, weekly pulse - TranscriptionColumn.svelte — col 2: per-doc progress bar when blocks exist - ReadyColumn.svelte — col 3: mint border when filled, dashed empty state - MissionControlStrip.svelte — strip wrapper, 1-col mobile / 3-col sm+ i18n: 19 new keys added to de/en/es (mission_control_*) Page wiring: - +page.server.ts: 4 new Promise.allSettled calls for segmentation-queue, transcription-queue, ready-to-read, weekly-stats; all failures silent - +page.svelte: MissionControlStrip rendered below the grid in isDashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>e15867e47dto9fb1821db5👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: 🚫 Changes requested
I read all 18 changed files carefully. The feature itself is solid — clean component decomposition, good use of
Promise.allSettled, keyed{#each}blocks, proper focus rings. But there are a handful of issues I can't sign off on.Blockers
1. PR description is stale — mentions removed features
The description still talks about V36 migration,
needsExpertfield,ExpertBadge.svelte, andPATCH /api/documents/{id}/needs-expert. All of these were removed in commitca0cf49. Reading the description and the diff at the same time is confusing. Update the description to reflect the actual implementation before merge.2. Missing
@Schema(requiredMode = REQUIRED)on both new DTOsTranscriptionQueueItemDTOandTranscriptionWeeklyStatsDTOare Java records with no@Schemaannotations:Per project code style, every backend-populated field needs
@Schema(requiredMode = Schema.RequiredMode.REQUIRED). Without it, the generatedapi.tsmakes every field optional, so callers must null-check fields that are never actually null. The generatedapi.tswas already committed to this PR — if annotations are added now, types need to be regenerated.3. SegmentationColumn links to
/documents/{id}but the spec says/enrich/{id}SegmentationColumn.svelte:53:The PR description explicitly states "Segmentierung column: links go to
/enrich/{id}". Segmentation work happens in/enrich, not in the document detail view. If this was intentionally changed from the spec, the PR description needs to reflect that. If it's an accident, fix the href.4. No tests for any of the new backend components
Five new Java classes (TranscriptionQueueController, TranscriptionQueueService, 4 native SQL queries), zero new tests. The only test change is adding
scriptType: 'UNKNOWN'to an unrelated fixture inbriefwechsel/page.svelte.spec.ts— that's a generated type catch-up, not a test for this feature.The native SQL queries in
DocumentRepositoryare especially in need of coverage: theHAVINGclause with a float division, theHASHTEXT-based shuffle sort, the weekly stats correlated subqueries. These are not testable with unit tests — they need Testcontainers with real PostgreSQL. Red/green/refactor applies here.Suggestions
5. Duplicate local type declarations across four components
TranscriptionQueueItemDTOandTranscriptionWeeklyStatsDTOare re-declared locally inMissionControlStrip.svelte,SegmentationColumn.svelte,TranscriptionColumn.svelte, andReadyColumn.svelte. The generatedapi.tsalready has these types. Either import from$lib/generated/api.tsor define them once in a shared$lib/types.tsand import from there.6.
formatDate()duplicated in all three column componentsIdentical function body in
SegmentationColumn.svelte:24-30,TranscriptionColumn.svelte:24-30,ReadyColumn.svelte:24-30. Extract to$lib/utils.tsor a<script module>block in a shared file.7. Weekly pulse text color inconsistency
SegmentationColumn.svelte:43:text-ink-2TranscriptionColumn.svelte:49:text-ink(darker)These are the same semantic element — the weekly activity count. Pick one.
8.
reviewedPctusestextedBlockCountas denominator; SQL threshold usesannotationCountReadyColumn.svelte:33-36computes the displayed percentage asreviewedBlockCount / textedBlockCount. The SQL inDocumentRepositoryfilters documents into the "ready" bucket based onreviewedBlockCount / annotationCount >= 0.90. A document could show "85% geprüft" in the frontend (of texted blocks) while qualifying for the ready bucket at a different ratio. Either use the same denominator in both places, or add a comment explaining why they differ intentionally.🏛️ Markus Keller — Application Architect
Verdict: ⚠️ Approved with concerns
The high-level structure is appropriate: a new controller → service → repository chain for a focused read-only feature. No cross-domain boundary violations. I have concerns about the Object[] projection pattern and one implicit schema assumption worth calling out.
Concerns (not hard blockers, but worth discussing before merge)
1. Object[] positional mapping is fragile — use a Spring Data projection instead
TranscriptionQueueService.mapRow()maps result columns by array index:If a developer reorders the SELECT columns in any of the four native queries, the mapping silently returns wrong data. The fix is straightforward — define a Spring Data interface projection and let JPA handle the mapping by column name:
Then the repository returns
List<TranscriptionQueueProjection>and the service maps it trivially. This also eliminates thetoUUID,toLocalDate,toInt,toLongtype-coercion helpers entirely.2.
findWeeklyStats()returnsObject[]for a scalar triple — worth a projection or record tooThree scalar counts as a positional array, with no names. A
TranscriptionWeeklyStatsProjectioninterface (or even inline construction in the query viaSELECT new …) would make this type-safe.3. Verify that
created_at/updated_atexist on the join tablesThe weekly stats query assumes
document_annotations.created_at,transcription_blocks.created_at, andtranscription_blocks.updated_atexist and are indexed:These columns exist on the main
documentstable and likely on the join tables, but there is no migration in this PR that adds them or the indexes. If they're missing, the query silently returns wrong results (PostgreSQL returns 0 from COUNT, not an error). Please confirm they exist in the current schema and that the indexes are present — otherwise this query will do a full table scan every time the dashboard loads.4. The
TranscriptionQueueService → DocumentRepositorydependency is acceptable hereTranscriptionQueueServicedirectly injectsDocumentRepository. This is technically within the document domain (transcription is a document concern), so it doesn't violate the cross-domain boundary rule. But if this service grows to touch other repositories, revisit.What's done well
Promise.allSettledin+page.server.tswith per-call null-fallback — dashboard never breaks on partial API failure ✅DEFAULT_QUEUE_SIZE = 5as a named constant rather than a magic number ✅🧪 Sara Holt — QA Engineer
Verdict: 🚫 Changes requested
This PR ships five new Java classes and four non-trivial native SQL queries with zero test coverage for any of them. That's the primary blocker. The frontend also has four new Svelte components with no component tests.
Blockers
1. No tests for TranscriptionQueueController or TranscriptionQueueService
Five new backend classes, no test files. At minimum I need:
@WebMvcTest(TranscriptionQueueController.class)verifying that:READ_ALL→ 403READ_ALL→ 200 with expected shape@ExtendWith(MockitoExtension.class)test forTranscriptionQueueServicecovering:getSegmentationQueue()delegates todocumentRepository.findSegmentationQueue(5)getWeeklyStats()maps the Object[] row correctly, including null values (doesrow[0] = nullproduce0L?)mapRow()handles all the type-coercion paths (UUID as String, Date as java.sql.Date, etc.)2. No integration tests for the four native SQL queries
The most valuable tests in this PR don't exist. The native SQL in
DocumentRepositoryhas real logic that cannot be validated without a real PostgreSQL instance:HAVING COUNT(…)::float / COUNT(…) >= 0.90threshold — is the float division correct when both counts are zero?HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)shuffle — does this compile and sort correctly in Postgres 16?findSegmentationQueueexcludesPLACEHOLDERstatus — does this work correctly when a document has no annotations?0orNULLwhen no activity exists in the last 7 days? (ThetoLong(null)path in the service handles null, but is that ever exercised?)These need Testcontainers (
postgres:16-alpine) integration tests with seeded data:3. The only test change in this PR is a fixture update in an unrelated spec
briefwechsel/page.svelte.spec.tshadscriptType: 'UNKNOWN'added to themakeDocfactory — this is a generated type alignment fix, not a test for any of the new feature behavior.Suggestions
4. No frontend component tests for the 4 new Svelte components
The column components have meaningful logic:
blockProgress()returns 0 whenannotationCount === 0,reviewedPct()returns 0 whentextedBlockCount === 0,formatDate()uses locale-aware formatting, and the empty state inReadyColumnis conditionally rendered. These are all testable withvitest-browser-svelte:5. Test the
Promise.allSettleddegraded dashboard pathThe load function in
+page.server.tsusesPromise.allSettled— test that the load function returns empty arrays when all four new API calls fail. This is the behavior that prevents dashboard breakage, and it should be verified with a mock API client that rejects all four calls.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
From a security standpoint, this PR is clean. I checked authorization boundaries, SQL injection surface, data exposure, and frontend security. Nothing concerning.
What I verified
Authorization — LGTM ✅
TranscriptionQueueControlleruses@RequirePermission(Permission.READ_ALL)at the class level, covering all four endpoints. ThePermissionAspectenforces this via AOP. Unauthenticated and underprivileged users cannot reach these endpoints. Consistent with the rest of the archive.SQL injection — LGTM ✅
All four native queries use parameterized bindings exclusively. The only parameter is
@Param("limit")bound to the static constantDEFAULT_QUEUE_SIZE = 5. There is zero user-controlled input flowing into the queries. TheHASHTEXTexpression usesd.id(a UUID column, not user input) and a server-side date function. No injection risk.Data exposure — LGTM ✅
The DTO surface is limited: document
id,title,documentDate, and three integer counts. No file paths, no user identities, no permission data, no internal states are exposed. The limit of 5 results per queue prevents bulk enumeration even for authenticated users.No logging of sensitive data ✅
TranscriptionQueueServicehas no@Slf4jlogger, consistent with a pure query-mapping service.Frontend security — LGTM ✅
The column components receive data through
+page.server.tsprops — no client-side fetch calls, no API routes exposed to the browser. Document links (/documents/{id},/enrich?filter=…) are internal routes.One observation (not a vulnerability)
The ReadyColumn CTA links to
/enrich?filter=NEEDS_SEGMENTATION&next=1. This route and filter name should be validated to exist — a dead link is a UX issue, not a security issue, but hardcoded internal URLs are worth reviewing whenever the enrich route changes.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
The overall visual direction is right: semantic tokens, min-h-[44px] touch targets, keyed lists, focus rings on every interactive element. One accessibility gap in the progress bar, and one usability concern about where the Segmentierung links go.
Blocker
1. Progress bar in TranscriptionColumn is not accessible
TranscriptionColumn.svelte:64-68:This is a purely visual progress bar with no accessible semantics. Screen readers will see two anonymous
<div>elements with no meaning. Fix:The progress is already communicated in text above the bar (
{texted} / {total} Blöcke), soaria-hidden="true"on the decorative bar is the right call. Without it, a screen reader reads an empty, unnamed element — confusing.High-priority concerns
2. SegmentationColumn links go to
/documents/{id}— but the Segmentierung task happens in/enrich/{id}When a user clicks a document in the Segmentierung column, they land on the document detail page, which is a reading view. The work they're being asked to do (drawing annotation boxes) happens in
/enrich/{id}. Sending them to/documents/{id}means an extra click to find the Segmentieren button.If the spec has been updated to use
/documents/{id}, that's fine, but please confirm intentionally — the PR description still says/enrich/{id}.3.
MissionControlStripis completely invisible when all three queues are emptyThe strip is wrapped in
{#if segmentationDocs.length > 0 || transcriptionDocs.length > 0 || readyDocs.length > 0}. When all queues are empty, the whole section disappears — including the "Was braucht Aufmerksamkeit?" heading. The user receives no feedback about why the section is absent.ReadyColumnalready has a lovely empty state (dashed border, CTA). Consider showing the strip always (or at least after onboarding), with each column rendering its own empty state, so users understand what the section is for.What's done well
min-h-[44px]on every link in all three columns ✅ (WCAG 2.2 touch target)focus-visible:ring-2on all interactive elements ✅<section>wrapper,<h2>for the strip heading,<h3>for column headings ✅text-ink,bg-surface,text-ink-2,text-ink-3) ✅{#each docs as doc (doc.id)}— keyed lists, no position-based reconciliation ✅Intl.DateTimeFormatand the T12:00:00 UTC offset trick ✅bg-brand-mint/10) + percentage label ✅⚙️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ⚠️ Approved with concerns
No infrastructure changes in this PR — no new services, no new Compose entries, no CI workflow changes. That's clean. My concerns are about the operational cost of the new SQL queries that run on every dashboard load.
Concerns
1. Weekly stats query — three correlated subqueries on every dashboard load
findWeeklyStats()runs three separate correlated subqueries in a single statement:On a small archive (hundreds of documents) this is fine. On a larger one,
COUNT(DISTINCT …)with a date filter will scan the annotation and transcription_blocks tables fully unless there are indexes oncreated_at/updated_at. This query fires on every dashboard load.Verify that the following indexes exist (or add them in a migration):
If they don't exist, add them before this reaches production.
2. The three queue queries each do a full-table GROUP BY with HAVING — no LIMIT pushdown
The transcription and ready-to-read queries GROUP BY all documents, compute ratios, apply HAVING, and only then LIMIT to 5:
PostgreSQL cannot push the LIMIT inside the GROUP BY — it must compute all groups before filtering. On a large archive, this means scanning all documents with annotations for every dashboard load. Worth benchmarking with
EXPLAIN ANALYZEbefore shipping. For now the archive is small so it's not a blocker, but add a comment noting the known scaling limit.What's done well
Promise.allSettledmeans dashboard load doesn't fail if all four new endpoints are down ✅DEFAULT_QUEUE_SIZE = 5means if the archive grows, database load stays bounded ✅- Add formatMCDate() to $lib/utils/date.ts (locale-aware, medium format); remove duplicated inline formatDate() from all three column components - Replace local TranscriptionQueueItemDTO/TranscriptionWeeklyStatsDTO type declarations with imports from $lib/generated/api across all four components - Add dashed empty states to SegmentationColumn and TranscriptionColumn (ReadyColumn already had one) - Remove outer {#if} from MissionControlStrip so the section is always visible — each column owns its own empty state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Review concerns addressed
All blocker and accepted suggestion concerns from the review round have been resolved. Here's what was done and which commit covers it:
Felix Brandt — Changes requested → ✅ Resolved
1. PR description stale →
ca660f1— PR description updated; removed all mentions ofneedsExpert,ExpertBadge, V36 migration, andPATCH /api/documents/{id}/needs-expert. Added note explaining the intentional/documents/{id}link choice.2. Missing
@Schema(requiredMode = REQUIRED)on DTOs →adea7d4— BothTranscriptionQueueItemDTOandTranscriptionWeeklyStatsDTOannotated;api.tsregenerated (23410aa) — required fields are now non-optional in TypeScript.3. SegmentationColumn links to
/documents/{id}→ Confirmed intentional. Added a note to the PR description explaining the decision (users confirm which document needs work before entering the annotation tool).4. No tests for new backend components →
4cf01a0(controller, 12 tests),2e4d9a8(service, 6 tests),e041c75(repository SQL integration, 6 tests).5. Duplicate local type declarations →
06eb1ca— All four Svelte files now importcomponents['schemas']['TranscriptionQueueItemDTO']from$lib/generated/api.6.
formatDate()duplicated →06eb1ca—formatMCDate(isoDate, locale)added to$lib/utils/date.ts; all three column components now import it instead of declaring their own.7. Weekly pulse text color inconsistency →
d78685c—TranscriptionColumnchanged fromtext-inktotext-ink-2(matchesSegmentationColumn).8.
reviewedPctdenominator mismatch →d78685c—ReadyColumnnow usesannotationCountas the denominator, matching the SQL>= 0.90threshold.Markus Keller — Approved with concerns → ✅ Resolved
1. Object[] positional mapping fragile →
2e4d9a8—TranscriptionQueueProjectionandTranscriptionWeeklyStatsProjectioninterfaces introduced;DocumentRepositoryreturns projections;TranscriptionQueueServicemaps via getters; four type-coercion helpers removed.2.
findWeeklyStats()returns Object[] →2e4d9a8— ReturnsTranscriptionWeeklyStatsProjectionnow.3. Verify
created_at/updated_aton join tables → Confirmed present (V10 and V18 migrations). Indexes added inadea7d4/ renamed23410aato V38.Sara Holt — Changes requested → ✅ Resolved
1. No
@WebMvcTestfor controller →4cf01a0— 12 tests: 401/403/200 for all 4 endpoints.2. No service tests →
2e4d9a8— 6 tests: delegation withDEFAULT_QUEUE_SIZE=5, projection mapping.3. No integration tests for native SQL queries →
e041c75— 6 Testcontainers tests against real PostgreSQL 16: PLACEHOLDER exclusion, annotation exclusion, below-90%/above-90% HAVING logic, zeros-on-empty-DB weekly stats.4. No frontend component tests →
ca660f1— 17 tests acrossSegmentationColumn,TranscriptionColumn,ReadyColumn,MissionControlStrip.Leonie Voss — Approved with concerns → ✅ Resolved
1. Progress bar not accessible →
d78685c—aria-hidden="true"added to decorative progress bar div.2. SegmentationColumn link target → Confirmed intentional
/documents/{id}. PR description updated.3. Strip invisible when all queues empty →
06eb1ca— Outer{#if}removed; strip always renders;SegmentationColumnandTranscriptionColumnnow each have their own dashed empty state.Tobias Wendt — Approved with concerns → ✅ Resolved
1. Missing indexes for weekly stats query →
adea7d4/23410aa(V38) —idx_document_annotations_created_at,idx_transcription_blocks_created_at,idx_transcription_blocks_updated_at.2. Full-table GROUP BY HAVING with no LIMIT pushdown → Acknowledged known scaling limit; documented in PR description. Archive scale is currently small; benchmarking deferred.
Nora "NullX" Steiner — ✅ Already approved, no action needed
Final test counts
🤖 Generated with Claude Code