Phases 6-7

This commit is contained in:
Aerilyn Weber 2026-05-14 14:47:23 +09:00
parent 76a516a417
commit 029940b079
111 changed files with 17247 additions and 447 deletions

View file

@ -2,8 +2,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
const { mockUseSWR } = vi.hoisted(() => ({ mockUseSWR: vi.fn() }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('swr', () => ({ default: vi.fn(() => ({ data: undefined })) }));
vi.mock('swr', () => ({ default: mockUseSWR }));
vi.mock('@/services/cabinet', () => ({
getCabinetSummary: vi.fn(),
listCabinetItems: vi.fn(),
}));
vi.mock('@/services/purchases', () => ({
listPurchases: vi.fn(),
}));
vi.mock('@/services/refills', () => ({
getRefillAlerts: vi.fn(),
}));
vi.mock('@/services/cabinet-events', () => ({
listCabinetEvents: vi.fn(),
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
@ -14,6 +34,7 @@ import DashboardPage from '../page';
beforeEach(() => {
vi.clearAllMocks();
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
mockUseSWR.mockReturnValue({ data: undefined });
});
describe(DashboardPage.name, () => {
@ -27,13 +48,274 @@ describe(DashboardPage.name, () => {
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
it('renders page when household loaded', () => {
it('renders greeting with user name', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Alice' },
profile: { displayName: 'John Doe' },
});
render(<DashboardPage />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText(/John/)).toBeInTheDocument();
});
it('renders generic greeting without profile', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, profile: null });
render(<DashboardPage />);
expect(screen.getByText(/there/)).toBeInTheDocument();
});
it('renders cabinet items', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
const summaryData = { data: [{ _id: '1' }, { _id: '2' }] };
const cabinetData = {
data: [{ _id: 'c1', medicineName: 'Aspirin', quantity: 50, unit: 'tablets' }],
};
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: summaryData };
if (key.includes('cabinet-items')) return { data: cabinetData };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('Aspirin')).toBeInTheDocument();
expect(screen.getByText('50 tablets')).toBeInTheDocument();
});
it('shows empty states when no data', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('No cabinet items yet.')).toBeInTheDocument();
expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument();
expect(screen.getByText('No pending orders.')).toBeInTheDocument();
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
});
it('shows refill alerts with days left', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
const refillData = {
data: [{ medicineId: 'm1', medicineName: 'Vitamin C', daysUntilEmpty: 5 }],
};
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
if (key.includes('cabinet-items')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: refillData };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('Vitamin C')).toBeInTheDocument();
expect(screen.getByText('5d')).toBeInTheDocument();
expect(screen.getByText(/critically low/)).toBeInTheDocument();
});
it('shows pending purchases', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
const purchaseData = {
data: [
{
_id: 'p1',
storeName: 'Pharmacy Plus',
items: [{ name: 'A' }, { name: 'B' }],
status: 'ordered',
},
],
};
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: purchaseData };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('Pharmacy Plus')).toBeInTheDocument();
expect(screen.getByText('2 items')).toBeInTheDocument();
expect(screen.getByText('ordered')).toBeInTheDocument();
});
it('shows recent events', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
const eventData = {
data: [
{
_id: 'e1',
eventType: 'consumed',
medicineName: 'Aspirin',
createdAt: '2026-01-15T10:00:00.000Z',
},
],
};
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: eventData };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('consumed')).toBeInTheDocument();
expect(screen.getByText('Aspirin')).toBeInTheDocument();
});
it('shows "good shape" message when no critical alerts', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
if (key.includes('cabinet-items')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('Your cabinet is in good shape.')).toBeInTheDocument();
});
it('shows stat badges with summary data', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary'))
return { data: { data: [{ _id: '1' }, { _id: '2' }, { _id: '3' }] } };
if (key.includes('cabinet-items')) return { data: { data: [] } };
if (key.includes('refill-alerts'))
return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('Total medicines')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getAllByText('Running low')).toHaveLength(2);
expect(screen.getByText('2')).toBeInTheDocument();
});
it('handles store without name', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
const purchaseData = {
data: [{ _id: 'p1', storeName: undefined, items: [{ name: 'A' }], status: 'ordered' }],
};
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: { data: [] } };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: purchaseData };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('Unknown store')).toBeInTheDocument();
});
it('handles cabinet item with no name', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Jane' },
});
const cabinetData = {
data: [{ _id: 'c1', medicineName: undefined, quantity: 10, unit: 'pills' }],
};
mockUseSWR.mockImplementation((key: string) => {
if (!key) return { data: undefined };
if (key.includes('cabinet-summary')) return { data: { data: [] } };
if (key.includes('cabinet-items')) return { data: cabinetData };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };
return { data: undefined };
});
render(<DashboardPage />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,305 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockListRegimens } = vi.hoisted(() => ({
mockListRegimens: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/regimens', () => ({
listRegimens: mockListRegimens,
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
import SchedulePage from '../page';
beforeEach(() => vi.clearAllMocks());
const makeRegimen = (overrides = {}) => ({
_id: 'reg1',
householdId: 'hh1',
name: 'Daily Vitamins',
isActive: true,
startDate: '2026-01-01',
medications: [
{
medicineId: 'm1',
medicineName: 'Vitamin D',
medicineStrength: '1000',
medicineStrengthUnit: 'IU',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
timeOfDay: 'morning',
},
],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
});
describe('SchedulePage', () => {
it('shows loading skeleton when session is loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<SchedulePage />);
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
});
it('shows no household message', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<SchedulePage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('shows empty state when no regimens', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('No active regimens found.')).toBeInTheDocument();
});
});
it('shows set up link in empty state', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('Set up a regimen')).toBeInTheDocument();
});
});
it('renders medication in morning slot', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [makeRegimen()],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
expect(screen.getByText('Morning')).toBeInTheDocument();
expect(screen.getByText('1000 IU')).toBeInTheDocument();
expect(screen.getByText('Daily Vitamins')).toBeInTheDocument();
});
});
it('shows frequency label', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [makeRegimen()],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText(/Once daily/)).toBeInTheDocument();
});
});
it('shows custom frequency', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [
makeRegimen({
medications: [
{
medicineId: 'm1',
medicineName: 'Custom Med',
medicineStrength: '50',
medicineStrengthUnit: 'mg',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'custom',
customFrequencyPerDay: 4,
timeOfDay: 'morning',
},
],
}),
],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText(/4x daily/)).toBeInTheDocument();
});
});
it('shows instructions when present', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [
makeRegimen({
medications: [
{
medicineId: 'm1',
medicineName: 'Med A',
medicineStrength: '10',
medicineStrengthUnit: 'mg',
dosage: 1,
dosageUnit: 'pill',
frequency: 'daily',
timeOfDay: 'evening',
instructions: 'Take with food',
},
],
}),
],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('Take with food')).toBeInTheDocument();
expect(screen.getByText('Evening')).toBeInTheDocument();
});
});
it('shows dose count', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [makeRegimen()],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('1 dose')).toBeInTheDocument();
});
});
it('shows error when fetch fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockRejectedValue(new Error('Network error'));
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
it('shows fallback error for non-Error failures', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockRejectedValue('unexpected');
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('Failed to load regimens')).toBeInTheDocument();
});
});
it('groups into "any" slot when timeOfDay is missing', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [
makeRegimen({
medications: [
{
medicineId: 'm2',
medicineName: 'Aspirin',
medicineStrength: '100',
medicineStrengthUnit: 'mg',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'as_needed',
},
],
}),
],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('Aspirin')).toBeInTheDocument();
expect(screen.getByText('Any time')).toBeInTheDocument();
});
});
it('paginates through regimens', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens
.mockResolvedValueOnce({
data: [makeRegimen()],
pagination: { cursor: 'next', hasMore: true },
})
.mockResolvedValueOnce({
data: [
makeRegimen({
_id: 'reg2',
name: 'Second Regimen',
medications: [
{
medicineId: 'm2',
medicineName: 'Iron',
medicineStrength: '65',
medicineStrengthUnit: 'mg',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
timeOfDay: 'afternoon',
},
],
}),
],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
expect(screen.getByText('Iron')).toBeInTheDocument();
});
expect(mockListRegimens).toHaveBeenCalledTimes(2);
});
it('shows regimen and dose summary', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRegimens.mockResolvedValue({
data: [makeRegimen()],
pagination: { cursor: null, hasMore: false },
});
render(<SchedulePage />);
await waitFor(() => {
expect(screen.getByText(/1 active regimen/)).toBeInTheDocument();
expect(screen.getByText(/1 dose per day/)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,339 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { listPantryItems, transitionPantryItem, deletePantryItem } from '@/services/pantry';
import { StorageLocation, ItemStatus, FreshnessUrgency } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type { PantryItemResponseSchema } from '@meshitrack/shared';
type PantryItem = z.infer<typeof PantryItemResponseSchema>;
const STORAGE_TABS = [
{ value: '', label: 'All' },
{ value: StorageLocation.FRIDGE, label: 'Fridge' },
{ value: StorageLocation.FREEZER, label: 'Freezer' },
{ value: StorageLocation.PANTRY, label: 'Pantry' },
{ value: StorageLocation.COUNTER, label: 'Counter' },
] as const;
const URGENCY_COLORS: Record<string, { bg: string; color: string; label: string }> = {
[FreshnessUrgency.FRESH]: {
bg: 'var(--success-soft, #d4edda)',
color: 'var(--success, #28a745)',
label: 'Fresh',
},
[FreshnessUrgency.USE_SOON]: {
bg: 'var(--warning-soft, #fff3cd)',
color: 'var(--warning, #856404)',
label: 'Use soon',
},
[FreshnessUrgency.URGENT]: { bg: 'var(--danger-soft)', color: 'var(--danger)', label: 'Urgent' },
[FreshnessUrgency.CHECK]: { bg: 'var(--danger-soft)', color: 'var(--danger)', label: 'Check' },
[FreshnessUrgency.EXPIRED]: {
bg: 'var(--danger-soft)',
color: 'var(--danger)',
label: 'Expired',
},
};
const STATUS_LABELS: Record<string, string> = {
[ItemStatus.SEALED]: 'Sealed',
[ItemStatus.OPENED]: 'Opened',
[ItemStatus.PREPARED]: 'Prepared',
[ItemStatus.CONSUMED]: 'Consumed',
[ItemStatus.DISCARDED]: 'Discarded',
[ItemStatus.EXPIRED]: 'Expired',
};
const VALID_TRANSITIONS: Record<string, string[]> = {
[ItemStatus.SEALED]: [ItemStatus.OPENED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
[ItemStatus.OPENED]: [ItemStatus.PREPARED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
[ItemStatus.PREPARED]: [ItemStatus.CONSUMED, ItemStatus.DISCARDED],
};
const TRANSITION_LABELS: Record<string, string> = {
[ItemStatus.OPENED]: 'Open',
[ItemStatus.PREPARED]: 'Prepare',
[ItemStatus.CONSUMED]: 'Consume',
[ItemStatus.DISCARDED]: 'Discard',
};
export function PantryList({ householdId }: { householdId: string }) {
const [items, setItems] = useState<PantryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [storageFilter, setStorageFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const abortRef = useRef<AbortController | null>(null);
const fetchItems = useCallback(async () => {
if (!householdId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError('');
try {
const result = await listPantryItems(householdId, {
storageLocation: storageFilter || undefined,
status: statusFilter || undefined,
limit: 50,
});
setItems(result.data);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}, [householdId, storageFilter, statusFilter]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
async function handleTransition(id: string, status: string) {
try {
const updated = await transitionPantryItem(householdId, id, { status } as never);
setItems((prev) => prev.map((item) => (item._id === id ? updated : item)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Transition failed');
}
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}"?`)) return;
try {
await deletePantryItem(householdId, id);
setItems((prev) => prev.filter((item) => item._id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
return (
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
{/* Storage tabs */}
<div
style={{
display: 'flex',
gap: 4,
marginBottom: 16,
borderBottom: '1px solid var(--border)',
paddingBottom: 0,
}}
>
{STORAGE_TABS.map((tab) => {
const isActive = storageFilter === tab.value;
return (
<button
key={tab.value}
onClick={() => setStorageFilter(tab.value)}
style={{
padding: '8px 16px',
fontSize: 13,
fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--brand)' : 'var(--ink-muted)',
background: 'transparent',
border: 'none',
borderBottom: isActive ? '2px solid var(--brand)' : '2px solid transparent',
cursor: 'pointer',
marginBottom: -1,
}}
>
{tab.label}
</button>
);
})}
</div>
{/* Status filter */}
<div style={{ display: 'flex', gap: 12, marginBottom: 24, alignItems: 'center' }}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
}}
>
<option value="">All statuses</option>
<option value="sealed">Sealed</option>
<option value="opened">Opened</option>
<option value="prepared">Prepared</option>
</select>
</div>
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
{loading ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 16,
}}
>
{[...Array(6)].map((_, i) => (
<div
key={i}
style={{
height: 140,
background: 'var(--bg-elev)',
borderRadius: 'var(--r-md)',
opacity: 0.5,
}}
/>
))}
</div>
) : items.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '64px 24px',
color: 'var(--ink-muted)',
fontSize: 14,
}}
>
{storageFilter || statusFilter
? 'No items match your filters.'
: 'No pantry items yet. Add your first item.'}
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 16,
}}
>
{items.map((item) => (
<PantryCard
key={item._id}
item={item}
onTransition={handleTransition}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
);
}
function PantryCard({
item,
onTransition,
onDelete,
}: {
item: PantryItem;
onTransition: (id: string, status: string) => void;
onDelete: (id: string, name: string) => void;
}) {
const urgency =
URGENCY_COLORS[item.freshnessEstimate.urgency] ?? URGENCY_COLORS[FreshnessUrgency.FRESH];
const transitions = VALID_TRANSITIONS[item.status] ?? [];
const daysText =
item.freshnessEstimate.daysRemaining >= 0
? `${item.freshnessEstimate.daysRemaining}d left`
: `${Math.abs(item.freshnessEstimate.daysRemaining)}d overdue`;
return (
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
{/* Header row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: 14,
color: 'var(--ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.productName}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 2 }}>
{item.quantity} {item.unit}
</div>
</div>
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 'var(--r-sm)',
fontSize: 11,
fontWeight: 600,
background: urgency.bg,
color: urgency.color,
whiteSpace: 'nowrap',
}}
>
{urgency.label}
</span>
</div>
{/* Info */}
<div style={{ fontSize: 12, color: 'var(--ink-muted)', display: 'flex', gap: 12 }}>
<span>{STATUS_LABELS[item.status] ?? item.status}</span>
<span>{daysText}</span>
<span style={{ textTransform: 'capitalize' }}>{item.storageLocation}</span>
</div>
{/* Actions */}
{transitions.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 'auto' }}>
{transitions.map((status) => (
<button
key={status}
onClick={() => onTransition(item._id, status)}
style={{
padding: '4px 10px',
fontSize: 12,
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: status === ItemStatus.CONSUMED ? 'var(--brand-soft)' : 'var(--bg)',
color: status === ItemStatus.CONSUMED ? 'var(--brand)' : 'var(--ink-muted)',
cursor: 'pointer',
fontWeight: status === ItemStatus.CONSUMED ? 600 : 400,
}}
>
{TRANSITION_LABELS[status] ?? status}
</button>
))}
<button
onClick={() => onDelete(item._id, item.productName)}
style={{
padding: '4px 10px',
fontSize: 12,
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--danger)',
cursor: 'pointer',
marginLeft: 'auto',
}}
>
Delete
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockListPantryItems, mockTransitionPantryItem, mockDeletePantryItem } = vi.hoisted(() => ({
mockListPantryItems: vi.fn(),
mockTransitionPantryItem: vi.fn(),
mockDeletePantryItem: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/pantry', () => ({
listPantryItems: mockListPantryItems,
transitionPantryItem: mockTransitionPantryItem,
deletePantryItem: mockDeletePantryItem,
}));
vi.mock('next/link', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { default: (props: any) => props.children };
});
import PantryPage from '../page';
beforeEach(() => vi.clearAllMocks());
const SAMPLE_ITEM = {
_id: 'pi-1',
householdId: 'hh1',
productId: 'p1',
productName: 'Whole Milk',
storageLocation: 'fridge',
quantity: 1,
unit: 'liter',
purchaseDate: '2026-05-01T00:00:00.000Z',
status: 'sealed',
freshnessEstimate: {
estimatedExpiryDate: '2026-05-15T00:00:00.000Z',
daysRemaining: 11,
urgency: 'fresh',
source: 'rule',
},
createdBy: 'u1',
createdAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
};
describe('PantryPage', () => {
it('shows loading skeleton when session is loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<PantryPage />);
expect(screen.getByText('Pantry')).toBeInTheDocument();
expect(screen.queryByText('All')).not.toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<PantryPage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('renders pantry items when household exists', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [SAMPLE_ITEM],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Whole Milk')).toBeInTheDocument();
});
});
it('shows empty state when no items', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
});
});
it('shows freshness badge', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [SAMPLE_ITEM],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Fresh')).toBeInTheDocument();
expect(screen.getByText('11d left')).toBeInTheDocument();
});
});
it('shows overdue text for negative days', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [
{
...SAMPLE_ITEM,
_id: 'pi-2',
freshnessEstimate: {
...SAMPLE_ITEM.freshnessEstimate,
daysRemaining: -2,
urgency: 'expired',
},
},
],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('2d overdue')).toBeInTheDocument();
expect(screen.getByText('Expired')).toBeInTheDocument();
});
});
it('shows transition buttons for active items', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [SAMPLE_ITEM],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Open')).toBeInTheDocument();
expect(screen.getByText('Consume')).toBeInTheDocument();
expect(screen.getByText('Discard')).toBeInTheDocument();
});
});
it('handles transition click', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [SAMPLE_ITEM],
pagination: { cursor: null, hasMore: false },
});
mockTransitionPantryItem.mockResolvedValue({
...SAMPLE_ITEM,
status: 'opened',
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Open')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Open'));
await waitFor(() => {
expect(mockTransitionPantryItem).toHaveBeenCalledWith('hh1', 'pi-1', { status: 'opened' });
});
});
it('handles delete with confirmation', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [SAMPLE_ITEM],
pagination: { cursor: null, hasMore: false },
});
mockDeletePantryItem.mockResolvedValue(undefined);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(mockDeletePantryItem).toHaveBeenCalledWith('hh1', 'pi-1');
});
});
it('does not delete when confirm is cancelled', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [SAMPLE_ITEM],
pagination: { cursor: null, hasMore: false },
});
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Delete'));
expect(mockDeletePantryItem).not.toHaveBeenCalled();
});
it('filters by storage tab', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Fridge')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Fridge'));
await waitFor(() => {
expect(mockListPantryItems).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ storageLocation: 'fridge' }),
);
});
});
it('filters by status select', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
});
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'opened' } });
await waitFor(() => {
expect(mockListPantryItems).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ status: 'opened' }),
);
});
});
it('shows error when fetch fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockRejectedValue(new Error('Network error'));
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
it('shows error when transition fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [SAMPLE_ITEM],
pagination: { cursor: null, hasMore: false },
});
mockTransitionPantryItem.mockRejectedValue(new Error('Transition failed'));
render(<PantryPage />);
await waitFor(() => {
expect(screen.getByText('Open')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Open'));
await waitFor(() => {
expect(screen.getByText('Transition failed')).toBeInTheDocument();
});
});
it('shows filter empty state message', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
// Click a filter tab to trigger filter state
await waitFor(() => {
expect(screen.getByText('Fridge')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Fridge'));
await waitFor(() => {
expect(screen.getByText(/No items match your filters/)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,72 @@
'use client';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { PantryList } from './PantryList';
function PageSkeleton() {
return (
<div style={{ padding: '28px 32px' }}>
{[...Array(3)].map((_, i) => (
<div
key={i}
style={{
height: 80,
background: 'var(--bg-elev)',
borderRadius: 'var(--r-md)',
marginBottom: 12,
opacity: 0.5,
}}
/>
))}
</div>
);
}
function NoHousehold() {
return (
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to create or join a household before managing your pantry.
</p>
</div>
</div>
);
}
export default function PantryPage() {
const { householdId, isLoading } = useApi();
if (isLoading) {
return (
<>
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<>
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
<NoHousehold />
</>
);
}
return (
<>
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
<PantryList householdId={householdId} />
</>
);
}

View file

@ -0,0 +1,235 @@
'use client';
import { useState, useRef } from 'react';
import { importProducts } from '@/services/products';
export interface ImportDialogProps {
open: boolean;
onClose: () => void;
householdId: string;
onSuccess: () => void;
}
interface ImportResult {
imported: number;
skippedDuplicates: number;
errors: { row: number; message: string }[];
}
export function ImportDialog({ open, onClose, householdId, onSuccess }: ImportDialogProps) {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ImportResult | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
if (!open) return null;
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError('');
setResult(null);
}
async function handleUpload() {
if (!file) {
setError('Please select a file');
return;
}
setUploading(true);
setError('');
try {
const res = await importProducts(householdId, file);
setResult(res);
if (res.imported > 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
} finally {
setUploading(false);
}
}
function handleClose() {
setFile(null);
setError('');
setResult(null);
onClose();
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
};
return (
<div
onClick={handleClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--bg-base, #fff)',
borderRadius: 'var(--r-lg, 12px)',
border: '1px solid var(--border)',
width: '100%',
maxWidth: 480,
padding: 24,
}}
>
<h2 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
Import Products
</h2>
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
{result ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
style={{
padding: 16,
borderRadius: 'var(--r-md)',
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
}}
>
<p style={{ fontSize: 14, margin: '0 0 8px', color: 'var(--ink)' }}>
Import complete
</p>
<p style={{ fontSize: 13, margin: 0, color: 'var(--ink-muted)' }}>
Imported: <strong>{result.imported}</strong>
</p>
<p style={{ fontSize: 13, margin: 0, color: 'var(--ink-muted)' }}>
Skipped (duplicates): <strong>{result.skippedDuplicates}</strong>
</p>
{result.errors.length > 0 && (
<div style={{ marginTop: 8 }}>
<p style={{ fontSize: 12, color: 'var(--danger)', margin: 0 }}>
Errors ({result.errors.length}):
</p>
<ul
style={{
margin: '4px 0 0',
paddingLeft: 16,
fontSize: 12,
color: 'var(--danger)',
maxHeight: 120,
overflowY: 'auto',
}}
>
{result.errors.map((err, i) => (
<li key={i}>
Row {err.row}: {err.message}
</li>
))}
</ul>
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={handleClose}
style={{
padding: '8px 16px',
borderRadius: 'var(--r-md)',
border: 'none',
background: 'var(--brand)',
color: '#fff',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
Close
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<input
ref={inputRef}
type="file"
accept=".csv,.json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
style={{
...inputStyle,
cursor: 'pointer',
textAlign: 'left',
color: file ? 'var(--ink)' : 'var(--ink-muted)',
}}
>
{file ? file.name : 'Choose .csv or .json file...'}
</button>
{file && (
<p style={{ fontSize: 12, color: 'var(--ink-muted)', margin: '4px 0 0' }}>
Size: {(file.size / 1024).toFixed(1)} KB
</p>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button
type="button"
onClick={handleClose}
style={{
padding: '8px 16px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--ink-muted)',
fontSize: 14,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
type="button"
onClick={handleUpload}
disabled={!file || uploading}
style={{
padding: '8px 20px',
borderRadius: 'var(--r-md)',
border: 'none',
background: 'var(--brand)',
color: '#fff',
fontSize: 14,
fontWeight: 500,
cursor: !file || uploading ? 'not-allowed' : 'pointer',
opacity: !file || uploading ? 0.7 : 1,
}}
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,349 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { listProducts, deleteProduct, createProduct, updateProduct } from '@/services/products';
import { ProductCategory, type ServingUnit } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type { ProductResponseSchema, CreateProductInput } from '@meshitrack/shared';
import { ProductModal } from './ProductModal';
import { ImportDialog } from './ImportDialog';
type Product = z.infer<typeof ProductResponseSchema>;
const CATEGORY_OPTIONS = Object.values(ProductCategory);
const SERVING_UNIT_LABELS: Record<string, string> = {
g: 'g',
ml: 'ml',
piece: 'pc',
slice: 'sl',
};
export function ProductList({ householdId }: { householdId: string }) {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [addModalOpen, setAddModalOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const fetchProducts = useCallback(async () => {
if (!householdId) return;
setLoading(true);
setError('');
try {
const result = await listProducts(householdId, {
q: debouncedSearch || undefined,
category: category || undefined,
limit: 50,
});
setProducts(result.data);
} catch (err) {
if (err instanceof Error) setError(err.message);
} finally {
setLoading(false);
}
}, [householdId, debouncedSearch, category]);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}"?`)) return;
try {
await deleteProduct(householdId, id);
setProducts((prev) => prev.filter((p) => p._id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
async function handleCreate(data: CreateProductInput) {
await createProduct(householdId, data);
await fetchProducts();
}
async function handleEdit(data: CreateProductInput) {
if (!editingProduct) return;
await updateProduct(householdId, editingProduct._id, data);
await fetchProducts();
}
return (
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
{/* Filters */}
<div
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<input
type="text"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
minWidth: 240,
}}
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
style={{
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
}}
>
<option value="">All categories</option>
{CATEGORY_OPTIONS.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ')}
</option>
))}
</select>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => setImportDialogOpen(true)}
style={{
padding: '8px 14px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
cursor: 'pointer',
}}
>
Import
</button>
<button
type="button"
onClick={() => setAddModalOpen(true)}
style={{
padding: '8px 14px',
borderRadius: 'var(--r-md)',
border: 'none',
background: 'var(--brand)',
color: '#fff',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
Add Product
</button>
</div>
</div>
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
{loading ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 16,
}}
>
{[...Array(6)].map((_, i) => (
<div
key={i}
style={{
height: 120,
background: 'var(--bg-elev)',
borderRadius: 'var(--r-md)',
opacity: 0.5,
}}
/>
))}
</div>
) : products.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '64px 24px',
color: 'var(--ink-muted)',
fontSize: 14,
}}
>
<p>No products yet. Add your first product to get started.</p>
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 16,
}}
>
{products.map((product) => (
<ProductCard
key={product._id}
product={product}
onDelete={() => handleDelete(product._id, product.name)}
onEdit={() => setEditingProduct(product)}
/>
))}
</div>
)}
<ProductModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSave={handleCreate}
householdId={householdId}
title="Add Product"
/>
<ProductModal
open={editingProduct !== null}
onClose={() => setEditingProduct(null)}
onSave={handleEdit}
householdId={householdId}
initial={
editingProduct
? {
name: editingProduct.name,
brand: editingProduct.brand,
barcode: editingProduct.barcode,
category: editingProduct.category as ProductCategory,
servingSize: editingProduct.servingSize,
servingUnit: editingProduct.servingUnit as ServingUnit,
densityGPerMl: editingProduct.densityGPerMl,
nutrition: editingProduct.nutrition,
tags: editingProduct.tags,
imageUrl: editingProduct.imageUrl,
}
: undefined
}
title="Edit Product"
/>
<ImportDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
householdId={householdId}
onSuccess={fetchProducts}
/>
</div>
);
}
function ProductCard({
product,
onDelete,
onEdit,
}: {
product: Product;
onDelete: () => void;
onEdit: () => void;
}) {
return (
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<p style={{ fontWeight: 600, fontSize: 14, margin: 0, color: 'var(--ink)' }}>
{product.name}
</p>
{product.brand && (
<p style={{ fontSize: 12, color: 'var(--ink-muted)', margin: 0 }}>{product.brand}</p>
)}
</div>
<span
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-soft, #e8f0fe)',
color: 'var(--brand)',
textTransform: 'capitalize',
}}
>
{product.category.replace(/_/g, ' ')}
</span>
</div>
<div style={{ display: 'flex', gap: 16, fontSize: 13, color: 'var(--ink-muted)' }}>
<span>{product.nutrition.calories} kcal</span>
<span>
{product.servingSize}
{SERVING_UNIT_LABELS[product.servingUnit] ?? product.servingUnit}
</span>
</div>
<div style={{ display: 'flex', gap: 8, fontSize: 12, color: 'var(--ink-dim)' }}>
<span>P: {product.nutrition.protein}g</span>
<span>C: {product.nutrition.carbs}g</span>
<span>F: {product.nutrition.fat}g</span>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button
onClick={onEdit}
aria-label="Edit product"
style={{
padding: '4px 10px',
fontSize: 12,
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--ink-muted)',
cursor: 'pointer',
}}
>
Edit
</button>
<button
onClick={onDelete}
aria-label="Delete product"
style={{
padding: '4px 10px',
fontSize: 12,
borderRadius: 'var(--r-sm)',
border: '1px solid var(--danger)',
background: 'transparent',
color: 'var(--danger)',
cursor: 'pointer',
}}
>
Delete
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,533 @@
'use client';
import { useState, useEffect } from 'react';
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
import type { CreateProductInput } from '@meshitrack/shared';
import { lookupBarcode } from '@/services/products';
export interface ProductModalProps {
open: boolean;
onClose: () => void;
onSave: (data: CreateProductInput) => Promise<void>;
householdId: string;
initial?: Partial<CreateProductInput>;
title?: string;
}
const CATEGORY_OPTIONS = Object.values(ProductCategory);
const SERVING_UNIT_OPTIONS = Object.values(ServingUnit);
const SERVING_UNIT_LABELS: Record<string, string> = {
g: 'Grams (g)',
ml: 'Milliliters (ml)',
piece: 'Piece',
slice: 'Slice',
};
export function ProductModal({
open,
onClose,
onSave,
householdId,
initial,
title,
}: ProductModalProps) {
const [saving, setSaving] = useState(false);
const [lookingUp, setLookingUp] = useState(false);
const [error, setError] = useState('');
const [name, setName] = useState('');
const [brand, setBrand] = useState('');
const [barcode, setBarcode] = useState('');
const [category, setCategory] = useState<ProductCategory>(ProductCategory.OTHER);
const [servingSize, setServingSize] = useState<number | ''>('');
const [servingUnit, setServingUnit] = useState<ServingUnit>(ServingUnit.GRAMS);
const [densityGPerMl, setDensityGPerMl] = useState<number | ''>('');
const [tags, setTags] = useState('');
const [imageUrl, setImageUrl] = useState('');
// Nutrition
const [calories, setCalories] = useState<number | ''>('');
const [protein, setProtein] = useState<number | ''>('');
const [carbs, setCarbs] = useState<number | ''>('');
const [fat, setFat] = useState<number | ''>('');
const [fiber, setFiber] = useState<number | ''>('');
const [sugar, setSugar] = useState<number | ''>('');
const [sodium, setSodium] = useState<number | ''>('');
const [saturatedFat, setSaturatedFat] = useState<number | ''>('');
const [cholesterol, setCholesterol] = useState<number | ''>('');
useEffect(() => {
if (open) {
setName(initial?.name ?? '');
setBrand(initial?.brand ?? '');
setBarcode(initial?.barcode ?? '');
setCategory(initial?.category ?? ProductCategory.OTHER);
setServingSize(initial?.servingSize ?? '');
setServingUnit(initial?.servingUnit ?? ServingUnit.GRAMS);
setDensityGPerMl(initial?.densityGPerMl ?? '');
setTags(initial?.tags?.join(', ') ?? '');
setImageUrl(initial?.imageUrl ?? '');
setCalories(initial?.nutrition?.calories ?? '');
setProtein(initial?.nutrition?.protein ?? '');
setCarbs(initial?.nutrition?.carbs ?? '');
setFat(initial?.nutrition?.fat ?? '');
setFiber(initial?.nutrition?.fiber ?? '');
setSugar(initial?.nutrition?.sugar ?? '');
setSodium(initial?.nutrition?.sodium ?? '');
setSaturatedFat(initial?.nutrition?.saturatedFat ?? '');
setCholesterol(initial?.nutrition?.cholesterol ?? '');
setError('');
setSaving(false);
}
}, [open, initial]);
if (!open) return null;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Name is required');
return;
}
if (servingSize === '' || servingSize <= 0) {
setError('Serving size must be a positive number');
return;
}
if (calories === '' || protein === '' || carbs === '' || fat === '') {
setError('Calories, protein, carbs, and fat are required');
return;
}
const data: CreateProductInput = {
name: name.trim(),
category,
servingSize: Number(servingSize),
servingUnit,
nutrition: {
calories: Number(calories),
protein: Number(protein),
carbs: Number(carbs),
fat: Number(fat),
...(fiber !== '' && { fiber: Number(fiber) }),
...(sugar !== '' && { sugar: Number(sugar) }),
...(sodium !== '' && { sodium: Number(sodium) }),
...(saturatedFat !== '' && { saturatedFat: Number(saturatedFat) }),
...(cholesterol !== '' && { cholesterol: Number(cholesterol) }),
},
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
source: ProductSource.MANUAL,
...(brand.trim() && { brand: brand.trim() }),
...(barcode.trim() && { barcode: barcode.trim() }),
...(densityGPerMl !== '' && { densityGPerMl: Number(densityGPerMl) }),
...(imageUrl.trim() && { imageUrl: imageUrl.trim() }),
};
setSaving(true);
try {
await onSave(data);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--ink-muted)',
marginBottom: 4,
};
return (
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--bg-base, #fff)',
borderRadius: 'var(--r-lg, 12px)',
border: '1px solid var(--border)',
width: '100%',
maxWidth: 560,
maxHeight: '90vh',
overflowY: 'auto',
padding: 24,
}}
>
<h2 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
{title ?? (initial ? 'Edit Product' : 'Add Product')}
</h2>
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Basic info */}
<div>
<label style={labelStyle}>Name *</label>
<input
style={inputStyle}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Product name"
required
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Brand</label>
<input
style={inputStyle}
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="Optional"
/>
</div>
<div>
<label style={labelStyle}>Barcode</label>
<div style={{ display: 'flex', gap: 6 }}>
<input
style={{ ...inputStyle, flex: 1 }}
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
placeholder="8-14 digits"
/>
<button
type="button"
disabled={lookingUp || !barcode.trim()}
onClick={async () => {
if (!barcode.trim()) return;
setLookingUp(true);
setError('');
try {
const result = await lookupBarcode(householdId, barcode.trim());
if ('found' in result) {
setError('Product not found for this barcode');
} else {
setName(result.name ?? '');
setBrand(result.brand ?? '');
setCategory((result.category as ProductCategory) ?? ProductCategory.OTHER);
setServingSize(result.servingSize ?? '');
setServingUnit((result.servingUnit as ServingUnit) ?? ServingUnit.GRAMS);
setDensityGPerMl(result.densityGPerMl ?? '');
setTags(result.tags?.join(', ') ?? '');
setImageUrl(result.imageUrl ?? '');
setCalories(result.nutrition?.calories ?? '');
setProtein(result.nutrition?.protein ?? '');
setCarbs(result.nutrition?.carbs ?? '');
setFat(result.nutrition?.fat ?? '');
setFiber(result.nutrition?.fiber ?? '');
setSugar(result.nutrition?.sugar ?? '');
setSodium(result.nutrition?.sodium ?? '');
setSaturatedFat(result.nutrition?.saturatedFat ?? '');
setCholesterol(result.nutrition?.cholesterol ?? '');
}
} catch {
setError('Barcode lookup failed');
} finally {
setLookingUp(false);
}
}}
style={{
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink-muted)',
fontSize: 12,
cursor: lookingUp || !barcode.trim() ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
opacity: lookingUp || !barcode.trim() ? 0.5 : 1,
}}
>
{lookingUp ? 'Looking up...' : 'Lookup'}
</button>
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Category *</label>
<select
style={inputStyle}
value={category}
onChange={(e) => setCategory(e.target.value as ProductCategory)}
>
{CATEGORY_OPTIONS.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ')}
</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Serving Size *</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={servingSize}
onChange={(e) =>
setServingSize(e.target.value === '' ? '' : Number(e.target.value))
}
placeholder="e.g. 100"
required
/>
</div>
<div>
<label style={labelStyle}>Serving Unit *</label>
<select
style={inputStyle}
value={servingUnit}
onChange={(e) => setServingUnit(e.target.value as ServingUnit)}
>
{SERVING_UNIT_OPTIONS.map((unit) => (
<option key={unit} value={unit}>
{SERVING_UNIT_LABELS[unit] ?? unit}
</option>
))}
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Density (g/ml)</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={densityGPerMl}
onChange={(e) =>
setDensityGPerMl(e.target.value === '' ? '' : Number(e.target.value))
}
placeholder="Optional"
/>
</div>
<div>
<label style={labelStyle}>Image URL</label>
<input
style={inputStyle}
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://..."
/>
</div>
</div>
<div>
<label style={labelStyle}>Tags (comma-separated)</label>
<input
style={inputStyle}
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="e.g. organic, gluten-free"
/>
</div>
{/* Nutrition */}
<div
style={{
borderTop: '1px solid var(--border)',
paddingTop: 16,
marginTop: 4,
}}
>
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)', margin: '0 0 12px' }}>
Nutrition (per serving)
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Calories *</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={calories}
onChange={(e) => setCalories(e.target.value === '' ? '' : Number(e.target.value))}
required
/>
</div>
<div>
<label style={labelStyle}>Protein (g) *</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={protein}
onChange={(e) => setProtein(e.target.value === '' ? '' : Number(e.target.value))}
required
/>
</div>
<div>
<label style={labelStyle}>Carbs (g) *</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={carbs}
onChange={(e) => setCarbs(e.target.value === '' ? '' : Number(e.target.value))}
required
/>
</div>
<div>
<label style={labelStyle}>Fat (g) *</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={fat}
onChange={(e) => setFat(e.target.value === '' ? '' : Number(e.target.value))}
required
/>
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr',
gap: 12,
marginTop: 12,
}}
>
<div>
<label style={labelStyle}>Fiber (g)</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={fiber}
onChange={(e) => setFiber(e.target.value === '' ? '' : Number(e.target.value))}
/>
</div>
<div>
<label style={labelStyle}>Sugar (g)</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={sugar}
onChange={(e) => setSugar(e.target.value === '' ? '' : Number(e.target.value))}
/>
</div>
<div>
<label style={labelStyle}>Sodium (mg)</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={sodium}
onChange={(e) => setSodium(e.target.value === '' ? '' : Number(e.target.value))}
/>
</div>
<div>
<label style={labelStyle}>Sat. Fat (g)</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={saturatedFat}
onChange={(e) =>
setSaturatedFat(e.target.value === '' ? '' : Number(e.target.value))
}
/>
</div>
<div>
<label style={labelStyle}>Cholesterol (mg)</label>
<input
style={inputStyle}
type="number"
min={0}
step="any"
value={cholesterol}
onChange={(e) =>
setCholesterol(e.target.value === '' ? '' : Number(e.target.value))
}
/>
</div>
</div>
</div>
{/* Submit */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 8 }}>
<button
type="button"
onClick={onClose}
style={{
padding: '8px 16px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--ink-muted)',
fontSize: 14,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
style={{
padding: '8px 20px',
borderRadius: 'var(--r-md)',
border: 'none',
background: 'var(--brand)',
color: '#fff',
fontSize: 14,
fontWeight: 500,
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
}}
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const { mockImportProducts } = vi.hoisted(() => ({
mockImportProducts: vi.fn(),
}));
vi.mock('@/services/products', () => ({
importProducts: mockImportProducts,
}));
import { ImportDialog } from '../ImportDialog';
beforeEach(() => vi.clearAllMocks());
const defaultProps = {
open: true,
onClose: vi.fn(),
householdId: 'hh1',
onSuccess: vi.fn(),
};
describe('ImportDialog', () => {
it('renders nothing when closed', () => {
const { container } = render(<ImportDialog {...defaultProps} open={false} />);
expect(container.innerHTML).toBe('');
});
it('renders Import Products title when open', () => {
render(<ImportDialog {...defaultProps} />);
expect(screen.getByText('Import Products')).toBeInTheDocument();
});
it('shows file chooser button', () => {
render(<ImportDialog {...defaultProps} />);
expect(screen.getByText('Choose .csv or .json file...')).toBeInTheDocument();
});
it('shows error when upload clicked without file', async () => {
render(<ImportDialog {...defaultProps} />);
fireEvent.click(screen.getByText('Upload'));
expect(screen.getByText('Please select a file')).toBeInTheDocument();
});
it('shows file name after selection', () => {
render(<ImportDialog {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['name,category\nTest,other'], 'products.csv', {
type: 'text/csv',
});
fireEvent.change(input, { target: { files: [file] } });
expect(screen.getByText('products.csv')).toBeInTheDocument();
});
it('uploads file and shows success result', async () => {
mockImportProducts.mockResolvedValue({
imported: 5,
skippedDuplicates: 1,
errors: [],
});
render(<ImportDialog {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(screen.getByText('Upload'));
await waitFor(() => {
expect(screen.getByText('Import complete')).toBeInTheDocument();
});
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(defaultProps.onSuccess).toHaveBeenCalled();
});
it('shows errors in result', async () => {
mockImportProducts.mockResolvedValue({
imported: 0,
skippedDuplicates: 0,
errors: [{ row: 2, message: 'Missing name' }],
});
render(<ImportDialog {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(screen.getByText('Upload'));
await waitFor(() => {
expect(screen.getByText('Import complete')).toBeInTheDocument();
});
expect(screen.getByText(/Row 2: Missing name/)).toBeInTheDocument();
expect(defaultProps.onSuccess).not.toHaveBeenCalled();
});
it('shows error on import failure', async () => {
mockImportProducts.mockRejectedValue(new Error('Network error'));
render(<ImportDialog {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(screen.getByText('Upload'));
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
it('shows fallback error on non-Error rejection', async () => {
mockImportProducts.mockRejectedValue('unknown');
render(<ImportDialog {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(screen.getByText('Upload'));
await waitFor(() => {
expect(screen.getByText('Import failed')).toBeInTheDocument();
});
});
it('closes dialog via Cancel button', () => {
render(<ImportDialog {...defaultProps} />);
fireEvent.click(screen.getByText('Cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('closes dialog via Close button after result', async () => {
mockImportProducts.mockResolvedValue({
imported: 1,
skippedDuplicates: 0,
errors: [],
});
render(<ImportDialog {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(screen.getByText('Upload'));
await waitFor(() => {
expect(screen.getByText('Import complete')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Close'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,280 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const { mockLookupBarcode } = vi.hoisted(() => ({
mockLookupBarcode: vi.fn(),
}));
vi.mock('@/services/products', () => ({
lookupBarcode: mockLookupBarcode,
}));
import { ProductModal } from '../ProductModal';
import { ProductCategory, ServingUnit } from '@meshitrack/shared';
beforeEach(() => vi.clearAllMocks());
const defaultProps = {
open: true,
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
householdId: 'hh1',
};
describe('ProductModal', () => {
it('renders nothing when closed', () => {
const { container } = render(<ProductModal {...defaultProps} open={false} />);
expect(container.innerHTML).toBe('');
});
it('renders Add Product title by default', () => {
render(<ProductModal {...defaultProps} />);
expect(screen.getByText('Add Product')).toBeInTheDocument();
});
it('renders custom title when provided', () => {
render(<ProductModal {...defaultProps} title="Custom Title" />);
expect(screen.getByText('Custom Title')).toBeInTheDocument();
});
it('renders Edit Product title when initial is provided', () => {
render(<ProductModal {...defaultProps} initial={{ name: 'Test' }} />);
expect(screen.getByText('Edit Product')).toBeInTheDocument();
});
it('pre-fills form fields from initial', () => {
render(
<ProductModal
{...defaultProps}
initial={{
name: 'Chicken',
brand: 'Tyson',
barcode: '12345678',
category: ProductCategory.MEAT,
servingSize: 100,
servingUnit: ServingUnit.GRAMS,
tags: ['organic', 'protein'],
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
}}
/>,
);
expect(screen.getByDisplayValue('Chicken')).toBeInTheDocument();
expect(screen.getByDisplayValue('Tyson')).toBeInTheDocument();
expect(screen.getByDisplayValue('12345678')).toBeInTheDocument();
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
expect(screen.getByDisplayValue('organic, protein')).toBeInTheDocument();
expect(screen.getByDisplayValue('165')).toBeInTheDocument();
});
it('shows error when name is empty and form submitted', async () => {
render(<ProductModal {...defaultProps} />);
fireEvent.click(screen.getByText('Save'));
expect(screen.getByText('Name is required')).toBeInTheDocument();
expect(defaultProps.onSave).not.toHaveBeenCalled();
});
it('shows error when serving size is empty', async () => {
render(<ProductModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Test' },
});
fireEvent.click(screen.getByText('Save'));
expect(screen.getByText('Serving size must be a positive number')).toBeInTheDocument();
});
it('shows error when nutrition fields are missing', async () => {
render(<ProductModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
target: { value: '100' },
});
fireEvent.click(screen.getByText('Save'));
expect(
screen.getByText('Calories, protein, carbs, and fat are required'),
).toBeInTheDocument();
});
it('calls onSave with correct data on valid submit', async () => {
render(<ProductModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Chicken Breast' },
});
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
target: { value: '100' },
});
// Fill nutrition
const numberInputs = screen.getAllByRole('spinbutton');
// serving size is index 0, calories=1, protein=2, carbs=3, fat=4
fireEvent.change(numberInputs[1]!, { target: { value: '165' } });
fireEvent.change(numberInputs[2]!, { target: { value: '31' } });
fireEvent.change(numberInputs[3]!, { target: { value: '0' } });
fireEvent.change(numberInputs[4]!, { target: { value: '3.6' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(defaultProps.onSave).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Chicken Breast',
servingSize: 100,
nutrition: expect.objectContaining({
calories: 165,
protein: 31,
carbs: 0,
fat: 3.6,
}),
}),
);
});
});
it('closes modal after successful save', async () => {
render(<ProductModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
const numberInputs = screen.getAllByRole('spinbutton');
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it('shows error when onSave rejects', async () => {
const saveFn = vi.fn().mockRejectedValue(new Error('Server error'));
render(<ProductModal {...defaultProps} onSave={saveFn} />);
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
const numberInputs = screen.getAllByRole('spinbutton');
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByText('Server error')).toBeInTheDocument();
});
});
it('shows fallback error when onSave throws non-Error', async () => {
const saveFn = vi.fn().mockRejectedValue('unknown');
render(<ProductModal {...defaultProps} onSave={saveFn} />);
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
const numberInputs = screen.getAllByRole('spinbutton');
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByText('Failed to save')).toBeInTheDocument();
});
});
it('closes when Cancel is clicked', () => {
render(<ProductModal {...defaultProps} />);
fireEvent.click(screen.getByText('Cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('closes when backdrop is clicked', () => {
render(<ProductModal {...defaultProps} />);
// Click the backdrop (outermost div with onClick=onClose)
const backdrop = screen.getByText('Add Product').closest('div')!.parentElement!;
fireEvent.click(backdrop);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('Lookup button is disabled when barcode is empty', () => {
render(<ProductModal {...defaultProps} />);
const lookupBtn = screen.getByText('Lookup');
expect(lookupBtn).toBeDisabled();
});
it('barcode lookup fills form on success', async () => {
mockLookupBarcode.mockResolvedValue({
_id: 'p1',
householdId: 'hh1',
name: 'Nutella',
brand: 'Ferrero',
category: 'snacks',
servingSize: 15,
servingUnit: 'g',
nutrition: { calories: 80, protein: 1, carbs: 8.5, fat: 4.7 },
tags: ['spread'],
source: 'barcode_lookup',
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
});
render(<ProductModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
target: { value: '3017620422003' },
});
fireEvent.click(screen.getByText('Lookup'));
await waitFor(() => {
expect(screen.getByDisplayValue('Nutella')).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Ferrero')).toBeInTheDocument();
expect(screen.getByDisplayValue('80')).toBeInTheDocument();
});
it('barcode lookup shows not found', async () => {
mockLookupBarcode.mockResolvedValue({ found: false });
render(<ProductModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
target: { value: '0000000000000' },
});
fireEvent.click(screen.getByText('Lookup'));
await waitFor(() => {
expect(screen.getByText('Product not found for this barcode')).toBeInTheDocument();
});
});
it('barcode lookup shows error on failure', async () => {
mockLookupBarcode.mockRejectedValue(new Error('Network error'));
render(<ProductModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
target: { value: '1234567890123' },
});
fireEvent.click(screen.getByText('Lookup'));
await waitFor(() => {
expect(screen.getByText('Barcode lookup failed')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,228 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockListProducts, mockDeleteProduct } = vi.hoisted(() => ({
mockListProducts: vi.fn(),
mockDeleteProduct: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/products', () => ({
listProducts: mockListProducts,
deleteProduct: mockDeleteProduct,
}));
vi.mock('next/link', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { default: (props: any) => props.children };
});
import ProductsPage from '../page';
beforeEach(() => vi.clearAllMocks());
const SAMPLE_PRODUCT = {
_id: 'p1',
householdId: 'hh1',
name: 'Chicken Breast',
brand: 'Tyson',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
tags: [],
source: 'manual',
createdBy: 'u1',
createdAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
};
describe('ProductsPage', () => {
it('shows loading skeleton when session is loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<ProductsPage />);
expect(screen.getByText('Product Library')).toBeInTheDocument();
expect(screen.queryByText('All categories')).not.toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<ProductsPage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('renders products when household exists', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => {
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
});
expect(screen.getByText('Tyson')).toBeInTheDocument();
expect(screen.getByText('165 kcal')).toBeInTheDocument();
});
it('shows empty state when no products', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => {
expect(screen.getByText(/No products yet/)).toBeInTheDocument();
});
});
it('shows error message on fetch failure', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockRejectedValue(new Error('Network error'));
render(<ProductsPage />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
it('filters by search input with debounce', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
const searchInput = screen.getByPlaceholderText('Search products...');
fireEvent.change(searchInput, { target: { value: 'chicken' } });
await waitFor(
() => {
expect(mockListProducts).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ q: 'chicken' }),
);
},
{ timeout: 500 },
);
});
it('filters by category dropdown', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
const categorySelect = screen.getByRole('combobox');
fireEvent.change(categorySelect, { target: { value: 'meat' } });
await waitFor(() => {
expect(mockListProducts).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ category: 'meat' }),
);
});
});
it('deletes product on confirm', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
mockDeleteProduct.mockResolvedValue(undefined);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(mockDeleteProduct).toHaveBeenCalledWith('hh1', 'p1');
});
expect(screen.queryByText('Chicken Breast')).not.toBeInTheDocument();
});
it('does not delete when confirm is cancelled', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
fireEvent.click(deleteBtn);
expect(mockDeleteProduct).not.toHaveBeenCalled();
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
});
it('shows error when delete fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
mockDeleteProduct.mockRejectedValue(new Error('Delete failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(screen.getByText('Delete failed')).toBeInTheDocument();
});
});
it('shows macro breakdown in product card', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
expect(screen.getByText('P: 31g')).toBeInTheDocument();
expect(screen.getByText('C: 0g')).toBeInTheDocument();
expect(screen.getByText('F: 3.6g')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,72 @@
'use client';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { ProductList } from './ProductList';
function PageSkeleton() {
return (
<div style={{ padding: '28px 32px' }}>
{[...Array(3)].map((_, i) => (
<div
key={i}
style={{
height: 80,
background: 'var(--bg-elev)',
borderRadius: 'var(--r-md)',
marginBottom: 12,
opacity: 0.5,
}}
/>
))}
</div>
);
}
function NoHousehold() {
return (
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to create or join a household before managing your product library.
</p>
</div>
</div>
);
}
export default function ProductsPage() {
const { householdId, isLoading } = useApi();
if (isLoading) {
return (
<>
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<>
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
<NoHousehold />
</>
);
}
return (
<>
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
<ProductList householdId={householdId} />
</>
);
}

View file

@ -0,0 +1,589 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createRecipe, updateRecipe } from '@/services/recipes';
import { NutritionWarning } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type { RecipeResponseSchema, CreateRecipeInput } from '@meshitrack/shared';
type Recipe = z.infer<typeof RecipeResponseSchema>;
type IngredientUnit = CreateRecipeInput['ingredients'][number]['unit'];
type IngredientInput = {
productId: string;
productName: string;
quantity: number;
unit: IngredientUnit;
preparation: string;
isOptional: boolean;
};
type StepInput = {
order: number;
instruction: string;
duration: string;
tip: string;
};
const WARNING_LABELS: Partial<Record<string, string>> = {
[NutritionWarning.HIGH_CALORIES]: 'High calories',
[NutritionWarning.HIGH_SODIUM]: 'High sodium',
[NutritionWarning.HIGH_SUGAR]: 'High sugar',
[NutritionWarning.HIGH_SATURATED_FAT]: 'High sat fat',
[NutritionWarning.LOW_PROTEIN]: 'Low protein',
[NutritionWarning.LOW_FIBER]: 'Low fiber',
[NutritionWarning.HIGH_CHOLESTEROL]: 'High cholesterol',
};
const UNIT_OPTIONS = ['g', 'ml', 'piece', 'slice', 'oz', 'lb', 'cup', 'tbsp', 'tsp', 'fl_oz'];
function emptyIngredient(): IngredientInput {
return {
productId: '',
productName: '',
quantity: 100,
unit: 'g',
preparation: '',
isOptional: false,
};
}
function emptyStep(order: number): StepInput {
return { order, instruction: '', duration: '', tip: '' };
}
function ingredientFromRecipe(ing: Recipe['ingredients'][0]): IngredientInput {
return {
productId: ing.productId,
productName: ing.productName,
quantity: ing.originalQuantity ?? ing.quantity,
unit: ing.originalUnit ?? ing.unit,
preparation: ing.preparation ?? '',
isOptional: ing.isOptional,
};
}
function stepFromRecipe(step: Recipe['steps'][0]): StepInput {
return {
order: step.order,
instruction: step.instruction,
duration: step.duration ? String(step.duration) : '',
tip: step.tip ?? '',
};
}
function NutritionDisplay({
nutrition,
warnings,
}: {
nutrition: Recipe['perServingNutrition'] | null;
warnings: string[];
}) {
if (!nutrition) return null;
return (
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 16,
}}
>
<h3
style={{
fontSize: 13,
fontWeight: 600,
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--ink-muted)',
}}
>
Per serving (estimated)
</h3>
<div
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 13 }}
>
<span style={{ color: 'var(--ink-muted)' }}>Calories</span>
<span style={{ fontWeight: 600 }}>{Math.round(nutrition.calories)} kcal</span>
<span style={{ color: 'var(--ink-muted)' }}>Protein</span>
<span>{nutrition.protein.toFixed(1)}g</span>
<span style={{ color: 'var(--ink-muted)' }}>Carbs</span>
<span>{nutrition.carbs.toFixed(1)}g</span>
<span style={{ color: 'var(--ink-muted)' }}>Fat</span>
<span>{nutrition.fat.toFixed(1)}g</span>
</div>
{warnings.length > 0 && (
<div style={{ marginTop: 12 }}>
{warnings.map((w) => (
<div
key={w}
style={{
fontSize: 11,
color: 'var(--danger)',
padding: '2px 0',
}}
>
{WARNING_LABELS[w] ?? w}
</div>
))}
</div>
)}
</div>
);
}
interface RecipeEditorProps {
householdId: string;
existing?: Recipe;
}
export function RecipeEditor({ householdId, existing }: RecipeEditorProps) {
const router = useRouter();
const [name, setName] = useState(existing?.name ?? '');
const [description, setDescription] = useState(existing?.description ?? '');
const [servings, setServings] = useState(existing?.servings ?? 4);
const [prepTime, setPrepTime] = useState(existing?.prepTime ? String(existing.prepTime) : '');
const [cookTime, setCookTime] = useState(existing?.cookTime ? String(existing.cookTime) : '');
const [cuisine, setCuisine] = useState(existing?.cuisine ?? '');
const [tags, setTags] = useState(existing?.tags.join(', ') ?? '');
const [isFavorite, setIsFavorite] = useState(existing?.isFavorite ?? false);
const [ingredients, setIngredients] = useState<IngredientInput[]>(
existing ? existing.ingredients.map(ingredientFromRecipe) : [emptyIngredient()],
);
const [steps, setSteps] = useState<StepInput[]>(
existing ? existing.steps.map(stepFromRecipe) : [emptyStep(1)],
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
function updateIngredient(
i: number,
field: keyof IngredientInput,
value: IngredientInput[keyof IngredientInput],
) {
setIngredients((prev) =>
prev.map((ing, idx) => (idx === i ? { ...ing, [field]: value } : ing)),
);
}
function removeIngredient(i: number) {
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
}
function addIngredient() {
setIngredients((prev) => [...prev, emptyIngredient()]);
}
function updateStep(i: number, field: keyof StepInput, value: string) {
setSteps((prev) => prev.map((s, idx) => (idx === i ? { ...s, [field]: value } : s)));
}
function addStep() {
setSteps((prev) => [...prev, emptyStep(prev.length + 1)]);
}
function removeStep(i: number) {
setSteps((prev) =>
prev.filter((_, idx) => idx !== i).map((s, idx) => ({ ...s, order: idx + 1 })),
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSaving(true);
const tagList = tags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const payload = {
name: name.trim(),
description: description.trim() || undefined,
servings,
prepTime: prepTime ? Number(prepTime) : undefined,
cookTime: cookTime ? Number(cookTime) : undefined,
cuisine: cuisine.trim() || undefined,
tags: tagList,
isFavorite,
ingredients: ingredients.map((ing) => ({
productId: ing.productId.trim(),
productName: ing.productName.trim(),
quantity: Number(ing.quantity),
unit: ing.unit,
preparation: ing.preparation.trim() || undefined,
isOptional: ing.isOptional,
})),
steps: steps
.filter((s) => s.instruction.trim())
.map((s, i) => ({
order: i + 1,
instruction: s.instruction.trim(),
duration: s.duration ? Number(s.duration) : undefined,
tip: s.tip.trim() || undefined,
})),
};
try {
if (existing) {
await updateRecipe(householdId, existing._id, payload);
router.push(`/recipes/${existing._id}`);
} else {
const created = await createRecipe(householdId, payload);
router.push(`/recipes/${created._id}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save recipe');
setSaving(false);
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--ink)',
fontSize: 14,
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 12,
fontWeight: 600,
color: 'var(--ink-muted)',
marginBottom: 4,
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
return (
<form
onSubmit={handleSubmit}
style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 32, maxWidth: 1100 }}
>
{/* Main form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{error && <p style={{ color: 'var(--danger)', fontSize: 14, margin: 0 }}>{error}</p>}
{/* Basics */}
<div>
<label style={labelStyle}>Name *</label>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
style={inputStyle}
placeholder="Recipe name"
/>
</div>
<div>
<label style={labelStyle}>Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
style={{ ...inputStyle, resize: 'vertical' }}
placeholder="Brief description..."
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<div>
<label style={labelStyle}>Servings *</label>
<input
type="number"
required
min={1}
value={servings}
onChange={(e) => setServings(Number(e.target.value))}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Prep time (min)</label>
<input
type="number"
min={0}
value={prepTime}
onChange={(e) => setPrepTime(e.target.value)}
style={inputStyle}
placeholder="0"
/>
</div>
<div>
<label style={labelStyle}>Cook time (min)</label>
<input
type="number"
min={0}
value={cookTime}
onChange={(e) => setCookTime(e.target.value)}
style={inputStyle}
placeholder="0"
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Cuisine</label>
<input
value={cuisine}
onChange={(e) => setCuisine(e.target.value)}
style={inputStyle}
placeholder="Italian, Japanese..."
/>
</div>
<div>
<label style={labelStyle}>Tags (comma-separated)</label>
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
style={inputStyle}
placeholder="vegetarian, quick..."
/>
</div>
</div>
<label
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={isFavorite}
onChange={(e) => setIsFavorite(e.target.checked)}
/>
Mark as favorite
</label>
{/* Ingredients */}
<div>
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>Ingredients</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{ingredients.map((ing, i) => (
<div
key={i}
style={{
display: 'grid',
gridTemplateColumns: '2fr 80px 90px 1fr auto',
gap: 8,
alignItems: 'center',
}}
>
<input
placeholder="Product name"
value={ing.productName}
onChange={(e) => {
updateIngredient(i, 'productName', e.target.value);
updateIngredient(
i,
'productId',
e.target.value.toLowerCase().replace(/\s+/g, '-'),
);
}}
style={{ ...inputStyle, fontSize: 13 }}
required
/>
<input
type="number"
placeholder="Qty"
value={ing.quantity}
min={0}
onChange={(e) => updateIngredient(i, 'quantity', e.target.value)}
style={{ ...inputStyle, fontSize: 13 }}
required
/>
<select
value={ing.unit}
onChange={(e) => updateIngredient(i, 'unit', e.target.value as IngredientUnit)}
style={{ ...inputStyle, fontSize: 13 }}
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
<input
placeholder="Preparation (optional)"
value={ing.preparation}
onChange={(e) => updateIngredient(i, 'preparation', e.target.value)}
style={{ ...inputStyle, fontSize: 13 }}
/>
<button
type="button"
onClick={() => removeIngredient(i)}
disabled={ingredients.length === 1}
style={{
background: 'none',
border: 'none',
color: 'var(--ink-muted)',
cursor: 'pointer',
fontSize: 18,
padding: '0 4px',
lineHeight: 1,
}}
>
&times;
</button>
</div>
))}
</div>
<button
type="button"
onClick={addIngredient}
style={{
marginTop: 10,
fontSize: 13,
color: 'var(--brand)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
>
+ Add ingredient
</button>
<p style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 6 }}>
Volume/imperial units (cup, tbsp, etc.) are automatically converted to metric before
saving. A product must be linked for nutrition to calculate.
</p>
</div>
{/* Steps */}
<div>
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>Instructions</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{steps.map((step, i) => (
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--ink-muted)',
minWidth: 20,
paddingTop: 10,
}}
>
{i + 1}.
</span>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
<textarea
value={step.instruction}
onChange={(e) => updateStep(i, 'instruction', e.target.value)}
rows={2}
style={{ ...inputStyle, resize: 'vertical', fontSize: 13 }}
placeholder="Instruction..."
/>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 8 }}>
<input
type="number"
placeholder="Duration (min)"
value={step.duration}
min={0}
onChange={(e) => updateStep(i, 'duration', e.target.value)}
style={{ ...inputStyle, fontSize: 12 }}
/>
<input
placeholder="Tip (optional)"
value={step.tip}
onChange={(e) => updateStep(i, 'tip', e.target.value)}
style={{ ...inputStyle, fontSize: 12 }}
/>
</div>
</div>
<button
type="button"
onClick={() => removeStep(i)}
disabled={steps.length === 1}
style={{
background: 'none',
border: 'none',
color: 'var(--ink-muted)',
cursor: 'pointer',
fontSize: 18,
padding: '0 4px',
lineHeight: 1,
paddingTop: 8,
}}
>
&times;
</button>
</div>
))}
</div>
<button
type="button"
onClick={addStep}
style={{
marginTop: 10,
fontSize: 13,
color: 'var(--brand)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
>
+ Add step
</button>
</div>
{/* Submit */}
<div style={{ display: 'flex', gap: 12, paddingTop: 8 }}>
<button
type="submit"
disabled={saving}
style={{
padding: '10px 24px',
background: 'var(--brand)',
color: '#fff',
border: 'none',
borderRadius: 'var(--r-md)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
opacity: saving ? 0.7 : 1,
}}
>
{saving ? 'Saving...' : existing ? 'Save changes' : 'Create recipe'}
</button>
<button
type="button"
onClick={() => router.back()}
style={{
padding: '10px 16px',
background: 'none',
color: 'var(--ink-muted)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
fontSize: 14,
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
{/* Nutrition sidebar */}
<div style={{ paddingTop: 8 }}>
<NutritionDisplay
nutrition={existing?.perServingNutrition ?? null}
warnings={existing?.warnings ?? []}
/>
<p style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 10, lineHeight: 1.5 }}>
Nutrition is calculated server-side when you save. Link ingredients to products in the
product library for accurate data.
</p>
</div>
</form>
);
}

View file

@ -0,0 +1,331 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import Link from 'next/link';
import { listRecipes, deleteRecipe } from '@/services/recipes';
import { NutritionWarning } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type { RecipeResponseSchema } from '@meshitrack/shared';
type Recipe = z.infer<typeof RecipeResponseSchema>;
const WARNING_LABELS: Partial<Record<string, string>> = {
[NutritionWarning.HIGH_CALORIES]: 'High cal',
[NutritionWarning.HIGH_SODIUM]: 'High sodium',
[NutritionWarning.HIGH_SUGAR]: 'High sugar',
[NutritionWarning.HIGH_SATURATED_FAT]: 'High sat fat',
[NutritionWarning.LOW_PROTEIN]: 'Low protein',
[NutritionWarning.LOW_FIBER]: 'Low fiber',
[NutritionWarning.HIGH_CHOLESTEROL]: 'High chol',
};
function formatTime(minutes?: number): string {
if (!minutes) return '';
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
export function RecipeList({ householdId }: { householdId: string }) {
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [filterCuisine, setFilterCuisine] = useState('');
const [favoritesOnly, setFavoritesOnly] = useState(false);
const debouncedSearch = useDebounce(search, 300);
const abortRef = useRef<AbortController | null>(null);
const fetchRecipes = useCallback(async () => {
if (!householdId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError('');
try {
const result = await listRecipes(householdId, {
q: debouncedSearch || undefined,
cuisine: filterCuisine || undefined,
isFavorite: favoritesOnly || undefined,
limit: 50,
});
setRecipes(result.data);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}, [householdId, debouncedSearch, filterCuisine, favoritesOnly]);
useEffect(() => {
fetchRecipes();
}, [fetchRecipes]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}"?`)) return;
try {
await deleteRecipe(householdId, id);
setRecipes((prev) => prev.filter((r) => r._id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete recipe');
}
}
return (
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
{/* Toolbar */}
<div
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<input
type="search"
placeholder="Search recipes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
flex: '1 1 240px',
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
}}
/>
<input
type="text"
placeholder="Cuisine..."
value={filterCuisine}
onChange={(e) => setFilterCuisine(e.target.value)}
style={{
width: 160,
padding: '8px 12px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
color: 'var(--ink)',
fontSize: 14,
}}
/>
<label
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={favoritesOnly}
onChange={(e) => setFavoritesOnly(e.target.checked)}
/>
Favorites
</label>
<Link
href="/recipes/new"
style={{
padding: '8px 16px',
background: 'var(--brand)',
color: '#fff',
borderRadius: 'var(--r-md)',
fontSize: 14,
textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
+ New Recipe
</Link>
</div>
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
{loading ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 16,
}}
>
{[...Array(6)].map((_, i) => (
<div
key={i}
style={{
height: 160,
background: 'var(--bg-elev)',
borderRadius: 'var(--r-md)',
opacity: 0.5,
}}
/>
))}
</div>
) : recipes.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '64px 24px',
color: 'var(--ink-muted)',
fontSize: 14,
}}
>
{search || filterCuisine || favoritesOnly
? 'No recipes match your filters.'
: 'No recipes yet. Create your first recipe.'}
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 16,
}}
>
{recipes.map((recipe) => (
<RecipeCard key={recipe._id} recipe={recipe} onDelete={handleDelete} />
))}
</div>
)}
</div>
);
}
function RecipeCard({
recipe,
onDelete,
}: {
recipe: Recipe;
onDelete: (id: string, name: string) => void;
}) {
const time = recipe.totalTime ?? (recipe.prepTime ?? 0) + (recipe.cookTime ?? 0);
return (
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Link
href={`/recipes/${recipe._id}`}
style={{
fontSize: 16,
fontWeight: 600,
color: 'var(--ink)',
textDecoration: 'none',
lineHeight: 1.3,
}}
>
{recipe.isFavorite && <span style={{ marginRight: 4 }}>&#9733;</span>}
{recipe.name}
</Link>
</div>
{recipe.cuisine && (
<span style={{ fontSize: 12, color: 'var(--ink-muted)' }}>{recipe.cuisine}</span>
)}
<div
style={{
display: 'flex',
gap: 16,
fontSize: 13,
color: 'var(--ink-muted)',
}}
>
<span>
{recipe.servings} serving{recipe.servings !== 1 ? 's' : ''}
</span>
{time > 0 && <span>{formatTime(time)}</span>}
<span style={{ color: 'var(--ink)', fontWeight: 500 }}>
{Math.round(recipe.perServingNutrition.calories)} kcal
</span>
</div>
{recipe.warnings.length > 0 && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{recipe.warnings.slice(0, 3).map((w) => (
<span
key={w}
style={{
fontSize: 11,
padding: '2px 6px',
borderRadius: 4,
background: 'var(--danger-soft, #fee)',
color: 'var(--danger)',
}}
>
{WARNING_LABELS[w] ?? w}
</span>
))}
</div>
)}
{recipe.tags.length > 0 && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{recipe.tags.slice(0, 4).map((tag) => (
<span
key={tag}
style={{
fontSize: 11,
padding: '2px 6px',
borderRadius: 4,
background: 'var(--bg-subtle, var(--bg))',
color: 'var(--ink-muted)',
border: '1px solid var(--border)',
}}
>
{tag}
</span>
))}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Link
href={`/recipes/${recipe._id}/edit`}
style={{
fontSize: 13,
color: 'var(--brand)',
textDecoration: 'none',
}}
>
Edit
</Link>
<button
type="button"
onClick={() => onDelete(recipe._id, recipe.name)}
style={{
background: 'none',
border: 'none',
fontSize: 13,
color: 'var(--ink-muted)',
cursor: 'pointer',
padding: 0,
}}
>
Delete
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockGetRecipe, mockScaleRecipe } = vi.hoisted(() => ({
mockGetRecipe: vi.fn(),
mockScaleRecipe: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
getRecipe: mockGetRecipe,
scaleRecipe: mockScaleRecipe,
listRecipes: vi.fn(),
deleteRecipe: vi.fn(),
createRecipe: vi.fn(),
updateRecipe: vi.fn(),
importRecipeFromText: vi.fn(),
importRecipeFromUrl: vi.fn(),
listRecipesByProduct: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'r1' }),
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
import RecipeDetailPage from '../page';
beforeEach(() => vi.clearAllMocks());
const SAMPLE_RECIPE = {
_id: 'r1',
householdId: 'hh1',
name: 'Spaghetti Bolognese',
description: 'Classic Italian pasta',
servings: 4,
prepTime: 15,
cookTime: 30,
totalTime: 45,
cuisine: 'Italian',
tags: ['pasta'],
isFavorite: true,
ingredients: [
{
productId: 'p1',
productName: 'Spaghetti',
quantity: 400,
unit: 'g',
isOptional: false,
},
{
productId: 'p2',
productName: 'Parmesan',
quantity: 50,
unit: 'g',
isOptional: true,
preparation: 'grated',
},
],
steps: [
{ order: 1, instruction: 'Boil water', duration: 10 },
{ order: 2, instruction: 'Cook pasta', tip: 'Al dente' },
],
perServingNutrition: {
calories: 450,
protein: 25,
carbs: 55,
fat: 12,
fiber: 3,
sugar: 5,
sodium: 400,
saturatedFat: 4,
cholesterol: 50,
},
totalNutrition: {
calories: 1800,
protein: 100,
carbs: 220,
fat: 48,
},
warnings: ['high_calories'],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
describe('RecipeDetailPage', () => {
it('shows loading skeleton', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<RecipeDetailPage />);
expect(screen.getByText('Recipe')).toBeInTheDocument();
});
it('shows error when fetch fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockRejectedValue(new Error('Not found'));
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Not found')).toBeInTheDocument();
});
});
it('shows recipe not found fallback', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
// Resolve with null-like to trigger error path
mockGetRecipe.mockRejectedValue(new Error('Recipe not found'));
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Recipe not found')).toBeInTheDocument();
});
});
it('renders recipe details', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
expect(screen.getByText('Classic Italian pasta')).toBeInTheDocument();
expect(screen.getByText('4 servings')).toBeInTheDocument();
expect(screen.getByText('Starred')).toBeInTheDocument();
});
});
it('shows ingredients with optional indicator', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
expect(screen.getByText('Parmesan')).toBeInTheDocument();
expect(screen.getByText('(optional)')).toBeInTheDocument();
});
});
it('shows steps with duration and tips', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Boil water')).toBeInTheDocument();
expect(screen.getByText('Cook pasta')).toBeInTheDocument();
expect(screen.getByText('Tip: Al dente')).toBeInTheDocument();
expect(screen.getByText('10 min')).toBeInTheDocument();
});
});
it('shows nutritional warnings', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Nutritional alerts')).toBeInTheDocument();
});
});
it('shows nutrition panel', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('450 kcal')).toBeInTheDocument();
expect(screen.getByText('25.0g')).toBeInTheDocument();
});
});
it('shows edit link', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Edit')).toBeInTheDocument();
});
});
it('handles non-Error fetch failure', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockRejectedValue('string error');
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
});
});
it('renders recipe without description', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue({
...SAMPLE_RECIPE,
description: undefined,
warnings: [],
isFavorite: false,
});
render(<RecipeDetailPage />);
await waitFor(() => {
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
expect(screen.queryByText('Starred')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockGetRecipe } = vi.hoisted(() => ({
mockGetRecipe: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
getRecipe: mockGetRecipe,
createRecipe: vi.fn(),
updateRecipe: vi.fn(),
listRecipes: vi.fn(),
deleteRecipe: vi.fn(),
scaleRecipe: vi.fn(),
importRecipeFromText: vi.fn(),
importRecipeFromUrl: vi.fn(),
listRecipesByProduct: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'r1' }),
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
import EditRecipePage from '../page';
beforeEach(() => vi.clearAllMocks());
const SAMPLE_RECIPE = {
_id: 'r1',
name: 'Pasta',
description: '',
servings: 4,
tags: [],
isFavorite: false,
ingredients: [
{ productId: 'p1', productName: 'Flour', quantity: 200, unit: 'g', isOptional: false },
],
steps: [{ order: 1, instruction: 'Mix' }],
perServingNutrition: { calories: 200, protein: 5, carbs: 30, fat: 4 },
totalNutrition: { calories: 800, protein: 20, carbs: 120, fat: 16 },
warnings: [],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
};
describe('EditRecipePage', () => {
it('shows loading state', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<EditRecipePage />);
expect(screen.getByText('Edit Recipe')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows error when fetch fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockRejectedValue(new Error('Not found'));
render(<EditRecipePage />);
await waitFor(() => {
expect(screen.getByText('Not found')).toBeInTheDocument();
});
});
it('renders editor when recipe loads', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<EditRecipePage />);
await waitFor(() => {
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
expect(screen.getByText('Save changes')).toBeInTheDocument();
});
});
it('shows fallback when recipe is not found', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockRejectedValue('unexpected');
render(<EditRecipePage />);
await waitFor(() => {
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,61 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { RecipeEditor } from '../../RecipeEditor';
import { getRecipe } from '@/services/recipes';
import type { z } from 'zod/v4';
import type { RecipeResponseSchema } from '@meshitrack/shared';
type Recipe = z.infer<typeof RecipeResponseSchema>;
export default function EditRecipePage() {
const params = useParams<{ id: string }>();
const { householdId, isLoading: sessionLoading } = useApi();
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!householdId || !params.id) return;
getRecipe(householdId, params.id)
.then(setRecipe)
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load recipe'))
.finally(() => setLoading(false));
}, [householdId, params.id]);
const title = recipe?.name ? `Edit: ${recipe.name}` : 'Edit Recipe';
if (sessionLoading || loading) {
return (
<>
<SetPageHeader title="Edit Recipe" crumbs={['Recipes', 'Edit']} />
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
Loading...
</div>
</>
);
}
if (error || !recipe) {
return (
<>
<SetPageHeader title="Edit Recipe" crumbs={['Recipes', 'Edit']} />
<div style={{ padding: '28px 32px', color: 'var(--danger)', fontSize: 14 }}>
{error || 'Recipe not found'}
</div>
</>
);
}
return (
<>
<SetPageHeader title={title} crumbs={['Recipes', recipe.name, 'Edit']} />
<div style={{ padding: '28px 32px 56px' }}>
<RecipeEditor householdId={householdId!} existing={recipe} />
</div>
</>
);
}

View file

@ -0,0 +1,420 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { getRecipe, scaleRecipe } from '@/services/recipes';
import { NutritionWarning } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type { RecipeResponseSchema } from '@meshitrack/shared';
type Recipe = z.infer<typeof RecipeResponseSchema>;
const WARNING_LABELS: Partial<Record<string, string>> = {
[NutritionWarning.HIGH_CALORIES]: 'High calories (>800 kcal/serving)',
[NutritionWarning.HIGH_SODIUM]: 'High sodium (>1500mg/serving)',
[NutritionWarning.HIGH_SUGAR]: 'High sugar (>25g/serving)',
[NutritionWarning.HIGH_SATURATED_FAT]: 'High saturated fat (>13g/serving)',
[NutritionWarning.LOW_PROTEIN]: 'Low protein (<10g/serving)',
[NutritionWarning.LOW_FIBER]: 'Low fiber (<3g/serving)',
[NutritionWarning.HIGH_CHOLESTEROL]: 'High cholesterol (>200mg/serving)',
};
function formatTime(minutes?: number): string {
if (!minutes) return '';
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function NutritionPanel({
nutrition,
label,
}: {
nutrition: Recipe['perServingNutrition'];
label: string;
}) {
return (
<div>
<h3
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--ink-muted)',
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{label}
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px' }}>
<NutrRow label="Calories" value={`${Math.round(nutrition.calories)} kcal`} bold />
<NutrRow label="Protein" value={`${nutrition.protein.toFixed(1)}g`} />
<NutrRow label="Carbs" value={`${nutrition.carbs.toFixed(1)}g`} />
<NutrRow label="Fat" value={`${nutrition.fat.toFixed(1)}g`} />
{nutrition.fiber !== undefined && (
<NutrRow label="Fiber" value={`${nutrition.fiber.toFixed(1)}g`} />
)}
{nutrition.sugar !== undefined && (
<NutrRow label="Sugar" value={`${nutrition.sugar.toFixed(1)}g`} />
)}
{nutrition.sodium !== undefined && (
<NutrRow label="Sodium" value={`${Math.round(nutrition.sodium)}mg`} />
)}
{nutrition.saturatedFat !== undefined && (
<NutrRow label="Sat. fat" value={`${nutrition.saturatedFat.toFixed(1)}g`} />
)}
{nutrition.cholesterol !== undefined && (
<NutrRow label="Cholesterol" value={`${Math.round(nutrition.cholesterol)}mg`} />
)}
</div>
</div>
);
}
function NutrRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<>
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>{label}</span>
<span style={{ fontSize: 13, color: 'var(--ink)', fontWeight: bold ? 600 : 400 }}>
{value}
</span>
</>
);
}
export default function RecipeDetailPage() {
const params = useParams<{ id: string }>();
const { householdId, isLoading: sessionLoading } = useApi();
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [scaledServings, setScaledServings] = useState<number | null>(null);
const [scaledRecipe, setScaledRecipe] = useState<Recipe | null>(null);
const [scaling, setScaling] = useState(false);
useEffect(() => {
if (!householdId || !params.id) return;
setLoading(true);
getRecipe(householdId, params.id)
.then((r) => {
setRecipe(r);
setScaledServings(r.servings);
})
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load recipe'))
.finally(() => setLoading(false));
}, [householdId, params.id]);
async function handleScale() {
if (!householdId || !recipe || !scaledServings) return;
if (scaledServings === recipe.servings) {
setScaledRecipe(null);
return;
}
setScaling(true);
try {
const result = await scaleRecipe(householdId, recipe._id, { targetServings: scaledServings });
setScaledRecipe(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to scale recipe');
} finally {
setScaling(false);
}
}
if (sessionLoading || loading) {
return (
<>
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
<div style={{ padding: '28px 32px' }}>
<div
style={{
height: 40,
width: 200,
background: 'var(--bg-elev)',
borderRadius: 8,
opacity: 0.5,
}}
/>
</div>
</>
);
}
if (error || !recipe) {
return (
<>
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
<div style={{ padding: '28px 32px', color: 'var(--danger)', fontSize: 14 }}>
{error || 'Recipe not found'}
</div>
</>
);
}
const displayRecipe = scaledRecipe ?? recipe;
const time = recipe.totalTime ?? (recipe.prepTime ?? 0) + (recipe.cookTime ?? 0);
return (
<>
<SetPageHeader
title={recipe.name}
subtitle={recipe.cuisine ?? undefined}
crumbs={['Recipes', recipe.name]}
/>
<div style={{ padding: '28px 32px 56px', maxWidth: 1100 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 32 }}>
{/* Main content */}
<div>
{/* Meta */}
<div
style={{
display: 'flex',
gap: 16,
marginBottom: 24,
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>
{recipe.servings} servings
</span>
{time > 0 && (
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>{formatTime(time)}</span>
)}
{recipe.isFavorite && (
<span style={{ fontSize: 14, color: 'var(--brand)' }}>Starred</span>
)}
<Link
href={`/recipes/${recipe._id}/edit`}
style={{
marginLeft: 'auto',
fontSize: 13,
color: 'var(--brand)',
textDecoration: 'none',
}}
>
Edit
</Link>
</div>
{recipe.description && (
<p
style={{
fontSize: 14,
color: 'var(--ink-muted)',
marginBottom: 24,
lineHeight: 1.6,
}}
>
{recipe.description}
</p>
)}
{/* Warnings */}
{recipe.warnings.length > 0 && (
<div
style={{
background: 'var(--danger-soft, #fee)',
border: '1px solid var(--danger)',
borderRadius: 'var(--r-md)',
padding: '12px 16px',
marginBottom: 24,
}}
>
<p
style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--danger)' }}
>
Nutritional alerts
</p>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{recipe.warnings.map((w) => (
<li key={w} style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 2 }}>
{WARNING_LABELS[w] ?? w}
</li>
))}
</ul>
</div>
)}
{/* Ingredients */}
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Ingredients</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{displayRecipe.ingredients.map((ing, i) => (
<li
key={i}
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid var(--border)',
fontSize: 14,
}}
>
<span>
{ing.isOptional && (
<span style={{ color: 'var(--ink-muted)', fontSize: 12 }}>(optional) </span>
)}
{ing.productName}
{ing.preparation && (
<span style={{ color: 'var(--ink-muted)' }}>, {ing.preparation}</span>
)}
</span>
<span style={{ color: 'var(--ink-muted)', marginLeft: 16 }}>
{ing.originalQuantity != null && ing.originalUnit
? `${ing.originalQuantity} ${ing.originalUnit}`
: `${ing.quantity} ${ing.unit}`}
</span>
</li>
))}
</ul>
</section>
{/* Steps */}
{recipe.steps.length > 0 && (
<section>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Instructions</h2>
<ol style={{ paddingLeft: 20, margin: 0 }}>
{recipe.steps
.slice()
.sort((a, b) => a.order - b.order)
.map((step) => (
<li key={step.order} style={{ marginBottom: 16 }}>
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
{step.instruction}
</p>
{step.duration && (
<p style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 4 }}>
{formatTime(step.duration)}
</p>
)}
{step.tip && (
<p
style={{
fontSize: 12,
color: 'var(--brand)',
marginTop: 4,
fontStyle: 'italic',
}}
>
Tip: {step.tip}
</p>
)}
</li>
))}
</ol>
</section>
)}
</div>
{/* Sidebar */}
<div>
{/* Scale */}
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 16,
marginBottom: 20,
}}
>
<h3 style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>Scale Recipe</h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="number"
min={1}
max={500}
value={scaledServings ?? recipe.servings}
onChange={(e) => setScaledServings(Number(e.target.value))}
style={{
width: 70,
padding: '6px 8px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--ink)',
fontSize: 14,
}}
/>
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>servings</span>
<button
type="button"
onClick={handleScale}
disabled={scaling}
style={{
padding: '6px 12px',
background: 'var(--brand)',
color: '#fff',
border: 'none',
borderRadius: 'var(--r-sm)',
fontSize: 13,
cursor: 'pointer',
opacity: scaling ? 0.7 : 1,
}}
>
{scaling ? '...' : 'Scale'}
</button>
</div>
{scaledRecipe && (
<button
type="button"
onClick={() => {
setScaledRecipe(null);
setScaledServings(recipe.servings);
}}
style={{
marginTop: 8,
fontSize: 12,
color: 'var(--ink-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
>
Reset to original
</button>
)}
</div>
{/* Nutrition */}
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 16,
marginBottom: 16,
}}
>
<NutritionPanel
nutrition={displayRecipe.perServingNutrition}
label={`Per serving (${displayRecipe.servings} total)`}
/>
</div>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 16,
}}
>
<NutritionPanel
nutrition={displayRecipe.totalNutrition}
label="Total (all servings)"
/>
</div>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,229 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockCreateRecipe, mockUpdateRecipe } = vi.hoisted(() => ({
mockCreateRecipe: vi.fn(),
mockUpdateRecipe: vi.fn(),
}));
const { mockPush, mockBack } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockBack: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
createRecipe: mockCreateRecipe,
updateRecipe: mockUpdateRecipe,
listRecipes: vi.fn(),
deleteRecipe: vi.fn(),
getRecipe: vi.fn(),
scaleRecipe: vi.fn(),
importRecipeFromText: vi.fn(),
importRecipeFromUrl: vi.fn(),
listRecipesByProduct: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush, back: mockBack }),
useParams: () => ({ id: 'r1' }),
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
import { RecipeEditor } from '../RecipeEditor';
beforeEach(() => vi.clearAllMocks());
describe('RecipeEditor', () => {
it('renders create form by default', () => {
render(<RecipeEditor householdId="hh1" />);
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
expect(screen.getByText('Create recipe')).toBeInTheDocument();
expect(screen.getByText('Ingredients')).toBeInTheDocument();
expect(screen.getByText('Instructions')).toBeInTheDocument();
});
it('renders edit form when existing recipe provided', () => {
const existing = {
_id: 'r1',
name: 'Pasta',
description: 'Good pasta',
servings: 2,
prepTime: 10,
cookTime: 20,
cuisine: 'Italian',
tags: ['pasta', 'quick'],
isFavorite: true,
ingredients: [
{
productId: 'p1',
productName: 'Spaghetti',
quantity: 200,
unit: 'g',
originalQuantity: 200,
originalUnit: 'g',
isOptional: false,
},
],
steps: [{ order: 1, instruction: 'Boil water', duration: 5, tip: 'Use salted water' }],
perServingNutrition: { calories: 300, protein: 10, carbs: 40, fat: 8 },
totalNutrition: { calories: 600, protein: 20, carbs: 80, fat: 16 },
warnings: [],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
expect(screen.getByDisplayValue('Good pasta')).toBeInTheDocument();
expect(screen.getByText('Save changes')).toBeInTheDocument();
});
it('submits create form', async () => {
mockCreateRecipe.mockResolvedValue({ _id: 'new1' });
render(<RecipeEditor householdId="hh1" />);
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
target: { value: 'New Recipe' },
});
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Flour' },
});
fireEvent.click(screen.getByText('Create recipe'));
await waitFor(() => {
expect(mockCreateRecipe).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'New Recipe' }),
);
expect(mockPush).toHaveBeenCalledWith('/recipes/new1');
});
});
it('submits update form', async () => {
mockUpdateRecipe.mockResolvedValue({ _id: 'r1' });
const existing = {
_id: 'r1',
name: 'Old Name',
description: '',
servings: 4,
tags: [],
isFavorite: false,
ingredients: [
{
productId: 'p1',
productName: 'Test',
quantity: 100,
unit: 'g',
isOptional: false,
},
],
steps: [{ order: 1, instruction: 'Do thing' }],
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 3 },
totalNutrition: { calories: 400, protein: 20, carbs: 40, fat: 12 },
warnings: [],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
fireEvent.change(screen.getByDisplayValue('Old Name'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Save changes'));
await waitFor(() => {
expect(mockUpdateRecipe).toHaveBeenCalledWith(
'hh1',
'r1',
expect.objectContaining({ name: 'New Name' }),
);
expect(mockPush).toHaveBeenCalledWith('/recipes/r1');
});
});
it('shows error when create fails', async () => {
mockCreateRecipe.mockRejectedValue(new Error('Server error'));
render(<RecipeEditor householdId="hh1" />);
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Item' },
});
fireEvent.click(screen.getByText('Create recipe'));
await waitFor(() => {
expect(screen.getByText('Server error')).toBeInTheDocument();
});
});
it('shows fallback error for non-Error failure', async () => {
mockCreateRecipe.mockRejectedValue('unexpected');
render(<RecipeEditor householdId="hh1" />);
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Item' },
});
fireEvent.click(screen.getByText('Create recipe'));
await waitFor(() => {
expect(screen.getByText('Failed to save recipe')).toBeInTheDocument();
});
});
it('can add and remove ingredients', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add ingredient'));
const nameInputs = screen.getAllByPlaceholderText('Product name');
expect(nameInputs).toHaveLength(2);
});
it('can add and remove steps', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add step'));
const stepInputs = screen.getAllByPlaceholderText('Instruction...');
expect(stepInputs).toHaveLength(2);
});
it('cancel button calls router.back', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('Cancel'));
expect(mockBack).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,423 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockListRecipes, mockDeleteRecipe } = vi.hoisted(() => ({
mockListRecipes: vi.fn(),
mockDeleteRecipe: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
listRecipes: mockListRecipes,
deleteRecipe: mockDeleteRecipe,
createRecipe: vi.fn(),
updateRecipe: vi.fn(),
getRecipe: vi.fn(),
scaleRecipe: vi.fn(),
importRecipeFromText: vi.fn(),
importRecipeFromUrl: vi.fn(),
listRecipesByProduct: vi.fn(),
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
import RecipesPage from '../page';
beforeEach(() => vi.clearAllMocks());
const SAMPLE_RECIPE = {
_id: 'r1',
householdId: 'hh1',
name: 'Spaghetti Bolognese',
description: 'Classic Italian pasta',
servings: 4,
prepTime: 15,
cookTime: 30,
totalTime: 45,
cuisine: 'Italian',
tags: ['pasta', 'comfort'],
isFavorite: true,
ingredients: [
{
productId: 'p1',
productName: 'Spaghetti',
quantity: 400,
unit: 'g',
isOptional: false,
},
],
steps: [{ order: 1, instruction: 'Boil water' }],
perServingNutrition: {
calories: 450,
protein: 25,
carbs: 55,
fat: 12,
fiber: 3,
sugar: 5,
sodium: 400,
},
totalNutrition: {
calories: 1800,
protein: 100,
carbs: 220,
fat: 48,
},
warnings: ['high_calories'],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
describe('RecipesPage', () => {
it('shows loading skeleton when session is loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<RecipesPage />);
expect(screen.getByText('Recipes')).toBeInTheDocument();
expect(screen.queryByText('Search recipes...')).not.toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<RecipesPage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('renders recipe list when household exists', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
});
});
it('shows empty state when no recipes', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText(/No recipes yet/)).toBeInTheDocument();
});
});
it('shows nutrition info on recipe card', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('450 kcal')).toBeInTheDocument();
expect(screen.getByText('4 servings')).toBeInTheDocument();
});
});
it('shows time on recipe card', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('45 min')).toBeInTheDocument();
});
});
it('shows warning labels', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('High cal')).toBeInTheDocument();
});
});
it('shows tags on recipe card', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('pasta')).toBeInTheDocument();
expect(screen.getByText('comfort')).toBeInTheDocument();
});
});
it('shows favorite star', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
// Star character is rendered for favorites
expect(screen.getByText('Spaghetti Bolognese').closest('a')).toBeInTheDocument();
});
});
it('shows edit link', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Edit')).toBeInTheDocument();
});
});
it('handles delete with confirmation', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
mockDeleteRecipe.mockResolvedValue(undefined);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(mockDeleteRecipe).toHaveBeenCalledWith('hh1', 'r1');
});
});
it('does not delete when confirm is cancelled', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Delete'));
expect(mockDeleteRecipe).not.toHaveBeenCalled();
});
it('shows error when fetch fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockRejectedValue(new Error('Network error'));
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
it('shows filter empty state message', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Cuisine...')).toBeInTheDocument();
});
fireEvent.change(screen.getByPlaceholderText('Cuisine...'), {
target: { value: 'Thai' },
});
await waitFor(() => {
expect(screen.getByText(/No recipes match your filters/)).toBeInTheDocument();
});
});
it('shows recipe without totalTime using prep+cook', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [{ ...SAMPLE_RECIPE, totalTime: undefined }],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('45 min')).toBeInTheDocument();
});
});
it('formats hours correctly', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [{ ...SAMPLE_RECIPE, totalTime: 90 }],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('1h 30m')).toBeInTheDocument();
});
});
it('formats exact hours', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [{ ...SAMPLE_RECIPE, totalTime: 120 }],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('2h')).toBeInTheDocument();
});
});
it('handles delete error', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
mockDeleteRecipe.mockRejectedValue(new Error('Delete failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(screen.getByText('Delete failed')).toBeInTheDocument();
});
});
it('handles non-Error delete failure', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [SAMPLE_RECIPE],
pagination: { cursor: null, hasMore: false },
});
mockDeleteRecipe.mockRejectedValue('string error');
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(screen.getByText('Failed to delete recipe')).toBeInTheDocument();
});
});
it('shows + New Recipe link', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('+ New Recipe')).toBeInTheDocument();
});
});
it('recipe with no warnings renders without warning badges', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [{ ...SAMPLE_RECIPE, warnings: [], tags: [] }],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
expect(screen.queryByText('High cal')).not.toBeInTheDocument();
});
});
it('toggles favorites checkbox', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListRecipes.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<RecipesPage />);
await waitFor(() => {
expect(screen.getByLabelText('Favorites')).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Favorites'));
await waitFor(() => {
expect(mockListRecipes).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ isFavorite: true }),
);
});
});
});

View file

@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
createRecipe: vi.fn(),
updateRecipe: vi.fn(),
listRecipes: vi.fn(),
deleteRecipe: vi.fn(),
getRecipe: vi.fn(),
scaleRecipe: vi.fn(),
importRecipeFromText: vi.fn(),
importRecipeFromUrl: vi.fn(),
listRecipesByProduct: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
import NewRecipePage from '../page';
beforeEach(() => vi.clearAllMocks());
describe('NewRecipePage', () => {
it('shows loading state', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<NewRecipePage />);
expect(screen.getByText('New Recipe')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<NewRecipePage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('renders editor when household exists', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render(<NewRecipePage />);
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
expect(screen.getByText('Create recipe')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,40 @@
'use client';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { RecipeEditor } from '../RecipeEditor';
export default function NewRecipePage() {
const { householdId, isLoading } = useApi();
if (isLoading) {
return (
<>
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
Loading...
</div>
</>
);
}
if (!householdId) {
return (
<>
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
You need to create or join a household first.
</div>
</>
);
}
return (
<>
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
<div style={{ padding: '28px 32px 56px' }}>
<RecipeEditor householdId={householdId} />
</div>
</>
);
}

View file

@ -0,0 +1,72 @@
'use client';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { RecipeList } from './RecipeList';
function PageSkeleton() {
return (
<div style={{ padding: '28px 32px' }}>
{[...Array(3)].map((_, i) => (
<div
key={i}
style={{
height: 80,
background: 'var(--bg-elev)',
borderRadius: 'var(--r-md)',
marginBottom: 12,
opacity: 0.5,
}}
/>
))}
</div>
);
}
function NoHousehold() {
return (
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to create or join a household before managing recipes.
</p>
</div>
</div>
);
}
export default function RecipesPage() {
const { householdId, isLoading } = useApi();
if (isLoading) {
return (
<>
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<>
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
<NoHousehold />
</>
);
}
return (
<>
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
<RecipeList householdId={householdId} />
</>
);
}

View file

@ -0,0 +1,84 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const { mockUsePathname } = vi.hoisted(() => ({
mockUsePathname: vi.fn(),
}));
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
vi.mock('next/navigation', () => ({
usePathname: mockUsePathname,
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
import { Sidebar } from '../layout/Sidebar';
describe('Sidebar', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUsePathname.mockReturnValue('/dashboard');
mockUseApi.mockReturnValue({ profile: { displayName: 'Alice' } });
});
it('renders brand name', () => {
render(<Sidebar />);
expect(screen.getByText('MeshiTrack')).toBeInTheDocument();
});
it('renders navigation sections', () => {
render(<Sidebar />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Medicines')).toBeInTheDocument();
expect(screen.getByText('Food')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('renders medicines nav items', () => {
render(<Sidebar />);
expect(screen.getByText('Cabinet')).toBeInTheDocument();
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
expect(screen.getByText('Regimens')).toBeInTheDocument();
expect(screen.getByText('Library')).toBeInTheDocument();
});
it('renders food nav items', () => {
render(<Sidebar />);
expect(screen.getByText('Recipes')).toBeInTheDocument();
expect(screen.getByText('Pantry')).toBeInTheDocument();
});
it('highlights active route', () => {
mockUsePathname.mockReturnValue('/medicines/cabinet');
render(<Sidebar />);
const cabinetLink = screen.getByText('Cabinet').closest('a');
expect(cabinetLink).toHaveAttribute('href', '/medicines/cabinet');
});
it('shows user avatar with display name', () => {
render(<Sidebar />);
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
});
it('shows fallback name when no profile', () => {
mockUseApi.mockReturnValue({ profile: null });
render(<Sidebar />);
expect(screen.getByLabelText('User')).toBeInTheDocument();
});
it('highlights nested route', () => {
mockUsePathname.mockReturnValue('/medicines/cabinet/some-id');
render(<Sidebar />);
// Cabinet link should still match via startsWith
const cabinetLink = screen.getByText('Cabinet').closest('a');
expect(cabinetLink).toBeInTheDocument();
});
});

View file

@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { ThemeProvider, useTheme } from '../ThemeProvider';
import { Providers } from '../Providers';
// Mock next-auth SessionProvider
vi.mock('next-auth/react', () => ({
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
function ThemeConsumer() {
const { theme, accent, setTheme, setAccent, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="accent">{accent}</span>
<button onClick={() => setTheme('dark')}>Set dark</button>
<button onClick={() => setAccent('cobalt')}>Set cobalt</button>
<button onClick={toggleTheme}>Toggle</button>
</div>
);
}
describe('ThemeProvider', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
document.documentElement.style.cssText = '';
});
it('provides default light/sage theme', () => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('theme').textContent).toBe('light');
expect(screen.getByTestId('accent').textContent).toBe('sage');
});
it('hydrates from localStorage', () => {
localStorage.setItem('mt-theme', 'dark');
localStorage.setItem('mt-accent', 'terracotta');
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('theme').textContent).toBe('dark');
expect(screen.getByTestId('accent').textContent).toBe('terracotta');
});
it('setTheme updates theme and persists', () => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
act(() => fireEvent.click(screen.getByText('Set dark')));
expect(screen.getByTestId('theme').textContent).toBe('dark');
expect(localStorage.getItem('mt-theme')).toBe('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('setAccent updates accent and applies CSS vars', () => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
act(() => fireEvent.click(screen.getByText('Set cobalt')));
expect(screen.getByTestId('accent').textContent).toBe('cobalt');
expect(localStorage.getItem('mt-accent')).toBe('cobalt');
expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#2e5aa8');
});
it('toggleTheme flips light to dark', () => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
act(() => fireEvent.click(screen.getByText('Toggle')));
expect(screen.getByTestId('theme').textContent).toBe('dark');
});
it('toggleTheme flips dark to light', () => {
localStorage.setItem('mt-theme', 'dark');
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
act(() => fireEvent.click(screen.getByText('Toggle')));
expect(screen.getByTestId('theme').textContent).toBe('light');
});
it('useTheme throws outside provider', () => {
expect(() => render(<ThemeConsumer />)).toThrow('useTheme must be used inside ThemeProvider');
});
});
describe('Providers', () => {
it('renders children with session and theme providers', () => {
render(
<Providers>
<span>child content</span>
</Providers>,
);
expect(screen.getByText('child content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,42 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@/components/ThemeProvider', () => ({
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
}));
vi.mock('@/components/layout/PageHeaderContext', () => ({
usePageHeader: () => ({
header: {
title: 'Test Page',
subtitle: 'Subtitle here',
crumbs: ['Home', 'Test Page'],
actions: null,
},
}),
}));
import { TopBar } from '../layout/TopBar';
describe('TopBar', () => {
it('renders title and subtitle', () => {
render(<TopBar />);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Page');
expect(screen.getByText('Subtitle here')).toBeInTheDocument();
});
it('renders breadcrumbs', () => {
render(<TopBar />);
expect(screen.getByText('Home')).toBeInTheDocument();
});
it('renders search input', () => {
render(<TopBar />);
expect(screen.getByPlaceholderText(/Search medicines/)).toBeInTheDocument();
});
it('renders theme toggle button', () => {
render(<TopBar />);
expect(screen.getByLabelText('Toggle theme')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Avatar } from '../ui/Avatar';
import { IconButton } from '../ui/IconButton';
import { Ring } from '../ui/Ring';
import { SparkBars } from '../ui/SparkBars';
import { SupplyBar } from '../ui/SupplyBar';
describe('Avatar', () => {
it('renders initial from name', () => {
render(<Avatar name="Alice" />);
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
});
it('uses custom size', () => {
render(<Avatar name="Bob" size={48} />);
const el = screen.getByLabelText('Bob');
expect(el).toHaveStyle({ width: '48px', height: '48px' });
});
});
describe('IconButton', () => {
it('renders with label and icon', () => {
render(<IconButton icon="settings" label="Settings" />);
expect(screen.getByLabelText('Settings')).toBeInTheDocument();
});
it('renders notification dot when dot=true', () => {
render(<IconButton icon="bell" label="Notifications" dot />);
// dot is a span inside the button
const btn = screen.getByLabelText('Notifications');
expect(btn.querySelectorAll('span').length).toBeGreaterThanOrEqual(1);
});
});
describe('Ring', () => {
it('renders value and total', () => {
render(<Ring value={5} total={10} />);
expect(screen.getByLabelText('5 of 10 doses taken')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('handles zero total', () => {
render(<Ring value={0} total={0} />);
expect(screen.getByText('0')).toBeInTheDocument();
});
});
describe('SparkBars', () => {
it('renders bars with labels', () => {
const data = [
{ label: 'Jan', amount: 100 },
{ label: 'Feb', amount: 200 },
];
render(<SparkBars data={data} />);
expect(screen.getByText('Jan')).toBeInTheDocument();
expect(screen.getByText('Feb')).toBeInTheDocument();
});
it('renders formatted amounts', () => {
const data = [{ label: 'Mar', amount: 5000 }];
render(<SparkBars data={data} />);
expect(screen.getByText('5k')).toBeInTheDocument();
});
});
describe('SupplyBar', () => {
it('renders days value', () => {
render(<SupplyBar days={30} />);
expect(screen.getByText('30')).toBeInTheDocument();
expect(screen.getByText('d')).toBeInTheDocument();
});
it('renders for critical days', () => {
render(<SupplyBar days={5} />);
expect(screen.getByText('5')).toBeInTheDocument();
});
it('renders for medium days', () => {
render(<SupplyBar days={10} />);
expect(screen.getByText('10')).toBeInTheDocument();
});
});

View file

@ -71,6 +71,27 @@ const NAV: NavItem[] = [
section: 'Medicines',
},
{ id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' },
{
id: 'recipes',
label: 'Recipes',
href: '/recipes',
icon: 'list',
section: 'Food',
},
{
id: 'pantry',
label: 'Pantry',
href: '/pantry',
icon: 'fridge',
section: 'Food',
},
{
id: 'products',
label: 'Product Library',
href: '/products',
icon: 'list',
section: 'Food',
},
];
function groupNav(items: NavItem[]): [string, NavItem[]][] {

View file

@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPatch: vi.fn(),
mockDelete: vi.fn(),
}));
vi.mock('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import {
listPantryItems,
getPantryItem,
createPantryItem,
updatePantryItem,
transitionPantryItem,
batchTransitionPantryItems,
getExpiringSoon,
getWasteStats,
deletePantryItem,
} from '../pantry';
beforeEach(() => vi.clearAllMocks());
describe('pantry service', () => {
it('listPantryItems with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPantryItems('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry');
});
it('listPantryItems builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPantryItems('hh1', {
storageLocation: 'fridge',
status: 'sealed',
urgency: 'urgent',
productId: 'p1',
limit: 10,
});
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('storageLocation=fridge');
expect(url).toContain('status=sealed');
expect(url).toContain('urgency=urgent');
expect(url).toContain('productId=p1');
expect(url).toContain('limit=10');
});
it('listPantryItems with cursor', async () => {
mockGet.mockResolvedValue({ data: [] });
await listPantryItems('hh1', { cursor: 'cur1' });
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
});
it('getPantryItem calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'item1' });
await getPantryItem('hh1', 'item1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/item1');
});
it('createPantryItem calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'item1' });
const data = { productId: 'p1', storageLocation: 'fridge', quantity: 1, unit: 'piece' };
await createPantryItem('hh1', data as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry', data);
});
it('updatePantryItem calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'item1' });
await updatePantryItem('hh1', 'item1', { quantity: 2 });
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/pantry/item1', { quantity: 2 });
});
it('transitionPantryItem calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'item1' });
await transitionPantryItem('hh1', 'item1', { status: 'opened' as never });
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/item1/transition', {
status: 'opened',
});
});
it('batchTransitionPantryItems calls POST', async () => {
mockPost.mockResolvedValue({ transitioned: 2, failed: 0 });
const data = { itemIds: ['a', 'b'], status: 'consumed' as const };
await batchTransitionPantryItems('hh1', data as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/batch-transition', data);
});
it('getExpiringSoon with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await getExpiringSoon('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/expiring-soon');
});
it('getExpiringSoon builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await getExpiringSoon('hh1', { days: 3, cursor: 'c1', limit: 5 });
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('days=3');
expect(url).toContain('cursor=c1');
expect(url).toContain('limit=5');
});
it('getWasteStats with no period', async () => {
mockGet.mockResolvedValue({ wastePercentage: 10 });
await getWasteStats('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats');
});
it('getWasteStats with period', async () => {
mockGet.mockResolvedValue({ wastePercentage: 10 });
await getWasteStats('hh1', 'week');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats?period=week');
});
it('deletePantryItem calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deletePantryItem('hh1', 'item1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/pantry/item1');
});
});

View file

@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPatch: vi.fn(),
mockDelete: vi.fn(),
}));
vi.mock('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import {
listProducts,
getProduct,
lookupBarcode,
createProduct,
updateProduct,
deleteProduct,
smartAddProduct,
importProducts,
} from '../products';
beforeEach(() => vi.clearAllMocks());
describe('products service', () => {
it('listProducts with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listProducts('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products');
});
it('listProducts builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listProducts('hh1', {
q: 'chicken',
category: 'meat',
tags: 'organic',
barcode: '1234',
cursor: 'cur1',
limit: 10,
});
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('q=chicken');
expect(url).toContain('category=meat');
expect(url).toContain('tags=organic');
expect(url).toContain('barcode=1234');
expect(url).toContain('cursor=cur1');
expect(url).toContain('limit=10');
});
it('getProduct calls GET with correct path', async () => {
mockGet.mockResolvedValue({ _id: 'p1' });
await getProduct('hh1', 'p1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/p1');
});
it('lookupBarcode calls GET barcode endpoint', async () => {
mockGet.mockResolvedValue({ _id: 'p1' });
await lookupBarcode('hh1', '1234567890');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/barcode/1234567890');
});
it('createProduct calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'p1' });
const data = {
name: 'Chicken',
category: 'meat' as never,
servingSize: 100,
servingUnit: 'g' as never,
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
tags: [],
source: 'manual' as never,
};
await createProduct('hh1', data);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products', data);
});
it('updateProduct calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'p1' });
await updateProduct('hh1', 'p1', { name: 'Updated' });
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/products/p1', { name: 'Updated' });
});
it('deleteProduct calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deleteProduct('hh1', 'p1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/products/p1');
});
it('smartAddProduct calls POST smart-add', async () => {
mockPost.mockResolvedValue({ available: false, message: 'LLM not configured' });
const result = await smartAddProduct('hh1', 'chicken breast');
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products/smart-add', {
text: 'chicken breast',
});
expect(result.available).toBe(false);
});
describe('importProducts', () => {
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
});
it('uploads file via fetch and returns result', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ imported: 3, skippedDuplicates: 1, errors: [] }),
});
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
const result = await importProducts('hh1', file);
expect(result.imported).toBe(3);
expect(result.skippedDuplicates).toBe(1);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/households/hh1/products/import'),
expect.objectContaining({ method: 'POST' }),
);
});
it('throws on non-ok response with JSON body', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
json: () => Promise.resolve({ message: 'File too large' }),
});
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
await expect(importProducts('hh1', file)).rejects.toThrow('File too large');
});
it('throws with status on non-ok response without JSON', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.reject(new Error('not json')),
});
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
await expect(importProducts('hh1', file)).rejects.toThrow(
'Import failed: 500 Internal Server Error',
);
});
it('throws with status when JSON body has no message', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: () => Promise.resolve({}),
});
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
await expect(importProducts('hh1', file)).rejects.toThrow('Import failed: 422');
});
});
});

View file

@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPatch: vi.fn(),
mockDelete: vi.fn(),
}));
vi.mock('../api-client', () => ({
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
}));
import {
listRecipes,
getRecipe,
createRecipe,
updateRecipe,
deleteRecipe,
scaleRecipe,
importRecipeFromText,
importRecipeFromUrl,
listRecipesByProduct,
} from '../recipes';
beforeEach(() => vi.clearAllMocks());
describe('recipes service', () => {
it('listRecipes with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipes('hh1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes');
});
it('listRecipes builds query string', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipes('hh1', {
q: 'pasta',
tags: 'italian',
cuisine: 'Italian',
maxCalories: 500,
isFavorite: true,
cursor: 'cur1',
limit: 10,
});
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('q=pasta');
expect(url).toContain('tags=italian');
expect(url).toContain('cuisine=Italian');
expect(url).toContain('maxCalories=500');
expect(url).toContain('isFavorite=true');
expect(url).toContain('cursor=cur1');
expect(url).toContain('limit=10');
});
it('getRecipe calls GET', async () => {
mockGet.mockResolvedValue({ _id: 'r1' });
await getRecipe('hh1', 'r1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/r1');
});
it('createRecipe calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'r1' });
const data = { name: 'Pasta' };
await createRecipe('hh1', data as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes', data);
});
it('updateRecipe calls PATCH', async () => {
mockPatch.mockResolvedValue({ _id: 'r1' });
await updateRecipe('hh1', 'r1', { name: 'Updated' } as never);
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/recipes/r1', { name: 'Updated' });
});
it('deleteRecipe calls DELETE', async () => {
mockDelete.mockResolvedValue(undefined);
await deleteRecipe('hh1', 'r1');
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/recipes/r1');
});
it('scaleRecipe calls POST', async () => {
mockPost.mockResolvedValue({ _id: 'r1' });
await scaleRecipe('hh1', 'r1', { targetServings: 8 });
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/r1/scale', {
targetServings: 8,
});
});
it('importRecipeFromText calls POST', async () => {
mockPost.mockResolvedValue({ available: false });
await importRecipeFromText('hh1', { text: 'recipe text' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-text', {
text: 'recipe text',
});
});
it('importRecipeFromUrl calls POST', async () => {
mockPost.mockResolvedValue({ available: false });
await importRecipeFromUrl('hh1', { url: 'http://example.com' } as never);
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-url', {
url: 'http://example.com',
});
});
it('listRecipesByProduct with no query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipesByProduct('hh1', 'p1');
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/by-product/p1');
});
it('listRecipesByProduct with query', async () => {
mockGet.mockResolvedValue({ data: [] });
await listRecipesByProduct('hh1', 'p1', { cursor: 'c1', limit: 5 });
const url = mockGet.mock.calls[0][0] as string;
expect(url).toContain('cursor=c1');
expect(url).toContain('limit=5');
});
});

