Cleanup after initial plan

This commit is contained in:
Aerilyn Weber 2026-05-19 10:13:40 +09:00
parent d2a7e652b3
commit 245520fb50
53 changed files with 6733 additions and 621 deletions

View file

@ -9,7 +9,10 @@ vi.mock('swr', () => ({ default: mockUseSWR }));
vi.mock('@/services/cabinet', () => ({
getCabinetSummary: vi.fn(),
listCabinetItems: vi.fn(),
}));
vi.mock('@/services/regimens', () => ({
getBurnRates: vi.fn(),
}));
vi.mock('@/services/purchases', () => ({
@ -68,22 +71,30 @@ describe(DashboardPage.name, () => {
expect(screen.getByText(/there/)).toBeInTheDocument();
});
it('renders cabinet items', () => {
it('renders cabinet items that are in active regimens', () => {
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' }],
const summaryData = {
data: [
{ medicineId: '1', medicineName: 'Aspirin', totalQuantity: 50, unit: 'tablets' },
{ medicineId: '2', medicineName: 'Ibuprofen', totalQuantity: 100, unit: 'tablets' },
],
};
const burnRateData = {
data: [
{ medicineId: '1', medicineName: 'Aspirin', daysUntilEmpty: 10 },
],
};
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('burn-rates')) return { data: burnRateData };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };
@ -93,7 +104,8 @@ describe(DashboardPage.name, () => {
render(<DashboardPage />);
expect(screen.getByText('Aspirin')).toBeInTheDocument();
expect(screen.getByText('50 tablets')).toBeInTheDocument();
// Ibuprofen should NOT be in the document because it has no burn rate
expect(screen.queryByText('Ibuprofen')).not.toBeInTheDocument();
});
it('shows empty states when no data', () => {
@ -106,7 +118,7 @@ describe(DashboardPage.name, () => {
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('burn-rates')) 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: [] } };
@ -115,7 +127,7 @@ describe(DashboardPage.name, () => {
render(<DashboardPage />);
expect(screen.getByText('No cabinet items yet.')).toBeInTheDocument();
expect(screen.getByText('No active medicines in regimens.')).toBeInTheDocument();
expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument();
expect(screen.getByText('No pending orders.')).toBeInTheDocument();
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
@ -134,8 +146,8 @@ describe(DashboardPage.name, () => {
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('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
if (key.includes('burn-rates')) 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: [] } };
@ -170,7 +182,7 @@ describe(DashboardPage.name, () => {
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('burn-rates')) 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: [] } };
@ -205,7 +217,7 @@ describe(DashboardPage.name, () => {
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('burn-rates')) 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 };
@ -227,8 +239,8 @@ describe(DashboardPage.name, () => {
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('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } };
if (key.includes('burn-rates')) 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: [] } };
@ -250,8 +262,8 @@ describe(DashboardPage.name, () => {
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: [] } };
return { data: { data: [{ medicineId: '1' }, { medicineId: '2' }, { medicineId: '3' }] } };
if (key.includes('burn-rates')) return { data: { data: [] } };
if (key.includes('refill-alerts'))
return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } };
if (key.includes('purchases')) return { data: { data: [] } };
@ -281,7 +293,7 @@ describe(DashboardPage.name, () => {
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('burn-rates')) 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: [] } };
@ -300,14 +312,18 @@ describe(DashboardPage.name, () => {
profile: { displayName: 'Jane' },
});
const cabinetData = {
data: [{ _id: 'c1', medicineName: undefined, quantity: 10, unit: 'pills' }],
const summaryData = {
data: [{ medicineId: 'c1', medicineName: undefined, totalQuantity: 10, unit: 'pills' }],
};
const burnRateData = {
data: [{ medicineId: 'c1', medicineName: undefined, daysUntilEmpty: 5 }],
};
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('cabinet-summary')) return { data: summaryData };
if (key.includes('burn-rates')) return { data: burnRateData };
if (key.includes('refill-alerts')) return { data: { data: [] } };
if (key.includes('purchases')) return { data: { data: [] } };
if (key.includes('cabinet-events')) return { data: { data: [] } };

View file

@ -2,15 +2,17 @@
import useSWR from 'swr';
import { useApi } from '@/lib/useApi';
import { getCabinetSummary, listCabinetItems } from '@/services/cabinet';
import { getCabinetSummary } from '@/services/cabinet';
import { listPurchases } from '@/services/purchases';
import { getRefillAlerts } from '@/services/refills';
import { listCabinetEvents } from '@/services/cabinet-events';
import { getBurnRates } from '@/services/regimens';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Pill } from '@/components/ui/Pill';
import { Icon } from '@/components/ui/Icon';
import { SupplyBar } from '@/components/ui/SupplyBar';
function now() {
return new Date();
@ -35,8 +37,8 @@ export default function DashboardPage() {
getCabinetSummary(householdId!),
);
const { data: cabinetItems } = useSWR(householdId ? `cabinet-items-${householdId}` : null, () =>
listCabinetItems(householdId!, { limit: 10 }),
const { data: burnRates } = useSWR(householdId ? `burn-rates-${householdId}` : null, () =>
getBurnRates(householdId!),
);
const { data: pendingPurchases } = useSWR(
@ -154,66 +156,56 @@ export default function DashboardPage() {
gap: 6,
}}
>
{cabinetItems?.data.length ? (
cabinetItems.data.slice(0, 8).map((item) => (
<div
key={item._id}
style={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 12,
alignItems: 'center',
fontSize: 12,
padding: '4px 0',
}}
>
{(() => {
const itemsWithBurnRate = summary?.data.filter((item) =>
burnRates?.data.some((br) => br.medicineId === item.medicineId),
);
if (!itemsWithBurnRate?.length) {
return <EmptyState message="No active medicines in regimens." />;
}
return itemsWithBurnRate.slice(0, 8).map((item) => {
const matchedBR = burnRates?.data.find(
(br) => br.medicineId === item.medicineId,
);
return (
<div
key={item.medicineId}
style={{
fontWeight: 500,
color: 'var(--ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'grid',
gridTemplateColumns: '160px 1fr',
gap: 12,
alignItems: 'center',
fontSize: 12,
padding: '4px 0',
}}
>
{item.medicineName ?? 'Unknown'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
flex: 1,
height: 6,
background: 'var(--bg-inset)',
borderRadius: 3,
fontWeight: 500,
color: 'var(--ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={item.medicineName}
>
<div
style={{
height: '100%',
width: `${Math.min(100, (item.quantity / 100) * 100)}%`,
background: 'var(--brand)',
borderRadius: 3,
}}
/>
{item.medicineName ?? 'Unknown'}
</div>
<div style={{ flex: 1 }}>
{matchedBR && matchedBR.daysUntilEmpty !== null ? (
<SupplyBar days={matchedBR.daysUntilEmpty} />
) : (
<div style={{ fontSize: 11, color: 'var(--ink-faint)' }}>
As needed
</div>
)}
</div>
<span
className="num"
style={{
fontSize: 12,
fontWeight: 600,
minWidth: 40,
textAlign: 'right',
}}
>
{item.quantity} {item.unit}
</span>
</div>
</div>
))
) : (
<EmptyState message="No cabinet items yet." />
)}
);
});
})()}
</div>
</Card>
</div>

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import Link from 'next/link';
import useSWR, { mutate } from 'swr';
import { useApi } from '@/lib/useApi';
import {
listCabinetItems,
getCabinetSummary,
@ -208,9 +210,6 @@ function StatsStrip({ items }: { items: SummaryItem[] }) {
export function CabinetTab({ householdId }: { householdId: string }) {
const [view, setView] = useState<'summary' | 'detail'>('summary');
const [summaryItems, setSummaryItems] = useState<SummaryItem[]>([]);
const [cabinetItems, setCabinetItems] = useState<CabinetItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [filterStatus, setFilterStatus] = useState('');
@ -218,43 +217,55 @@ export function CabinetTab({ householdId }: { householdId: string }) {
const [expandedItems, setExpandedItems] = useState<CabinetItem[]>([]);
const [expandLoading, setExpandLoading] = useState(false);
const fetchData = useCallback(async () => {
if (!householdId) return;
setLoading(true);
try {
if (view === 'summary') {
const result = await getCabinetSummary(householdId);
setSummaryItems(result.data);
if (expandedMedicine) {
const expanded = await listCabinetItems(householdId, {
medicineId: expandedMedicine,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(expanded.data);
if (expanded.data.length === 0) {
setExpandedMedicine(null);
}
}
} else {
const result = await listCabinetItems(householdId, {
status: (filterStatus as CabinetItemStatus) || undefined,
limit: 50,
});
setCabinetItems(result.data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load cabinet');
} finally {
setLoading(false);
}
}, [householdId, view, filterStatus, expandedMedicine]);
const summaryKey = householdId && view === 'summary' ? `cabinet-summary-${householdId}` : null;
const detailKey = householdId && view === 'detail' ? `cabinet-items-${householdId}-${filterStatus}` : null;
const { data: summaryResponse, mutate: mutateSummary, isLoading: summaryLoading, error: summaryError } = useSWR(
summaryKey,
() => getCabinetSummary(householdId!),
);
const { data: detailResponse, mutate: mutateDetail, isLoading: detailLoading, error: detailError } = useSWR(
detailKey,
() =>
listCabinetItems(householdId!, {
status: (filterStatus as CabinetItemStatus) || undefined,
limit: 50,
}),
);
useEffect(() => {
if (householdId) {
fetchData();
const err = summaryError || detailError;
if (err) {
setError(err instanceof Error ? err.message : 'Failed to load cabinet');
}
}, [householdId, fetchData]);
}, [summaryError, detailError]);
const summaryItems = summaryResponse?.data ?? [];
const cabinetItems = detailResponse?.data ?? [];
const loading = (view === 'summary' && summaryLoading) || (view === 'detail' && detailLoading);
const refreshExpanded = async (medId: string) => {
if (!householdId) return;
setExpandLoading(true);
try {
const result = await listCabinetItems(householdId, {
medicineId: medId,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(result.data);
if (result.data.length === 0) {
setExpandedMedicine(null);
}
} catch {
setExpandedItems([]);
} finally {
setExpandLoading(false);
}
};
async function handleExpand(medicineId: string) {
if (!householdId) return;
@ -264,36 +275,59 @@ export function CabinetTab({ householdId }: { householdId: string }) {
return;
}
setExpandedMedicine(medicineId);
setExpandLoading(true);
try {
const result = await listCabinetItems(householdId, {
medicineId,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(result.data);
} catch {
setExpandedItems([]);
} finally {
setExpandLoading(false);
}
refreshExpanded(medicineId);
}
async function handleAdjust(itemId: string, delta: number) {
if (!householdId) return;
// Optimistic updates
if (view === 'summary') {
mutateSummary(async (current) => {
if (!current) return current;
// Finding which medicine this item belongs to is hard without more info in the ID,
// but summary view items are aggregated.
// For simplicity in summary view, we'll just trigger a refresh or find by mapping.
// However, we can also mutate the expanded items if they are visible.
return current;
}, { revalidate: false });
if (expandedMedicine) {
setExpandedItems(prev => prev.map(item =>
item._id === itemId ? { ...item, quantity: Math.max(0, item.quantity + delta) } : item
));
}
} else {
mutateDetail(async (current) => {
if (!current) return current;
return {
...current,
data: current.data.map(item =>
item._id === itemId ? { ...item, quantity: Math.max(0, item.quantity + delta) } : item
)
};
}, { revalidate: false });
}
try {
await adjustCabinetItemQuantity(householdId, itemId, { delta });
fetchData();
mutateSummary();
mutateDetail();
if (expandedMedicine) refreshExpanded(expandedMedicine);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to adjust quantity');
mutateSummary();
mutateDetail();
}
}
async function handleDelete(itemId: string) {
if (!householdId || !confirm('Delete this item permanently?')) return;
if (!householdId || !window.confirm('Delete this item permanently?')) return;
try {
await deleteCabinetItem(householdId, itemId);
fetchData();
mutateSummary();
mutateDetail();
if (expandedMedicine) refreshExpanded(expandedMedicine);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
@ -394,7 +428,8 @@ export function CabinetTab({ householdId }: { householdId: string }) {
householdId={householdId}
onCreated={() => {
setShowForm(false);
fetchData();
mutateSummary();
mutateDetail();
}}
onCancel={() => setShowForm(false)}
/>
@ -447,7 +482,7 @@ function SummaryView({
expandedItems: CabinetItem[];
expandLoading: boolean;
onExpand: (id: string) => void;
onAdjust: (id: string, delta: number) => void;
onAdjust: (id: string, delta: number) => Promise<void>;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
@ -597,7 +632,7 @@ function DetailView({
onDelete,
}: {
items: CabinetItem[];
onAdjust: (id: string, delta: number) => void;
onAdjust: (id: string, delta: number) => Promise<void>;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
@ -639,9 +674,11 @@ function CabinetItemCard({
}: {
item: CabinetItem;
showMedicineName?: boolean;
onAdjust: (id: string, delta: number) => void;
onAdjust: (id: string, delta: number) => Promise<void>;
onDelete: (id: string) => void;
}) {
const [isPending, setIsPending] = useState(false);
const statusStyle =
item.status === 'active'
? { background: 'var(--ok-soft)', color: 'var(--ok)' }
@ -649,6 +686,16 @@ function CabinetItemCard({
? { background: 'var(--danger-soft)', color: 'var(--danger)' }
: { background: 'var(--bg-inset)', color: 'var(--ink-muted)' };
async function handleAdjust(delta: number) {
if (isPending) return;
setIsPending(true);
try {
await onAdjust(item._id, delta);
} finally {
setIsPending(false);
}
}
return (
<div
style={{
@ -658,6 +705,9 @@ function CabinetItemCard({
gap: 12,
width: '100%',
minWidth: 0,
opacity: isPending ? 0.6 : 1,
pointerEvents: isPending ? 'none' : 'auto',
transition: 'opacity 0.2s',
}}
>
{/* Left: info */}
@ -732,7 +782,7 @@ function CabinetItemCard({
{item.status === 'active' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<button
onClick={() => onAdjust(item._id, -1)}
onClick={() => handleAdjust(-1)}
style={{
border: '1px solid var(--border)',
padding: '3px 8px',
@ -749,7 +799,7 @@ function CabinetItemCard({
-1
</button>
<button
onClick={() => onAdjust(item._id, 1)}
onClick={() => handleAdjust(1)}
style={{
border: '1px solid var(--border)',
padding: '3px 8px',

View file

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import {
listRegimens,
createRegimen,
@ -449,38 +450,42 @@ function BurnRateTable({ burnRates }: { burnRates: BurnRateItem[] }) {
// --- Main component ---
export function RegimensTab({ householdId }: { householdId: string }) {
const [regimens, setRegimens] = useState<Regimen[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null);
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
const [burnRates, setBurnRates] = useState<BurnRateItem[]>([]);
const [showBurnRate, setShowBurnRate] = useState(false);
const [burnRateLoading, setBurnRateLoading] = useState(false);
const fetchRegimens = useCallback(async () => {
setLoading(true);
try {
const query =
filterActive === 'active'
? { isActive: true }
: filterActive === 'inactive'
? { isActive: false }
: {};
const result = await listRegimens(householdId, { ...query, limit: 50 });
setRegimens(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load regimens');
} finally {
setLoading(false);
}
}, [householdId, filterActive]);
const query = useMemo(() => {
const q: any = { limit: 50 };
if (filterActive === 'active') q.isActive = true;
if (filterActive === 'inactive') q.isActive = false;
return q;
}, [filterActive]);
const swrKey = householdId ? `regimens-${householdId}-${JSON.stringify(query)}` : null;
const { data: regimensResponse, mutate: mutateRegimens, isLoading: loading, error: swrError } = useSWR(
swrKey,
() => listRegimens(householdId, query)
);
const regimens = regimensResponse?.data ?? [];
const { data: burnRateResponse, mutate: mutateBurnRates, isLoading: burnRateSWRLoading, error: burnRateError } = useSWR(
householdId && showBurnRate ? `burn-rates-${householdId}` : null,
() => getBurnRates(householdId)
);
const burnRates = burnRateResponse?.data ?? [];
const burnRateLoading = showBurnRate && burnRateSWRLoading;
useEffect(() => {
fetchRegimens();
}, [fetchRegimens]);
const err = swrError || burnRateError;
if (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
}
}, [swrError, burnRateError]);
// Medicines are needed for the form
useEffect(() => {
@ -490,39 +495,50 @@ export function RegimensTab({ householdId }: { householdId: string }) {
}, [householdId]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete regimen "${name}"?`)) return;
if (!window.confirm(`Delete regimen "${name}"?`)) return;
// Optimistic delete
mutateRegimens(async (current) => {
if (!current) return current;
return { ...current, data: current.data.filter((r: any) => r._id !== id) };
}, { revalidate: false });
try {
await deleteRegimen(householdId, id);
setRegimens((prev) => prev.filter((r) => r._id !== id));
mutateRegimens();
if (showBurnRate) mutateBurnRates();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
mutateRegimens();
}
}
async function handleToggleActive(regimen: Regimen) {
const nextActive = !regimen.isActive;
// Optimistic toggle
mutateRegimens(async (current) => {
if (!current) return current;
return {
...current,
data: current.data.map((r: any) => r._id === regimen._id ? { ...r, isActive: nextActive } : r)
};
}, { revalidate: false });
try {
const updated = await updateRegimen(householdId, regimen._id, {
isActive: !regimen.isActive,
await updateRegimen(householdId, regimen._id, {
isActive: nextActive,
});
setRegimens((prev) => prev.map((r) => (r._id === regimen._id ? updated : r)));
mutateRegimens();
if (showBurnRate) mutateBurnRates();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update');
mutateRegimens();
}
}
async function handleShowBurnRate() {
setShowBurnRate((prev) => !prev);
if (!showBurnRate && burnRates.length === 0) {
setBurnRateLoading(true);
try {
const result = await getBurnRates(householdId);
setBurnRates(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load burn rates');
} finally {
setBurnRateLoading(false);
}
}
}
const isFormOpen = showForm || editingRegimen !== null;
@ -585,8 +601,8 @@ export function RegimensTab({ householdId }: { householdId: string }) {
medicines={medicines}
onSaved={() => {
setShowForm(false);
fetchRegimens();
setBurnRates([]);
mutateRegimens();
if (showBurnRate) mutateBurnRates();
}}
onCancel={() => setShowForm(false)}
/>
@ -599,8 +615,8 @@ export function RegimensTab({ householdId }: { householdId: string }) {
initial={editingRegimen}
onSaved={() => {
setEditingRegimen(null);
fetchRegimens();
setBurnRates([]);
mutateRegimens();
if (showBurnRate) mutateBurnRates();
}}
onCancel={() => setEditingRegimen(null)}
/>

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SWRConfig } from 'swr';
import type React from 'react';
const {
@ -63,9 +64,13 @@ beforeEach(() => {
mockListMedicines.mockResolvedValue(emptyMeds);
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
describe('CabinetTab', () => {
it('shows empty state when no items', async () => {
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText(/cabinet is empty/i)).toBeInTheDocument());
});
@ -73,7 +78,7 @@ describe('CabinetTab', () => {
it('shows error when list fails', async () => {
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument());
});
@ -81,7 +86,7 @@ describe('CabinetTab', () => {
it('dismisses error on Dismiss click', async () => {
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Server error'));
await userEvent.click(screen.getByText('Dismiss'));
@ -89,7 +94,7 @@ describe('CabinetTab', () => {
});
it('toggles Add to Cabinet form', async () => {
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -100,7 +105,7 @@ describe('CabinetTab', () => {
});
it('switches between Summary and All Items views', async () => {
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -114,7 +119,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -130,7 +135,7 @@ describe('CabinetTab', () => {
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 11 });
mockGetCabinetSummary.mockResolvedValue(emptySummary);
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -158,7 +163,7 @@ describe('CabinetTab', () => {
],
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
expect(screen.getByText(/2 items/)).toBeInTheDocument();
@ -185,7 +190,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin'));
await userEvent.click(screen.getByText('Metformin').closest('button')!);
@ -201,7 +206,7 @@ describe('CabinetTab', () => {
it('submits AddToCabinetForm with medicine selection validation', async () => {
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -229,7 +234,7 @@ describe('CabinetTab', () => {
});
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -266,7 +271,7 @@ describe('CabinetTab', () => {
});
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -308,7 +313,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -346,7 +351,7 @@ describe('CabinetTab', () => {
],
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -369,7 +374,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -384,7 +389,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -403,7 +408,7 @@ describe('CabinetTab', () => {
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 9 });
mockGetCabinetSummary.mockResolvedValue(emptySummary);
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -435,7 +440,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin'));
await userEvent.click(screen.getByText('Metformin').closest('button')!);
@ -451,7 +456,7 @@ describe('CabinetTab', () => {
it('shows error when createCabinetItem fails', async () => {
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -471,7 +476,7 @@ describe('CabinetTab', () => {
mockDeleteCabinetItem.mockResolvedValue({});
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -489,7 +494,7 @@ describe('CabinetTab', () => {
});
mockAdjustCabinetItemQuantity.mockRejectedValue(new Error('Adjust failed'));
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -508,7 +513,7 @@ describe('CabinetTab', () => {
mockDeleteCabinetItem.mockRejectedValue(new Error('Delete failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -520,7 +525,7 @@ describe('CabinetTab', () => {
});
it('cancels AddToCabinetForm with internal Cancel button', async () => {
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -551,12 +556,12 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin'));
// First click expands — handleExpand + useEffect both call listCabinetItems
// First click expands — handleExpand calls listCabinetItems
await userEvent.click(screen.getByText('Metformin').closest('button')!);
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(1));
const callsAfterExpand = mockListCabinetItems.mock.calls.length;
// Second click collapses — no additional listCabinetItems calls
@ -584,12 +589,12 @@ describe('CabinetTab', () => {
});
mockListCabinetItems.mockRejectedValue(new Error('Expand failed'));
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Metformin'));
await userEvent.click(screen.getByText('Metformin').closest('button')!);
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(1));
// No items should be shown (empty after error)
expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument();
});
@ -603,7 +608,7 @@ describe('CabinetTab', () => {
});
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));
@ -621,7 +626,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All Items'));
await userEvent.click(screen.getByText('All Items'));
@ -637,7 +642,7 @@ describe('CabinetTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await userEvent.click(screen.getByText('Add to Cabinet'));
// Wait for medicine options to load (covers medicines.map callback)
@ -665,7 +670,7 @@ describe('CabinetTab', () => {
});
mockCreateCabinetItem.mockRejectedValue('unexpected');
render(<CabinetTab householdId="hh1" />);
render(<CabinetTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Add to Cabinet'));
await userEvent.click(screen.getByText('Add to Cabinet'));

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SWRConfig } from 'swr';
const {
mockListRegimens,
@ -59,9 +60,13 @@ beforeEach(() => {
mockGetBurnRates.mockResolvedValue({ data: [] });
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
describe('RegimensTab', () => {
it('shows empty state when no regimens', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText(/No regimens yet/)).toBeInTheDocument());
});
@ -72,7 +77,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Morning Routine')).toBeInTheDocument());
});
@ -80,7 +85,7 @@ describe('RegimensTab', () => {
it('shows error when list fails', async () => {
mockListRegimens.mockRejectedValue(new Error('Load failed'));
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText('Load failed')).toBeInTheDocument());
});
@ -88,7 +93,7 @@ describe('RegimensTab', () => {
it('dismisses error', async () => {
mockListRegimens.mockRejectedValue(new Error('Load failed'));
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Load failed'));
await userEvent.click(screen.getByText('Dismiss'));
@ -96,7 +101,7 @@ describe('RegimensTab', () => {
});
it('toggles new regimen form', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getByText('New Regimen'));
@ -110,7 +115,7 @@ describe('RegimensTab', () => {
it('creates a regimen when form is submitted', async () => {
mockCreateRegimen.mockResolvedValue(regimen);
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -137,7 +142,7 @@ describe('RegimensTab', () => {
mockDeleteRegimen.mockResolvedValue({});
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Delete'));
@ -148,7 +153,7 @@ describe('RegimensTab', () => {
it('shows burn rate section when toggled', async () => {
mockGetBurnRates.mockResolvedValue({ data: [] });
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate'));
@ -165,7 +170,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit'));
@ -180,7 +185,7 @@ describe('RegimensTab', () => {
});
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit'));
@ -201,7 +206,7 @@ describe('RegimensTab', () => {
});
it('shows validation error when submitting regimen form with no medications', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -228,7 +233,7 @@ describe('RegimensTab', () => {
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Delete'));
@ -255,7 +260,7 @@ describe('RegimensTab', () => {
],
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate'));
@ -270,7 +275,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit'));
@ -287,7 +292,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('All regimens'));
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
@ -296,7 +301,7 @@ describe('RegimensTab', () => {
});
it('adds a medication in the regimen form', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -309,7 +314,7 @@ describe('RegimensTab', () => {
});
it('toggles isActive checkbox in regimen form', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -328,7 +333,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -358,7 +363,7 @@ describe('RegimensTab', () => {
});
it('changes instructions field in medication row', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -372,7 +377,7 @@ describe('RegimensTab', () => {
});
it('changes frequency to custom and sets times per day', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -398,7 +403,7 @@ describe('RegimensTab', () => {
});
it('changes time of day in medication row', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -418,7 +423,7 @@ describe('RegimensTab', () => {
it('shows error when burn rate fetch fails', async () => {
mockGetBurnRates.mockRejectedValue(new Error('Burn rate failed'));
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate'));
@ -433,7 +438,7 @@ describe('RegimensTab', () => {
});
mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Deactivate'));
@ -448,7 +453,7 @@ describe('RegimensTab', () => {
});
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Deactivate'));
@ -457,7 +462,7 @@ describe('RegimensTab', () => {
});
it('cancels new regimen form using internal Cancel button', async () => {
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -473,7 +478,7 @@ describe('RegimensTab', () => {
it('filters regimens by inactive status', async () => {
mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(mockListRegimens).toHaveBeenCalledTimes(1));
@ -491,7 +496,7 @@ describe('RegimensTab', () => {
it('shows filtered empty state when filter is active and no results', async () => {
mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByDisplayValue('All regimens'));
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
@ -502,7 +507,7 @@ describe('RegimensTab', () => {
it('shows null when form is open and regimens list is empty', async () => {
mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -525,7 +530,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => expect(screen.getByText(/2 medications/)).toBeInTheDocument());
});
@ -534,18 +539,18 @@ describe('RegimensTab', () => {
mockGetBurnRates.mockRejectedValue('burn failed');
mockListRegimens.mockResolvedValue(emptyRegimens);
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate'));
await waitFor(() => expect(screen.getByText('Failed to load burn rates')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Failed to load data')).toBeInTheDocument());
});
it('shows fallback error when non-Error thrown on create regimen', async () => {
mockCreateRegimen.mockRejectedValue('save failed');
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('New Regimen'));
await userEvent.click(screen.getAllByText('New Regimen')[0]);
@ -563,7 +568,7 @@ describe('RegimensTab', () => {
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
render(<RegimensTab householdId="hh1" />, { wrapper });
await waitFor(() => screen.getByText('Morning Routine'));
await userEvent.click(screen.getByTitle('Edit'));

View file

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import { listPantryItems, transitionPantryItem, deletePantryItem } from '@/services/pantry';
import { StorageLocation, ItemStatus, FreshnessUrgency } from '@meshitrack/shared';
import type { z } from 'zod/v4';
@ -59,55 +60,65 @@ const TRANSITION_LABELS: Record<string, string> = {
};
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]);
const query = useMemo(() => ({
storageLocation: storageFilter || undefined,
status: statusFilter || undefined,
limit: 50,
}), [storageFilter, statusFilter]);
const swrKey = householdId ? `pantry-${householdId}-${JSON.stringify(query)}` : null;
const { data: pantryResponse, mutate: mutatePantry, isLoading: loading, error: swrError } = useSWR(
swrKey,
() => listPantryItems(householdId, query)
);
const items = pantryResponse?.data ?? [];
useEffect(() => {
fetchItems();
}, [fetchItems]);
if (swrError) setError(swrError instanceof Error ? swrError.message : 'Failed to load pantry');
}, [swrError]);
async function handleTransition(id: string, status: string) {
// Optimistic update
mutatePantry(async (current: any) => {
if (!current) return current;
return {
...current,
data: current.data.map((item: any) => (item._id === id ? { ...item, status } : item))
};
}, { revalidate: false });
try {
const updated = await transitionPantryItem(householdId, id, { status } as never);
setItems((prev) => prev.map((item) => (item._id === id ? updated : item)));
mutatePantry();
} catch (err) {
setError(err instanceof Error ? err.message : 'Transition failed');
mutatePantry();
}
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}"?`)) return;
if (!window.confirm(`Delete "${name}"?`)) return;
// Optimistic delete
mutatePantry(async (current: any) => {
if (!current) return current;
return {
...current,
data: current.data.filter((item: any) => item._id !== id)
};
}, { revalidate: false });
try {
await deletePantryItem(householdId, id);
setItems((prev) => prev.filter((item) => item._id !== id));
mutatePantry();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
mutatePantry();
}
}

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { SWRConfig } from 'swr';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
@ -28,6 +29,10 @@ import PantryPage from '../page';
beforeEach(() => vi.clearAllMocks());
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
const SAMPLE_ITEM = {
_id: 'pi-1',
householdId: 'hh1',
@ -53,7 +58,7 @@ describe('PantryPage', () => {
it('shows loading skeleton when session is loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<PantryPage />);
render(<PantryPage />, { wrapper });
expect(screen.getByText('Pantry')).toBeInTheDocument();
expect(screen.queryByText('All')).not.toBeInTheDocument();
@ -62,7 +67,7 @@ describe('PantryPage', () => {
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<PantryPage />);
render(<PantryPage />, { wrapper });
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
@ -74,7 +79,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Whole Milk')).toBeInTheDocument();
@ -88,7 +93,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
@ -102,7 +107,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Fresh')).toBeInTheDocument();
@ -127,7 +132,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('2d overdue')).toBeInTheDocument();
@ -142,7 +147,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Open')).toBeInTheDocument();
@ -162,7 +167,7 @@ describe('PantryPage', () => {
status: 'opened',
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Open')).toBeInTheDocument();
@ -184,7 +189,7 @@ describe('PantryPage', () => {
mockDeletePantryItem.mockResolvedValue(undefined);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
@ -205,7 +210,7 @@ describe('PantryPage', () => {
});
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
@ -223,7 +228,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Fridge')).toBeInTheDocument();
@ -246,7 +251,7 @@ describe('PantryPage', () => {
pagination: { cursor: null, hasMore: false },
});
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
@ -266,7 +271,7 @@ describe('PantryPage', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPantryItems.mockRejectedValue(new Error('Network error'));
render(<PantryPage />);
render(<PantryPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();

View file

@ -5,9 +5,11 @@ const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockListProducts, mockDeleteProduct } = vi.hoisted(() => ({
const { mockListProducts, mockDeleteProduct, mockCreateProduct, mockUpdateProduct } = vi.hoisted(() => ({
mockListProducts: vi.fn(),
mockDeleteProduct: vi.fn(),
mockCreateProduct: vi.fn(),
mockUpdateProduct: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
@ -15,6 +17,8 @@ vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/products', () => ({
listProducts: mockListProducts,
deleteProduct: mockDeleteProduct,
createProduct: mockCreateProduct,
updateProduct: mockUpdateProduct,
}));
vi.mock('next/link', () => {
@ -225,4 +229,114 @@ describe('ProductsPage', () => {
expect(screen.getByText('C: 0g')).toBeInTheDocument();
expect(screen.getByText('F: 3.6g')).toBeInTheDocument();
});
it('toggles import dialog open and close', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Import')).toBeInTheDocument());
fireEvent.click(screen.getByText('Import'));
expect(screen.getByText('Import Products')).toBeInTheDocument();
// Use button name or text inside the dialog container to close
const cancelBtns = screen.getAllByRole('button', { name: /Cancel/i });
fireEvent.click(cancelBtns[cancelBtns.length - 1]);
});
it('toggles add product modal open and close', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await waitFor(() => expect(screen.getByText('Add Product')).toBeInTheDocument());
fireEvent.click(screen.getByText('Add Product'));
const headings = screen.getAllByRole('heading');
expect(headings.some(h => h.textContent === 'Add Product')).toBe(true);
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
fireEvent.click(cancelBtn);
});
it('opens and closes edit product modal', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
render(<ProductsPage />);
await screen.findByText('Chicken Breast');
const editBtn = screen.getByRole('button', { name: /edit product/i });
fireEvent.click(editBtn);
const headings = screen.getAllByRole('heading');
expect(headings.some(h => h.textContent === 'Edit Product')).toBe(true);
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
fireEvent.click(cancelBtn);
});
it('submits add product modal successfully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
mockCreateProduct.mockResolvedValue({ _id: 'pnew' });
render(<ProductsPage />);
await screen.findByText('Add Product');
fireEvent.click(screen.getByText('Add Product'));
const nameInput = screen.getByPlaceholderText('Product name');
fireEvent.change(nameInput, { target: { value: 'Fresh Banana' } });
// Fill mandatory numeric inputs to satisfy form validation
const numInputs = screen.getAllByRole('spinbutton');
numInputs.forEach(input => {
fireEvent.change(input, { target: { value: '100' } });
});
const saveBtn = screen.getByRole('button', { name: /^Save$/i });
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockCreateProduct).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Fresh Banana' }));
});
});
it('submits edit product modal successfully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListProducts.mockResolvedValue({
data: [SAMPLE_PRODUCT],
pagination: { cursor: null, hasMore: false },
});
mockUpdateProduct.mockResolvedValue({ _id: 'p1' });
render(<ProductsPage />);
await screen.findByText('Chicken Breast');
const editBtn = screen.getByRole('button', { name: /edit product/i });
fireEvent.click(editBtn);
const nameInput = screen.getByPlaceholderText('Product name');
fireEvent.change(nameInput, { target: { value: 'Updated Chicken' } });
const saveBtn = screen.getByRole('button', { name: /^Save$/i });
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockUpdateProduct).toHaveBeenCalledWith('hh1', 'p1', expect.objectContaining({ name: 'Updated Chicken' }));
});
});
});

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
@ -228,4 +228,64 @@ describe('RecipeDetailPage', () => {
expect(screen.queryByText('Starred')).not.toBeInTheDocument();
});
});
it('scales the recipe servings', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
mockScaleRecipe.mockResolvedValue({
...SAMPLE_RECIPE,
servings: 8,
ingredients: SAMPLE_RECIPE.ingredients.map(i => ({ ...i, quantity: i.quantity * 2 })),
});
render(<RecipeDetailPage />);
await screen.findByText('Spaghetti Bolognese');
const scaleInput = screen.getByRole('spinbutton');
fireEvent.change(scaleInput, { target: { value: '8' } });
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
fireEvent.click(scaleBtn);
await waitFor(() => {
expect(mockScaleRecipe).toHaveBeenCalledWith('hh1', 'r1', { targetServings: 8 });
});
// Test resetting back to original
const resetBtn = screen.getByRole('button', { name: /Reset to original/i });
fireEvent.click(resetBtn);
expect((scaleInput as HTMLInputElement).value).toBe('4');
});
it('handles scale recipe failure gracefully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
mockScaleRecipe.mockRejectedValue(new Error('Scale failed'));
render(<RecipeDetailPage />);
await screen.findByText('Spaghetti Bolognese');
const scaleInput = screen.getByRole('spinbutton');
fireEvent.change(scaleInput, { target: { value: '6' } });
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
fireEvent.click(scaleBtn);
await waitFor(() => {
expect(screen.getByText('Scale failed')).toBeInTheDocument();
});
});
it('skips scaling if servings did not change', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<RecipeDetailPage />);
await screen.findByText('Spaghetti Bolognese');
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
fireEvent.click(scaleBtn);
expect(mockScaleRecipe).not.toHaveBeenCalled();
});
});

View file

@ -226,4 +226,37 @@ describe('RecipeEditor', () => {
expect(mockBack).toHaveBeenCalled();
});
it('handles updating ingredient optional properties and removing ingredients', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add ingredient'));
const prepInputs = screen.getAllByPlaceholderText(/Preparation/i);
fireEvent.change(prepInputs[0], { target: { value: 'Diced' } });
const removeBtns = screen.getAllByRole('button', { name: '×' });
fireEvent.click(removeBtns[0]);
const remainingPrepInputs = screen.getAllByPlaceholderText(/Preparation/i);
expect(remainingPrepInputs).toHaveLength(1);
});
it('handles step metadata and removing steps', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add step'));
const durationInputs = screen.getAllByPlaceholderText(/Duration/i);
const tipInputs = screen.getAllByPlaceholderText(/Tip/i);
fireEvent.change(durationInputs[0], { target: { value: '15' } });
fireEvent.change(tipInputs[0], { target: { value: "Don't burn it" } });
const removeBtns = screen.getAllByRole('button', { name: '×' });
fireEvent.click(removeBtns[removeBtns.length - 1]);
const remainingDurationInputs = screen.getAllByPlaceholderText(/Duration/i);
expect(remainingDurationInputs).toHaveLength(1);
});
});

View file

@ -0,0 +1,267 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SWRConfig } from 'swr';
import ShoppingListDetailPage from '../page';
import * as useApiModule from '@/lib/useApi';
import * as ShoppingListsService from '@/services/shopping-lists';
import * as ProductsService from '@/services/products';
import * as useShoppingListSyncModule from '@/lib/useShoppingListSync';
vi.mock('@/lib/useApi');
vi.mock('@/services/shopping-lists');
vi.mock('@/services/products');
vi.mock('@/lib/useShoppingListSync');
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}));
// Mock next/navigation params
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'list1' }),
useRouter: () => ({ push: mockPush, back: vi.fn() }),
}));
describe('ShoppingListDetailPage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock browser API modals
window.confirm = vi.fn().mockReturnValue(true);
window.alert = vi.fn();
vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1',
isLoading: false,
user: null,
token: '123',
} as any);
vi.mocked(ProductsService.listProducts).mockResolvedValue({
data: [
{ _id: 'p1', name: 'Apple', category: 'Produce', unit: 'pcs' }
]
} as any);
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
id: 'list1',
name: 'Test List',
status: 'active',
items: [
{ id: 'item1', productId: 'p1', quantity: 5, checked: false, unit: 'pcs' },
],
createdAt: '2026-05-10',
} as any);
vi.mocked(useShoppingListSyncModule.useShoppingListSync).mockReturnValue({
isConnected: true,
error: null,
toggleItemCheck: vi.fn(),
});
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);
it('renders loading initially', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
render(<ShoppingListDetailPage />, { wrapper });
expect(screen.getByText(/Hydrating session checklist/i)).toBeInTheDocument();
});
it('loads and displays the list', async () => {
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Test List')).toBeInTheDocument();
expect(screen.getAllByText('Apple')[0]).toBeInTheDocument();
});
});
it('toggles an item check', async () => {
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const checkbox = screen.getByRole('button', { name: /Toggle check for Apple/i });
fireEvent.click(checkbox);
expect(useShoppingListSyncModule.useShoppingListSync).toHaveBeenCalled();
});
it('adds a new item to the list', async () => {
vi.mocked(ShoppingListsService.addShoppingItem).mockResolvedValue({
id: 'list1',
items: [
{ id: 'item1', productId: 'p1', quantity: 5, checked: false },
{ id: 'item2', customName: 'Banana', quantity: 2, checked: false },
]
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const customInput = screen.getByPlaceholderText(/e.g., Generic Flour/i);
fireEvent.change(customInput, { target: { value: 'Banana' } });
const submitBtn = screen.getByRole('button', { name: /Add to List/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(ShoppingListsService.addShoppingItem).toHaveBeenCalledWith(
'hh1',
'list1',
expect.objectContaining({ customName: 'Banana' })
);
});
});
it('completes the trip and syncs to pantry', async () => {
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
id: 'list1',
name: 'Test List',
status: 'active',
items: [
{ id: 'item1', productId: 'p1', quantity: 5, checked: true, addedToPantry: false },
],
createdAt: '2026-05-10',
} as any);
vi.mocked(ShoppingListsService.updateShoppingList).mockResolvedValue({ status: 'completed' } as any);
vi.mocked(ShoppingListsService.syncToPantry).mockResolvedValue({ addedCount: 1 } as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const syncBtn = screen.getByRole('button', { name: /Sync.*items to Pantry/i });
fireEvent.click(syncBtn);
await waitFor(() => {
expect(ShoppingListsService.syncToPantry).toHaveBeenCalledWith('hh1', 'list1');
expect(ShoppingListsService.updateShoppingList).toHaveBeenCalledWith('hh1', 'list1', { status: 'completed' });
});
});
it('deletes an item from the list', async () => {
vi.mocked(ShoppingListsService.removeShoppingItem).mockResolvedValue({
id: 'list1',
items: []
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const trashBtn = screen.getByRole('button', { name: /Delete Apple/i });
fireEvent.click(trashBtn);
await waitFor(() => {
expect(ShoppingListsService.removeShoppingItem).toHaveBeenCalledWith('hh1', 'list1', 'item1');
});
});
it('loads store comparisons when clicked', async () => {
vi.mocked(ShoppingListsService.getBasketStoreComparison).mockResolvedValue({
singleStoreOptions: [
{ storeId: 's1', storeName: 'Walmart', estimatedTotal: 45.00, itemsCovered: 1 }
]
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const checkBtn = screen.getByRole('button', { name: /Check Lowest Store Options/i });
fireEvent.click(checkBtn);
await waitFor(() => {
expect(ShoppingListsService.getBasketStoreComparison).toHaveBeenCalledWith('hh1', 'list1');
expect(screen.getByText('Walmart')).toBeInTheDocument();
expect(screen.getByText('$45.00')).toBeInTheDocument();
});
});
it('fills out the complete add item form and submits', async () => {
vi.mocked(ShoppingListsService.addShoppingItem).mockResolvedValue({
id: 'list1',
items: [
{ id: 'item1', productId: 'p1', quantity: 10, checked: false, unit: 'piece', notes: 'Fresh' }
]
} as any);
render(<ShoppingListDetailPage />, { wrapper });
await waitFor(() => expect(screen.getAllByText('Apple')[0]).toBeInTheDocument());
const selectInputs = screen.getAllByRole('combobox');
// First combobox is Link Product Catalog
fireEvent.change(selectInputs[0], { target: { value: 'p1' } });
const qtyInput = screen.getByRole('spinbutton');
fireEvent.change(qtyInput, { target: { value: '10' } });
// Second combobox is Unit
fireEvent.change(selectInputs[1], { target: { value: 'piece' } });
const notesInput = screen.getByPlaceholderText(/Brand preference/i);
fireEvent.change(notesInput, { target: { value: 'Fresh' } });
const submitBtn = screen.getByRole('button', { name: /Add to List/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(ShoppingListsService.addShoppingItem).toHaveBeenCalledWith(
'hh1',
'list1',
expect.objectContaining({
productId: 'p1',
quantity: 10,
unit: 'piece',
notes: 'Fresh'
})
);
});
});
it('navigates back to hub when button clicked', async () => {
render(<ShoppingListDetailPage />, { wrapper });
await screen.findAllByText('Apple');
const backBtn = screen.getByRole('button', { name: /Back to Hub/i });
fireEvent.click(backBtn);
expect(mockPush).toHaveBeenCalledWith('/shopping-lists');
});
it('handles delete failure gracefully', async () => {
vi.mocked(ShoppingListsService.removeShoppingItem).mockRejectedValue(new Error('Delete failed'));
const spy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ShoppingListDetailPage />, { wrapper });
await screen.findAllByText('Apple');
const trashBtn = screen.getByRole('button', { name: /Delete Apple/i });
fireEvent.click(trashBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith('Delete failed');
});
spy.mockRestore();
});
it('cancels sync to pantry if confirm is rejected', async () => {
vi.mocked(ShoppingListsService.getShoppingList).mockResolvedValue({
id: 'list1',
name: 'Test List',
status: 'active',
items: [
{ id: 'item1', productId: 'p1', checked: true, addedToPantry: false, quantity: 5, unit: 'pcs' }
]
} as any);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<ShoppingListDetailPage />, { wrapper });
const syncBtn = await screen.findByRole('button', { name: /Sync 1 items to Pantry/i });
fireEvent.click(syncBtn);
expect(confirmSpy).toHaveBeenCalled();
expect(ShoppingListsService.syncToPantry).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});

View file

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import { useApi } from '@/lib/useApi';
import { useParams, useRouter } from 'next/navigation';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
@ -22,16 +23,9 @@ export default function ShoppingListDetailsPage() {
const { id: listId } = useParams() as { id: string };
const router = useRouter();
// List state
const [list, setList] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Side panel States
const [storeOptions, setStoreOptions] = useState<any[]>([]);
const [isStoreLoading, setIsStoreLoading] = useState(false);
// Form states for Add Item
const [products, setProducts] = useState<any[]>([]);
const [selectedProductId, setSelectedProductId] = useState('');
const [customItemName, setCustomItemName] = useState('');
@ -41,18 +35,15 @@ export default function ShoppingListDetailsPage() {
const [isAdding, setIsAdding] = useState(false);
// Load core list context
const fetchList = useCallback(async () => {
if (!householdId || !listId) return;
setLoading(true);
try {
const data = await getShoppingList(householdId, listId);
setList(data);
} catch (err: any) {
setError(err.message || 'Shopping list not found');
} finally {
setLoading(false);
}
}, [householdId, listId]);
const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null;
const { data: list, mutate: mutateList, isLoading: listLoading, error: listError } = useSWR(
swrKey,
() => getShoppingList(householdId!, listId)
);
useEffect(() => {
if (listError) setError(listError.message || 'Shopping list not found');
}, [listError]);
// Run price comparisons
const fetchComparisons = useCallback(async () => {
@ -68,10 +59,6 @@ export default function ShoppingListDetailsPage() {
}
}, [householdId, listId]);
useEffect(() => {
fetchList();
}, [fetchList]);
// Pre-load household products for predictive inputs
useEffect(() => {
if (!householdId) return;
@ -81,28 +68,8 @@ export default function ShoppingListDetailsPage() {
// Handle WS Remote Event Broadcasts
const handleRemoteSync = useCallback((msg: any) => {
console.log('🔔 Remote state delta payload:', msg);
if (msg.type === 'ITEM_UPDATED') {
setList((prev: any) => {
if (!prev) return prev;
return {
...prev,
items: prev.items.map((it: any) =>
it.id === msg.itemId ? { ...it, ...msg.updates } : it
),
};
});
} else if (msg.type === 'ITEM_ADDED') {
setList((prev: any) => {
if (!prev) return prev;
return { ...prev, items: [...prev.items, msg.item] };
});
} else if (msg.type === 'ITEM_REMOVED') {
setList((prev: any) => {
if (!prev) return prev;
return { ...prev, items: prev.items.filter((it: any) => it.id !== msg.itemId) };
});
}
}, []);
mutateList(); // Revalidate with server on remote changes
}, [mutateList]);
// Inject Real-Time Hooks
const { isConnected, toggleItemCheck } = useShoppingListSync(
@ -115,21 +82,23 @@ export default function ShoppingListDetailsPage() {
const handleToggleCheck = async (itemId: string, currentChecked: boolean) => {
const nextChecked = !currentChecked;
// Optimistic Client Update for ultimate snappy responsiveness
setList((prev: any) => ({
// Optimistic Client Update
mutateList(async (prev: any) => ({
...prev,
items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it)
}));
}), { revalidate: false });
// Emit to WS Channel (broadcasts immediately to all other clients)
// Emit to WS Channel
toggleItemCheck(itemId, nextChecked);
// Persist standard Rest fallback ensuring safety
if (householdId) {
try {
await updateShoppingItem(householdId, listId, itemId, { checked: nextChecked });
mutateList();
} catch (err) {
console.error('Persistent toggle sync fail', err);
mutateList();
}
}
};
@ -148,7 +117,7 @@ export default function ShoppingListDetailsPage() {
notes: notes.trim() || undefined,
});
setList(updated);
mutateList(updated);
// Clear inputs
setSelectedProductId('');
setCustomItemName('');
@ -163,25 +132,33 @@ export default function ShoppingListDetailsPage() {
const handleDeleteItem = async (itemId: string) => {
if (!householdId) return;
// Optimistic delete
mutateList(async (prev: any) => ({
...prev,
items: prev.items.filter((it: any) => it.id !== itemId)
}), { revalidate: false });
try {
const updated = await removeShoppingItem(householdId, listId, itemId);
setList(updated);
mutateList(updated);
} catch (err: any) {
console.error(err.message);
window.alert(err.message);
mutateList();
}
};
// 3. Execute Final Checkout / Pantry Sync
const handleSyncToPantry = async () => {
if (!householdId) return;
if (!householdId || !list) return;
const readyItems = list.items.filter((i: any) => i.checked && !i.addedToPantry);
if (readyItems.length === 0) return;
if (!confirm(`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`)) return;
if (!window.confirm(`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`)) return;
try {
const res = await syncToPantry(householdId, listId);
alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`);
window.alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`);
// Mark list as completed automatically if all are done
const allChecked = list.items.every((i: any) => i.checked || i.addedToPantry);
@ -189,9 +166,9 @@ export default function ShoppingListDetailsPage() {
await updateShoppingList(householdId, listId, { status: 'completed' as any });
}
fetchList();
mutateList();
} catch (err: any) {
alert('Migration sync error: ' + err.message);
window.alert('Migration sync error: ' + err.message);
}
};
@ -207,7 +184,7 @@ export default function ShoppingListDetailsPage() {
return groups;
}, [list]);
if (isAuthLoading || loading) return <div style={{ padding: 40 }}>Hydrating session checklist...</div>;
if (isAuthLoading || listLoading) return <div style={{ padding: 40 }}>Hydrating session checklist...</div>;
if (error || !list) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length;
@ -287,6 +264,7 @@ export default function ShoppingListDetailsPage() {
{/* Checkbox circle */}
<button
onClick={() => handleToggleCheck(it.id, it.checked)}
aria-label={`Toggle check for ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`}
style={{
width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`,
@ -324,6 +302,7 @@ export default function ShoppingListDetailsPage() {
<button
onClick={() => handleDeleteItem(it.id)}
aria-label={`Delete ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, color: 'var(--ink-muted)', opacity: 0.5 }}
>
<Icon name="trash" style={{ width: 14 }} />

View file

@ -0,0 +1,220 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ShoppingListsPage from '../page';
import * as useApiModule from '@/lib/useApi';
import * as ShoppingListsService from '@/services/shopping-lists';
import * as MealPlansService from '@/services/meal-plans';
vi.mock('@/lib/useApi');
vi.mock('@/services/shopping-lists');
vi.mock('@/services/meal-plans');
describe('ShoppingListsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1',
isLoading: false,
user: null,
token: '123',
});
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'list1', name: 'Groceries', status: 'active', items: [], totalEstimatedCost: 10, createdAt: '2026-05-10' } as any,
]);
});
it('renders loading initially', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
render(<ShoppingListsPage />);
expect(screen.getByText('Groceries')).toBeInTheDocument();
});
it('renders prompt if no household', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: false } as any);
render(<ShoppingListsPage />);
expect(screen.getByText('Please join a household.')).toBeInTheDocument();
});
it('loads and displays shopping lists', async () => {
render(<ShoppingListsPage />);
await waitFor(() => {
expect(screen.getByText(/Groceries/i)).toBeInTheDocument();
});
});
it('toggles create list modal and submits', async () => {
vi.mocked(ShoppingListsService.createShoppingList).mockResolvedValue({
id: 'list2', name: 'New List', status: 'active', items: [], createdAt: '2026-05-11'
} as any);
render(<ShoppingListsPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /New Shopping List/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
// Wait for modal
const input = await screen.findByPlaceholderText(/e.g., Weekly Costco Run/i);
fireEvent.change(input, { target: { value: 'New List' } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
await waitFor(() => {
expect(ShoppingListsService.createShoppingList).toHaveBeenCalledWith('hh1', { name: 'New List', items: [] });
});
});
it('opens generate from meal plan modal', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1,
page: 1,
limit: 10,
});
vi.mocked(ShoppingListsService.generateFromMealPlan).mockResolvedValue({
id: 'list3', name: 'Generated', status: 'active', items: [], createdAt: '2026-05-11'
} as any);
render(<ShoppingListsPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /Generate from Meal Plan/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
await waitFor(() => {
expect(MealPlansService.listMealPlans).toHaveBeenCalled();
});
const generateBtn = await screen.findByText(/Week of/i);
fireEvent.click(generateBtn);
await waitFor(() => {
expect(ShoppingListsService.generateFromMealPlan).toHaveBeenCalledWith('hh1', 'mp1');
});
});
it('handles fetch errors', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockRejectedValue(new Error('Failed to load'));
render(<ShoppingListsPage />);
await waitFor(() => {
expect(screen.getByText('Failed to load')).toBeInTheDocument();
});
});
it('can cancel/close creation and gap scanning modals', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [],
total: 0, page: 1, limit: 10
});
const { container } = render(<ShoppingListsPage />);
// 1. Open Create Modal and close via overlay backdrop click
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
const createHeading = screen.getByText('Create Shopping List');
expect(createHeading).toBeInTheDocument();
// The overlay is the grandparent of the heading. Let's click it!
const overlay = createHeading.parentElement?.parentElement;
if (overlay) fireEvent.click(overlay);
expect(screen.queryByText('Create Shopping List')).not.toBeInTheDocument();
// 2. Re-open Create Modal and close via Cancel button click
fireEvent.click(screen.getByRole('button', { name: /New Shopping List/i }));
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(screen.queryByText('Create Shopping List')).not.toBeInTheDocument();
// 3. Open Gap Modal and close via Close button
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
await waitFor(() => {
expect(screen.getByText('Scan Meal Plan Gaps')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Close/i }));
expect(screen.queryByText('Scan Meal Plan Gaps')).not.toBeInTheDocument();
});
it('renders empty state and opens create modal', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
render(<ShoppingListsPage />);
const createFirstBtn = await screen.findByRole('button', { name: /Create First List/i });
fireEvent.click(createFirstBtn);
expect(screen.getByText('Create Shopping List')).toBeInTheDocument();
});
it('renders completed lists', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'listc', name: 'Completed Groceries', status: 'completed', items: [], totalEstimatedCost: 25, createdAt: '2026-05-09' } as any
]);
render(<ShoppingListsPage />);
await screen.findByText('Completed Runs');
expect(screen.getByText('Completed Groceries')).toBeInTheDocument();
});
it('handles generate from meal plan failure', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1, page: 1, limit: 10
});
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue(new Error('Gen failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ShoppingListsPage />);
fireEvent.click(screen.getByRole('button', { name: /Generate from Meal Plan/i }));
const generateBtn = await screen.findByText(/Week of/i);
fireEvent.click(generateBtn);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Gen failed');
});
alertSpy.mockRestore();
});
it('sorts lists by status and creation date', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'list1', name: 'List 1', status: 'completed', createdAt: '2026-05-10', items: [], totalEstimatedCost: 10 } as any,
{ id: 'list2', name: 'List 2', status: 'active', createdAt: '2026-05-12', items: [], totalEstimatedCost: 20 } as any,
]);
render(<ShoppingListsPage />);
await screen.findByText('List 2');
expect(screen.getByText('List 1')).toBeInTheDocument();
});
it('handles create list failure', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
vi.mocked(ShoppingListsService.createShoppingList).mockRejectedValue(new Error('Create failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ShoppingListsPage />);
const openBtn = await screen.findByRole('button', { name: /Create First List/i });
fireEvent.click(openBtn);
const input = screen.getByPlaceholderText(/e.g., Weekly Costco Run/i);
fireEvent.change(input, { target: { value: 'New List' } });
const submitBtn = screen.getByRole('button', { name: /^Create$/ });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Create failed');
});
alertSpy.mockRestore();
});
it('handles open gap modal fetch failure', async () => {
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([]);
vi.mocked(MealPlansService.listMealPlans).mockRejectedValue(new Error('Fetch plans failed'));
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<ShoppingListsPage />);
const btn = await screen.findByRole('button', { name: /Generate from Meal Plan/i });
fireEvent.click(btn);
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
spy.mockRestore();
});
});

