Phase 9
This commit is contained in:
parent
e396f5088c
commit
a1801af63b
36 changed files with 4783 additions and 31 deletions
|
|
@ -19,6 +19,7 @@
|
|||
"next-auth": "^5.0.0-beta.30",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.1",
|
||||
"swr": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
463
packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx
Normal file
463
packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
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 {
|
||||
getShoppingList,
|
||||
addShoppingItem,
|
||||
updateShoppingItem,
|
||||
removeShoppingItem,
|
||||
syncToPantry,
|
||||
getBasketStoreComparison,
|
||||
updateShoppingList,
|
||||
} from '@/services/shopping-lists';
|
||||
import { listProducts } from '@/services/products';
|
||||
import { useShoppingListSync } from '@/lib/useShoppingListSync';
|
||||
|
||||
export default function ShoppingListDetailsPage() {
|
||||
const { householdId, isLoading: isAuthLoading } = useApi();
|
||||
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('');
|
||||
const [qty, setQty] = useState(1);
|
||||
const [unit, setUnit] = useState('g');
|
||||
const [notes, setNotes] = useState('');
|
||||
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]);
|
||||
|
||||
// Run price comparisons
|
||||
const fetchComparisons = useCallback(async () => {
|
||||
if (!householdId || !listId) return;
|
||||
setIsStoreLoading(true);
|
||||
try {
|
||||
const comparison = await getBasketStoreComparison(householdId, listId);
|
||||
setStoreOptions(comparison.singleStoreOptions || []);
|
||||
} catch (err) {
|
||||
console.error('Comparison load fail', err);
|
||||
} finally {
|
||||
setIsStoreLoading(false);
|
||||
}
|
||||
}, [householdId, listId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
// Pre-load household products for predictive inputs
|
||||
useEffect(() => {
|
||||
if (!householdId) return;
|
||||
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);
|
||||
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) };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Inject Real-Time Hooks
|
||||
const { isConnected, toggleItemCheck } = useShoppingListSync(
|
||||
householdId || '',
|
||||
listId,
|
||||
handleRemoteSync
|
||||
);
|
||||
|
||||
// 1. Perform Live Interactivity (Toggle Checks)
|
||||
const handleToggleCheck = async (itemId: string, currentChecked: boolean) => {
|
||||
const nextChecked = !currentChecked;
|
||||
|
||||
// Optimistic Client Update for ultimate snappy responsiveness
|
||||
setList((prev: any) => ({
|
||||
...prev,
|
||||
items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it)
|
||||
}));
|
||||
|
||||
// Emit to WS Channel (broadcasts immediately to all other clients)
|
||||
toggleItemCheck(itemId, nextChecked);
|
||||
|
||||
// Persist standard Rest fallback ensuring safety
|
||||
if (householdId) {
|
||||
try {
|
||||
await updateShoppingItem(householdId, listId, itemId, { checked: nextChecked });
|
||||
} catch (err) {
|
||||
console.error('Persistent toggle sync fail', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Handle Item Mutations
|
||||
const handleAddItem = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!householdId) return;
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const updated = await addShoppingItem(householdId, listId, {
|
||||
productId: selectedProductId || undefined,
|
||||
customName: !selectedProductId ? customItemName.trim() : undefined,
|
||||
quantity: qty,
|
||||
unit: unit as any,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
|
||||
setList(updated);
|
||||
// Clear inputs
|
||||
setSelectedProductId('');
|
||||
setCustomItemName('');
|
||||
setQty(1);
|
||||
setNotes('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (itemId: string) => {
|
||||
if (!householdId) return;
|
||||
try {
|
||||
const updated = await removeShoppingItem(householdId, listId, itemId);
|
||||
setList(updated);
|
||||
} catch (err: any) {
|
||||
console.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Execute Final Checkout / Pantry Sync
|
||||
const handleSyncToPantry = async () => {
|
||||
if (!householdId) 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;
|
||||
|
||||
try {
|
||||
const res = await syncToPantry(householdId, listId);
|
||||
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);
|
||||
if (allChecked) {
|
||||
await updateShoppingList(householdId, listId, { status: 'completed' as any });
|
||||
}
|
||||
|
||||
fetchList();
|
||||
} catch (err: any) {
|
||||
alert('Migration sync error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Collate items categorized for satisfying view
|
||||
const categorizedItems = useMemo(() => {
|
||||
if (!list) return {};
|
||||
const groups: Record<string, any[]> = {};
|
||||
list.items.forEach((it: any) => {
|
||||
const cat = it.category || 'Other / Misc';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(it);
|
||||
});
|
||||
return groups;
|
||||
}, [list]);
|
||||
|
||||
if (isAuthLoading || loading) 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 totalCount = list.items.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title={list.name}
|
||||
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' }}>
|
||||
<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',
|
||||
}} />
|
||||
{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' }}>
|
||||
<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}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Workspace Split Grid */}
|
||||
<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>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{Object.entries(categorizedItems).map(([cat, items]: [string, any]) => (
|
||||
<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 }}>
|
||||
{cat}
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((it: any) => (
|
||||
<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)',
|
||||
transition: 'all 0.15s',
|
||||
opacity: it.checked ? 0.65 : 1,
|
||||
textDecoration: it.checked ? 'line-through' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Checkbox circle */}
|
||||
<button
|
||||
onClick={() => handleToggleCheck(it.id, it.checked)}
|
||||
style={{
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<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)' }}>
|
||||
~${it.estimatedPrice.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Migrated Badge */}
|
||||
{it.addedToPantry && (
|
||||
<Pill tone="ok">
|
||||
<Icon name="box" style={{ width: 10, marginRight: 4 }} /> Pantry
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteItem(it.id)}
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, color: 'var(--ink-muted)', opacity: 0.5 }}
|
||||
>
|
||||
<Icon name="trash" style={{ width: 14 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 }}>
|
||||
<Icon name="plus" style={{ width: 16 }} /> Add Grocery Item
|
||||
</h4>
|
||||
<form onSubmit={handleAddItem} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Link Product Catalog</label>
|
||||
<select
|
||||
value={selectedProductId}
|
||||
onChange={(e) => {
|
||||
setSelectedProductId(e.target.value);
|
||||
if (e.target.value) setCustomItemName('');
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">-- Create Manual Custom Input --</option>
|
||||
{products.map(p => <option key={p._id} value={p._id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!selectedProductId && (
|
||||
<div>
|
||||
<label style={labelStyle}>Custom Custom Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g., Generic Flour"
|
||||
value={customItemName}
|
||||
onChange={e => setCustomItemName(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="0.01"
|
||||
step="any"
|
||||
value={qty}
|
||||
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}>
|
||||
<option value="g">Grams</option>
|
||||
<option value="ml">Milliliters</option>
|
||||
<option value="piece">Pieces</option>
|
||||
<option value="slice">Slices</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Brand preference, etc."
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isAdding} style={{ width: '100%' }}>
|
||||
{isAdding ? 'Appending...' : 'Add to List'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
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',
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
369
packages/web/src/app/(dashboard)/shopping-lists/page.tsx
Normal file
369
packages/web/src/app/(dashboard)/shopping-lists/page.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
'use client';
|
||||
|
||||
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 { getShoppingLists, createShoppingList } from '@/services/shopping-lists';
|
||||
import { listMealPlans } from '@/services/meal-plans';
|
||||
import { generateFromMealPlan } from '@/services/shopping-lists';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ShoppingListsPage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
const [lists, setLists] = useState<any[]>([]);
|
||||
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 [mealPlanLoading, setMealPlanLoading] = useState(false);
|
||||
|
||||
const fetchLists = useCallback(async () => {
|
||||
if (!householdId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getShoppingLists(householdId);
|
||||
// Sort by active status first, then newest first
|
||||
data.sort((a, b) => {
|
||||
if (a.status === 'active' && b.status !== 'active') return -1;
|
||||
if (a.status !== 'active' && b.status === 'active') return 1;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
setLists(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load shopping lists');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLists();
|
||||
}, [fetchLists]);
|
||||
|
||||
const handleCreateList = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newListName.trim() || !householdId) return;
|
||||
try {
|
||||
const res = await createShoppingList(householdId, {
|
||||
name: newListName.trim(),
|
||||
items: [],
|
||||
});
|
||||
setNewListName('');
|
||||
setIsCreateModalOpen(false);
|
||||
// Redirect or update list
|
||||
setLists((prev) => [res, ...prev]);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create list');
|
||||
}
|
||||
};
|
||||
|
||||
const openGapModal = async () => {
|
||||
setIsGapModalOpen(true);
|
||||
if (!householdId) return;
|
||||
setMealPlanLoading(true);
|
||||
try {
|
||||
const plans = await listMealPlans(householdId);
|
||||
setRecentMealPlans(plans.data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setMealPlanLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateFromPlan = async (mealPlanId: string) => {
|
||||
if (!householdId) return;
|
||||
try {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 }}>
|
||||
<MetricCard
|
||||
icon="store"
|
||||
title="Active Lists"
|
||||
value={String(activeLists.length)}
|
||||
subtitle="Ready to shop"
|
||||
color="var(--brand)"
|
||||
/>
|
||||
<MetricCard
|
||||
icon="list"
|
||||
title="Pending Items"
|
||||
value={String(totalPendingItems)}
|
||||
subtitle="Across all active trips"
|
||||
color="var(--warning, #f59e0b)"
|
||||
/>
|
||||
<MetricCard
|
||||
icon="tag"
|
||||
title="Est. Total Value"
|
||||
value={`$${totalActiveCost.toFixed(2)}`}
|
||||
subtitle="Estimated current cart"
|
||||
color="var(--success, #10b981)"
|
||||
/>
|
||||
<MetricCard
|
||||
icon="trend"
|
||||
title="Spending Trend"
|
||||
value="Analyics"
|
||||
subtitle="Visualize price fluctuations"
|
||||
color="var(--ink-muted)"
|
||||
link="/shopping-lists/prices"
|
||||
/>
|
||||
</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', gap: 12 }}>
|
||||
<Button variant="ghost" onClick={openGapModal}>
|
||||
<Icon name="zap" style={{ marginRight: 6, width: 16 }} />
|
||||
Generate from Meal Plan
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Icon name="plus" style={{ marginRight: 6, width: 16 }} />
|
||||
New Shopping List
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
) : 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!
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)}>Create First List</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Active Section */}
|
||||
{activeLists.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20, marginBottom: 40 }}>
|
||||
{activeLists.map((list) => (
|
||||
<ShoppingListCard key={list._id} list={list} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 }}>
|
||||
{completedLists.map((list) => (
|
||||
<ShoppingListCard key={list._id} list={list} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Creation Modal Dialog Overlay */}
|
||||
{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}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--ink-muted)', marginBottom: 6 }}>Checklist Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g., Weekly Costco Run"
|
||||
value={newListName}
|
||||
onChange={(e) => setNewListName(e.target.value)}
|
||||
style={inputStyle}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<Button type="button" variant="ghost" onClick={() => setIsCreateModalOpen(false)}>Cancel</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meal Plan Gap Generator Modal */}
|
||||
{isGapModalOpen && (
|
||||
<div style={overlayStyle} onClick={() => setIsGapModalOpen(false)}>
|
||||
<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!
|
||||
</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)' }}>
|
||||
No meal plans configured. Build a plan first!
|
||||
</div>
|
||||
) : (
|
||||
<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' });
|
||||
return (
|
||||
<button
|
||||
key={plan._id}
|
||||
onClick={() => 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>
|
||||
<Icon name="chevronRight" style={{ width: 16, color: 'var(--ink-muted)' }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="ghost" onClick={() => setIsGapModalOpen(false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ icon, title, value, subtitle, color, link }: any) {
|
||||
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 }}>
|
||||
<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 }}>
|
||||
{subtitle}
|
||||
{link && <Icon name="chevronRight" style={{ width: 12 }} />}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
return link ? <Link href={link} style={{ textDecoration: 'none' }}>{content}</Link> : content;
|
||||
}
|
||||
|
||||
function ShoppingListCard({ list }: { list: any }) {
|
||||
const total = list.items.length;
|
||||
const checked = list.items.filter((i: any) => 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 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 }}>
|
||||
<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>
|
||||
</div>
|
||||
<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' }}>
|
||||
<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)' }}>
|
||||
${list.totalEstimatedCost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Progress Bar */}
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
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',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
197
packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx
Normal file
197
packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
'use client';
|
||||
|
||||
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 { getPriceAnalytics } from '@/services/prices';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
export default function PricesAnalyticsPage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadAnalytics = useCallback(async () => {
|
||||
if (!householdId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getPriceAnalytics(householdId);
|
||||
setData(result);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load analytics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId]);
|
||||
|
||||
useEffect(() => {
|
||||
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>;
|
||||
|
||||
const COLORS = ['var(--brand)', 'var(--success)', 'var(--warning)', '#a855f7', '#ec4899', '#3b82f6'];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Inflation & Spend Metrics"
|
||||
subtitle="Interactive real-time visualization of your historical grocery ledger ledger"
|
||||
/>
|
||||
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
}}>
|
||||
<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' }}>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Grid Layout for Interactive Charts */}
|
||||
<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>
|
||||
<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 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<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 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Category Distribution */}
|
||||
<Card style={{ padding: 24 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Spending Distrubution 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 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<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]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{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>
|
||||
)}
|
||||
</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)'
|
||||
};
|
||||
|
||||
const tooltipStyle: React.CSSProperties = {
|
||||
background: '#1f2937', border: '1px solid #374151', borderRadius: 8,
|
||||
color: '#fff', fontSize: 12,
|
||||
};
|
||||
102
packages/web/src/lib/useShoppingListSync.ts
Normal file
102
packages/web/src/lib/useShoppingListSync.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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;
|
||||
}
|
||||
|
||||
export function useShoppingListSync(
|
||||
householdId: string,
|
||||
listId: string,
|
||||
onRemoteChange: (msg: SyncUpdateMessage) => void
|
||||
) {
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!householdId || !listId) return;
|
||||
|
||||
// Close previous socket if active
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const url = getShoppingListSyncSocketUrl(householdId, listId);
|
||||
const ws = new WebSocket(url);
|
||||
socketRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
console.log(`🛒 Connected to shopping list real-time sync: ${listId}`);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload: SyncUpdateMessage = JSON.parse(event.data);
|
||||
onRemoteChange(payload);
|
||||
} catch (err) {
|
||||
console.error('Failed parsing real-time grocery payload', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setError('Connection interrupt');
|
||||
};
|
||||
|
||||
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)...`);
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Fatal WebSocket initialization', err);
|
||||
setError('Sync failed to initialize');
|
||||
}
|
||||
}, [householdId, listId, onRemoteChange]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
// Clear hook handlers to prevent state leakage during dismount
|
||||
socketRef.current.onclose = null;
|
||||
socketRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const toggleItemCheck = useCallback((itemId: string, checked: boolean) => {
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'TOGGLE_ITEM',
|
||||
itemId,
|
||||
checked,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
error,
|
||||
toggleItemCheck,
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ class ApiClient {
|
|||
return this._accessToken !== null;
|
||||
}
|
||||
|
||||
public get baseUrl(): string {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
71
packages/web/src/services/prices.ts
Normal file
71
packages/web/src/services/prices.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
PriceRecordResponseSchema,
|
||||
PriceHistoryResponseSchema,
|
||||
FoodStoreComparisonResponseSchema,
|
||||
FoodSpendingAnalyticsResponseSchema,
|
||||
CreatePriceRecordSchema,
|
||||
BulkPriceRecordInputSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type PriceRecordResponse = z.infer<typeof PriceRecordResponseSchema>;
|
||||
type PriceHistoryResponse = z.infer<typeof PriceHistoryResponseSchema>;
|
||||
type FoodStoreComparisonResponse = z.infer<typeof FoodStoreComparisonResponseSchema>;
|
||||
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
|
||||
): Promise<PriceRecordResponse> {
|
||||
return apiClient.post<PriceRecordResponse>(`/households/${householdId}/prices`, data);
|
||||
}
|
||||
|
||||
export async function recordBulkPrices(
|
||||
householdId: string,
|
||||
data: BulkPriceRecordInput
|
||||
): Promise<PriceRecordResponse[]> {
|
||||
return apiClient.post<PriceRecordResponse[]>(`/households/${householdId}/prices/bulk`, data);
|
||||
}
|
||||
|
||||
export async function getPriceHistory(
|
||||
householdId: string,
|
||||
productId: string,
|
||||
query?: {
|
||||
storeId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<PriceHistoryResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.storeId) params.set('storeId', query.storeId);
|
||||
if (query?.startDate) params.set('startDate', query.startDate);
|
||||
if (query?.endDate) params.set('endDate', query.endDate);
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<PriceHistoryResponse>(
|
||||
`/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function compareStores(
|
||||
householdId: string,
|
||||
productId: string
|
||||
): Promise<FoodStoreComparisonResponse> {
|
||||
return apiClient.get<FoodStoreComparisonResponse>(
|
||||
`/households/${householdId}/prices/compare/${productId}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPriceAnalytics(
|
||||
householdId: string
|
||||
): Promise<FoodSpendingAnalyticsResponse> {
|
||||
return apiClient.get<FoodSpendingAnalyticsResponse>(
|
||||
`/households/${householdId}/prices/analytics`
|
||||
);
|
||||
}
|
||||
119
packages/web/src/services/shopping-lists.ts
Normal file
119
packages/web/src/services/shopping-lists.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
ShoppingListResponseSchema,
|
||||
CreateShoppingListSchema,
|
||||
UpdateShoppingListSchema,
|
||||
AddShoppingItemSchema,
|
||||
UpdateShoppingItemSchema,
|
||||
ShoppingListSyncToPantryResponseSchema,
|
||||
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 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> {
|
||||
return apiClient.get<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`);
|
||||
}
|
||||
|
||||
export async function createShoppingList(
|
||||
householdId: string,
|
||||
data: CreateShoppingListInput
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists`, data);
|
||||
}
|
||||
|
||||
export async function updateShoppingList(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateShoppingListInput
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.patch<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteShoppingList(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/households/${householdId}/shopping-lists/${id}`);
|
||||
}
|
||||
|
||||
// -- Nested Item Operations --
|
||||
|
||||
export async function addShoppingItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: AddShoppingItemInput
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}/items`, data);
|
||||
}
|
||||
|
||||
export async function updateShoppingItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
itemId: string,
|
||||
data: UpdateShoppingItemInput
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.patch<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeShoppingItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
itemId: string
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.delete<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`
|
||||
);
|
||||
}
|
||||
|
||||
// -- Workflows --
|
||||
|
||||
export async function generateFromMealPlan(
|
||||
householdId: string,
|
||||
mealPlanId: string
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.post<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncToPantry(
|
||||
householdId: string,
|
||||
id: string
|
||||
): Promise<ShoppingListSyncToPantryResponse> {
|
||||
return apiClient.post<ShoppingListSyncToPantryResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/sync-to-pantry`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBasketStoreComparison(
|
||||
householdId: string,
|
||||
id: string
|
||||
): Promise<BasketStoreComparisonResponse> {
|
||||
return apiClient.get<BasketStoreComparisonResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/stores`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates direct WebSocket path for collaborative checklist syncing.
|
||||
*/
|
||||
export function getShoppingListSyncSocketUrl(householdId: string, id: string): string {
|
||||
// Derive WS protocol based on configured API baseURL protocol (defaulting to unsafe ws for localhost)
|
||||
const baseUrl = apiClient.baseUrl || '';
|
||||
const isSecure = baseUrl.startsWith('https');
|
||||
const cleanHost = baseUrl.replace(/^https?:\/\//, '');
|
||||
const protocol = isSecure ? 'wss' : 'ws';
|
||||
return `${protocol}://${cleanHost}/households/${householdId}/shopping-lists/${id}/sync`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue