Add additional lint rules

This commit is contained in:
Aerilyn Weber 2026-05-19 16:15:15 +09:00
parent 02d782c3da
commit 420b18eb78
67 changed files with 3686 additions and 1415 deletions

View file

@ -47,6 +47,102 @@ export default tseslint.config(
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/member-ordering': [
'error',
{
default: [
'public-static-field',
'protected-static-field',
'private-static-field',
'public-instance-field',
'protected-instance-field',
'private-instance-field',
'constructor',
'public-instance-method',
'protected-instance-method',
'private-instance-method',
],
},
],
'@typescript-eslint/explicit-function-return-type': [
'error',
{
allowExpressions: true,
allowTypedFunctionExpressions: true,
allowHigherOrderFunctions: true,
allowDirectConstAssertionInArrowFunctions: true,
},
],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'allow',
},
{
selector: 'variable',
format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'allow',
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
{
selector: 'interface',
format: ['PascalCase'],
custom: {
regex: '^I[A-Z]',
match: false,
},
},
{
selector: 'objectLiteralProperty',
format: null,
},
{
selector: 'objectLiteralMethod',
format: null,
},
{
selector: 'function',
format: ['camelCase', 'PascalCase'],
},
{
selector: 'import',
format: ['camelCase', 'PascalCase'],
},
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
attributes: false,
},
},
],
},
},
{
// React components are functions returning JSX and don't need explicit return types
files: ['**/*.tsx'],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
},
},
{
// Relax some rules in test files
files: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'@typescript-eslint/explicit-member-accessibility': 'off',
},
},
prettierRecommended,

View file

