refactor(briefwechsel): extract bilateral DistributionBar component

Lifts the inline distribution bar out of ConversationTimeline so the
same two-tone ratio widget can be reused on other bilateral surfaces
(e.g. the person detail page). Markup/styling is byte-identical to
the inline version; only the prop interface is new.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-23 14:32:04 +02:00
parent ce1d118882
commit bc337fb445
2 changed files with 112 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
<script lang="ts">
interface Props {
outCount: number;
inCount: number;
senderName: string;
receiverName: string;
}
let { outCount, inCount, senderName, receiverName }: Props = $props();
const total = $derived(outCount + inCount);
const outPct = $derived(total > 0 ? (outCount / total) * 100 : 0);
const shortSenderName = $derived(senderName.split(' ')[0] ?? senderName);
const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
</script>
<div
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
role="img"
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}"
>
<div class="flex justify-between text-sm font-bold">
<span class="inline-flex items-center gap-1 text-primary"
>{outCount} von {shortSenderName}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
<span class="inline-flex items-center gap-1 text-accent"
>{inCount} von {shortReceiverName}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
</div>
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
<div
data-testid="dist-bar-segment"
class="h-full bg-primary transition-all"
style="width: {outPct}%"
></div>
<div
data-testid="dist-bar-segment"
class="h-full bg-accent transition-all"
style="width: {100 - outPct}%"
></div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import DistributionBar from './DistributionBar.svelte';
afterEach(() => {
cleanup();
});
describe('DistributionBar', () => {
it('renders both counts and short names, and the two-tone fill bar', async () => {
render(DistributionBar, {
outCount: 3,
inCount: 7,
senderName: 'Hans Müller',
receiverName: 'Anna Schmidt'
});
const container = document.querySelector('[role="img"]') as HTMLElement;
expect(container).toBeTruthy();
expect(container.getAttribute('aria-label')).toContain('3 von Hans Müller');
expect(container.getAttribute('aria-label')).toContain('7 von Anna Schmidt');
expect(container.textContent).toContain('3 von Hans');
expect(container.textContent).toContain('7 von Anna');
// 3/10 → 30% / 70% split on the two segments
const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]');
expect(segments).toHaveLength(2);
expect((segments[0] as HTMLElement).style.width).toBe('30%');
expect((segments[1] as HTMLElement).style.width).toBe('70%');
});
it('falls back to the full name when it has no space to split', async () => {
render(DistributionBar, {
outCount: 1,
inCount: 0,
senderName: 'SingleWord',
receiverName: 'Another'
});
const container = document.querySelector('[role="img"]') as HTMLElement;
expect(container.textContent).toContain('1 von SingleWord');
});
it('renders a zero-percent left segment when outCount is zero', async () => {
render(DistributionBar, {
outCount: 0,
inCount: 4,
senderName: 'Hans',
receiverName: 'Anna'
});
const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]');
expect((segments[0] as HTMLElement).style.width).toBe('0%');
expect((segments[1] as HTMLElement).style.width).toBe('100%');
});
});