feat(transcription): add sticky review progress counter to TranscriptionEditView

Shows 'X / Y geprüft' with a brand-mint progress bar at the top of the
transcription panel. Derived from the blocks prop — no extra state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-13 13:59:35 +02:00
parent 33dc4654e5
commit 73229077be
2 changed files with 109 additions and 68 deletions

View File

@@ -50,6 +50,9 @@ let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
let pendingTexts = new SvelteMap<string, string>();
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
let hasBlocks = $derived(blocks.length > 0);
let reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
let totalCount = $derived(blocks.length);
let reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
function getSaveState(blockId: string): SaveState {
return saveStates.get(blockId) ?? 'idle';
@@ -263,78 +266,92 @@ $effect(() => {
});
</script>
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
<div class="flex h-full flex-col overflow-y-auto bg-surface">
{#if hasBlocks}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col gap-3"
bind:this={listEl}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each sortedBlocks as block, i (block.id)}
{#if dropTargetIdx === i}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Sticky review progress header -->
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
<p class="font-sans text-xs text-ink-2">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p>
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
<div
data-block-wrapper
onblur={handleBlur}
onpointerdown={(e) => handleGripDown(e, block.id)}
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
>
<TranscriptionBlock
blockId={block.id}
documentId={documentId}
blockNumber={i + 1}
text={block.text}
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
saveState={getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
onTextChange={(text) => handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() => handleRetry(block.id)}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
isFirst={i === 0}
isLast={i === sortedBlocks.length - 1}
/>
</div>
{/each}
{#if dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- Next block CTA — dashed outline hint -->
<div
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
>
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
class="h-full rounded-full bg-brand-mint transition-all duration-300"
style="width: {reviewProgress}%"
></div>
</div>
{#if canRunOcr && onTriggerOcr}
<details class="mt-6">
<summary
class="cursor-pointer font-sans text-xs font-medium text-ink-3 transition-colors hover:text-brand-navy"
</div>
<div class="p-4">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col gap-3"
bind:this={listEl}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each sortedBlocks as block, i (block.id)}
{#if dropTargetIdx === i}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-block-wrapper
onblur={handleBlur}
onpointerdown={(e) => handleGripDown(e, block.id)}
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
>
{m.ocr_rerun_label()}
</summary>
<div class="mt-3 max-w-xs">
<OcrTrigger
existingBlockCount={blocks.length}
storedScriptType={storedScriptType}
onTrigger={onTriggerOcr}
<TranscriptionBlock
blockId={block.id}
documentId={documentId}
blockNumber={i + 1}
text={block.text}
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
saveState={getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
onTextChange={(text) => handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() => handleRetry(block.id)}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
isFirst={i === 0}
isLast={i === sortedBlocks.length - 1}
/>
</div>
</details>
{/if}
{/each}
{#if dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- Next block CTA — dashed outline hint -->
<div
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
>
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
</div>
{#if canRunOcr && onTriggerOcr}
<details class="mt-6">
<summary
class="cursor-pointer font-sans text-xs font-medium text-ink-3 transition-colors hover:text-brand-navy"
>
{m.ocr_rerun_label()}
</summary>
<div class="mt-3 max-w-xs">
<OcrTrigger
existingBlockCount={blocks.length}
storedScriptType={storedScriptType}
onTrigger={onTriggerOcr}
/>
</div>
</details>
{/if}
</div>
</div>
{:else}
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">

View File

@@ -13,7 +13,9 @@ const block1 = {
text: 'Block eins',
label: null,
sortOrder: 0,
version: 0
version: 0,
source: 'MANUAL' as const,
reviewed: false
};
const block2 = {
id: 'b2',
@@ -22,7 +24,9 @@ const block2 = {
text: 'Block zwei',
label: null,
sortOrder: 1,
version: 0
version: 0,
source: 'OCR' as const,
reviewed: true
};
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
@@ -232,3 +236,23 @@ describe('TranscriptionEditView — delete block', () => {
expect(onDeleteBlock).not.toHaveBeenCalled();
});
});
// ─── Review progress counter ──────────────────────────────────────────────────
describe('TranscriptionEditView — review progress counter', () => {
it('shows reviewed count and total when blocks exist', async () => {
// block1: reviewed=false, block2: reviewed=true → "1 / 2 geprüft"
renderView();
await expect.element(page.getByText(/1 \/ 2 geprüft/)).toBeInTheDocument();
});
it('shows 0 reviewed when no blocks are reviewed', async () => {
renderView({ blocks: [block1] }); // block1.reviewed = false
await expect.element(page.getByText(/0 \/ 1 geprüft/)).toBeInTheDocument();
});
it('does not show progress counter when there are no blocks', async () => {
renderView({ blocks: [] });
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
});
});