View file

@ -0,0 +1,113 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
PantryItemResponseSchema,
PantryItemListResponseSchema,
WasteStatsResponseSchema,
BatchTransitionResponseSchema,
CreatePantryItemInput,
UpdatePantryItemInput,
TransitionPantryItemInput,
BatchTransitionInput,
} from '@meshitrack/shared';
type PantryItemResponse = z.infer<typeof PantryItemResponseSchema>;
type PantryItemListResponse = z.infer<typeof PantryItemListResponseSchema>;
type WasteStatsResponse = z.infer<typeof WasteStatsResponseSchema>;
type BatchTransitionResponse = z.infer<typeof BatchTransitionResponseSchema>;
export interface PantryQuery {
storageLocation?: string;
status?: string;
urgency?: string;
productId?: string;
cursor?: string;
limit?: number;
}
export async function listPantryItems(
householdId: string,
query?: PantryQuery,
): Promise<PantryItemListResponse> {
const params = new URLSearchParams();
if (query?.storageLocation) params.set('storageLocation', query.storageLocation);
if (query?.status) params.set('status', query.status);
if (query?.urgency) params.set('urgency', query.urgency);
if (query?.productId) params.set('productId', query.productId);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<PantryItemListResponse>(
`/households/${householdId}/pantry${qs ? `?${qs}` : ''}`,
);
}
export async function getPantryItem(householdId: string, id: string): Promise<PantryItemResponse> {
return apiClient.get<PantryItemResponse>(`/households/${householdId}/pantry/${id}`);
}
export async function createPantryItem(
householdId: string,
data: CreatePantryItemInput,
): Promise<PantryItemResponse> {
return apiClient.post<PantryItemResponse>(`/households/${householdId}/pantry`, data);
}
export async function updatePantryItem(
householdId: string,
id: string,
data: UpdatePantryItemInput,
): Promise<PantryItemResponse> {
return apiClient.patch<PantryItemResponse>(`/households/${householdId}/pantry/${id}`, data);
}
export async function transitionPantryItem(
householdId: string,
id: string,
data: TransitionPantryItemInput,
): Promise<PantryItemResponse> {
return apiClient.post<PantryItemResponse>(
`/households/${householdId}/pantry/${id}/transition`,
data,
);
}
export async function batchTransitionPantryItems(
householdId: string,
data: BatchTransitionInput,
): Promise<BatchTransitionResponse> {
return apiClient.post<BatchTransitionResponse>(
`/households/${householdId}/pantry/batch-transition`,
data,
);
}
export async function getExpiringSoon(
householdId: string,
query?: { days?: number; cursor?: string; limit?: number },
): Promise<PantryItemListResponse> {
const params = new URLSearchParams();
if (query?.days !== undefined) params.set('days', String(query.days));
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<PantryItemListResponse>(
`/households/${householdId}/pantry/expiring-soon${qs ? `?${qs}` : ''}`,
);
}
export async function getWasteStats(
householdId: string,
period?: string,
): Promise<WasteStatsResponse> {
const params = new URLSearchParams();
if (period) params.set('period', period);
const qs = params.toString();
return apiClient.get<WasteStatsResponse>(
`/households/${householdId}/pantry/stats${qs ? `?${qs}` : ''}`,
);
}
export async function deletePantryItem(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/pantry/${id}`);
}

View file

@ -0,0 +1,107 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
ProductResponseSchema,
ProductListResponseSchema,
CreateProductInput,
UpdateProductInput,
} from '@meshitrack/shared';
type ProductResponse = z.infer<typeof ProductResponseSchema>;
type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
export interface ProductQuery {
q?: string;
category?: string;
tags?: string;
barcode?: string;
cursor?: string;
limit?: number;
}
export async function listProducts(
householdId: string,
query?: ProductQuery,
): Promise<ProductListResponse> {
const params = new URLSearchParams();
if (query?.q) params.set('q', query.q);
if (query?.category) params.set('category', query.category);
if (query?.tags) params.set('tags', query.tags);
if (query?.barcode) params.set('barcode', query.barcode);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<ProductListResponse>(
`/households/${householdId}/products${qs ? `?${qs}` : ''}`,
);
}
export async function getProduct(householdId: string, id: string): Promise<ProductResponse> {
return apiClient.get<ProductResponse>(`/households/${householdId}/products/${id}`);
}
export async function lookupBarcode(
householdId: string,
barcode: string,
): Promise<ProductResponse | { found: false }> {
return apiClient.get<ProductResponse | { found: false }>(
`/households/${householdId}/products/barcode/${barcode}`,
);
}
export async function createProduct(
householdId: string,
data: CreateProductInput,
): Promise<ProductResponse> {
return apiClient.post<ProductResponse>(`/households/${householdId}/products`, data);
}
export async function updateProduct(
householdId: string,
id: string,
data: UpdateProductInput,
): Promise<ProductResponse> {
return apiClient.patch<ProductResponse>(`/households/${householdId}/products/${id}`, data);
}
export async function deleteProduct(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/products/${id}`);
}
export async function smartAddProduct(
householdId: string,
text: string,
): Promise<{ available: false; message: string }> {
return apiClient.post<{ available: false; message: string }>(
`/households/${householdId}/products/smart-add`,
{ text },
);
}
export async function importProducts(
householdId: string,
file: File,
): Promise<{
imported: number;
skippedDuplicates: number;
errors: { row: number; message: string }[];
}> {
const formData = new FormData();
formData.append('file', file);
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
const res = await fetch(`${baseUrl}/households/${householdId}/products/import`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
let message: string;
try {
const body = await res.json();
message = body.message || `Import failed: ${res.status}`;
} catch {
message = `Import failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
return res.json();
}

View file

@ -0,0 +1,101 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
RecipeResponseSchema,
RecipeListResponseSchema,
CreateRecipeInput,
UpdateRecipeInput,
ScaleRecipeInput,
ImportRecipeTextInput,
ImportRecipeUrlInput,
} from '@meshitrack/shared';
type RecipeResponse = z.infer<typeof RecipeResponseSchema>;
type RecipeListResponse = z.infer<typeof RecipeListResponseSchema>;
export interface RecipeQuery {
q?: string;
tags?: string;
cuisine?: string;
maxCalories?: number;
isFavorite?: boolean;
cursor?: string;
limit?: number;
}
export async function listRecipes(
householdId: string,
query?: RecipeQuery,
): Promise<RecipeListResponse> {
const params = new URLSearchParams();
if (query?.q) params.set('q', query.q);
if (query?.tags) params.set('tags', query.tags);
if (query?.cuisine) params.set('cuisine', query.cuisine);
if (query?.maxCalories !== undefined) params.set('maxCalories', String(query.maxCalories));
if (query?.isFavorite !== undefined) params.set('isFavorite', String(query.isFavorite));
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<RecipeListResponse>(
`/households/${householdId}/recipes${qs ? `?${qs}` : ''}`,
);
}
export async function getRecipe(householdId: string, id: string): Promise<RecipeResponse> {
return apiClient.get<RecipeResponse>(`/households/${householdId}/recipes/${id}`);
}
export async function createRecipe(
householdId: string,
data: CreateRecipeInput,
): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`/households/${householdId}/recipes`, data);
}
export async function updateRecipe(
householdId: string,
id: string,
data: UpdateRecipeInput,
): Promise<RecipeResponse> {
return apiClient.patch<RecipeResponse>(`/households/${householdId}/recipes/${id}`, data);
}
export async function deleteRecipe(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/recipes/${id}`);
}
export async function scaleRecipe(
householdId: string,
id: string,
data: ScaleRecipeInput,
): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`/households/${householdId}/recipes/${id}/scale`, data);
}
export async function importRecipeFromText(
householdId: string,
data: ImportRecipeTextInput,
): Promise<{ available: boolean; draft?: unknown }> {
return apiClient.post(`/households/${householdId}/recipes/import-text`, data);
}
export async function importRecipeFromUrl(
householdId: string,
data: ImportRecipeUrlInput,
): Promise<{ available: boolean; draft?: unknown }> {
return apiClient.post(`/households/${householdId}/recipes/import-url`, data);
}
export async function listRecipesByProduct(
householdId: string,
productId: string,
query?: { cursor?: string; limit?: number },
): Promise<RecipeListResponse> {
const params = new URLSearchParams();
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<RecipeListResponse>(
`/households/${householdId}/recipes/by-product/${productId}${qs ? `?${qs}` : ''}`,
);
}

View file

@ -20,27 +20,27 @@ export default defineConfig({
coverage: {
provider: 'v8',
enabled: false,
// @ts-expect-error -- v8 provider supports `all` but types lag behind
all: true,
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/test-utils.tsx',
'src/mocks/**',
'src/app/layout.tsx',
'src/app/api/**',
'src/lib/auth.ts',
'src/proxy.ts',
'src/**/*.d.ts',
'src/app/(dashboard)/layout.tsx',
'src/components/**',
'src/app/login/**',
],
reporter: ['text', 'lcov', 'json-summary', 'html'],
reportsDirectory: './coverage',
thresholds: {
lines: 24,
functions: 18,
branches: 18,
statements: 24,
lines: 90,
functions: 85,
branches: 75,
statements: 85,
},
},
testTimeout: 10_000,