@ -1,11 +1,17 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import { useApi } from '@/lib/useApi';
import { useParams, useRouter } from 'next/navigation';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, Button, Icon, Pill } from '@/components/ui';
import type {
BasketStoreComparisonResponse,
ShoppingItem,
ShoppingListResponse,
} from '@/services/shopping-lists';
import { type ServingUnit } from '@meshitrack/shared';
import {
getShoppingList,
addShoppingItem,
@ -15,6 +21,7 @@ import {
getBasketStoreComparison,
updateShoppingList,
} from '@/services/shopping-lists';
import type { ProductResponse } from '@/services/products';
import { listProducts } from '@/services/products';
import { useShoppingListSync } from '@/lib/useShoppingListSync';
@ -24,9 +31,11 @@ export default function ShoppingListDetailsPage() {
const router = useRouter();
const [error, setError] = useState('');
const [storeOptions, setStoreOptions] = useState<any[]>([]);
const [isStoreLoading, setIsStoreLoading] = useState(false);
const [products, setProducts] = useState<any[]>([]);
const [storeOptions, setStoreOptions] = useState<
BasketStoreComparisonResponse['singleStoreOptions']
>([]);
const [_isStoreLoading, setIsStoreLoading] = useState(false);
const [products, setProducts] = useState<ProductResponse[]>([]);
const [selectedProductId, setSelectedProductId] = useState('');
const [customItemName, setCustomItemName] = useState('');
const [qty, setQty] = useState(1);
@ -36,10 +45,12 @@ export default function ShoppingListDetailsPage() {
// Load core list context
const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null;
const { data: list, mutate: mutateList, isLoading: listLoading, error: listError } = useSWR(
swrKey,
() => getShoppingList(householdId!, listId)
);
const {
data: list,
mutate: mutateList,
isLoading: listLoading,
error: listError,
} = useSWR<ShoppingListResponse, Error>(swrKey, () => getShoppingList(householdId!, listId));
useEffect(() => {
if (listError) setError(listError.message || 'Shopping list not found');
@ -61,31 +72,42 @@ export default function ShoppingListDetailsPage() {
// Pre-load household products for predictive inputs
useEffect(() => {
if (!householdId) return;
listProducts(householdId).then(res => setProducts(res.data)).catch(console.error);
listProducts(householdId)
.then((res) => setProducts(res.data))
.catch(console.error);
}, [householdId]);
// Handle WS Remote Event Broadcasts
const handleRemoteSync = useCallback((msg: any) => {
console.log('🔔 Remote state delta payload:', msg);
mutateList(); // Revalidate with server on remote changes
}, [mutateList]);
const handleRemoteSync = useCallback(
(msg: unknown) => {
console.log('🔔 Remote state delta payload:', msg);
void mutateList(); // Revalidate with server on remote changes
},
[mutateList],
);
// Inject Real-Time Hooks
const { isConnected, toggleItemCheck } = useShoppingListSync(
householdId || '',
listId,
handleRemoteSync
handleRemoteSync,
);
// 1. Perform Live Interactivity (Toggle Checks)
const handleToggleCheck = async (itemId: string, currentChecked: boolean) => {
const nextChecked = !currentChecked;
// Optimistic Client Update
mutateList(async (prev: any) => ({
...prev,
items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it)
}), { revalidate: false });
void mutateList(
async (prev) => {
if (!prev) return undefined;
return {
...prev,
items: prev.items.map((it) => (it.id === itemId ? { ...it, checked: nextChecked } : it)),
};
},
{ revalidate: false },
);
// Emit to WS Channel
toggleItemCheck(itemId, nextChecked);
@ -93,10 +115,10 @@ export default function ShoppingListDetailsPage() {
// Persist standard Rest fallback ensuring safety
try {
await updateShoppingItem(householdId!, listId, itemId, { checked: nextChecked });
mutateList();
void mutateList();
} catch (err) {
console.error('Persistent toggle sync fail', err);
mutateList();
void mutateList();
}
};
@ -109,18 +131,18 @@ export default function ShoppingListDetailsPage() {
productId: selectedProductId || undefined,
customName: !selectedProductId ? customItemName.trim() : undefined,
quantity: qty,
unit: unit as any,
unit: unit as ServingUnit,
notes: notes.trim() || undefined,
});
mutateList(updated);
void mutateList(updated);
// Clear inputs
setSelectedProductId('');
setCustomItemName('');
setQty(1);
setNotes('');
} catch (err: any) {
alert(err.message);
} catch (err) {
alert(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsAdding(false);
}
@ -128,47 +150,60 @@ export default function ShoppingListDetailsPage() {
const handleDeleteItem = async (itemId: string) => {
// Optimistic delete
mutateList(async (prev: any) => ({
...prev,
items: prev.items.filter((it: any) => it.id !== itemId)
}), { revalidate: false });
void mutateList(
async (prev) => {
if (!prev) return undefined;
return {
...prev,
items: prev.items.filter((it) => it.id !== itemId),
};
},
{ revalidate: false },
);
try {
const updated = await removeShoppingItem(householdId!, listId, itemId);
mutateList(updated);
} catch (err: any) {
window.alert(err.message);
mutateList();
void mutateList(updated);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
window.alert(message);
void mutateList();
}
};
// 3. Execute Final Checkout / Pantry Sync
const handleSyncToPantry = async () => {
const readyItems = list!.items.filter((i: any) => i.checked && !i.addedToPantry);
const readyItems = list!.items.filter((i) => i.checked && !i.addedToPantry);
if (!window.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);
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);
const allChecked = list!.items.every((i) => i.checked || i.addedToPantry);
if (allChecked) {
await updateShoppingList(householdId!, listId, { status: 'completed' as any });
await updateShoppingList(householdId!, listId, { status: 'completed' });
}
mutateList();
} catch (err: any) {
window.alert('Migration sync error: ' + err.message);
void mutateList();
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
window.alert('Migration sync error: ' + message);
}
};
// Collate items categorized for satisfying view
const categorizedItems = useMemo(() => {
if (!list) return {};
const groups: Record<string, any[]> = {};
list.items.forEach((it: any) => {
const groups: Record<string, ShoppingItem[]> = {};
list.items.forEach((it) => {
const cat = it.category || 'Other / Misc';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(it);
@ -176,11 +211,12 @@ export default function ShoppingListDetailsPage() {
return groups;
}, [list]);
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>;
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;
const checkedCount = list.items.filter((i: any) => i.checked).length;
const itemsPendingSync = list.items.filter((i) => i.checked && !i.addedToPantry).length;
const totalCount = list.items.length;
return (
@ -190,64 +226,137 @@ export default function ShoppingListDetailsPage() {
subtitle="Perform live checkout check-offs synchronously across multiple household devices."
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, padding: '0 32px', marginTop: -12, marginBottom: 12, maxWidth: 1400, margin: '-12px auto 12px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
fontSize: 13,
padding: '0 32px',
marginTop: -12,
marginBottom: 12,
maxWidth: 1400,
margin: '-12px auto 12px',
}}
>
<Pill tone={list.status === 'completed' ? 'ok' : 'info'}>{list.status}</Pill>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--ink-muted)' }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: isConnected ? 'var(--success, #10b981)' : 'var(--danger, #ef4444)',
boxShadow: isConnected ? '0 0 8px var(--success)' : 'none',
}} />
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: isConnected ? 'var(--success, #10b981)' : 'var(--danger, #ef4444)',
boxShadow: isConnected ? '0 0 8px var(--success)' : 'none',
}}
/>
{isConnected ? 'Live Sync Channel Operational' : 'Connecting Sync...'}
</div>
</div>
<div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}>
{/* Top Action Strip */}
<div style={{ display: 'flex', gap: 12, marginBottom: 24, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
justifyContent: 'space-between',
flexWrap: 'wrap',
}}
>
<Button variant="ghost" onClick={() => router.push('/shopping-lists')}>
<Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Hub
</Button>
<div style={{ display: 'flex', gap: 12 }}>
<Button variant="ghost" onClick={fetchComparisons}>
<Button
variant="ghost"
onClick={() => {
void fetchComparisons();
}}
>
<Icon name="trend" style={{ marginRight: 6, width: 16 }} /> Check Lowest Store Options
</Button>
{itemsPendingSync > 0 && (
<Button onClick={handleSyncToPantry} style={{ background: 'var(--success)', borderColor: 'var(--success)', color: '#fff' }}>
<Icon name="box" style={{ marginRight: 6, width: 16 }} /> Sync {itemsPendingSync} items to Pantry
<Button
onClick={() => {
void handleSyncToPantry();
}}
style={{
background: 'var(--success)',
borderColor: 'var(--success)',
color: '#fff',
}}
>
<Icon name="box" style={{ marginRight: 6, width: 16 }} /> Sync {itemsPendingSync}{' '}
items to Pantry
</Button>
)}
</div>
</div>
{/* Main Workspace Split Grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 380px',
gap: 32,
alignItems: 'start',
}}
>
{/* Left: Categorized Checklist Grid */}
<div>
{totalCount === 0 ? (
<Card style={{ padding: 40, textAlign: 'center', background: 'var(--bg-elev)', border: '1px dashed var(--border)' }}>
<Icon name="list" style={{ width: 40, color: 'var(--ink-muted)', marginBottom: 16 }} />
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)' }}>Checklist is Empty</h4>
<p style={{ fontSize: 13, color: 'var(--ink-muted)' }}>Add missing ingredients using the pane on the right.</p>
<Card
style={{
padding: 40,
textAlign: 'center',
background: 'var(--bg-elev)',
border: '1px dashed var(--border)',
}}
>
<Icon
name="list"
style={{ width: 40, color: 'var(--ink-muted)', marginBottom: 16 }}
/>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)' }}>
Checklist is Empty
</h4>
<p style={{ fontSize: 13, color: 'var(--ink-muted)' }}>
Add missing ingredients using the pane on the right.
</p>
</Card>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{Object.entries(categorizedItems).map(([cat, items]: [string, any]) => (
{Object.entries(categorizedItems).map(([cat, items]) => (
<div key={cat}>
<h4 style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12, borderBottom: '1px solid var(--border)', paddingBottom: 6 }}>
<h4
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: 12,
borderBottom: '1px solid var(--border)',
paddingBottom: 6,
}}
>
{cat}
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((it: any) => (
{items.map((it) => (
<div
key={it.id}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 16px', background: 'var(--bg-elev)',
border: '1px solid var(--border)', borderRadius: 'var(--r-md)',
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '12px 16px',
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
transition: 'all 0.15s',
opacity: it.checked ? 0.65 : 1,
textDecoration: it.checked ? 'line-through' : 'none',
@ -255,32 +364,70 @@ 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}`}
onClick={() => {
void 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%',
width: 22,
height: 22,
borderRadius: '50%',
border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`,
background: it.checked ? 'var(--success, #10b981)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', flexShrink: 0, padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
padding: 0,
}}
>
{it.checked && <Icon name="check" style={{ width: 12, color: '#fff' }} />}
{it.checked && (
<Icon name="check" style={{ width: 12, color: '#fff' }} />
)}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: it.checked ? 'var(--ink-muted)' : 'var(--ink)' }}>
{it.productId ? products.find(p => p._id === it.productId)?.name || 'Ingredient Loading...' : it.customName}
<div
style={{
fontWeight: 600,
fontSize: 14,
color: it.checked ? 'var(--ink-muted)' : 'var(--ink)',
}}
>
{it.productId
? products.find((p) => p._id === it.productId)?.name ||
'Ingredient Loading...'
: it.customName}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)', display: 'flex', gap: 10, marginTop: 2 }}>
<span>Qty: {it.quantity} {it.unit}</span>
<div
style={{
fontSize: 11,
color: 'var(--ink-muted)',
display: 'flex',
gap: 10,
marginTop: 2,
}}
>
<span>
Qty: {it.quantity} {it.unit}
</span>
{it.notes && <span> Note: {it.notes}</span>}
</div>
</div>
{/* Estimated Price Tag */}
{it.estimatedPrice && !it.checked && (
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-muted)', background: 'var(--bg)', padding: '4px 8px', borderRadius: 'var(--r-sm)' }}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--ink-muted)',
background: 'var(--bg)',
padding: '4px 8px',
borderRadius: 'var(--r-sm)',
}}
>
~${it.estimatedPrice.toFixed(2)}
</div>
)}
@ -293,9 +440,18 @@ 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 }}
onClick={() => {
void 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 }} />
</button>
@ -310,13 +466,26 @@ export default function ShoppingListDetailsPage() {
{/* Right Side Panel: Context Inputs */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* Pane A: Add New Item */}
<Card style={{ padding: 20 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<h4
style={{
fontSize: 15,
fontWeight: 600,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Icon name="plus" style={{ width: 16 }} /> Add Grocery Item
</h4>
<form onSubmit={handleAddItem} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<form
onSubmit={(e) => {
void handleAddItem(e);
}}
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
>
<div>
<label style={labelStyle}>Link Product Catalog</label>
<select
@ -328,7 +497,11 @@ export default function ShoppingListDetailsPage() {
style={selectStyle}
>
<option value="">-- Create Manual Custom Input --</option>
{products.map(p => <option key={p._id} value={p._id}>{p.name}</option>)}
{products.map((p) => (
<option key={p._id} value={p._id}>
{p.name}
</option>
))}
</select>
</div>
@ -340,7 +513,7 @@ export default function ShoppingListDetailsPage() {
required
placeholder="e.g., Generic Flour"
value={customItemName}
onChange={e => setCustomItemName(e.target.value)}
onChange={(e) => setCustomItemName(e.target.value)}
style={inputStyle}
/>
</div>
@ -355,13 +528,17 @@ export default function ShoppingListDetailsPage() {
min="0.01"
step="any"
value={qty}
onChange={e => setQty(parseFloat(e.target.value) || 0)}
onChange={(e) => setQty(parseFloat(e.target.value) || 0)}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Unit</label>
<select value={unit} onChange={e => setUnit(e.target.value)} style={selectStyle}>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
style={selectStyle}
>
<option value="g">Grams</option>
<option value="ml">Milliliters</option>
<option value="piece">Pieces</option>
@ -376,7 +553,7 @@ export default function ShoppingListDetailsPage() {
type="text"
placeholder="Brand preference, etc."
value={notes}
onChange={e => setNotes(e.target.value)}
onChange={(e) => setNotes(e.target.value)}
style={inputStyle}
/>
</div>
@ -390,19 +567,67 @@ export default function ShoppingListDetailsPage() {
{/* Pane B: Real-Time Store Optimizer */}
{storeOptions.length > 0 && (
<Card style={{ padding: 20 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<Icon name="trend" style={{ width: 16, color: 'var(--brand)' }} /> Lowest Store Basket Rank
<h4
style={{
fontSize: 15,
fontWeight: 600,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Icon name="trend" style={{ width: 16, color: 'var(--brand)' }} /> Lowest Store
Basket Rank
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{storeOptions.map((opt, idx) => (
<div key={opt.storeId} style={{ padding: 12, background: 'var(--bg)', border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)', borderRadius: 'var(--r-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 13 }}>{opt.storeName}</span>
<span style={{ fontSize: 14, fontWeight: 700, color: idx === 0 ? 'var(--success)' : 'var(--ink)' }}>${opt.estimatedTotal.toFixed(2)}</span>
<div
key={opt.storeId}
style={{
padding: 12,
background: 'var(--bg)',
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
borderRadius: 'var(--r-md)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 13 }}>
{opt.storeName}
</span>
<span
style={{
fontSize: 14,
fontWeight: 700,
color: idx === 0 ? 'var(--success)' : 'var(--ink)',
}}
>
${opt.estimatedTotal.toFixed(2)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>
<span>Covered: {opt.itemsCovered}/{totalCount} products</span>
{idx === 0 && <span style={{ color: 'var(--success)', fontWeight: 600 }}>Cheapest Single Trip</span>}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: 'var(--ink-muted)',
marginTop: 4,
}}
>
<span>
Covered: {opt.itemsCovered}/{totalCount} products
</span>
{idx === 0 && (
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
Cheapest Single Trip
</span>
)}
</div>
</div>
))}
@ -417,18 +642,34 @@ export default function ShoppingListDetailsPage() {
}
const labelStyle: React.CSSProperties = {
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--ink-muted)',
textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6,
display: 'block',
fontSize: 11,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.03em',
marginBottom: 6,
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)',
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)',
fontSize: 13, outline: 'none',
width: '100%',
padding: '8px 12px',
borderRadius: 'var(--r-md)',
background: 'var(--bg)',
border: '1px solid var(--border)',
color: 'var(--ink)',
fontSize: 13,
outline: 'none',
};
const selectStyle: React.CSSProperties = {
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)',
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)',
fontSize: 13, outline: 'none', height: 36,
width: '100%',
padding: '8px 12px',
borderRadius: 'var(--r-md)',
background: 'var(--bg)',
border: '1px solid var(--border)',
color: 'var(--ink)',
fontSize: 13,
outline: 'none',
height: 36,
};

View file

@ -4,24 +4,35 @@ import { useState, useEffect, useCallback } from 'react';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, Button, Icon, Pill } from '@/components/ui';
import type { ShoppingListResponse } from '@/services/shopping-lists';
import { getShoppingLists, createShoppingList } from '@/services/shopping-lists';
import type { MealPlanResponse } from '@/services/meal-plans';
import { listMealPlans } from '@/services/meal-plans';
import { generateFromMealPlan } from '@/services/shopping-lists';
import Link from 'next/link';
interface MetricCardProps {
icon: string;
title: string;
value: string;
subtitle: string;
color: string;
link?: string;
}
export default function ShoppingListsPage() {
const { householdId, isLoading } = useApi();
const [lists, setLists] = useState<any[]>([]);
const [lists, setLists] = useState<ShoppingListResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Modal States
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isGapModalOpen, setIsGapModalOpen] = useState(false);
// Form States
const [newListName, setNewListName] = useState('');
const [recentMealPlans, setRecentMealPlans] = useState<any[]>([]);
const [recentMealPlans, setRecentMealPlans] = useState<MealPlanResponse[]>([]);
const [mealPlanLoading, setMealPlanLoading] = useState(false);
const fetchLists = useCallback(async () => {
@ -36,15 +47,16 @@ export default function ShoppingListsPage() {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
setLists(data);
} catch (err: any) {
setError(err.message || 'Failed to load shopping lists');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load shopping lists';
setError(message);
} finally {
setLoading(false);
}
}, [householdId]);
useEffect(() => {
fetchLists();
void fetchLists();
}, [fetchLists]);
const handleCreateList = async (e: React.FormEvent) => {
@ -59,8 +71,9 @@ export default function ShoppingListsPage() {
setIsCreateModalOpen(false);
// Redirect or update list
setLists((prev) => [res, ...prev]);
} catch (err: any) {
alert(err.message || 'Failed to create list');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create list';
alert(message);
}
};
@ -82,28 +95,44 @@ export default function ShoppingListsPage() {
const res = await generateFromMealPlan(householdId!, mealPlanId);
setIsGapModalOpen(false);
setLists((prev) => [res, ...prev]);
} catch (err: any) {
alert(err.message || 'Failed to generate groceries from meal plan');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to generate groceries from meal plan';
alert(message);
}
};
if (isLoading) return <SetPageHeader title="Groceries" subtitle="Analyze needs and track baskets" />;
if (isLoading)
return <SetPageHeader title="Groceries" subtitle="Analyze needs and track baskets" />;
if (!householdId) return <div style={{ padding: 32 }}>Please join a household.</div>;
const activeLists = lists.filter(l => l.status === 'active' || l.status === 'shopping');
const completedLists = lists.filter(l => l.status === 'completed' || l.status === 'archived');
const activeLists = lists.filter((l) => l.status === 'active' || l.status === 'shopping');
const completedLists = lists.filter((l) => l.status === 'completed' || l.status === 'archived');
// Derive stats
const totalActiveCost = activeLists.reduce((sum, l) => sum + (l.totalEstimatedCost || 0), 0);
const totalPendingItems = activeLists.reduce((sum, l) => sum + l.items.filter((i: any) => !i.checked).length, 0);
const totalPendingItems = activeLists.reduce(
(sum, l) => sum + l.items.filter((i) => !i.checked).length,
0,
);
return (
<>
<SetPageHeader title="Grocery & Shopping" subtitle="Streamline your checklist, check gaps, and compare costs." />
<SetPageHeader
title="Grocery & Shopping"
subtitle="Streamline your checklist, check gaps, and compare costs."
/>
<div style={{ padding: '28px 32px 64px', maxWidth: 1300, margin: '0 auto' }}>
{/* 1. Beautiful Stats Band */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 20, marginBottom: 32 }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: 20,
marginBottom: 32,
}}
>
<MetricCard
icon="store"
title="Active Lists"
@ -128,7 +157,7 @@ export default function ShoppingListsPage() {
<MetricCard
icon="trend"
title="Spending Trend"
value="Analyics"
value="Analytics"
subtitle="Visualize price fluctuations"
color="var(--ink-muted)"
link="/shopping-lists/prices"
@ -136,10 +165,26 @@ export default function ShoppingListsPage() {
</div>
{/* 2. Action Row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, flexWrap: 'wrap', gap: 16 }}>
<h3 style={{ fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>Checklists & Baskets</h3>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
flexWrap: 'wrap',
gap: 16,
}}
>
<h3 style={{ fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
Checklists & Baskets
</h3>
<div style={{ display: 'flex', gap: 12 }}>
<Button variant="ghost" onClick={openGapModal}>
<Button
variant="ghost"
onClick={() => {
void openGapModal();
}}
>
<Icon name="zap" style={{ marginRight: 6, width: 16 }} />
Generate from Meal Plan
</Button>
@ -150,19 +195,70 @@ export default function ShoppingListsPage() {
</div>
</div>
{error && <div style={{ color: 'var(--danger)', padding: 16, background: 'var(--danger-soft)', borderRadius: 'var(--r-md)', marginBottom: 24 }}>{error}</div>}
{error && (
<div
style={{
color: 'var(--danger)',
padding: 16,
background: 'var(--danger-soft)',
borderRadius: 'var(--r-md)',
marginBottom: 24,
}}
>
{error}
</div>
)}
{/* 3. Lists Grid */}
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}>
{[1, 2, 3].map(i => <div key={i} style={{ height: 180, borderRadius: 'var(--r-lg)', border: '1px solid var(--border)', background: 'var(--bg-elev)', opacity: 0.4 }} />)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 20,
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
height: 180,
borderRadius: 'var(--r-lg)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
opacity: 0.4,
}}
/>
))}
</div>
) : activeLists.length === 0 && completedLists.length === 0 ? (
<div style={{ textAlign: 'center', padding: '80px 24px', background: 'var(--bg-elev)', border: '1px dashed var(--border)', borderRadius: 'var(--r-lg)' }}>
<Icon name="store" style={{ width: 48, height: 48, color: 'var(--ink-muted)', marginBottom: 16 }} />
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>No Shopping Lists Found</h4>
<p style={{ color: 'var(--ink-muted)', fontSize: 14, marginBottom: 24, maxWidth: 400, margin: '0 auto 24px' }}>
Create an empty manual checklist, or dynamically auto-generate missing ingredients directly from your meal plan!
<div
style={{
textAlign: 'center',
padding: '80px 24px',
background: 'var(--bg-elev)',
border: '1px dashed var(--border)',
borderRadius: 'var(--r-lg)',
}}
>
<Icon
name="store"
style={{ width: 48, height: 48, color: 'var(--ink-muted)', marginBottom: 16 }}
/>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>
No Shopping Lists Found
</h4>
<p
style={{
color: 'var(--ink-muted)',
fontSize: 14,
marginBottom: 24,
maxWidth: 400,
margin: '0 auto 24px',
}}
>
Create an empty manual checklist, or dynamically auto-generate missing ingredients
directly from your meal plan!
</p>
<Button onClick={() => setIsCreateModalOpen(true)}>Create First List</Button>
</div>
@ -170,7 +266,14 @@ export default function ShoppingListsPage() {
<>
{/* Active Section */}
{activeLists.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20, marginBottom: 40 }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 20,
marginBottom: 40,
}}
>
{activeLists.map((list) => (
<ShoppingListCard key={list._id} list={list} />
))}
@ -180,8 +283,25 @@ export default function ShoppingListsPage() {
{/* Past Section */}
{completedLists.length > 0 && (
<>
<h4 style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 16 }}>Completed Runs</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}>
<h4
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 16,
}}
>
Completed Runs
</h4>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 20,
}}
>
{completedLists.map((list) => (
<ShoppingListCard key={list._id} list={list} />
))}
@ -196,10 +316,26 @@ export default function ShoppingListsPage() {
{isCreateModalOpen && (
<div style={overlayStyle} onClick={() => setIsCreateModalOpen(false)}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>Create Shopping List</h3>
<form onSubmit={handleCreateList}>
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
Create Shopping List
</h3>
<form
onSubmit={(e) => {
void handleCreateList(e);
}}
>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--ink-muted)', marginBottom: 6 }}>Checklist Name</label>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--ink-muted)',
marginBottom: 6,
}}
>
Checklist Name
</label>
<input
type="text"
required
@ -211,7 +347,9 @@ export default function ShoppingListsPage() {
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<Button type="button" variant="ghost" onClick={() => setIsCreateModalOpen(false)}>Cancel</Button>
<Button type="button" variant="ghost" onClick={() => setIsCreateModalOpen(false)}>
Cancel
</Button>
<Button type="submit">Create</Button>
</div>
</form>
@ -225,28 +363,58 @@ export default function ShoppingListsPage() {
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Scan Meal Plan Gaps</h3>
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 20 }}>
Select a scheduled weekly plan. We will cross-reference your recipe ingredient requirements vs active pantry inventory to auto-generate your grocery shortages!
Select a scheduled weekly plan. We will cross-reference your recipe ingredient
requirements vs active pantry inventory to auto-generate your grocery shortages!
</p>
{mealPlanLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}>Loading schedules...</div>
) : recentMealPlans.length === 0 ? (
<div style={{ padding: 16, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, textAlign: 'center', fontSize: 14, color: 'var(--ink-muted)' }}>
<div
style={{
padding: 16,
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 8,
textAlign: 'center',
fontSize: 14,
color: 'var(--ink-muted)',
}}
>
No meal plans configured. Build a plan first!
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxHeight: 300, overflowY: 'auto', marginBottom: 20 }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
maxHeight: 300,
overflowY: 'auto',
marginBottom: 20,
}}
>
{recentMealPlans.slice(0, 5).map((plan) => {
const dateStr = new Date(plan.weekStartDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const dateStr = new Date(plan.weekStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<button
key={plan._id}
onClick={() => handleGenerateFromPlan(plan._id)}
onClick={() => {
void handleGenerateFromPlan(plan._id);
}}
style={planRowStyle}
>
<div style={{ textAlign: 'left' }}>
<div style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 14 }}>Week of {dateStr}</div>
<div style={{ fontSize: 12, color: 'var(--ink-muted)' }}>Status: <span style={{ textTransform: 'capitalize' }}>{plan.status}</span></div>
<div style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 14 }}>
Week of {dateStr}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-muted)' }}>
Status: <span style={{ textTransform: 'capitalize' }}>{plan.status}</span>
</div>
</div>
<Icon name="chevronRight" style={{ width: 16, color: 'var(--ink-muted)' }} />
</button>
@ -254,9 +422,11 @@ export default function ShoppingListsPage() {
})}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="ghost" onClick={() => setIsGapModalOpen(false)}>Close</Button>
<Button variant="ghost" onClick={() => setIsGapModalOpen(false)}>
Close
</Button>
</div>
</div>
</div>
@ -265,62 +435,159 @@ export default function ShoppingListsPage() {
);
}
function MetricCard({ icon, title, value, subtitle, color, link }: any) {
function MetricCard({ icon, title, value, subtitle, color, link }: MetricCardProps) {
const content = (
<Card style={{ padding: 20, height: '100%', display: 'flex', alignItems: 'center', gap: 16, position: 'relative', overflow: 'hidden', cursor: link ? 'pointer' : 'default' }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Card
style={{
padding: 20,
height: '100%',
display: 'flex',
alignItems: 'center',
gap: 16,
position: 'relative',
overflow: 'hidden',
cursor: link ? 'pointer' : 'default',
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: '50%',
background: `${color}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name={icon} style={{ width: 22, height: 22, color }} />
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>{title}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--ink)', margin: '2px 0' }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--ink-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
<div
style={{
fontSize: 12,
fontWeight: 500,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.02em',
}}
>
{title}
</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--ink)', margin: '2px 0' }}>
{value}
</div>
<div
style={{
fontSize: 12,
color: 'var(--ink-muted)',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{subtitle}
{link && <Icon name="chevronRight" style={{ width: 12 }} />}
</div>
</div>
</Card>
);
return link ? <Link href={link} style={{ textDecoration: 'none' }}>{content}</Link> : content;
return link ? (
<Link href={link} style={{ textDecoration: 'none' }}>
{content}
</Link>
) : (
content
);
}
function ShoppingListCard({ list }: { list: any }) {
function ShoppingListCard({ list }: { list: ShoppingListResponse }) {
const total = list.items.length;
const checked = list.items.filter((i: any) => i.checked).length;
const checked = list.items.filter((i) => i.checked).length;
const progress = total > 0 ? Math.round((checked / total) * 100) : 0;
const date = new Date(list.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const date = new Date(list.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
const isActive = list.status === 'active' || list.status === 'shopping';
return (
<Link href={`/shopping-lists/${list._id}`} style={{ textDecoration: 'none' }}>
<Card style={{
padding: 20,
transition: 'all 0.2s ease',
border: isActive ? '1px solid var(--border-hover, #444)' : '1px solid var(--border)',
position: 'relative',
background: isActive ? 'rgba(255,255,255,0.02)' : 'var(--bg-elev)',
display: 'flex',
flexDirection: 'column',
gap: 12,
cursor: 'pointer',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<Card
style={{
padding: 20,
transition: 'all 0.2s ease',
border: isActive ? '1px solid var(--border-hover, #444)' : '1px solid var(--border)',
position: 'relative',
background: isActive ? 'rgba(255,255,255,0.02)' : 'var(--bg-elev)',
display: 'flex',
flexDirection: 'column',
gap: 12,
cursor: 'pointer',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
}}
>
<div>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', margin: 0, lineHeight: 1.3 }}>{list.name}</h4>
<span style={{ fontSize: 11, color: 'var(--ink-muted)', display: 'inline-block', marginTop: 4 }}>Created {date}</span>
<h4
style={{
fontSize: 16,
fontWeight: 600,
color: 'var(--ink)',
margin: 0,
lineHeight: 1.3,
}}
>
{list.name}
</h4>
<span
style={{
fontSize: 11,
color: 'var(--ink-muted)',
display: 'inline-block',
marginTop: 4,
}}
>
Created {date}
</span>
</div>
<Pill tone={list.status === 'active' ? 'info' : list.status === 'shopping' ? 'warn' : 'ghost'}>
<Pill
tone={list.status === 'active' ? 'info' : list.status === 'shopping' ? 'warn' : 'ghost'}
>
{list.status === 'shopping' ? 'Live' : list.status}
</Pill>
</div>
<div style={{ fontSize: 13, color: 'var(--ink-muted)', display: 'flex', gap: 16, alignItems: 'center' }}>
<div
style={{
fontSize: 13,
color: 'var(--ink-muted)',
display: 'flex',
gap: 16,
alignItems: 'center',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="list" style={{ width: 14 }} /> {checked}/{total} items
</span>
{list.totalEstimatedCost && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontWeight: 600, color: 'var(--ink)' }}>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontWeight: 600,
color: 'var(--ink)',
}}
>
${list.totalEstimatedCost.toFixed(2)}
</span>
)}
@ -328,12 +595,30 @@ function ShoppingListCard({ list }: { list: any }) {
{/* Custom Progress Bar */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ink-muted)', marginBottom: 4 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: 'var(--ink-muted)',
marginBottom: 4,
}}
>
<span>Progress</span>
<span>{progress}%</span>
</div>
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${progress}%`, background: progress === 100 ? 'var(--success, #10b981)' : 'var(--brand)', borderRadius: 3, transition: 'width 0.4s cubic-bezier(0.4, 0, 0.2, 1)' }} />
<div
style={{ height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}
>
<div
style={{
height: '100%',
width: `${progress}%`,
background: progress === 100 ? 'var(--success, #10b981)' : 'var(--brand)',
borderRadius: 3,
transition: 'width 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</div>
</div>
</Card>
@ -342,26 +627,50 @@ function ShoppingListCard({ list }: { list: any }) {
}
const overlayStyle: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999,
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
padding: 16,
};
const modalStyle: React.CSSProperties = {
background: 'var(--bg-elev)', border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)', padding: 24, width: '100%', maxWidth: 460,
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
padding: 24,
width: '100%',
maxWidth: 460,
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', borderRadius: 'var(--r-md)',
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)',
fontSize: 14, outline: 'none',
width: '100%',
padding: '10px 14px',
borderRadius: 'var(--r-md)',
background: 'var(--bg)',
border: '1px solid var(--border)',
color: 'var(--ink)',
fontSize: 14,
outline: 'none',
};
const planRowStyle: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 16px', background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: 'var(--r-md)', width: '100%', cursor: 'pointer', transition: 'all 0.15s',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
width: '100%',
cursor: 'pointer',
transition: 'all 0.15s',
};

View file

@ -4,7 +4,8 @@ import { useState, useEffect, useCallback } from 'react';
import { useApi } from '@/lib/useApi';
import { useRouter } from 'next/navigation';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, Button, Icon, Pill } from '@/components/ui';
import { Card, Button, Icon } from '@/components/ui';
import type { FoodSpendingAnalyticsResponse } from '@/services/prices';
import { getPriceAnalytics } from '@/services/prices';
import {
ResponsiveContainer,
@ -16,14 +17,25 @@ import {
YAxis,
CartesianGrid,
Tooltip,
Legend,
Cell,
} from 'recharts';
interface SpendingByCategoryItem {
category: string;
total: number;
}
interface AverageBasketByStoreItem {
storeId: string;
storeName: string;
avgTotal: number;
tripCount: number;
}
export default function PricesAnalyticsPage() {
const { householdId, isLoading } = useApi();
const router = useRouter();
const [data, setData] = useState<any>(null);
const [data, setData] = useState<FoodSpendingAnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@ -33,21 +45,31 @@ export default function PricesAnalyticsPage() {
try {
const result = await getPriceAnalytics(householdId);
setData(result);
} catch (err: any) {
setError(err.message || 'Failed to load analytics');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load analytics';
setError(message);
} finally {
setLoading(false);
}
}, [householdId]);
useEffect(() => {
loadAnalytics();
void loadAnalytics();
}, [loadAnalytics]);
if (isLoading || loading) return <div style={{ padding: 40 }}>Synthesizing financial graphs...</div>;
if (error || !data) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
if (isLoading || loading)
return <div style={{ padding: 40 }}>Synthesizing financial graphs...</div>;
if (error || !data)
return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
const COLORS = ['var(--brand)', 'var(--success)', 'var(--warning)', '#a855f7', '#ec4899', '#3b82f6'];
const COLORS = [
'var(--brand)',
'var(--success)',
'var(--warning)',
'#a855f7',
'#ec4899',
'#3b82f6',
];
return (
<>
@ -57,7 +79,6 @@ export default function PricesAnalyticsPage() {
/>
<div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}>
<Button variant="ghost" onClick={() => router.push('/shopping-lists')}>
<Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Checklists
@ -66,34 +87,76 @@ export default function PricesAnalyticsPage() {
{/* 1. Immediate Red Alert Banner: Inflation Markup >10% */}
{data.priceAlerts.length > 0 && (
<div style={{
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: 'var(--r-lg)',
padding: 20, marginBottom: 32,
display: 'flex', gap: 16, alignItems: 'flex-start'
}}>
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: 'var(--danger)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}>
<div
style={{
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: 'var(--r-lg)',
padding: 20,
marginBottom: 32,
display: 'flex',
gap: 16,
alignItems: 'flex-start',
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--danger)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name="alert" style={{ width: 20, color: '#fff' }} />
</div>
<div style={{ flex: 1 }}>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 6 }}>Significant Inflation Markers Detected</h4>
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 16 }}>The following item markups exceeded the baseline 10% deviation thresholds compared to their trailing averages:</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
{data.priceAlerts.map((alert: any, idx: number) => (
<div key={idx} style={{ padding: 12, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 6 }}>
Significant Inflation Markers Detected
</h4>
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 16 }}>
The following item markups exceeded the baseline 10% deviation thresholds compared
to their trailing averages:
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}
>
{data.priceAlerts.map((alert, idx) => (
<div
key={idx}
style={{
padding: 12,
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink)' }}>{alert.productName}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>At {alert.storeName}</div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink)' }}>
{alert.productName}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
At {alert.storeName}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 14 }}>+{alert.changePercent.toFixed(0)}%</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>${alert.previousPrice} ${alert.currentPrice}</div>
<div style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 14 }}>
+{alert.changePercent.toFixed(0)}%
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
${alert.previousPrice} ${alert.currentPrice}
</div>
</div>
</div>
))}
@ -103,22 +166,44 @@ export default function PricesAnalyticsPage() {
)}
{/* 2. Grid Layout for Interactive Charts */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(500px, 1fr))', gap: 28, marginBottom: 32 }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(500px, 1fr))',
gap: 28,
marginBottom: 32,
}}
>
{/* Time Series Spend Trend */}
<Card style={{ padding: 24 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Monthly Spending Velocities</h4>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
Monthly Spending Velocities
</h4>
<div style={{ height: 300 }}>
{data.spendingOverTime.length === 0 ? (
<div style={emptyStyle}>No historical spend records found.</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data.spendingOverTime} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
<LineChart
data={data.spendingOverTime}
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="period" stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
<XAxis
dataKey="period"
stroke="var(--ink-muted)"
fontSize={11}
tickLine={false}
/>
<YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} />
<Line type="monotone" dataKey="total" stroke="var(--brand)" strokeWidth={3} activeDot={{ r: 6 }} />
<Line
type="monotone"
dataKey="total"
stroke="var(--brand)"
strokeWidth={3}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
)}
@ -127,21 +212,33 @@ export default function PricesAnalyticsPage() {
{/* Category Distribution */}
<Card style={{ padding: 24 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Spending Distrubution by Category</h4>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
Spending Distribution by Category
</h4>
<div style={{ height: 300 }}>
{data.spendingByCategory.length === 0 ? (
<div style={emptyStyle}>No categorized allocations recorded yet.</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.spendingByCategory} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
<BarChart
data={data.spendingByCategory}
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="category" stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
<XAxis
dataKey="category"
stroke="var(--ink-muted)"
fontSize={11}
tickLine={false}
/>
<YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="total" radius={[4, 4, 0, 0]}>
{data.spendingByCategory.map((entry: any, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
{(data.spendingByCategory as SpendingByCategoryItem[]).map(
(_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
),
)}
</Bar>
</BarChart>
</ResponsiveContainer>
@ -152,46 +249,99 @@ export default function PricesAnalyticsPage() {
{/* 3. Average Basket Comparisons (Grid of Stores) */}
<Card style={{ padding: 24 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Average Complete Basket Totals per Store</h4>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
Average Complete Basket Totals per Store
</h4>
{data.averageBasketByStore.length === 0 ? (
<div style={emptyStyle}>Create multiple shopping trips to visualize basket trends.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 20 }}>
{data.averageBasketByStore.sort((a:any, b:any) => a.avgTotal - b.avgTotal).map((store: any, idx: number) => (
<div key={store.storeId} style={{
padding: 20, background: 'var(--bg-elev)',
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
borderRadius: 'var(--r-md)', position: 'relative', overflow: 'hidden'
}}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', marginBottom: 8 }}>{store.storeName}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: idx === 0 ? 'var(--success)' : 'var(--ink)' }}>${store.avgTotal.toFixed(2)}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>Based on {store.tripCount} simulated checkouts</div>
{idx === 0 && (
<div style={{
position: 'absolute', top: 0, right: 0,
background: 'var(--success)', color: '#fff',
fontSize: 9, padding: '4px 8px', borderBottomLeftRadius: 'var(--r-sm)',
fontWeight: 700, textTransform: 'uppercase'
}}>Best Value</div>
)}
</div>
))}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 20,
}}
>
{(data.averageBasketByStore as AverageBasketByStoreItem[])
.sort((a, b) => a.avgTotal - b.avgTotal)
.map((store, idx) => (
<div
key={store.storeId}
style={{
padding: 20,
background: 'var(--bg-elev)',
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
borderRadius: 'var(--r-md)',
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
marginBottom: 8,
}}
>
{store.storeName}
</div>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: idx === 0 ? 'var(--success)' : 'var(--ink)',
}}
>
${store.avgTotal.toFixed(2)}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>
Based on {store.tripCount} simulated checkouts
</div>
{idx === 0 && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'var(--success)',
color: '#fff',
fontSize: 9,
padding: '4px 8px',
borderBottomLeftRadius: 'var(--r-sm)',
fontWeight: 700,
textTransform: 'uppercase',
}}
>
Best Value
</div>
)}
</div>
))}
</div>
)}
</Card>
</div>
</>
);
}
const emptyStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100%', color: 'var(--ink-muted)', fontSize: 13, border: '1px dashed var(--border)',
borderRadius: 'var(--r-md)'
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'var(--ink-muted)',
fontSize: 13,
border: '1px dashed var(--border)',
borderRadius: 'var(--r-md)',
};
const tooltipStyle: React.CSSProperties = {
background: '#1f2937', border: '1px solid #374151', borderRadius: 8,
color: '#fff', fontSize: 12,
background: '#1f2937',
border: '1px solid #374151',
borderRadius: 8,
color: '#fff',
fontSize: 12,
};

View file

@ -332,14 +332,14 @@ function StoresContent({ householdId }: { householdId: string }) {
}, [householdId, search, filterTag]);
useEffect(() => {
fetchStores();
void fetchStores();
}, [fetchStores]);
async function handleDeactivate(store: Store) {
if (!confirm(`Deactivate "${store.name}"?`)) return;
try {
await deactivateStore(householdId, store._id);
fetchStores();
void fetchStores();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to deactivate store');
}
@ -376,7 +376,7 @@ function StoresContent({ householdId }: { householdId: string }) {
householdId={householdId}
onSaved={() => {
setShowForm(false);
fetchStores();
void fetchStores();
}}
onCancel={() => setShowForm(false)}
/>
@ -388,7 +388,7 @@ function StoresContent({ householdId }: { householdId: string }) {
initial={editingStore}
onSaved={() => {
setEditingStore(null);
fetchStores();
void fetchStores();
}}
onCancel={() => setEditingStore(null)}
/>

View file

@ -13,20 +13,68 @@ interface AccentTokens {
const ACCENTS: Record<Accent, AccentTokens> = {
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' },
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' },
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' },
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' },
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',
},
},
};

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
'use client';
import { useSession } from 'next-auth/react';

View file

@ -1,17 +1,18 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { useEffect, useRef, useState, useCallback } from 'react';
import { getShoppingListSyncSocketUrl } from '@/services/shopping-lists';
export interface SyncUpdateMessage {
type: 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_REMOVED';
itemId?: string;
item?: any;
updates?: any;
item?: unknown;
updates?: unknown;
}
export function useShoppingListSync(
householdId: string,
listId: string,
onRemoteChange: (msg: SyncUpdateMessage) => void
onRemoteChange: (msg: SyncUpdateMessage) => void,
) {
const socketRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
@ -40,7 +41,7 @@ export function useShoppingListSync(
ws.onmessage = (event) => {
try {
const payload: SyncUpdateMessage = JSON.parse(event.data);
const payload = JSON.parse(event.data) as SyncUpdateMessage;
onRemoteChange(payload);
} catch (err) {
console.error('Failed parsing real-time grocery payload', err);
@ -54,13 +55,15 @@ export function useShoppingListSync(
ws.onclose = (event) => {
setIsConnected(false);
console.log(`🔌 Sync severed: ${event.reason || 'Disconnected'}`);
// Simple linear backoff reconnect
if (reconnectAttemptsRef.current < 5) {
reconnectAttemptsRef.current += 1;
const delay = Math.min(1000 * reconnectAttemptsRef.current, 5000);
setTimeout(() => {
console.log(`🔄 Attempting sync handshake reconnect (${reconnectAttemptsRef.current}/5)...`);
console.log(
`🔄 Attempting sync handshake reconnect (${reconnectAttemptsRef.current}/5)...`,
);
connect();
}, delay);
}
@ -76,7 +79,7 @@ export function useShoppingListSync(
return () => {
if (socketRef.current) {
// Clear hook handlers to prevent state leakage during dismount
socketRef.current.onclose = null;
socketRef.current.onclose = null;
socketRef.current.close();
}
};
@ -89,7 +92,7 @@ export function useShoppingListSync(
type: 'TOGGLE_ITEM',
itemId,
checked,
})
}),
);
}
}, []);

View file

@ -15,33 +15,6 @@ class ApiClient {
return BASE_URL;
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`;
}
return headers;
}
private async handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message: string;
try {
const body = await res.json();
message = body.message || `Request failed: ${res.status}`;
} catch {
message = `Request failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return res.json();
}
public async get<T>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
headers: this.getHeaders(),
@ -78,6 +51,33 @@ class ApiClient {
});
return this.handleResponse<T>(res);
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`;
}
return headers;
}
private async handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message: string;
try {
const body = (await res.json()) as { message?: string };
message = body.message || `Request failed: ${res.status}`;
} catch {
message = `Request failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
}
export const apiClient = new ApiClient();

