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:
54
frontend/src/lib/components/DistributionBar.svelte
Normal file
54
frontend/src/lib/components/DistributionBar.svelte
Normal 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>
|
||||||
58
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal file
58
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal 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%');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user