feat(documents): rebalance list row — summary + archive chips, restored sender/receiver
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m43s
CI / OCR Service Tests (push) Successful in 33s
CI / Backend Unit Tests (push) Failing after 2m57s

Refill the columns that went visually empty after the previous dedup
commit (`fc0fc57`):

- Middle column gains the document `summary` (line-clamp-2, italic,
  with `summaryOffsets` highlighting — the backend already populates
  the offsets, the frontend just wasn't rendering them) and a row of
  thin neutral chips for `archiveBox`, `archiveFolder`, and `location`
  (~99% of docs in the corpus carry these). Chips are desktop-only
  and skip empty values.
- Right column restores `VON sender` and `AN receivers`, now with
  `<mark>` highlighting that the previous right-column copy lacked,
  so search matches stay visible there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-23 08:44:49 +02:00
parent fc0fc57409
commit a7efb0044c
2 changed files with 150 additions and 43 deletions

View File

@@ -20,6 +20,15 @@ const snippet = $derived(item.matchData?.transcriptionSnippet ?? null);
const snippetSegments = $derived(
snippet ? applyOffsets(snippet, item.matchData?.snippetOffsets ?? []) : null
);
const summary = $derived(doc.summary?.trim() ? doc.summary : null);
const summarySegments = $derived(
summary ? applyOffsets(summary, item.matchData?.summaryOffsets ?? []) : null
);
const archiveChips = $derived(
[doc.archiveBox, doc.archiveFolder, doc.location].filter(
(c): c is string => !!c && c.trim().length > 0
)
);
const senderMatched = $derived(item.matchData?.senderMatched ?? false);
const matchedReceiverIds = $derived(new Set(item.matchData?.matchedReceiverIds ?? []));
const matchedTagIds = $derived(new Set(item.matchData?.matchedTagIds ?? []));
@@ -77,46 +86,36 @@ function safeTagColor(color: string | null | undefined): string {
</p>
{/if}
<!-- Sender / receivers — desktop only, stacked -->
<div class="mt-2 mb-2 hidden flex-col gap-1 font-sans text-xs text-ink-2 sm:flex">
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
<span class="ml-1">
{#if doc.sender}
{#if senderMatched}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{doc.sender.displayName}</mark
>
{:else}
{doc.sender.displayName}
{/if}
<!-- Summary excerpt — only when populated -->
{#if summarySegments}
<p
data-testid="doc-summary"
class="mt-1 mb-2 line-clamp-2 font-serif text-sm text-ink-2 italic"
>
{#each summarySegments as seg, i (i)}
{#if seg.highlight}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{seg.text}
{/if}
</span>
{/each}
</p>
{/if}
<!-- Archive metadata chips — desktop only -->
{#if archiveChips.length > 0}
<div class="mt-2 hidden flex-wrap items-center gap-1.5 sm:flex">
{#each archiveChips as chip, i (i)}
<span
class="rounded border border-line px-1.5 py-0.5 font-sans text-[10px] tracking-widest text-ink-3 uppercase"
>{chip}</span
>
{/each}
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
<span class="ml-1">
{#if doc.receivers && doc.receivers.length > 0}
{#each doc.receivers as receiver, i (receiver.id)}
{#if i > 0}<span>, </span>{/if}
{#if matchedReceiverIds.has(receiver.id)}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{receiver.displayName}</mark
>
{:else}
{receiver.displayName}
{/if}
{/each}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
</div>
{/if}
<!-- Tags -->
{#if doc.tags && doc.tags.length > 0}
@@ -158,6 +157,43 @@ function safeTagColor(color: string | null | undefined): string {
<div>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
<span class="ml-1">
{#if doc.sender}
{#if senderMatched}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{doc.sender.displayName}</mark
>
{:else}
{doc.sender.displayName}
{/if}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
<span class="ml-1">
{#if doc.receivers && doc.receivers.length > 0}
{#each doc.receivers as receiver, i (receiver.id)}
{#if i > 0}<span>, </span>{/if}
{#if matchedReceiverIds.has(receiver.id)}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{receiver.displayName}</mark
>
{:else}
{receiver.displayName}
{/if}
{/each}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
<div class="flex items-start gap-2">
<ProgressRing percentage={item.completionPercentage} />
<div class="flex h-9 items-center">

View File

@@ -118,28 +118,99 @@ describe('DocumentRow sender', () => {
await expect.element(unknownElements.first()).toBeInTheDocument();
});
it('renders the sender display name only once across the row', async () => {
it('highlights the sender when senderMatched is true', async () => {
const item = makeItem({
document: {
...makeItem().document,
sender: { id: 's1', displayName: 'Großmutter Maria' }
},
matchData: {
...makeItem().matchData,
senderMatched: true
}
});
render(DocumentRow, { item });
const matches = await page.getByText('Großmutter Maria').all();
expect(matches.length).toBe(1);
const mark = page.getByRole('mark').first();
await expect.element(mark).toHaveTextContent('Großmutter Maria');
});
it('renders each receiver display name only once across the row', async () => {
it('highlights a receiver when matchedReceiverIds includes its id', async () => {
const item = makeItem({
document: {
...makeItem().document,
receivers: [{ id: 'r1', displayName: 'Onkel Karl' }]
},
matchData: {
...makeItem().matchData,
matchedReceiverIds: ['r1']
}
});
render(DocumentRow, { item });
const matches = await page.getByText('Onkel Karl').all();
expect(matches.length).toBe(1);
const mark = page.getByRole('mark').first();
await expect.element(mark).toHaveTextContent('Onkel Karl');
});
});
// ─── Summary ─────────────────────────────────────────────────────────────────
describe('DocumentRow summary', () => {
it('renders the document summary when present', async () => {
const item = makeItem({
document: {
...makeItem().document,
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
}
});
render(DocumentRow, { item });
await expect
.element(page.getByTestId('doc-summary'))
.toHaveTextContent('Brief von Eugenie über die Heimreise aus dem Süden.');
});
it('does not render the summary block when summary is empty', async () => {
render(DocumentRow, { item: makeItem() });
await expect.element(page.getByTestId('doc-summary')).not.toBeInTheDocument();
});
it('applies summary search-match highlight via summaryOffsets', async () => {
const item = makeItem({
document: { ...makeItem().document, summary: 'Brief über Menton' },
matchData: {
...makeItem().matchData,
summaryOffsets: [{ start: 11, length: 6 }]
}
});
render(DocumentRow, { item });
const mark = page.getByRole('mark').first();
await expect.element(mark).toHaveTextContent('Menton');
});
});
// ─── Archive chips ───────────────────────────────────────────────────────────
describe('DocumentRow archive chips', () => {
it('renders the archive box chip when set', async () => {
const item = makeItem({
document: { ...makeItem().document, archiveBox: 'K3' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('K3')).toBeInTheDocument();
});
it('renders the archive folder chip when set', async () => {
const item = makeItem({
document: { ...makeItem().document, archiveFolder: 'Mappe A' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
});
it('renders the location chip when meta_location is set', async () => {
const item = makeItem({
document: { ...makeItem().document, location: 'Berlin' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
});