View file

@ -0,0 +1,101 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ShoppingListPricesPage from '../page';
import * as useApiModule from '@/lib/useApi';
import * as PricesService from '@/services/prices';
vi.mock('@/lib/useApi');
vi.mock('@/services/prices');
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: vi.fn(),
}),
}));
describe('ShoppingListPricesPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1',
isLoading: false,
user: null,
token: '123',
} as any);
vi.mocked(PricesService.getPriceAnalytics).mockResolvedValue({
timeframe: { start: '2026-04-01', end: '2026-05-14' },
totalSpent: 150.00,
totalRecords: 10,
storeBreakdown: [
{ storeId: 'store1', storeName: 'Costco', spent: 100 },
{ storeId: 'store2', storeName: 'Trader Joes', spent: 50 },
],
recentPriceChanges: [
{
productId: 'p1',
productName: 'Milk',
storeId: 'store1',
storeName: 'Costco',
oldPrice: 3.50,
newPrice: 4.00,
percentageChange: 14.28,
trend: 'up',
}
],
priceAlerts: [
{ productName: 'Bread', storeName: 'Costco', changePercent: 15, previousPrice: 2.00, currentPrice: 2.30 }
],
spendingOverTime: [
{ period: 'Apr', total: 50 },
{ period: 'May', total: 100 }
],
spendingByCategory: [
{ category: 'Produce', total: 30 },
{ category: 'Bakery', total: 20 }
],
averageBasketByStore: [
{ storeId: 'store1', storeName: 'Costco', avgTotal: 150.00, tripCount: 5 },
{ storeId: 'store2', storeName: 'Trader Joes', avgTotal: 50.00, tripCount: 2 },
],
} as any);
});
it('renders loading initially', () => {
vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null, isLoading: true } as any);
render(<ShoppingListPricesPage />);
expect(screen.getByText(/Synthesizing financial graphs/i)).toBeInTheDocument();
});
it('loads and displays analytics', async () => {
render(<ShoppingListPricesPage />);
await waitFor(() => {
expect(screen.getByText('$150.00')).toBeInTheDocument();
expect(screen.getByText('Costco')).toBeInTheDocument();
expect(screen.getByText('Trader Joes')).toBeInTheDocument();
});
});
it('handles fetch errors gracefully', async () => {
vi.mocked(PricesService.getPriceAnalytics).mockRejectedValue(new Error('Analytics failed'));
render(<ShoppingListPricesPage />);
await waitFor(() => {
expect(screen.getByText(/Analytics failed/i)).toBeInTheDocument();
});
});
it('navigates back to checklists when button is clicked', async () => {
render(<ShoppingListPricesPage />);
await screen.findByText('Costco');
const backBtn = screen.getByRole('button', { name: /Back to Checklists/i });
fireEvent.click(backBtn);
expect(mockPush).toHaveBeenCalledWith('/shopping-lists');
});
});

View file

@ -7,17 +7,27 @@ type Theme = 'light' | 'dark';
type Accent = 'sage' | 'cobalt' | 'terracotta' | 'graphite';
interface AccentTokens {
brand: string;
deep: string;
soft: string;
softInk: string;
light: { brand: string; deep: string; soft: string; softInk: string; brandInk: string };
dark: { brand: string; deep: string; soft: string; softInk: string; brandInk: string };
}
const ACCENTS: Record<Accent, AccentTokens> = {
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8', softInk: '#1e4a32' },
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5', softInk: '#1d3d75' },
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de', softInk: '#7d3825' },
graphite: { brand: '#2c2c28', deep: '#000000', soft: '#e8e6df', softInk: '#2c2c28' },
sage: {
light: { brand: '#10b981', deep: '#059669', soft: 'rgba(16,185,129,0.1)', softInk: '#047857', brandInk: '#ffffff' },
dark: { brand: '#34d399', deep: '#10b981', soft: 'rgba(52,211,153,0.15)', softInk: '#6ee7b7', brandInk: '#022c22' },
},
cobalt: {
light: { brand: '#3b82f6', deep: '#2563eb', soft: 'rgba(59,130,246,0.1)', softInk: '#1d4ed8', brandInk: '#ffffff' },
dark: { brand: '#60a5fa', deep: '#3b82f6', soft: 'rgba(96,165,250,0.15)', softInk: '#93c5fd', brandInk: '#172554' },
},
terracotta: {
light: { brand: '#f43f5e', deep: '#e11d48', soft: 'rgba(244,63,94,0.1)', softInk: '#be123c', brandInk: '#ffffff' },
dark: { brand: '#fb7185', deep: '#f43f5e', soft: 'rgba(251,113,133,0.15)', softInk: '#fda4af', brandInk: '#4c0519' },
},
graphite: {
light: { brand: '#52525b', deep: '#3f3f46', soft: 'rgba(82,82,91,0.1)', softInk: '#27272a', brandInk: '#ffffff' },
dark: { brand: '#a1a1aa', deep: '#71717a', soft: 'rgba(161,161,170,0.15)', softInk: '#d4d4d8', brandInk: '#18181b' },
},
};
interface ThemeContextValue {
@ -36,13 +46,14 @@ export function useTheme(): ThemeContextValue {
return ctx;
}
function applyAccent(accent: Accent) {
const ac = ACCENTS[accent];
function applyAccent(accent: Accent, theme: Theme) {
const ac = ACCENTS[accent][theme];
const r = document.documentElement.style;
r.setProperty('--brand', ac.brand);
r.setProperty('--brand-deep', ac.deep);
r.setProperty('--brand-soft', ac.soft);
r.setProperty('--brand-soft-ink', ac.softInk);
r.setProperty('--brand-ink', ac.brandInk);
r.setProperty('--viz-1', ac.brand);
}
@ -66,9 +77,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Sync accent to DOM + localStorage.
useEffect(() => {
applyAccent(accent);
applyAccent(accent, theme);
localStorage.setItem('mt-accent', accent);
}, [accent]);
}, [accent, theme]);
function setTheme(t: Theme) {
setThemeState(t);

View file

@ -78,7 +78,7 @@ describe('ThemeProvider', () => {
expect(screen.getByTestId('accent').textContent).toBe('cobalt');
expect(localStorage.getItem('mt-accent')).toBe('cobalt');
expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#2e5aa8');
expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#3b82f6');
});
it('toggleTheme flips light to dark', () => {

View file

@ -5,6 +5,7 @@ import { IconButton } from '../ui/IconButton';
import { Ring } from '../ui/Ring';
import { SparkBars } from '../ui/SparkBars';
import { SupplyBar } from '../ui/SupplyBar';
import { Card, CardHeader, CardBody } from '../ui/Card';
describe('Avatar', () => {
it('renders initial from name', () => {
@ -82,3 +83,36 @@ describe('SupplyBar', () => {
expect(screen.getByText('10')).toBeInTheDocument();
});
});
describe('Card', () => {
it('renders children and applies custom styles', () => {
render(
<Card className="custom-card" style={{ margin: '20px' }}>
<div>Card Content</div>
</Card>
);
expect(screen.getByText('Card Content')).toBeInTheDocument();
});
it('renders header with title, subtitle and action', () => {
render(
<CardHeader
title="My Card"
subtitle="My Subtitle"
action={<button>Click Me</button>}
/>
);
expect(screen.getByText('My Card')).toBeInTheDocument();
expect(screen.getByText('My Subtitle')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Click Me' })).toBeInTheDocument();
});
it('renders body with children', () => {
render(
<CardBody>
<div>Body Content</div>
</CardBody>
);
expect(screen.getByText('Body Content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { PageHeaderProvider, usePageHeader } from '../PageHeaderContext';
function TestComponent() {
const { header, setHeader } = usePageHeader();
return (
<div>
<span data-testid="title">{header.title}</span>
<button onClick={() => setHeader({ title: 'New Title' })}>Change</button>
</div>
);
}
describe('PageHeaderContext', () => {
it('provides and updates header state', () => {
render(
<PageHeaderProvider>
<TestComponent />
</PageHeaderProvider>
);
expect(screen.getByTestId('title').textContent).toBe('MeshiTrack');
fireEvent.click(screen.getByText('Change'));
expect(screen.getByTestId('title').textContent).toBe('New Title');
});
it('returns fallback when used outside provider', () => {
render(<TestComponent />);
// Outside provider title returns '' fallback
expect(screen.getByTestId('title').textContent).toBe('');
});
});

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import * as UI from '../index';
describe('UI Index Exports', () => {
it('should export all defined UI components', () => {
expect(UI.Icon).toBeDefined();
expect(UI.Card).toBeDefined();
expect(UI.Button).toBeDefined();
expect(UI.Pill).toBeDefined();
expect(UI.SupplyBar).toBeDefined();
expect(UI.Ring).toBeDefined();
expect(UI.SparkBars).toBeDefined();
expect(UI.IconButton).toBeDefined();
expect(UI.Avatar).toBeDefined();
});
});

View file

@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useShoppingListSync } from './useShoppingListSync';
vi.mock('@/services/shopping-lists', () => ({
getShoppingListSyncSocketUrl: vi.fn(() => 'ws://localhost/sync'),
}));
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
url: string;
readyState = 1; // OPEN
onopen: any = null;
onmessage: any = null;
onerror: any = null;
onclose: any = null;
send = vi.fn();
close = vi.fn();
constructor(url: string) {
this.url = url;
}
}
describe('useShoppingListSync', () => {
let originalWebSocket: any;
let createdSockets: MockWebSocket[] = [];
beforeEach(() => {
vi.clearAllMocks();
createdSockets = [];
originalWebSocket = global.WebSocket;
const MockClass = class extends MockWebSocket {
constructor(url: string) {
super(url);
createdSockets.push(this);
}
};
(MockClass as any).OPEN = 1;
(MockClass as any).CONNECTING = 0;
global.WebSocket = MockClass as any;
});
afterEach(() => {
global.WebSocket = originalWebSocket;
});
it('initializes and connects to the correct socket URL', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
expect(createdSockets).toHaveLength(1);
expect(createdSockets[0].url).toBe('ws://localhost/sync');
});
it('handles incoming item_updated messages correctly', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
act(() => {
if (ws.onmessage) ws.onmessage({ data: JSON.stringify({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }) });
});
expect(onSync).toHaveBeenCalledWith({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } });
});
it('broadcasts toggle item messages when connected', () => {
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
act(() => {
result.current.toggleItemCheck('item1', true);
});
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({
type: 'TOGGLE_ITEM',
itemId: 'item1',
checked: true,
}));
});
it('handles disconnect and reconnect backoff', () => {
vi.useFakeTimers();
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
let ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
expect(result.current.isConnected).toBe(true);
act(() => { if (ws.onclose) ws.onclose({ reason: 'test' }); });
expect(result.current.isConnected).toBe(false);
// After 1000ms it should attempt reconnect
act(() => { vi.advanceTimersByTime(1000); });
expect(createdSockets).toHaveLength(2);
vi.useRealTimers();
});
});

View file

@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as MealPlansService from './meal-plans';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
describe('meal-plans service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('listMealPlans with and without query', async () => {
await MealPlansService.listMealPlans('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans');
await MealPlansService.listMealPlans('hh1', { cursor: 'cur', limit: 10 });
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans?cursor=cur&limit=10');
});
it('getMealPlanByWeek', async () => {
await MealPlansService.getMealPlanByWeek('hh1', '2026-05-10');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/week/2026-05-10');
});
it('getMealPlan', async () => {
await MealPlansService.getMealPlan('hh1', 'mp1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
});
it('createMealPlan', async () => {
const data = { weekStartDate: '2026-05-10' } as any;
await MealPlansService.createMealPlan('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/meal-plans', data);
});
it('updateMealPlan', async () => {
const data = { days: [] } as any;
await MealPlansService.updateMealPlan('hh1', 'mp1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1', data);
});
it('updateMealPlanStatus', async () => {
await MealPlansService.updateMealPlanStatus('hh1', 'mp1', 'active' as any);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/status', { status: 'active' });
});
it('deleteMealPlan', async () => {
await MealPlansService.deleteMealPlan('hh1', 'mp1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1');
});
it('getSuggestions', async () => {
await MealPlansService.getSuggestions('hh1', 3);
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/suggestions?limit=3');
});
it('getShoppingGap', async () => {
await MealPlansService.getShoppingGap('hh1', 'mp1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/gap');
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as NutritionTargetsService from './nutrition-targets';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('nutrition-targets service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('getActiveNutritionTarget', async () => {
await NutritionTargetsService.getActiveNutritionTarget('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets');
});
it('getNutritionTargetHistory', async () => {
await NutritionTargetsService.getNutritionTargetHistory('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets/history');
});
it('setNutritionTarget', async () => {
const data = { calories: 2000 } as any;
await NutritionTargetsService.setNutritionTarget('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets', data);
});
it('calculateTargetPreset', async () => {
await NutritionTargetsService.calculateTargetPreset('hh1', 2500, 'gain');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets/presets', { calories: 2500, strategy: 'gain' });
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as PricesService from './prices';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('prices service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('recordPrice', async () => {
const data = { productId: 'p1', price: 10 } as any;
await PricesService.recordPrice('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices', data);
});
it('recordBulkPrices', async () => {
const data = { storeId: 's1', items: [] } as any;
await PricesService.recordBulkPrices('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices/bulk', data);
});
it('getPriceHistory with and without query', async () => {
await PricesService.getPriceHistory('hh1', 'p1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/history/p1');
await PricesService.getPriceHistory('hh1', 'p1', {
storeId: 's1',
startDate: '2026-05-01',
endDate: '2026-05-10',
cursor: 'cur',
limit: 10,
});
expect(apiClient.get).toHaveBeenCalledWith(
'/households/hh1/prices/history/p1?storeId=s1&startDate=2026-05-01&endDate=2026-05-10&cursor=cur&limit=10'
);
});
it('compareStores', async () => {
await PricesService.compareStores('hh1', 'p1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/compare/p1');
});
it('getPriceAnalytics', async () => {
await PricesService.getPriceAnalytics('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/analytics');
});
});

View file

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from './api-client';
import * as ShoppingListsService from './shopping-lists';
vi.mock('./api-client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
baseUrl: 'http://localhost:3001',
},
}));
describe('shopping-lists service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('getShoppingLists', async () => {
await ShoppingListsService.getShoppingLists('hh1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists');
});
it('getShoppingList', async () => {
await ShoppingListsService.getShoppingList('hh1', 'list1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
});
it('createShoppingList', async () => {
const data = { name: 'Test' } as any;
await ShoppingListsService.createShoppingList('hh1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists', data);
});
it('updateShoppingList', async () => {
const data = { name: 'Updated' } as any;
await ShoppingListsService.updateShoppingList('hh1', 'list1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1', data);
});
it('deleteShoppingList', async () => {
await ShoppingListsService.deleteShoppingList('hh1', 'list1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1');
});
it('addShoppingItem', async () => {
const data = { productId: 'p1' } as any;
await ShoppingListsService.addShoppingItem('hh1', 'list1', data);
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items', data);
});
it('updateShoppingItem', async () => {
const data = { checked: true } as any;
await ShoppingListsService.updateShoppingItem('hh1', 'list1', 'item1', data);
expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1', data);
});
it('removeShoppingItem', async () => {
await ShoppingListsService.removeShoppingItem('hh1', 'list1', 'item1');
expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1');
});
it('generateFromMealPlan', async () => {
await ShoppingListsService.generateFromMealPlan('hh1', 'mp1');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/from-meal-plan/mp1');
});
it('syncToPantry', async () => {
await ShoppingListsService.syncToPantry('hh1', 'list1');
expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/sync-to-pantry');
});
it('getBasketStoreComparison', async () => {
await ShoppingListsService.getBasketStoreComparison('hh1', 'list1');
expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/stores');
});
it('getShoppingListSyncSocketUrl handles insecure and secure contexts', () => {
const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync');
apiClient.baseUrl = 'https://api.meshitrack.com';
const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync');
});
});

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff