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:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user