feat(members): implement /members page — Kachel-Ansicht (E2, issue #48)

Backend:
- Rename V006 migration to V026 (avoid conflict with existing V006)
- Migration adds invalidated_at + partial unique index on household_invite

Frontend:
- Toast.svelte — new system component (message + dismiss)
- SegmentedControl.svelte — new system component (options, value, onchange)
- members/+page.server.ts — loads members + active invite
- members/[userId]/+server.ts — DELETE/PATCH proxy
- members/invites/+server.ts — POST (regenerate) proxy
- MemberCard.svelte — tile with avatar, kebab, inline role edit
- RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile)
- InviteCard.svelte + InvitePanel.svelte — invite management UI
- MemberGrid.svelte — responsive 4/2-col grid with sorted members
- members/+page.svelte — page composing all components with optimistic updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 19:01:08 +02:00
committed by marcel
parent 6aef12fa3c
commit 9ccd367d74
21 changed files with 1170 additions and 1 deletions

View File

@@ -0,0 +1,50 @@
<script lang="ts">
let {
options,
value,
onchange
}: {
options: { value: string; label: string }[];
value: string;
onchange: (value: string) => void;
} = $props();
</script>
<div role="group" class="segmented-control">
{#each options as option (option.value)}
<button
type="button"
aria-pressed={option.value === value ? 'true' : 'false'}
class="segment"
class:active={option.value === value}
onclick={() => onchange(option.value)}
>
{option.label}
</button>
{/each}
</div>
<style>
.segmented-control {
display: flex;
background: var(--color-surface-raised);
border-radius: var(--radius-md);
padding: 2px;
}
.segment {
border: none;
cursor: pointer;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
border-radius: var(--radius-sm);
color: inherit;
}
.segment.active {
background: var(--color-page);
}
</style>

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SegmentedControl from './SegmentedControl.svelte';
const options = [
{ value: 'planner', label: 'Planer' },
{ value: 'member', label: 'Mitglied' }
];
describe('SegmentedControl', () => {
it('renders all option labels', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
});
it('marks the active option with aria-pressed', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
});
it('calls onchange with the new value when an option is clicked', async () => {
const onchange = vi.fn();
render(SegmentedControl, { props: { options, value: 'planner', onchange } });
await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
expect(onchange).toHaveBeenCalledWith('member');
});
});

View File

@@ -0,0 +1,31 @@
<script lang="ts">
const { message, visible, ondismiss }: {
message: string;
visible: boolean;
ondismiss?: () => void;
} = $props();
</script>
{#if visible}
<div
role="status"
style="
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-overlay);
border-radius: var(--radius-lg);
color: var(--color-text);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
"
>
<span>{message}</span>
<button aria-label="Schließen" onclick={ondismiss}>✕</button>
</div>
{/if}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import Toast from './Toast.svelte';
describe('Toast', () => {
it('is not mounted when visible is false', () => {
render(Toast, { props: { message: 'Hallo', visible: false } });
expect(screen.queryByRole('status')).toBeNull();
});
it('shows the message when visible is true', () => {
render(Toast, { props: { message: 'Gespeichert', visible: true } });
expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
});
it('calls ondismiss when close button is clicked', async () => {
const ondismiss = vi.fn();
render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
expect(ondismiss).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,17 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const api = apiClient(fetch);
const [membersRes, inviteRes] = await Promise.all([
api.GET('/v1/households/mine/members'),
api.GET('/v1/households/mine/invites')
]);
return {
members: membersRes.data ?? [],
currentUserId: locals.benutzer!.id,
activeInvite: inviteRes.data?.data ?? null
};
};

View File

@@ -1 +1,97 @@
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>
<script lang="ts">
import { untrack } from 'svelte';
import MemberGrid from './MemberGrid.svelte';
import InvitePanel from './InvitePanel.svelte';
import RemoveDialog from './RemoveDialog.svelte';
import Toast from '$lib/components/Toast.svelte';
let { data } = $props();
let members = $state(untrack(() => data.members as { userId: string; displayName: string; role: string; joinedAt: string }[]));
let activeInvite = $state(untrack(() => data.activeInvite as { inviteCode: string; shareUrl: string; expiresAt: string } | null));
let showInvitePanel = $state(false);
let removeTarget: { userId: string; displayName: string; role: string; joinedAt: string } | null = $state(null);
let toastMessage = $state('');
let toastVisible = $state(false);
const currentUserRole = $derived(members.find((m) => m.userId === data.currentUserId)?.role ?? 'member');
const isPlanner = $derived(currentUserRole === 'planner');
function showToast(message: string) {
toastMessage = message;
toastVisible = true;
}
async function handleRoleChange(
member: { userId: string; displayName: string; role: string; joinedAt: string },
newRole: string
) {
const original = members.slice();
members = members.map((m) => (m.userId === member.userId ? { ...m, role: newRole } : m));
const res = await fetch('/members/' + member.userId, {
method: 'PATCH',
body: JSON.stringify({ role: newRole })
});
if (!res.ok) {
members = original;
showToast('Fehler beim Ändern der Rolle');
}
}
function handleRemove(member: { userId: string; displayName: string; role: string; joinedAt: string }) {
removeTarget = member;
}
async function handleConfirmRemove() {
if (!removeTarget) return;
const target = removeTarget;
const original = members.slice();
removeTarget = null;
members = members.filter((m) => m.userId !== target.userId);
const res = await fetch('/members/' + target.userId, { method: 'DELETE' });
if (!res.ok) {
members = original;
showToast('Fehler beim Entfernen des Mitglieds');
}
}
async function handleRegenerate() {
const res = await fetch('/members/invites', { method: 'POST' });
if (res.ok) {
activeInvite = await res.json();
}
}
</script>
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
<main>
<h1>Mitglieder</h1>
<MemberGrid
{members}
currentUserId={data.currentUserId}
{isPlanner}
showInviteCard={isPlanner}
onremove={handleRemove}
onrolechange={handleRoleChange}
oninviteclick={() => (showInvitePanel = !showInvitePanel)}
/>
{#if showInvitePanel && isPlanner && activeInvite}
<InvitePanel invite={activeInvite} onregenerate={handleRegenerate} />
{/if}
<RemoveDialog
show={removeTarget !== null}
member={removeTarget ?? { userId: '', displayName: '', role: '', joinedAt: '' }}
onconfirm={handleConfirmRemove}
oncancel={() => (removeTarget = null)}
/>
<Toast message={toastMessage} visible={toastVisible} ondismiss={() => (toastVisible = false)} />
</main>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
let {
onclick
}: {
onclick: () => void;
} = $props();
</script>
<button
type="button"
data-testid="invite-card"
{onclick}
style="background: var(--color-surface); border-radius: var(--radius-xl); border: 2px dashed var(--color-border); padding: 20px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; width: 100%;"
>
<span style="font-size: 2rem; line-height: 1;" aria-hidden="true">+</span>
<span>Einladen</span>
</button>

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import InviteCard from './InviteCard.svelte';
describe('InviteCard', () => {
it('renders the invite tile', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('shows a descriptive label', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByText(/einladen/i)).toBeInTheDocument();
});
it('calls onclick when tile is clicked', async () => {
const onclick = vi.fn();
render(InviteCard, { props: { onclick } });
await userEvent.click(screen.getByTestId('invite-card'));
expect(onclick).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,50 @@
<script lang="ts">
let {
invite,
onregenerate
}: {
invite: { inviteCode: string; shareUrl: string; expiresAt: string };
onregenerate: () => void;
} = $props();
let copied = $state(false);
function copy() {
if (navigator.clipboard) {
navigator.clipboard.writeText(invite.shareUrl);
}
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
function formatExpiry(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
</script>
<div
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 20px; margin-top: 16px; border: 1px solid var(--color-border);"
>
<p
style="font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0 0 12px;"
>
{invite.shareUrl || invite.inviteCode}
</p>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;">
<button type="button" data-testid="copy-btn" onclick={copy}>
{copied ? 'Kopiert ✓' : 'Kopieren'}
</button>
<button type="button" data-testid="regenerate-btn" onclick={onregenerate}>
Neu generieren
</button>
</div>
<p style="margin: 0; font-size: 0.875rem;">
Läuft ab: {formatExpiry(invite.expiresAt)}
</p>
</div>

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import InvitePanel from './InvitePanel.svelte';
const invite = {
inviteCode: 'ABC123XY',
shareUrl: 'https://example.com/join/ABC123XY',
expiresAt: '2026-12-01T00:00:00Z'
};
describe('InvitePanel', () => {
it('shows the invite URL', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
});
it('has a copy button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('has a regenerate button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
});
it('calls onregenerate when regenerate button is clicked', async () => {
const onregenerate = vi.fn();
render(InvitePanel, { props: { invite, onregenerate } });
await userEvent.click(screen.getByTestId('regenerate-btn'));
expect(onregenerate).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { browser } from '$app/environment';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
member,
isCurrentUser,
isPlanner,
onremove,
onrolechange
}: {
member: Member;
isCurrentUser: boolean;
isPlanner: boolean;
onremove: (member: Member) => void;
onrolechange: (member: Member, newRole: string) => void;
} = $props();
let menuOpen = $state(false);
let editingRole = $state(false);
const roleOptions = [
{ value: 'planner', label: 'Planer' },
{ value: 'member', label: 'Mitglied' }
];
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
let cardEl: HTMLElement | undefined = $state(undefined);
$effect(() => {
if (!browser || !menuOpen) return;
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
menuOpen = false;
}
}
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
});
$effect(() => {
if (!browser || !menuOpen) return;
function onClickAway(e: MouseEvent) {
if (cardEl && !cardEl.contains(e.target as Node)) {
menuOpen = false;
}
}
document.addEventListener('click', onClickAway);
return () => document.removeEventListener('click', onClickAway);
});
</script>
<article
bind:this={cardEl}
data-testid="member-card"
style="
background: var(--color-surface);
border-radius: var(--radius-xl);
padding: 20px;
position: relative;
{isCurrentUser ? 'border: 2px solid var(--color-success);' : ''}
"
class="member-card"
>
<!-- Avatar -->
<div
style="
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-success-dark);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
margin-bottom: 12px;
"
>
{initials}
</div>
<!-- Name row -->
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;">
<span style="font-weight: 500;">{member.displayName}</span>
{#if isCurrentUser}
<span
style="
background: var(--color-success-tint);
color: var(--color-success-dark);
border-radius: 999px;
font-size: 11px;
padding: 2px 8px;
"
>Du</span>
{/if}
</div>
<!-- Role badge or SegmentedControl -->
{#if editingRole}
<SegmentedControl
options={roleOptions}
value={member.role}
onchange={(newValue) => {
onrolechange(member, newValue);
editingRole = false;
}}
/>
{:else}
<span style="font-size: 13px; color: var(--color-text-muted);">
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
</span>
{/if}
<!-- Kebab button -->
{#if isPlanner && !isCurrentUser}
<button
data-testid="kebab-btn"
type="button"
class="kebab-btn"
onclick={() => { menuOpen = true; }}
aria-label="Optionen"
style="
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
line-height: 1;
font-size: 18px;
color: var(--color-text-muted);
"
></button>
<!-- Dropdown -->
{#if menuOpen}
<div
style="
position: absolute;
top: 40px;
right: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
z-index: 10;
min-width: 140px;
overflow: hidden;
"
>
<button
type="button"
style="
display: block;
width: 100%;
padding: 10px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
"
onclick={() => { menuOpen = false; editingRole = true; }}
>Rolle ändern</button>
<button
type="button"
style="
display: block;
width: 100%;
padding: 10px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--color-danger, #e53e3e);
"
onclick={() => { menuOpen = false; onremove(member); }}
>Entfernen</button>
</div>
{/if}
{/if}
</article>
<style>
.kebab-btn {
opacity: 0;
}
@media (hover: none) {
.kebab-btn {
opacity: 1;
}
}
.member-card:hover .kebab-btn {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import MemberCard from './MemberCard.svelte';
const plannerMember = {
userId: 'u1',
displayName: 'Sarah',
role: 'planner',
joinedAt: '2024-01-01T00:00:00Z'
};
const regularMember = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('MemberCard', () => {
it('shows the member display name', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
});
it('shows "Du"-badge when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
it('does not show kebab button when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('does not show kebab button when viewer is not a planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('shows kebab button for other members when viewer is planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
});
it('opens dropdown when kebab is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
expect(screen.getByText('Entfernen')).toBeInTheDocument();
});
it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
const onremove = vi.fn();
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove,
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Entfernen'));
expect(onremove).toHaveBeenCalledWith(regularMember);
});
it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByRole('group')).toBeInTheDocument();
});
it('closes dropdown on Escape key', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Entfernen')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByText('Entfernen')).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import MemberCard from './MemberCard.svelte';
import InviteCard from './InviteCard.svelte';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
members,
currentUserId,
isPlanner,
showInviteCard,
onremove,
onrolechange,
oninviteclick
}: {
members: Member[];
currentUserId: string;
isPlanner: boolean;
showInviteCard: boolean;
onremove: (member: any) => void;
onrolechange: (member: any, newRole: string) => void;
oninviteclick: () => void;
} = $props();
const sortedMembers = $derived(
[...members].sort((a, b) => {
if (a.userId === currentUserId) return -1;
if (b.userId === currentUserId) return 1;
return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime();
})
);
</script>
<div class="member-grid">
{#each sortedMembers as m (m.userId)}
<MemberCard
member={m}
isCurrentUser={m.userId === currentUserId}
{isPlanner}
{onremove}
onrolechange={(m, role) => onrolechange(m, role)}
/>
{/each}
{#if isPlanner && showInviteCard}
<InviteCard onclick={oninviteclick} />
{/if}
</div>
<style>
.member-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
.member-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import MemberGrid from './MemberGrid.svelte';
const members = [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
{ userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
];
describe('MemberGrid', () => {
it('renders all member cards', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
expect(screen.getByText('Tom')).toBeInTheDocument();
expect(screen.getByText('Anna')).toBeInTheDocument();
});
it('shows invite card when showInviteCard is true and isPlanner is true', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('hides invite card when isPlanner is false', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u2',
isPlanner: false,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.queryByTestId('invite-card')).toBeNull();
});
it('shows "Du"-badge on the current user card', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: false,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import BottomSheet from '$lib/components/BottomSheet.svelte';
let {
show,
member,
onconfirm,
oncancel
}: {
show: boolean;
member: { userId: string; displayName: string; role: string; joinedAt: string };
onconfirm: () => void;
oncancel: () => void;
} = $props();
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768;
</script>
{#if show}
{#if isMobile()}
<BottomSheet open={show} onclose={oncancel}>
<div data-testid="remove-dialog" style="padding: 24px;">
<h2 style="margin: 0 0 12px;">Mitglied entfernen</h2>
<p>
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
</p>
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
<button
type="button"
onclick={oncancel}
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</BottomSheet>
{:else}
<div
data-testid="dialog-backdrop"
role="presentation"
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,0.4); display: flex; align-items: center; justify-content: center;"
>
<div
data-testid="remove-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="remove-dialog-title"
tabindex="-1"
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 24px; max-width: 400px; width: 90%;"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 id="remove-dialog-title" style="margin: 0 0 12px;">Mitglied entfernen</h2>
<p>
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
</p>
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
<button
type="button"
onclick={oncancel}
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import RemoveDialog from './RemoveDialog.svelte';
const member = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('RemoveDialog', () => {
it('is not rendered when show is false', () => {
render(RemoveDialog, {
props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.queryByTestId('remove-dialog')).toBeNull();
});
it('shows the member displayName in dialog', () => {
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
expect(screen.getByText(/Tom/)).toBeInTheDocument();
});
it('calls onconfirm when confirm button is clicked', async () => {
const onconfirm = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm, oncancel: vi.fn() }
});
await userEvent.click(screen.getByTestId('confirm-remove-btn'));
expect(onconfirm).toHaveBeenCalledOnce();
});
it('calls oncancel when cancel button is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
it('does NOT call oncancel when backdrop is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
const backdrop = screen.getByTestId('dialog-backdrop');
await userEvent.click(backdrop);
expect(oncancel).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const DELETE: RequestHandler = async ({ fetch, params }) => {
const api = apiClient(fetch);
const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } }
});
return new Response(null, { status: response?.status ?? 204 });
};
export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
const body = await request.json();
const api = apiClient(fetch);
const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } },
body
});
return json(data, { status: response?.status ?? 200 });
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
const mockDelete = vi.fn();
const mockPatch = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
}));
const USER_UUID = '22222222-2222-2222-2222-222222222222';
describe('members server routes', () => {
let DELETE: any;
let PATCH: any;
beforeEach(async () => {
mockDelete.mockReset();
mockPatch.mockReset();
vi.resetModules();
const mod = await import('./+server');
DELETE = mod.DELETE;
PATCH = mod.PATCH;
});
it('DELETE proxies to backend and returns 204', async () => {
mockDelete.mockResolvedValue({ response: { status: 204 } });
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: vi.fn() }
} as any;
const res = await DELETE(event);
expect(res.status).toBe(204);
});
it('PATCH proxies to backend and returns member response', async () => {
mockPatch.mockResolvedValue({
data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
response: { status: 200 }
});
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: async () => ({ role: 'planner' }) }
} as any;
const res = await PATCH(event);
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const POST: RequestHandler = async ({ fetch }) => {
const api = apiClient(fetch);
const { data, response } = await api.POST('/v1/households/mine/invites');
return json(data, { status: response?.status ?? 201 });
};

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/server/api', () => ({
apiClient: vi.fn(() => ({
GET: vi.fn()
}))
}));
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
describe('members page.server load', () => {
let load: any;
beforeEach(async () => {
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('returns members and currentUserId', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') {
return {
data: [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
]
};
}
if (path === '/v1/households/mine/invites') {
return {
data: {
data: {
inviteCode: 'ABC123',
shareUrl: 'https://x.com/join/ABC123',
expiresAt: '2024-12-01T00:00:00Z'
}
}
};
}
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.members).toHaveLength(2);
expect(result.currentUserId).toBe('u1');
expect(result.activeInvite).toBeDefined();
});
it('returns null activeInvite when no active invite exists', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') return { data: [] };
if (path === '/v1/households/mine/invites') return { data: null };
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.activeInvite).toBeNull();
});
});