View file

@ -44,88 +44,65 @@ export interface ShoppingGapReport {
export async function listMealPlans(
householdId: string,
query?: MealPlanQuery
query?: MealPlanQuery,
): Promise<MealPlanListResponse> {
const params = new URLSearchParams();
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<MealPlanListResponse>(
`/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}`
`/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}`,
);
}
export async function getMealPlanByWeek(
householdId: string,
weekStartDate: string
weekStartDate: string,
): Promise<MealPlanResponse | { message: string }> {
return apiClient.get<MealPlanResponse | { message: string }>(
`/households/${householdId}/meal-plans/week/${weekStartDate}`
`/households/${householdId}/meal-plans/week/${weekStartDate}`,
);
}
export async function getMealPlan(
householdId: string,
id: string
): Promise<MealPlanResponse> {
return apiClient.get<MealPlanResponse>(
`/households/${householdId}/meal-plans/${id}`
);
export async function getMealPlan(householdId: string, id: string): Promise<MealPlanResponse> {
return apiClient.get<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}`);
}
export async function createMealPlan(
householdId: string,
data: CreateMealPlanInput
data: CreateMealPlanInput,
): Promise<MealPlanResponse> {
return apiClient.post<MealPlanResponse>(
`/households/${householdId}/meal-plans`,
data
);
return apiClient.post<MealPlanResponse>(`/households/${householdId}/meal-plans`, data);
}
export async function updateMealPlan(
householdId: string,
id: string,
data: UpdateMealPlanInput
data: UpdateMealPlanInput,
): Promise<MealPlanResponse> {
return apiClient.patch<MealPlanResponse>(
`/households/${householdId}/meal-plans/${id}`,
data
);
return apiClient.patch<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}`, data);
}
export async function updateMealPlanStatus(
householdId: string,
id: string,
status: MealPlanStatus
status: MealPlanStatus,
): Promise<MealPlanResponse> {
return apiClient.patch<MealPlanResponse>(
`/households/${householdId}/meal-plans/${id}/status`,
{ status }
);
return apiClient.patch<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}/status`, {
status,
});
}
export async function deleteMealPlan(
householdId: string,
id: string
): Promise<void> {
export async function deleteMealPlan(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/meal-plans/${id}`);
}
export async function getSuggestions(
householdId: string,
limit = 5
): Promise<RecipeSuggestion[]> {
export async function getSuggestions(householdId: string, limit = 5): Promise<RecipeSuggestion[]> {
return apiClient.get<RecipeSuggestion[]>(
`/households/${householdId}/meal-plans/suggestions?limit=${limit}`
`/households/${householdId}/meal-plans/suggestions?limit=${limit}`,
);
}
export async function getShoppingGap(
householdId: string,
id: string
): Promise<ShoppingGapReport> {
return apiClient.get<ShoppingGapReport>(
`/households/${householdId}/meal-plans/${id}/gap`
);
export async function getShoppingGap(householdId: string, id: string): Promise<ShoppingGapReport> {
return apiClient.get<ShoppingGapReport>(`/households/${householdId}/meal-plans/${id}/gap`);
}

View file

@ -1,45 +1,42 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
NutritionTargetResponseSchema,
SetNutritionTargetInput,
} from '@meshitrack/shared';
import type { NutritionTargetResponseSchema, SetNutritionTargetInput } from '@meshitrack/shared';
export type NutritionTargetResponse = z.infer<typeof NutritionTargetResponseSchema>;
export async function getActiveNutritionTarget(
householdId: string
householdId: string,
): Promise<NutritionTargetResponse | { message: string }> {
return apiClient.get<NutritionTargetResponse | { message: string }>(
`/households/${householdId}/nutrition-targets`
`/households/${householdId}/nutrition-targets`,
);
}
export async function getNutritionTargetHistory(
householdId: string
householdId: string,
): Promise<NutritionTargetResponse[]> {
return apiClient.get<NutritionTargetResponse[]>(
`/households/${householdId}/nutrition-targets/history`
`/households/${householdId}/nutrition-targets/history`,
);
}
export async function setNutritionTarget(
householdId: string,
data: SetNutritionTargetInput
data: SetNutritionTargetInput,
): Promise<NutritionTargetResponse> {
return apiClient.post<NutritionTargetResponse>(
`/households/${householdId}/nutrition-targets`,
data
data,
);
}
export async function calculateTargetPreset(
householdId: string,
calories: number,
strategy: 'maintenance' | 'loss' | 'gain'
strategy: 'maintenance' | 'loss' | 'gain',
): Promise<SetNutritionTargetInput> {
return apiClient.post<SetNutritionTargetInput>(
`/households/${householdId}/nutrition-targets/presets`,
{ calories, strategy }
{ calories, strategy },
);
}

View file

@ -12,20 +12,20 @@ import type {
type PriceRecordResponse = z.infer<typeof PriceRecordResponseSchema>;
type PriceHistoryResponse = z.infer<typeof PriceHistoryResponseSchema>;
type FoodStoreComparisonResponse = z.infer<typeof FoodStoreComparisonResponseSchema>;
type FoodSpendingAnalyticsResponse = z.infer<typeof FoodSpendingAnalyticsResponseSchema>;
export type FoodSpendingAnalyticsResponse = z.infer<typeof FoodSpendingAnalyticsResponseSchema>;
type CreatePriceRecordInput = z.infer<typeof CreatePriceRecordSchema>;
type BulkPriceRecordInput = z.infer<typeof BulkPriceRecordInputSchema>;
export async function recordPrice(
householdId: string,
data: CreatePriceRecordInput
data: CreatePriceRecordInput,
): Promise<PriceRecordResponse> {
return apiClient.post<PriceRecordResponse>(`/households/${householdId}/prices`, data);
}
export async function recordBulkPrices(
householdId: string,
data: BulkPriceRecordInput
data: BulkPriceRecordInput,
): Promise<PriceRecordResponse[]> {
return apiClient.post<PriceRecordResponse[]>(`/households/${householdId}/prices/bulk`, data);
}
@ -39,7 +39,7 @@ export async function getPriceHistory(
endDate?: string;
cursor?: string;
limit?: number;
}
},
): Promise<PriceHistoryResponse> {
const params = new URLSearchParams();
if (query?.storeId) params.set('storeId', query.storeId);
@ -49,23 +49,23 @@ export async function getPriceHistory(
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<PriceHistoryResponse>(
`/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}`
`/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}`,
);
}
export async function compareStores(
householdId: string,
productId: string
productId: string,
): Promise<FoodStoreComparisonResponse> {
return apiClient.get<FoodStoreComparisonResponse>(
`/households/${householdId}/prices/compare/${productId}`
`/households/${householdId}/prices/compare/${productId}`,
);
}
export async function getPriceAnalytics(
householdId: string
householdId: string,
): Promise<FoodSpendingAnalyticsResponse> {
return apiClient.get<FoodSpendingAnalyticsResponse>(
`/households/${householdId}/prices/analytics`
`/households/${householdId}/prices/analytics`,
);
}

View file

@ -7,8 +7,8 @@ import type {
UpdateProductInput,
} from '@meshitrack/shared';
type ProductResponse = z.infer<typeof ProductResponseSchema>;
type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
export type ProductResponse = z.infer<typeof ProductResponseSchema>;
export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
export interface ProductQuery {
q?: string;
@ -96,12 +96,16 @@ export async function importProducts(
if (!res.ok) {
let message: string;
try {
const body = await res.json();
const body = (await res.json()) as { message?: string };
message = body.message || `Import failed: ${res.status}`;
} catch {
message = `Import failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
return res.json();
return res.json() as Promise<{
imported: number;
skippedDuplicates: number;
errors: { row: number; message: string }[];
}>;
}

View file

@ -2,6 +2,7 @@ import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
ShoppingListResponseSchema,
ShoppingItemSchema,
CreateShoppingListSchema,
UpdateShoppingListSchema,
AddShoppingItemSchema,
@ -10,25 +11,31 @@ import type {
BasketStoreComparisonResponseSchema,
} from '@meshitrack/shared';
type ShoppingListResponse = z.infer<typeof ShoppingListResponseSchema>;
type CreateShoppingListInput = z.infer<typeof CreateShoppingListSchema>;
type UpdateShoppingListInput = z.infer<typeof UpdateShoppingListSchema>;
type AddShoppingItemInput = z.infer<typeof AddShoppingItemSchema>;
type UpdateShoppingItemInput = z.infer<typeof UpdateShoppingItemSchema>;
type ShoppingListSyncToPantryResponse = z.infer<typeof ShoppingListSyncToPantryResponseSchema>;
type BasketStoreComparisonResponse = z.infer<typeof BasketStoreComparisonResponseSchema>;
export type ShoppingListResponse = z.infer<typeof ShoppingListResponseSchema>;
export type ShoppingItem = z.infer<typeof ShoppingItemSchema>;
export type CreateShoppingListInput = z.infer<typeof CreateShoppingListSchema>;
export type UpdateShoppingListInput = z.infer<typeof UpdateShoppingListSchema>;
export type AddShoppingItemInput = z.infer<typeof AddShoppingItemSchema>;
export type UpdateShoppingItemInput = z.infer<typeof UpdateShoppingItemSchema>;
export type ShoppingListSyncToPantryResponse = z.infer<
typeof ShoppingListSyncToPantryResponseSchema
>;
export type BasketStoreComparisonResponse = z.infer<typeof BasketStoreComparisonResponseSchema>;
export async function getShoppingLists(householdId: string): Promise<ShoppingListResponse[]> {
return apiClient.get<ShoppingListResponse[]>(`/households/${householdId}/shopping-lists`);
}
export async function getShoppingList(householdId: string, id: string): Promise<ShoppingListResponse> {
export async function getShoppingList(
householdId: string,
id: string,
): Promise<ShoppingListResponse> {
return apiClient.get<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`);
}
export async function createShoppingList(
householdId: string,
data: CreateShoppingListInput
data: CreateShoppingListInput,
): Promise<ShoppingListResponse> {
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists`, data);
}
@ -36,9 +43,12 @@ export async function createShoppingList(
export async function updateShoppingList(
householdId: string,
id: string,
data: UpdateShoppingListInput
data: UpdateShoppingListInput,
): Promise<ShoppingListResponse> {
return apiClient.patch<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`, data);
return apiClient.patch<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}`,
data,
);
}
export async function deleteShoppingList(householdId: string, id: string): Promise<void> {
@ -50,30 +60,33 @@ export async function deleteShoppingList(householdId: string, id: string): Promi
export async function addShoppingItem(
householdId: string,
id: string,
data: AddShoppingItemInput
data: AddShoppingItemInput,
): Promise<ShoppingListResponse> {
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}/items`, data);
return apiClient.post<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}/items`,
data,
);
}
export async function updateShoppingItem(
householdId: string,
id: string,
itemId: string,
data: UpdateShoppingItemInput
data: UpdateShoppingItemInput,
): Promise<ShoppingListResponse> {
return apiClient.patch<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`,
data
data,
);
}
export async function removeShoppingItem(
householdId: string,
id: string,
itemId: string
itemId: string,
): Promise<ShoppingListResponse> {
return apiClient.delete<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`,
);
}
@ -81,28 +94,28 @@ export async function removeShoppingItem(
export async function generateFromMealPlan(
householdId: string,
mealPlanId: string
mealPlanId: string,
): Promise<ShoppingListResponse> {
return apiClient.post<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}`
`/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}`,
);
}
export async function syncToPantry(
householdId: string,
id: string
id: string,
): Promise<ShoppingListSyncToPantryResponse> {
return apiClient.post<ShoppingListSyncToPantryResponse>(
`/households/${householdId}/shopping-lists/${id}/sync-to-pantry`
`/households/${householdId}/shopping-lists/${id}/sync-to-pantry`,
);
}
export async function getBasketStoreComparison(
householdId: string,
id: string
id: string,
): Promise<BasketStoreComparisonResponse> {
return apiClient.get<BasketStoreComparisonResponse>(
`/households/${householdId}/shopping-lists/${id}/stores`
`/households/${householdId}/shopping-lists/${id}/stores`,
);
}

