feat(themen): show the subtree rollup count on reader surfaces (#698)

The /themen page (box header, child rows, aria-labels) and the dashboard
ThemenWidget now display subtreeDocumentCount instead of the direct
documentCount, so a topic's number reflects its whole sub-topic tree and
matches what /documents?tag=X actually returns. A parent with 0 direct
documents but documents under its children now shows a non-zero total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-31 12:25:52 +02:00
parent e8377b579e
commit 1fd8c2b2d2
4 changed files with 30 additions and 12 deletions

View File

@@ -41,8 +41,8 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
{#each shownTags as tag (tag.id)} {#each shownTags as tag (tag.id)}
<a <a
href="/documents?tag={encodeURIComponent(tag.name)}" href="/documents?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0 aria-label="{tag.name}{tag.subtreeDocumentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount }) ? ', ' + m.themen_dokumente({ count: tag.subtreeDocumentCount })
: ''}" : ''}"
class="flex cursor-pointer items-stretch overflow-hidden rounded-sm border border-line bg-canvas hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none" class="flex cursor-pointer items-stretch overflow-hidden rounded-sm border border-line bg-canvas hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
style="min-height: 56px" style="min-height: 56px"
@@ -54,9 +54,9 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
></span> ></span>
<span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3"> <span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3">
<span class="truncate font-serif text-sm font-semibold text-ink">{tag.name}</span> <span class="truncate font-serif text-sm font-semibold text-ink">{tag.name}</span>
{#if tag.documentCount > 0} {#if tag.subtreeDocumentCount > 0}
<span class="font-sans text-xs text-ink-3 tabular-nums"> <span class="font-sans text-xs text-ink-3 tabular-nums">
{m.themen_dokumente({ count: tag.documentCount })} {m.themen_dokumente({ count: tag.subtreeDocumentCount })}
</span> </span>
{/if} {/if}
</span> </span>

View File

@@ -12,9 +12,10 @@ type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function makeTag( function makeTag(
name: string, name: string,
documentCount: number, documentCount: number,
children: TagTreeNodeDTO[] = [] children: TagTreeNodeDTO[] = [],
subtreeDocumentCount: number = documentCount
): TagTreeNodeDTO { ): TagTreeNodeDTO {
return { id: 'id-' + name, name, documentCount, children }; return { id: 'id-' + name, name, documentCount, subtreeDocumentCount, children };
} }
describe('ThemenWidget', () => { describe('ThemenWidget', () => {
@@ -32,6 +33,14 @@ describe('ThemenWidget', () => {
expect(document.body.textContent).not.toContain('Leer'); expect(document.body.textContent).not.toContain('Leer');
}); });
it('displays the subtree rollup count for a tag with 0 direct documents', async () => {
// 0 direct documents, but the subtree rolls up to 8 — the widget shows the rollup.
const tags = [makeTag('Reisen', 0, [], 8)];
render(ThemenWidget, { tags });
expect(document.body.textContent).toContain('Reisen');
expect(document.body.textContent).toContain('8');
});
it('shows the empty state text when all tags are filtered out', async () => { it('shows the empty state text when all tags are filtered out', async () => {
render(ThemenWidget, { tags: [makeTag('Leer', 0)] }); render(ThemenWidget, { tags: [makeTag('Leer', 0)] });
expect(document.body.textContent).toMatch(/Noch keine Themen/); expect(document.body.textContent).toMatch(/Noch keine Themen/);

View File

@@ -41,14 +41,14 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
<a <a
href="/documents?tag={encodeURIComponent(tag.name)}" href="/documents?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0 aria-label="{tag.name}{tag.subtreeDocumentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount }) ? ', ' + m.themen_dokumente({ count: tag.subtreeDocumentCount })
: ''}" : ''}"
class="flex min-h-[56px] items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" class="flex min-h-[56px] items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
> >
<span class="font-serif text-base font-semibold text-ink">{tag.name}</span> <span class="font-serif text-base font-semibold text-ink">{tag.name}</span>
<span class="mr-1 ml-auto font-sans text-sm text-ink-3 tabular-nums"> <span class="mr-1 ml-auto font-sans text-sm text-ink-3 tabular-nums">
{#if tag.documentCount > 0}{tag.documentCount}{/if} {#if tag.subtreeDocumentCount > 0}{tag.subtreeDocumentCount}{/if}
</span> </span>
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint"></span> <span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint"></span>
</a> </a>
@@ -63,7 +63,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
> >
<span class="font-sans text-sm text-ink">{child.name}</span> <span class="font-sans text-sm text-ink">{child.name}</span>
<span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums"> <span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums">
{#if child.documentCount > 0}{child.documentCount}{/if} {#if child.subtreeDocumentCount > 0}{child.subtreeDocumentCount}{/if}
</span> </span>
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint"></span> <span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint"></span>
</a> </a>

View File

@@ -12,9 +12,10 @@ type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function makeTag( function makeTag(
name: string, name: string,
documentCount: number, documentCount: number,
children: TagTreeNodeDTO[] = [] children: TagTreeNodeDTO[] = [],
subtreeDocumentCount: number = documentCount
): TagTreeNodeDTO { ): TagTreeNodeDTO {
return { id: 'id-' + name, name, documentCount, children }; return { id: 'id-' + name, name, documentCount, subtreeDocumentCount, children };
} }
describe('/themen +page', () => { describe('/themen +page', () => {
@@ -48,6 +49,14 @@ describe('/themen +page', () => {
expect(document.body.textContent).toContain('Kriegsbriefe'); expect(document.body.textContent).toContain('Kriegsbriefe');
}); });
it('shows the subtree rollup in the header for a parent with 0 direct documents', async () => {
// AC#5: 0 direct docs, but the subtree rolls up to 7 (child contributes 3).
const tree = [makeTag('Reisen', 0, [makeTag('Italien', 3)], 7)];
render(ThemenPage, { data: { tree } });
expect(document.body.textContent).toContain('Reisen');
expect(document.body.textContent).toContain('7');
});
it('shows "+ N weitere" when a root tag has more than 5 children', async () => { it('shows "+ N weitere" when a root tag has more than 5 children', async () => {
const children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1)); const children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1));
const tree = [makeTag('Briefe', 10, children)]; const tree = [makeTag('Briefe', 10, children)];