feat(settings): implement /settings hub page (E1) — Kachel-Ansicht #55

Merged
marcel merged 16 commits from feat/issue-49-settings-kachel-hub into master 2026-04-10 17:39:42 +02:00
20 changed files with 446 additions and 18 deletions

View File

@@ -11,6 +11,7 @@
--color-surface: #f5f4ee;
--color-subtle: #edecea;
--color-border: #d8d7d0;
--color-border-hover: #c0bfb8;
--color-text: #1c1c18;
--color-text-muted: #6b6a63;

View File

@@ -0,0 +1,29 @@
<script lang="ts">
interface Props {
title: string;
href: string;
cta: string;
meta?: string;
}
let { title, href, cta, meta }: Props = $props();
</script>
<a
{href}
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
>
<span class="font-[var(--font-sans)] text-[16px] font-medium text-[var(--color-text)]">
{title}
</span>
{#if meta}
<p data-testid="card-meta" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
{meta}
</p>
{/if}
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
{cta}
</span>
</a>

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import SettingsCard from './SettingsCard.svelte';
describe('SettingsCard', () => {
it('renders the title', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
expect(screen.getByText('Vorräte')).toBeInTheDocument();
});
it('renders as an anchor tag with the given href', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →' } });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/household/staples');
});
it('renders the cta text', () => {
render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } });
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
});
it('renders meta text when provided', () => {
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →', meta: '3 Mitglieder' } });
expect(screen.getByText('3 Mitglieder')).toBeInTheDocument();
});
it('does not render meta element when meta is not provided', () => {
render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →' } });
expect(screen.queryByTestId('card-meta')).not.toBeInTheDocument();
});
});

View File

@@ -31,7 +31,7 @@ describe('AppShell', () => {
it('renders all navigation links from all nav variants', () => {
render(AppShell, { props: defaultProps });
const links = screen.getAllByRole('link');
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
expect(links).toHaveLength(13);
// Mobile: 4, Tablet: 4, Desktop: 4 = 12 total
expect(links).toHaveLength(12);
});
});

View File