View file

@ -7,10 +7,10 @@ import type {
UpdateStoreSchema,
} from '@meshitrack/shared';
type StoreResponse = z.infer<typeof StoreResponseSchema>;
type StoreListResponse = z.infer<typeof StoreListResponseSchema>;
type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
export type StoreResponse = z.infer<typeof StoreResponseSchema>;
export type StoreListResponse = z.infer<typeof StoreListResponseSchema>;
export type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
export type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
export async function listStores(
householdId: string,

View file

@ -14,9 +14,20 @@ describe('ShoppingListsPage', () => {
vi.clearAllMocks();
vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1',
householdIds: ['hh1'],
isLoading: false,
user: null,
token: '123',
isAuthenticated: true,
profile: {
_id: 'user1',
keycloakId: 'user1',
displayName: 'Test User',
email: 'test@example.com',
householdIds: ['hh1'],
defaultHouseholdId: 'hh1',
createdAt: '2026-05-10T00:00:00.000Z',
updatedAt: '2026-05-10T00:00:00.000Z',
},
refreshProfile: vi.fn(),
});
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'list2', name: 'Completed Costco', status: 'completed', items: [], totalEstimatedCost: 50, createdAt: '2026-05-09' } as any,
@ -68,9 +79,7 @@ describe('ShoppingListsPage', () => {
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,
pagination: { cursor: null, hasMore: false }
});
vi.mocked(ShoppingListsService.generateFromMealPlan).mockResolvedValue({
id: 'list3', name: 'Generated', status: 'active', items: [], createdAt: '2026-05-11'
@ -104,7 +113,7 @@ describe('ShoppingListsPage', () => {
it('can cancel/close creation and gap scanning modals', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [],
total: 0, page: 1, limit: 10
pagination: { cursor: null, hasMore: false }
});
const { container } = render(<ShoppingListsPage />);
@ -165,7 +174,7 @@ describe('ShoppingListsPage', () => {
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
pagination: { cursor: null, hasMore: false }
});
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue(new Error('Gen failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
@ -314,7 +323,7 @@ describe('ShoppingListsPage', () => {
it('handles gap scanner generation failure with generic error fallback', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1, page: 1, limit: 10
pagination: { cursor: null, hasMore: false }
});
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue({});
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});

View file

@ -130,7 +130,7 @@ describe('Icon', () => {
});
it('renders null for invalid icon name', () => {
const { container } = render(<Icon name="nonexistent" as any />);
const { container } = render(<Icon name={"nonexistent" as any} />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg?.childNodes.length).toBe(0);

View file

@ -80,7 +80,7 @@ describe('shopping-lists service', () => {
const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync');
apiClient.baseUrl = 'https://api.meshitrack.com';
(apiClient as any).baseUrl = 'https://api.meshitrack.com';
const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync');