feat(settings): implement /settings hub page (E1) — Kachel-Ansicht #55
@@ -11,6 +11,7 @@
|
|||||||
--color-surface: #f5f4ee;
|
--color-surface: #f5f4ee;
|
||||||
--color-subtle: #edecea;
|
--color-subtle: #edecea;
|
||||||
--color-border: #d8d7d0;
|
--color-border: #d8d7d0;
|
||||||
|
--color-border-hover: #c0bfb8;
|
||||||
--color-text: #1c1c18;
|
--color-text: #1c1c18;
|
||||||
--color-text-muted: #6b6a63;
|
--color-text-muted: #6b6a63;
|
||||||
|
|
||||||
|
|||||||
29
frontend/src/lib/components/SettingsCard.svelte
Normal file
29
frontend/src/lib/components/SettingsCard.svelte
Normal 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>
|
||||||
32
frontend/src/lib/components/SettingsCard.test.ts
Normal file
32
frontend/src/lib/components/SettingsCard.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@ describe('AppShell', () => {
|
|||||||
it('renders all navigation links from all nav variants', () => {
|
it('renders all navigation links from all nav variants', () => {
|
||||||
render(AppShell, { props: defaultProps });
|
render(AppShell, { props: defaultProps });
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
// Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
|
// Mobile: 4, Tablet: 4, Desktop: 4 = 12 total
|
||||||
expect(links).toHaveLength(13);
|
expect(links).toHaveLength(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
{section.title}
|
{section.title}
|
||||||
</p>
|
</p>
|
||||||
{#each section.items as item (item.href)}
|
{#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
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
|||||||
@@ -28,17 +28,17 @@ describe('DesktopSidebar', () => {
|
|||||||
expect(screen.getByText('Einkauf')).toBeInTheDocument();
|
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' } });
|
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||||
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Einstellungen')).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' } });
|
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
expect(links).toHaveLength(5);
|
expect(links).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks active item with aria-current="page"', () => {
|
it('marks active item with aria-current="page"', () => {
|
||||||
@@ -59,3 +59,18 @@ describe('DesktopSidebar', () => {
|
|||||||
expect(widget).toBeInTheDocument();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
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)}
|
{#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
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
|||||||
@@ -53,3 +53,18 @@ describe('MobileTabBar', () => {
|
|||||||
expect(recipesLink).not.toHaveAttribute('aria-current');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ describe('nav config', () => {
|
|||||||
expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
|
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);
|
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', () => {
|
it('does not match unrelated route', () => {
|
||||||
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface NavItem {
|
|||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
extraPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavSection {
|
export interface NavSection {
|
||||||
@@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [
|
|||||||
{ href: '/planner', label: 'Planer', icon: '📅' },
|
{ href: '/planner', label: 'Planer', icon: '📅' },
|
||||||
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
|
||||||
{ href: '/shopping', label: 'Einkauf', 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 {
|
export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean {
|
||||||
return pathname === href || pathname.startsWith(href + '/');
|
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[] = [
|
export const desktopNavSections: NavSection[] = [
|
||||||
@@ -32,8 +37,7 @@ export const desktopNavSections: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: 'Haushalt',
|
title: 'Haushalt',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/members', label: 'Mitglieder', icon: '👥' },
|
{ href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] }
|
||||||
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if isOnboarding}
|
{#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 -->
|
<!-- 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]">
|
<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} />
|
<ProgressSidebar currentStep={2} />
|
||||||
@@ -44,8 +44,10 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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>
|
<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" />
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
|
|||||||
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
|
||||||
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
21
frontend/src/routes/(app)/settings/+page.server.ts
Normal file
21
frontend/src/routes/(app)/settings/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
105
frontend/src/routes/(app)/settings/page.server.test.ts
Normal file
105
frontend/src/routes/(app)/settings/page.server.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
frontend/src/routes/(app)/settings/page.test.ts
Normal file
71
frontend/src/routes/(app)/settings/page.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user