@@ -24,7 +24,7 @@
{section.title}
</p>
{#each section.items as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}

View File

@@ -28,17 +28,17 @@ describe('DesktopSidebar', () => {
expect(screen.getByText('Einkauf')).toBeInTheDocument();
});
it('renders Household section with 2 items', () => {
it('renders Household section with Einstellungen', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
expect(screen.getByText('Haushalt')).toBeInTheDocument();
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
expect(screen.queryByText('Mitglieder')).not.toBeInTheDocument();
});
it('has 5 navigation links total', () => {
it('has 4 navigation links total', () => {
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
expect(links).toHaveLength(4);
});
it('marks active item with aria-current="page"', () => {
@@ -59,3 +59,18 @@ describe('DesktopSidebar', () => {
expect(widget).toBeInTheDocument();
});
});
describe('DesktopSidebar — extraPaths active state', () => {
it('marks Einstellungen active when on /household/staples', async () => {
const { readable } = await import('svelte/store');
vi.doMock('$app/stores', () => ({
page: readable({ url: new URL('http://localhost/household/staples') })
}));
vi.resetModules();
const { render: r, screen: s } = await import('@testing-library/svelte');
const { default: Sidebar } = await import('./DesktopSidebar.svelte');
r(Sidebar, { props: { appName: 'Test', householdName: 'Test' } });
const link = s.getByRole('link', { name: /einstellungen/i });
expect(link).toHaveAttribute('aria-current', 'page');
});
});

View File

@@ -8,7 +8,7 @@
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
>
{#each mobileNavItems as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
{@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}

View File

@@ -53,3 +53,18 @@ describe('MobileTabBar', () => {
expect(recipesLink).not.toHaveAttribute('aria-current');
});
});
describe('MobileTabBar — extraPaths active state', () => {
it('marks Einstellungen active when on /household/staples', async () => {
const { readable } = await import('svelte/store');
vi.doMock('$app/stores', () => ({
page: readable({ url: new URL('http://localhost/household/staples') })
}));
vi.resetModules();
const { render: r, screen: s } = await import('@testing-library/svelte');
const { default: TabBar } = await import('./MobileTabBar.svelte');
r(TabBar);
const link = s.getByRole('link', { name: /einstellungen/i });
expect(link).toHaveAttribute('aria-current', 'page');
});
});

View File

@@ -34,9 +34,9 @@ describe('nav config', () => {
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
});
it('Household section has Members, Settings', () => {
it('Household section has Settings', () => {
const labels = desktopNavSections[1].items.map((item) => item.label);
expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
expect(labels).toEqual(['Einstellungen']);
});
});
@@ -56,5 +56,35 @@ describe('nav config', () => {
it('does not match unrelated route', () => {
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
});
it('matches when pathname is in extraPaths', () => {
expect(isActiveRoute('/settings', '/household/staples', ['/household/staples'])).toBe(true);
});
it('matches sub-route of extraPath', () => {
expect(isActiveRoute('/settings', '/household/staples/edit', ['/household/staples'])).toBe(true);
});
it('does not match extraPath with similar prefix', () => {
expect(isActiveRoute('/settings', '/household/staples-old', ['/household/staples'])).toBe(false);
});
it('returns false when extraPaths provided but no match', () => {
expect(isActiveRoute('/settings', '/members', ['/household/staples'])).toBe(false);
});
});
describe('NavItem extraPaths', () => {
it('Einstellungen desktop nav item includes /household/staples in extraPaths', () => {
const einstellungen = desktopNavSections
.flatMap((s) => s.items)
.find((i) => i.href === '/settings');
expect(einstellungen?.extraPaths).toContain('/household/staples');
});
it('Einstellungen mobile nav item includes /household/staples in extraPaths', () => {
const einstellungen = mobileNavItems.find((i) => i.href === '/settings');
expect(einstellungen?.extraPaths).toContain('/household/staples');
});
});
});

View File

@@ -2,6 +2,7 @@ export interface NavItem {
href: string;
label: string;
icon: string;
extraPaths?: string[];
}
export interface NavSection {
@@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [
{ href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
];
export function isActiveRoute(href: string, pathname: string): boolean {
return pathname === href || pathname.startsWith(href + '/');
export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean {
if (pathname === href || pathname.startsWith(href + '/')) return true;
if (extraPaths) {
return extraPaths.some((p) => pathname === p || pathname.startsWith(p + '/'));
}
return false;
}
export const desktopNavSections: NavSection[] = [
@@ -32,8 +37,7 @@ export const desktopNavSections: NavSection[] = [
{
title: 'Haushalt',
items: [
{ href: '/members', label: 'Mitglieder', icon: '👥' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
]
}
];

View File

@@ -14,7 +14,7 @@
</svelte:head>
{#if isOnboarding}
<div class="flex min-h-screen bg-[var(--color-page)]">
<div class="fixed inset-0 z-50 flex bg-[var(--color-page)]">
<!-- Desktop sidebar -->
<aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]">
<ProgressSidebar currentStep={2} />
@@ -44,8 +44,10 @@
</main>
</div>
{:else}
<div class="flex min-h-screen flex-col bg-[var(--color-page)]">
<div class="p-[16px_20px] md:p-[40px_56px]">
<a href="/settings" class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] mb-4 inline-block">← Einstellungen</a>
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
<StaplesManager categories={data.categories} context="settings" />
<p class="mt-4 font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.</p>
</div>
{/if}

View File

@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
});
it('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
expect(backLink).toBeInTheDocument();
});
it('back-link points to /settings', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
const backLink = screen.getByRole('link', { name: /← einstellungen/i });
expect(backLink).toHaveAttribute('href', '/settings');
});
it('renders hint text about autosave', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument();
});
it('renders hint text about next shopping list', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument();
});
it('does not render back-link in onboarding context', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument();
});
it('does not render hint text in onboarding context', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const api = apiClient(fetch);
const [ingredientsRes, householdRes] = await Promise.all([
api.GET('/v1/ingredients'),
api.GET('/v1/households/mine')
]);
const stapleCount = ingredientsRes.data?.filter((i) => i.isStaple).length ?? 0;
const memberCount = householdRes.data?.data?.members?.length ?? 0;
return {
stapleCount,
memberCount,
// hooks.server.ts guarantees benutzer is set for all (app) routes
userName: locals.benutzer!.name
};
};

View File

@@ -1 +1,72 @@
<h1 class="text-2xl font-medium p-6">Einstellungen</h1>
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte';
interface Props {
data: {
stapleCount: number;
memberCount: number;
userName: string;
};
}
let { data }: Props = $props();
</script>
<div class="p-[16px_20px] md:p-[40px_56px]">
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-8 text-[var(--color-text)]">Einstellungen</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px]">
<!-- Card 1: Vorräte (inline, conditional content) -->
<a
href="/household/staples"
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
>
<span class="font-[var(--font-sans)] text-[16px] font-medium">Vorräte</span>
{#if data.stapleCount > 0}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
<span
data-testid="staple-count"
class="font-[var(--font-display)] text-[28px] font-light leading-[1] tracking-[-0.02em] text-[var(--green-dark)]"
>{data.stapleCount}</span>
</p>
{:else}
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
Noch keine Vorräte eingerichtet
</p>
{/if}
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
{#if data.stapleCount > 0}
Vorräte bearbeiten →
{:else}
Jetzt einrichten →
{/if}
</span>
</a>
<!-- Card 2: Haushalt (inline, needs data-testid on member count) -->
<a
href="/members"
class="flex flex-col gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[28px] no-underline text-[var(--color-text)] shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-raised)] hover:border-[var(--color-border-hover)] transition-[box-shadow,border-color] duration-150 ease focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--green-dark)] focus-visible:outline-offset-2"
>
<span class="font-[var(--font-sans)] text-[16px] font-medium">Haushalt</span>
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] m-0">
<span data-testid="member-count">{data.memberCount}</span> Mitglieder
</p>
<span class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] mt-auto">
Mitglieder anzeigen →
</span>
</a>
<!-- Card 3: Profil (uses SettingsCard) -->
<SettingsCard
title="Profil"
href="/profile"
cta="Profil bearbeiten →"
meta={data.userName}
/>
</div>
</div>

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
const mockIngredients = [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
{ id: 'ing-2', name: 'Butter', isStaple: false },
{ id: 'ing-3', name: 'Salz', isStaple: true }
];
const mockHousehold = {
status: 'OK',
data: {
id: 'hh-1',
name: 'Familie Raddatz',
members: [
{ userId: 'u-1', name: 'Marcel' },
{ userId: 'u-2', name: 'Anna' },
{ userId: 'u-3', name: 'Ben' }
]
}
};
const mockLocals = { benutzer: { id: 'u-1', name: 'Marcel Raddatz' } };
describe('settings page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
function mockApiResponses() {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: mockHousehold, error: undefined });
}
});
}
it('returns stapleCount as number of ingredients where isStaple=true', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.stapleCount).toBe(2);
});
it('returns memberCount as number of household members', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.memberCount).toBe(3);
});
it('returns userName from locals.benutzer.name', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.userName).toBe('Marcel Raddatz');
});
it('fetches ingredients and household in parallel', async () => {
mockApiResponses();
await load({ fetch: vi.fn(), locals: mockLocals } as any);
const calls = mockGet.mock.calls.map((c) => c[0]);
expect(calls).toContain('/v1/ingredients');
expect(calls).toContain('/v1/households/mine');
});
it('defaults stapleCount to 0 when ingredients API fails', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: undefined, error: { status: 500 } });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: mockHousehold, error: undefined });
}
});
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.stapleCount).toBe(0);
});
it('defaults memberCount to 0 when household API fails', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
if (path === '/v1/households/mine') {
return Promise.resolve({ data: undefined, error: { status: 500 } });
}
});
const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
expect(result.memberCount).toBe(0);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
function makeData(overrides: Partial<{ stapleCount: number; memberCount: number; userName: string }> = {}) {
return {
stapleCount: 14,
memberCount: 3,
userName: 'Marcel Raddatz',
...overrides
};
}
describe('settings page — hub', () => {
it('renders the page heading Einstellungen', () => {
render(Page, { props: { data: makeData() } });
expect(screen.getByRole('heading', { name: /einstellungen/i })).toBeInTheDocument();
});
it('renders Vorräte card linking to /household/staples', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples');
expect(vorrateLink).toBeInTheDocument();
});
it('renders Haushalt card linking to /members', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const haushaltLink = links.find((l) => l.getAttribute('href') === '/members');
expect(haushaltLink).toBeInTheDocument();
});
it('renders Profil card linking to /profile', () => {
render(Page, { props: { data: makeData() } });
const links = screen.getAllByRole('link');
const profilLink = links.find((l) => l.getAttribute('href') === '/profile');
expect(profilLink).toBeInTheDocument();
});
it('shows stapleCount as a number in the Vorräte card', () => {
render(Page, { props: { data: makeData({ stapleCount: 14 }) } });
expect(screen.getByTestId('staple-count')).toHaveTextContent('14');
});
it('shows memberCount in the Haushalt card', () => {
render(Page, { props: { data: makeData({ memberCount: 3 }) } });
expect(screen.getByTestId('member-count')).toHaveTextContent('3');
});
it('shows userName in the Profil card meta', () => {
render(Page, { props: { data: makeData({ userName: 'Marcel Raddatz' }) } });
expect(screen.getByText('Marcel Raddatz')).toBeInTheDocument();
});
it('shows empty state text when stapleCount is 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
expect(screen.getByText(/noch keine vorräte/i)).toBeInTheDocument();
expect(screen.queryByTestId('staple-count')).not.toBeInTheDocument();
});
it('shows "Jetzt einrichten →" CTA when stapleCount is 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
expect(screen.getByText('Jetzt einrichten →')).toBeInTheDocument();
});
it('shows "Vorräte bearbeiten →" CTA when stapleCount > 0', () => {
render(Page, { props: { data: makeData({ stapleCount: 5 }) } });
expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
});
});