Implement stores and refills, improve testing
|
|
@ -1,9 +1,13 @@
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": ["Bash(npm run *)", "Bash(npm test *)", "Read(*), Glob(*)"],
|
||||||
"Bash(npm run:*)",
|
"deny": [
|
||||||
"Bash(ls:*)",
|
"Bash(grep *)",
|
||||||
"Bash(npm test:*)"
|
"Bash(ls *)",
|
||||||
|
"Bash(cat *)",
|
||||||
|
"Bash(head *)",
|
||||||
|
"Bash(tail *)",
|
||||||
|
"Bash(sed *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ dist
|
||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
mongo-data
|
mongo-data
|
||||||
|
keyfile
|
||||||
|
|
||||||
# Compiled output that may land in source dirs
|
# Compiled output that may land in source dirs
|
||||||
packages/*/src/**/*.js
|
packages/*/src/**/*.js
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ The API follows a **routes → services → repositories** pattern with Awilix c
|
||||||
Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules.
|
Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules.
|
||||||
|
|
||||||
### Domain Modules
|
### Domain Modules
|
||||||
**Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `refills/`
|
**Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `purchases/`, `refills/`
|
||||||
**Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/`
|
**Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/`
|
||||||
**Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/`
|
**Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/`
|
||||||
|
|
||||||
|
|
|
||||||
144
docs/design/MeshiTrack.html
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<title>MeshiTrack</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="styles/tokens.css"/>
|
||||||
|
<link rel="stylesheet" href="styles/shell.css"/>
|
||||||
|
<link rel="stylesheet" href="styles/cabinet.css"/>
|
||||||
|
<link rel="stylesheet" href="styles/dashboard.css"/>
|
||||||
|
<link rel="stylesheet" href="styles/schedule.css?v=2"/>
|
||||||
|
<link rel="stylesheet" href="styles/other.css?v=2"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="src/data.jsx"></script>
|
||||||
|
<script type="text/babel" src="src/Icon.jsx"></script>
|
||||||
|
<script type="text/babel" src="src/Shell.jsx"></script>
|
||||||
|
<script type="text/babel" src="src/Cabinet.jsx"></script>
|
||||||
|
<script type="text/babel" src="src/Dashboard.jsx"></script>
|
||||||
|
<script type="text/babel" src="src/Schedule.jsx"></script>
|
||||||
|
<script type="text/babel" src="src/OtherPages.jsx"></script>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
|
||||||
|
const TWEAKS = /*EDITMODE-BEGIN*/{
|
||||||
|
"theme": "light",
|
||||||
|
"accent": "sage"
|
||||||
|
}/*EDITMODE-END*/;
|
||||||
|
|
||||||
|
const ACCENTS = {
|
||||||
|
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8' },
|
||||||
|
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5' },
|
||||||
|
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de' },
|
||||||
|
graphite: { brand: '#2c2c28', deep: '#000', soft: '#e8e6df' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [route, setRoute] = useState('dashboard');
|
||||||
|
const [theme, setTheme] = useState(TWEAKS.theme || 'light');
|
||||||
|
const [accent, setAccent] = useState(TWEAKS.accent || 'sage');
|
||||||
|
const [tweaksOpen, setTweaksOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
const a = ACCENTS[accent] || ACCENTS.sage;
|
||||||
|
document.documentElement.style.setProperty('--brand', a.brand);
|
||||||
|
document.documentElement.style.setProperty('--brand-deep', a.deep);
|
||||||
|
document.documentElement.style.setProperty('--brand-soft', a.soft);
|
||||||
|
document.documentElement.style.setProperty('--brand-soft-ink', a.deep);
|
||||||
|
document.documentElement.style.setProperty('--viz-1', a.brand);
|
||||||
|
}, [theme, accent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
if (!e.data || typeof e.data !== 'object') return;
|
||||||
|
if (e.data.type === '__activate_edit_mode') setTweaksOpen(true);
|
||||||
|
if (e.data.type === '__deactivate_edit_mode') setTweaksOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTweak = (key, val) => {
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Sidebar, Topbar, NAV } = window.Shell;
|
||||||
|
const current = NAV.find(n => n.id === route);
|
||||||
|
|
||||||
|
const pages = {
|
||||||
|
dashboard: { title: 'Dashboard', subtitle: 'Household: Red Panda Den', Page: window.DashboardPage },
|
||||||
|
cabinet: { title: 'Medicine Cabinet', subtitle: 'Everything on hand, with days of supply', Page: window.CabinetPage },
|
||||||
|
schedule: { title: 'Schedule & Log', subtitle: 'Today and this week', Page: window.SchedulePage },
|
||||||
|
regimens: { title: 'Regimens', subtitle: 'Daily medication schedules', crumbs: ['Medicines', 'Regimens'], Page: window.RegimensPage },
|
||||||
|
organizer: { title: 'Pill Organizer', subtitle: 'Fill a week of pills at once', crumbs: ['Medicines', 'Organizer'], Page: window.OrganizerPage },
|
||||||
|
activity: { title: 'Activity', subtitle: 'Spending and cabinet changes', crumbs: ['Medicines', 'Activity'], Page: window.ActivityPage },
|
||||||
|
library: { title: 'Library', subtitle: 'All known medicines', crumbs: ['Medicines', 'Library'], Page: window.LibraryPage },
|
||||||
|
refills: { title: 'Refills', subtitle: 'Running-low alerts and shopping lists', Page: window.RefillsPage },
|
||||||
|
purchases: { title: 'Purchases', subtitle: 'Order history and pending arrivals', Page: window.PurchasesPage },
|
||||||
|
prices: { title: 'Prices', subtitle: 'Track & compare across stores', Page: window.PricesPage },
|
||||||
|
stores: { title: 'Stores', subtitle: 'Pharmacies and vendors', Page: window.StoresPage },
|
||||||
|
settings: { title: 'Settings', subtitle: 'Household and account', Page: window.SettingsPage },
|
||||||
|
};
|
||||||
|
|
||||||
|
const pg = pages[route];
|
||||||
|
const Page = pg.Page;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-app">
|
||||||
|
<Sidebar route={route} onNav={setRoute} />
|
||||||
|
<div className="mt-main" data-screen-label={'MT / ' + pg.title}>
|
||||||
|
<Topbar
|
||||||
|
title={pg.title}
|
||||||
|
subtitle={pg.subtitle}
|
||||||
|
crumbs={pg.crumbs}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={() => { const t = theme === 'light' ? 'dark' : 'light'; setTheme(t); setTweak('theme', t); }}
|
||||||
|
actions={route === 'cabinet' ? <button className="mt-btn mt-btn--primary"><span>+</span> Add to cabinet</button> : null}
|
||||||
|
/>
|
||||||
|
<Page onNav={setRoute} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'mt-tweaks' + (tweaksOpen ? ' is-open' : '')}>
|
||||||
|
<div className="mt-tweaks__title">
|
||||||
|
<span>Tweaks</span>
|
||||||
|
<button className="mt-btn mt-btn--subtle" onClick={() => setTweaksOpen(false)} style={{padding:'2px 6px'}}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-tweaks__row">
|
||||||
|
<span>Theme</span>
|
||||||
|
<div className="mt-seg">
|
||||||
|
<button className={theme==='light'?'is-active':''} onClick={()=>{setTheme('light'); setTweak('theme','light');}}>Light</button>
|
||||||
|
<button className={theme==='dark'?'is-active':''} onClick={()=>{setTheme('dark'); setTweak('theme','dark');}}>Dark</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-tweaks__row">
|
||||||
|
<span>Accent</span>
|
||||||
|
<div className="mt-seg">
|
||||||
|
{Object.keys(ACCENTS).map(k => (
|
||||||
|
<button key={k} className={accent===k?'is-active':''} onClick={()=>{setAccent(k); setTweak('accent',k);}} style={{textTransform:'capitalize'}}>{k}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize: 10, color: 'var(--ink-faint)', marginTop: 8, lineHeight: 1.5}}>
|
||||||
|
See alt-directions canvas for 3 novel cabinet layouts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
docs/design/screenshots/01-check-org2.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/01-check-org3.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/01-dashboard.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/02-cabinet.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/02-check-org2.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/02-check-org3.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/03-alt.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/check-cab.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/design/screenshots/check-org.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/check-sched3.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
docs/design/screenshots/check-sched4.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/design/screenshots/check-schedule.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/check-schedule2.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
355
docs/design/src/Cabinet.jsx
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
// Medicine Cabinet — the hero screen.
|
||||||
|
// Layout: summary strip + filter rail + "cabinet grid" of med cards with
|
||||||
|
// days-of-supply bar, expiry, lot info, and inline quick actions.
|
||||||
|
|
||||||
|
const { CABINET, MED, MEDICINES, PRICES, STORES } = window.MESHI;
|
||||||
|
|
||||||
|
const cabinetStatus = (row) => {
|
||||||
|
const days = Math.floor(row.qty / (row.perDose * (1 / row.daysPerUnit)));
|
||||||
|
// days until expiry
|
||||||
|
const exp = new Date(row.expiry);
|
||||||
|
const daysToExp = Math.round((exp - new Date('2026-04-18')) / 86400000);
|
||||||
|
let level = 'ok';
|
||||||
|
if (row.qty <= row.lowAt) level = 'low';
|
||||||
|
if (daysToExp < 60) level = level === 'low' ? 'critical' : 'expiring';
|
||||||
|
if (row.qty <= Math.ceil(row.lowAt / 2)) level = 'critical';
|
||||||
|
return { days, daysToExp, level };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtJPY = (n) => '¥' + Number(n).toLocaleString('en-US');
|
||||||
|
|
||||||
|
const CabinetSummary = () => {
|
||||||
|
const rows = CABINET.map(r => ({ ...r, ...cabinetStatus(r) }));
|
||||||
|
const total = rows.length;
|
||||||
|
const low = rows.filter(r => r.level === 'low' || r.level === 'critical').length;
|
||||||
|
const expiring = rows.filter(r => r.level === 'expiring' || r.level === 'critical').length;
|
||||||
|
const inventoryValue = rows.reduce((s, r) => {
|
||||||
|
const p = PRICES.find(p => p.medId === r.medId);
|
||||||
|
return s + (p ? (p.price / p.pkg) * r.qty : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Items', value: total, hint: 'distinct medicines', tone: 'ink' },
|
||||||
|
{ label: 'Running low', value: low, hint: 'need a refill soon', tone: 'danger' },
|
||||||
|
{ label: 'Expiring <60d', value: expiring, hint: 'check dates', tone: 'warn' },
|
||||||
|
{ label: 'Cabinet value', value: fmtJPY(Math.round(inventoryValue)), hint: 'at last paid price', tone: 'brand' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cab-summary">
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.label} className={'cab-summary__item cab-summary__item--' + s.tone}>
|
||||||
|
<div className="cab-summary__label">{s.label}</div>
|
||||||
|
<div className="cab-summary__value num">{s.value}</div>
|
||||||
|
<div className="cab-summary__hint">{s.hint}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DaysBar = ({ days, low = 14 }) => {
|
||||||
|
const cap = 60;
|
||||||
|
const pct = Math.min(100, (days / cap) * 100);
|
||||||
|
const tone = days <= low / 2 ? 'critical' : days <= low ? 'low' : 'ok';
|
||||||
|
return (
|
||||||
|
<div className={'daysbar daysbar--' + tone} title={days + ' days supply'}>
|
||||||
|
<div className="daysbar__track">
|
||||||
|
<div className="daysbar__fill" style={{ width: pct + '%' }} />
|
||||||
|
<div className="daysbar__mark" style={{ left: ((low / cap) * 100) + '%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="daysbar__label num">
|
||||||
|
<span className="daysbar__num">{days}</span>
|
||||||
|
<span className="daysbar__unit">days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CabinetCard = ({ row, onOpen }) => {
|
||||||
|
const med = MED[row.medId];
|
||||||
|
const { days, daysToExp, level } = cabinetStatus(row);
|
||||||
|
const price = PRICES.find(p => p.medId === row.medId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={'cab-card cab-card--lvl-' + level} onClick={onOpen}>
|
||||||
|
<div className="cab-card__top">
|
||||||
|
<div className="cab-card__swatch" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
|
||||||
|
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="cab-card__meta">
|
||||||
|
<div className="cab-card__name">{med.name}</div>
|
||||||
|
<div className="cab-card__strength">{med.strength} · {med.form}</div>
|
||||||
|
</div>
|
||||||
|
{level !== 'ok' && (
|
||||||
|
<span className={'cab-card__flag mt-pill mt-pill--' + (level === 'critical' ? 'danger' : level === 'low' ? 'danger' : 'warn')}>
|
||||||
|
{level === 'critical' ? 'Critical' : level === 'low' ? 'Low' : 'Expiring'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cab-card__qty">
|
||||||
|
<div className="cab-card__qtyNum num">{row.qty}</div>
|
||||||
|
<div className="cab-card__qtyUnit">{row.unit}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DaysBar days={days} low={row.lowAt} />
|
||||||
|
|
||||||
|
<div className="cab-card__foot">
|
||||||
|
<div className="cab-card__footItem">
|
||||||
|
<div className="cab-card__footLabel">Expiry</div>
|
||||||
|
<div className={'cab-card__footValue num' + (daysToExp < 60 ? ' is-warn' : '')}>
|
||||||
|
{row.expiry.slice(0, 7)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cab-card__footItem">
|
||||||
|
<div className="cab-card__footLabel">Lots</div>
|
||||||
|
<div className="cab-card__footValue num">{row.lots}</div>
|
||||||
|
</div>
|
||||||
|
<div className="cab-card__footItem">
|
||||||
|
<div className="cab-card__footLabel">Unit cost</div>
|
||||||
|
<div className="cab-card__footValue num">
|
||||||
|
{price ? fmtJPY(Math.round(price.price / price.pkg)) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CabinetFilters = ({ filter, setFilter, view, setView, query, setQuery }) => {
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'all', label: 'All', count: CABINET.length },
|
||||||
|
{ id: 'low', label: 'Running low', count: CABINET.filter(r => cabinetStatus(r).level !== 'ok').length },
|
||||||
|
{ id: 'rx', label: 'Prescription', count: CABINET.filter(r => MED[r.medId].category === 'Prescription').length },
|
||||||
|
{ id: 'supplement', label: 'Supplements', count: CABINET.filter(r => MED[r.medId].category === 'Supplement').length },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="cab-filters">
|
||||||
|
<div className="cab-filters__tabs">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={'cab-filters__tab' + (filter === t.id ? ' is-active' : '')}
|
||||||
|
onClick={() => setFilter(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
<span className="cab-filters__count num">{t.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cab-filters__tools">
|
||||||
|
<div className="cab-filters__search">
|
||||||
|
<Icon name="search" size={13} />
|
||||||
|
<input placeholder="Filter" value={query} onChange={e => setQuery(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-seg">
|
||||||
|
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>Grid</button>
|
||||||
|
<button className={view === 'table' ? 'is-active' : ''} onClick={() => setView('table')}>Table</button>
|
||||||
|
<button className={view === 'shelf' ? 'is-active' : ''} onClick={() => setView('shelf')}>Shelf</button>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost">
|
||||||
|
<Icon name="filter" size={14} /> Sort: Days left
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CabinetTable = ({ rows, onOpen }) => (
|
||||||
|
<div className="cab-table">
|
||||||
|
<div className="cab-table__head">
|
||||||
|
<div>Medicine</div>
|
||||||
|
<div className="num">Qty</div>
|
||||||
|
<div>Supply</div>
|
||||||
|
<div>Expiry</div>
|
||||||
|
<div className="num">Unit cost</div>
|
||||||
|
<div>Status</div>
|
||||||
|
</div>
|
||||||
|
{rows.map(r => {
|
||||||
|
const med = MED[r.medId];
|
||||||
|
const { days, daysToExp, level } = cabinetStatus(r);
|
||||||
|
const price = PRICES.find(p => p.medId === r.medId);
|
||||||
|
return (
|
||||||
|
<button key={r.medId} className="cab-table__row" onClick={() => onOpen(r)}>
|
||||||
|
<div className="cab-table__cell cab-table__cellMed">
|
||||||
|
<div className="cab-card__swatch cab-card__swatch--sm" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
|
||||||
|
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={12} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{med.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{med.strength} · {med.form}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="num" style={{ fontWeight: 500 }}>{r.qty}<span style={{ color: 'var(--ink-faint)', marginLeft: 4, fontWeight: 400 }}>{r.unit}</span></div>
|
||||||
|
<div><DaysBar days={days} low={r.lowAt} /></div>
|
||||||
|
<div className={'num' + (daysToExp < 60 ? ' is-warn' : '')}>{r.expiry}</div>
|
||||||
|
<div className="num">{price ? fmtJPY(Math.round(price.price / price.pkg)) : '—'}</div>
|
||||||
|
<div>
|
||||||
|
{level === 'ok' ? <span className="mt-pill mt-pill--ghost">In stock</span> : level === 'low' ? <span className="mt-pill mt-pill--danger">Low</span> : level === 'critical' ? <span className="mt-pill mt-pill--danger">Critical</span> : <span className="mt-pill mt-pill--warn">Expiring</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CabinetShelf = ({ rows, onOpen }) => {
|
||||||
|
// Group by category, then render as "shelves"
|
||||||
|
const groups = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
const cat = MED[r.medId].category;
|
||||||
|
(groups[cat] = groups[cat] || []).push(r);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="cab-shelf">
|
||||||
|
{Object.entries(groups).map(([cat, items]) => (
|
||||||
|
<div key={cat} className="cab-shelf__level">
|
||||||
|
<div className="cab-shelf__tag">{cat}</div>
|
||||||
|
<div className="cab-shelf__bottles">
|
||||||
|
{items.map(r => {
|
||||||
|
const med = MED[r.medId];
|
||||||
|
const { days, level } = cabinetStatus(r);
|
||||||
|
const fillPct = Math.min(100, (days / 60) * 100);
|
||||||
|
return (
|
||||||
|
<button key={r.medId} className={'cab-bottle cab-bottle--lvl-' + level} onClick={() => onOpen(r)}>
|
||||||
|
<div className="cab-bottle__body" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
|
||||||
|
<div className="cab-bottle__fill" style={{ height: fillPct + '%' }} />
|
||||||
|
<div className="cab-bottle__cap" />
|
||||||
|
</div>
|
||||||
|
<div className="cab-bottle__label">
|
||||||
|
<div className="cab-bottle__name">{med.name}</div>
|
||||||
|
<div className="cab-bottle__qty num">{r.qty} {r.unit}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="cab-shelf__line" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CabinetDetail = ({ row, onClose }) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const med = MED[row.medId];
|
||||||
|
const { days, daysToExp, level } = cabinetStatus(row);
|
||||||
|
const price = PRICES.find(p => p.medId === row.medId);
|
||||||
|
const allPrices = PRICES.filter(p => p.medId === row.medId);
|
||||||
|
const cheapest = allPrices.reduce((a, b) => (a && a.price / a.pkg < b.price / b.pkg ? a : b), null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cab-detail" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="cab-detail__panel">
|
||||||
|
<div className="cab-detail__head">
|
||||||
|
<div className="mt-row">
|
||||||
|
<div className="cab-card__swatch" style={{ '--c': 'var(--viz-' + med.color + ')', width: 44, height: 44 }}>
|
||||||
|
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="cab-detail__title">{med.name}</div>
|
||||||
|
<div className="cab-detail__sub">{med.strength} · {med.form} · {med.category}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-iconbtn" onClick={onClose}><Icon name="x" size={14} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cab-detail__hero">
|
||||||
|
<div>
|
||||||
|
<div className="cab-detail__bigLabel">On hand</div>
|
||||||
|
<div className="cab-detail__big num">{row.qty} <span>{row.unit}</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="cab-detail__bigLabel">Supply</div>
|
||||||
|
<div className="cab-detail__big num">{days} <span>days</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="cab-detail__bigLabel">Expires</div>
|
||||||
|
<div className={'cab-detail__big num' + (daysToExp < 60 ? ' is-warn' : '')}>{row.expiry}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cab-detail__actions">
|
||||||
|
<button className="mt-btn mt-btn--primary"><Icon name="check" size={14} /> Log intake</button>
|
||||||
|
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={14} /> Add to cabinet</button>
|
||||||
|
<button className="mt-btn mt-btn--ghost"><Icon name="truck" size={14} /> Reorder</button>
|
||||||
|
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={14} /> Adjust</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-divider" />
|
||||||
|
|
||||||
|
<div className="cab-detail__section">
|
||||||
|
<div className="cab-detail__sectionTitle">Prices across stores</div>
|
||||||
|
<div className="cab-detail__prices">
|
||||||
|
{allPrices.map(p => {
|
||||||
|
const s = STORES.find(s => s.id === p.store);
|
||||||
|
const isCheap = p === cheapest;
|
||||||
|
return (
|
||||||
|
<div key={p.store + p.date} className={'cab-detail__price' + (isCheap ? ' is-best' : '')}>
|
||||||
|
<div className="cab-detail__priceStore">
|
||||||
|
<div style={{ fontWeight: 500 }}>{s?.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{p.date}</div>
|
||||||
|
</div>
|
||||||
|
<div className="cab-detail__priceBody">
|
||||||
|
<div className="num" style={{ fontSize: 15, fontWeight: 500 }}>{fmtJPY(p.price)}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }} className="num">{fmtJPY(Math.round(p.price / p.pkg))} / {p.unit}</div>
|
||||||
|
</div>
|
||||||
|
{isCheap && <span className="mt-pill mt-pill--ok">Best</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{med.notes && (
|
||||||
|
<div className="cab-detail__section">
|
||||||
|
<div className="cab-detail__sectionTitle">Notes</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--ink-muted)' }}>{med.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CabinetPage = () => {
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
const [view, setView] = useState('grid');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
return CABINET
|
||||||
|
.filter(r => {
|
||||||
|
if (filter === 'low') return cabinetStatus(r).level !== 'ok';
|
||||||
|
if (filter === 'rx') return MED[r.medId].category === 'Prescription';
|
||||||
|
if (filter === 'supplement') return MED[r.medId].category === 'Supplement';
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.filter(r => !query || MED[r.medId].name.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
.sort((a, b) => cabinetStatus(a).days - cabinetStatus(b).days);
|
||||||
|
}, [filter, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-page">
|
||||||
|
<CabinetSummary />
|
||||||
|
<div style={{ height: 20 }} />
|
||||||
|
<CabinetFilters filter={filter} setFilter={setFilter} view={view} setView={setView} query={query} setQuery={setQuery} />
|
||||||
|
<div style={{ height: 16 }} />
|
||||||
|
{view === 'grid' && (
|
||||||
|
<div className="cab-grid">
|
||||||
|
{rows.map(r => <CabinetCard key={r.medId} row={r} onOpen={() => setDetail(r)} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view === 'table' && <CabinetTable rows={rows} onOpen={setDetail} />}
|
||||||
|
{view === 'shelf' && <CabinetShelf rows={rows} onOpen={setDetail} />}
|
||||||
|
<CabinetDetail row={detail} onClose={() => setDetail(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.CabinetPage = CabinetPage;
|
||||||
|
window.cabinetStatus = cabinetStatus;
|
||||||
|
window.fmtJPY = fmtJPY;
|
||||||
251
docs/design/src/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
// Dashboard — today at a glance. Integrated view of meds + intake + refills + spending.
|
||||||
|
|
||||||
|
const { TODAY_INTAKE, MED, CABINET, REGIMENS, SPENDING, PURCHASES, ACTIVITY, PRICES, STORES } = window.MESHI;
|
||||||
|
|
||||||
|
const DashboardPage = ({ onNav }) => {
|
||||||
|
const [intake, setIntake] = useState(TODAY_INTAKE);
|
||||||
|
|
||||||
|
const taken = intake.filter(i => i.status === 'taken').length;
|
||||||
|
const total = intake.length;
|
||||||
|
const pct = Math.round((taken / total) * 100);
|
||||||
|
|
||||||
|
const lowMeds = CABINET.map(r => ({ r, s: cabinetStatus(r) })).filter(x => x.s.level !== 'ok').sort((a, b) => a.s.days - b.s.days);
|
||||||
|
|
||||||
|
const thisMonth = SPENDING[SPENDING.length - 1].amount;
|
||||||
|
const lastMonth = SPENDING[SPENDING.length - 2].amount;
|
||||||
|
const spendDelta = ((thisMonth - lastMonth) / lastMonth) * 100;
|
||||||
|
|
||||||
|
const toggleTake = (id) => {
|
||||||
|
setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Days supply across meds for mini sparkline
|
||||||
|
const sparkMax = 60;
|
||||||
|
const supplyData = CABINET.map(r => ({ med: MED[r.medId], days: cabinetStatus(r).days }))
|
||||||
|
.sort((a, b) => a.days - b.days);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="dash-hero">
|
||||||
|
<div className="dash-hero__greet">
|
||||||
|
<div className="dash-hero__day">Saturday · April 18</div>
|
||||||
|
<div className="dash-hero__title">Good evening, aeri.</div>
|
||||||
|
<div className="dash-hero__sub">Evening pills are due in <span className="mt-mono" style={{color:'var(--ink-strong)'}}>2h 34m</span>. Everything else is on track.</div>
|
||||||
|
</div>
|
||||||
|
<div className="dash-hero__ring">
|
||||||
|
<svg viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<circle cx="60" cy="60" r="52" fill="none" stroke="var(--bg-inset)" strokeWidth="10" />
|
||||||
|
<circle cx="60" cy="60" r="52" fill="none" stroke="var(--brand)" strokeWidth="10"
|
||||||
|
strokeDasharray={2 * Math.PI * 52}
|
||||||
|
strokeDashoffset={2 * Math.PI * 52 * (1 - pct / 100)}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 60 60)"
|
||||||
|
/>
|
||||||
|
<text x="60" y="56" textAnchor="middle" fill="var(--ink-strong)" style={{fontFamily:'var(--font-display)', fontSize:24, fontWeight:500}}>{taken}<tspan fill="var(--ink-muted)" fontSize="14">/{total}</tspan></text>
|
||||||
|
<text x="60" y="74" textAnchor="middle" fill="var(--ink-muted)" fontSize="10" letterSpacing="1">DOSES TAKEN</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dash-grid">
|
||||||
|
<section className="mt-card dash-panel" style={{ gridColumn: 'span 8' }}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Today's schedule</div>
|
||||||
|
<div className="mt-card__sub">{total - taken} upcoming · {taken} completed</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('schedule')}>Open log <Icon name="arrow" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
<div className="dash-schedule">
|
||||||
|
{['08:00', '21:00'].map(time => {
|
||||||
|
const items = intake.filter(i => i.scheduled === time);
|
||||||
|
if (!items.length) return null;
|
||||||
|
const allTaken = items.every(i => i.status === 'taken');
|
||||||
|
return (
|
||||||
|
<div key={time} className={'dash-sched__slot' + (allTaken ? ' is-done' : '')}>
|
||||||
|
<div className="dash-sched__time">
|
||||||
|
<div className="mono" style={{fontSize:18, fontWeight:500, color:'var(--ink-strong)'}}>{time}</div>
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>{time === '08:00' ? 'Morning' : 'Evening'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dash-sched__items">
|
||||||
|
{items.map(i => {
|
||||||
|
const m = MED[i.medId];
|
||||||
|
return (
|
||||||
|
<button key={i.id} className={'dash-sched__pill' + (i.status === 'taken' ? ' is-taken' : '')} onClick={() => toggleTake(i.id)}>
|
||||||
|
<span className={'dash-sched__check' + (i.status === 'taken' ? ' is-taken' : '')}>
|
||||||
|
{i.status === 'taken' && <Icon name="check" size={10} />}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--viz-' + m.color + ')', width: 6, height: 6, borderRadius: 3, background: 'currentColor', display: 'inline-block' }} />
|
||||||
|
<span className="dash-sched__name">{m.name}</span>
|
||||||
|
<span className="dash-sched__dose">{i.qty} {m.form.toLowerCase()}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-card dash-panel" style={{ gridColumn: 'span 4' }}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Running low</div>
|
||||||
|
<div className="mt-card__sub">{lowMeds.length} need attention</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('refills')}>Refills</button>
|
||||||
|
</div>
|
||||||
|
<div className="dash-low">
|
||||||
|
{lowMeds.slice(0, 4).map(({ r, s }) => {
|
||||||
|
const m = MED[r.medId];
|
||||||
|
return (
|
||||||
|
<div key={r.medId} className="dash-low__item">
|
||||||
|
<div className="cab-card__swatch cab-card__swatch--sm" style={{ '--c': 'var(--viz-' + m.color + ')' }}>
|
||||||
|
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12} />
|
||||||
|
</div>
|
||||||
|
<div style={{flex:1, minWidth:0}}>
|
||||||
|
<div style={{fontSize:13, fontWeight:500, color:'var(--ink-strong)'}}>{m.name}</div>
|
||||||
|
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{r.qty} {r.unit}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{textAlign:'right'}}>
|
||||||
|
<div className="num" style={{fontSize:14, fontWeight:600, color: s.level === 'critical' ? 'var(--danger)' : 'var(--warn)'}}>{s.days}d</div>
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)'}}>left</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-card dash-panel" style={{ gridColumn: 'span 5' }}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Spending · 6 months</div>
|
||||||
|
<div className="mt-card__sub">{fmtJPY(thisMonth)} this month <span style={{color: spendDelta < 0 ? 'var(--ok)' : 'var(--danger)', marginLeft:4}}>{spendDelta < 0 ? '↓' : '↑'} {Math.abs(Math.round(spendDelta))}%</span></div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('activity')}>Details</button>
|
||||||
|
</div>
|
||||||
|
<div className="dash-spend">
|
||||||
|
<svg viewBox="0 0 360 140" width="100%" height="140" preserveAspectRatio="none">
|
||||||
|
{SPENDING.map((m, i) => {
|
||||||
|
const max = Math.max(...SPENDING.map(s => s.amount));
|
||||||
|
const h = (m.amount / max) * 100;
|
||||||
|
const x = 20 + i * 56;
|
||||||
|
const isLast = i === SPENDING.length - 1;
|
||||||
|
return (
|
||||||
|
<g key={m.month}>
|
||||||
|
<rect x={x} y={120 - h} width="36" height={h} rx="3"
|
||||||
|
fill={isLast ? 'var(--brand)' : 'var(--brand-soft)'}
|
||||||
|
stroke={isLast ? 'var(--brand-deep)' : 'none'}
|
||||||
|
/>
|
||||||
|
<text x={x + 18} y="135" textAnchor="middle" fontSize="9" fill="var(--ink-faint)">{m.month.slice(5)}</text>
|
||||||
|
<text x={x + 18} y={120 - h - 4} textAnchor="middle" fontSize="9" fill={isLast ? 'var(--ink)' : 'var(--ink-muted)'} fontWeight={isLast ? 600 : 400}>
|
||||||
|
{(m.amount / 1000).toFixed(0)}k
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-card dash-panel" style={{ gridColumn: 'span 7' }}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Cabinet — days of supply</div>
|
||||||
|
<div className="mt-card__sub">At current usage</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('cabinet')}>Open cabinet</button>
|
||||||
|
</div>
|
||||||
|
<div className="dash-supply">
|
||||||
|
{supplyData.map(s => {
|
||||||
|
const pct = Math.min(100, (s.days / sparkMax) * 100);
|
||||||
|
const tone = s.days <= 7 ? 'critical' : s.days <= 14 ? 'low' : 'ok';
|
||||||
|
return (
|
||||||
|
<div key={s.med.id} className="dash-supply__row">
|
||||||
|
<div className="dash-supply__name">{s.med.name}</div>
|
||||||
|
<div className="dash-supply__bar">
|
||||||
|
<div className={'dash-supply__fill dash-supply__fill--' + tone} style={{ width: pct + '%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="dash-supply__days num">{s.days}<span>d</span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-card dash-panel" style={{ gridColumn: 'span 6' }}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Pending orders</div>
|
||||||
|
<div className="mt-card__sub">{PURCHASES.filter(p => p.status === 'pending').length} awaiting arrival</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('purchases')}>All purchases</button>
|
||||||
|
</div>
|
||||||
|
<div className="dash-orders">
|
||||||
|
{PURCHASES.filter(p => p.status === 'pending').map(p => {
|
||||||
|
const s = STORES.find(s => s.id === p.store);
|
||||||
|
const total = p.items.reduce((a, i) => a + i.price, 0);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="dash-order">
|
||||||
|
<div className="dash-order__icon"><Icon name="truck" size={14} /></div>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
<div style={{fontWeight:500, fontSize:13}}>{s?.name}</div>
|
||||||
|
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{p.items.map(i => MED[i.medId].name).join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{textAlign:'right'}}>
|
||||||
|
<div className="num" style={{fontSize:13, fontWeight:500}}>{fmtJPY(total)}</div>
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)'}}>ordered {p.date}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{PURCHASES.filter(p => p.status === 'received').slice(0, 2).map(p => {
|
||||||
|
const s = STORES.find(s => s.id === p.store);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="dash-order is-past">
|
||||||
|
<div className="dash-order__icon"><Icon name="check" size={14} /></div>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
<div style={{fontSize:12, color:'var(--ink-muted)'}}>{s?.name} · {p.items.length} items received</div>
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)'}}>{p.date}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-card dash-panel" style={{ gridColumn: 'span 6' }}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Recent activity</div>
|
||||||
|
<div className="mt-card__sub">Cabinet changes</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('activity')}>See all</button>
|
||||||
|
</div>
|
||||||
|
<div className="dash-activity">
|
||||||
|
{ACTIVITY.slice(0, 5).map(a => {
|
||||||
|
const m = MED[a.medId];
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="dash-act">
|
||||||
|
<span className={'mt-pill mt-pill--' + (a.type === 'consumed' ? 'info' : a.type === 'added' ? 'ok' : 'warn')} style={{minWidth:76, justifyContent:'center'}}>{a.type}</span>
|
||||||
|
<span style={{flex:1, fontSize:12}}>
|
||||||
|
<strong style={{fontWeight:500}}>{m.name}</strong>
|
||||||
|
<span className="num" style={{marginLeft:6, color: a.delta < 0 ? 'var(--ink-muted)' : 'var(--ok)'}}>
|
||||||
|
{a.delta > 0 ? '+' : ''}{a.delta}
|
||||||
|
</span>
|
||||||
|
<span className="num" style={{color:'var(--ink-faint)', marginLeft:6}}>{a.before}→{a.after}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{fontSize:10, color:'var(--ink-faint)'}}>{a.at.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.DashboardPage = DashboardPage;
|
||||||
44
docs/design/src/Icon.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Minimal inline-SVG icon set. Stroke-based, 1.6px weight, 20px box.
|
||||||
|
const Icon = ({ name, size = 18, className = '', style }) => {
|
||||||
|
const s = { width: size, height: size, display: 'inline-block', flexShrink: 0, ...style };
|
||||||
|
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', className };
|
||||||
|
const paths = {
|
||||||
|
dashboard: <><rect x="3" y="3" width="8" height="10" rx="1.5"/><rect x="13" y="3" width="8" height="6" rx="1.5"/><rect x="3" y="15" width="8" height="6" rx="1.5"/><rect x="13" y="11" width="8" height="10" rx="1.5"/></>,
|
||||||
|
cabinet: <><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M4 12h16"/><circle cx="10" cy="7.5" r="0.6" fill="currentColor"/><circle cx="10" cy="16.5" r="0.6" fill="currentColor"/></>,
|
||||||
|
pill: <><rect x="2" y="9" width="20" height="6" rx="3" transform="rotate(-30 12 12)"/><path d="M7.4 7.8l9 5.2" transform="rotate(-30 12 12)"/></>,
|
||||||
|
clock: <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>,
|
||||||
|
list: <><path d="M8 6h13M8 12h13M8 18h13"/><circle cx="3.5" cy="6" r="0.8" fill="currentColor"/><circle cx="3.5" cy="12" r="0.8" fill="currentColor"/><circle cx="3.5" cy="18" r="0.8" fill="currentColor"/></>,
|
||||||
|
calendar: <><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></>,
|
||||||
|
store: <><path d="M3 9l1.5-5h15L21 9M3 9v11h18V9M3 9h18"/><path d="M9 20v-6h6v6"/></>,
|
||||||
|
tag: <><path d="M3 12l9-9h8v8l-9 9z"/><circle cx="15.5" cy="8.5" r="1.4"/></>,
|
||||||
|
truck: <><rect x="2" y="7" width="12" height="10" rx="1.5"/><path d="M14 10h5l3 4v3h-8"/><circle cx="7" cy="18.5" r="1.8"/><circle cx="17" cy="18.5" r="1.8"/></>,
|
||||||
|
refresh: <><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></>,
|
||||||
|
search: <><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></>,
|
||||||
|
plus: <><path d="M12 5v14M5 12h14"/></>,
|
||||||
|
check: <><path d="M5 12l5 5L20 6"/></>,
|
||||||
|
x: <><path d="M6 6l12 12M18 6L6 18"/></>,
|
||||||
|
chev: <><path d="M9 6l6 6-6 6"/></>,
|
||||||
|
chevDown: <><path d="M6 9l6 6 6-6"/></>,
|
||||||
|
alert: <><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></>,
|
||||||
|
bell: <><path d="M6 8a6 6 0 1 1 12 0c0 6 2 7 2 7H4s2-1 2-7zM10 19a2 2 0 0 0 4 0"/></>,
|
||||||
|
filter: <><path d="M4 5h16l-6 8v6l-4-2v-4z"/></>,
|
||||||
|
settings: <><circle cx="12" cy="12" r="3"/><path d="M20 12a8 8 0 0 0-.2-1.8l2-1.6-2-3.5-2.4 1a8 8 0 0 0-3-1.8L14 2h-4l-.4 2.4a8 8 0 0 0-3 1.8l-2.4-1-2 3.5 2 1.6A8 8 0 0 0 4 12c0 .6.1 1.2.2 1.8l-2 1.6 2 3.5 2.4-1a8 8 0 0 0 3 1.8L10 22h4l.4-2.4a8 8 0 0 0 3-1.8l2.4 1 2-3.5-2-1.6c.1-.6.2-1.2.2-1.8z"/></>,
|
||||||
|
sun: <><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5"/></>,
|
||||||
|
moon: <><path d="M20 14A8 8 0 0 1 10 4a8 8 0 1 0 10 10z"/></>,
|
||||||
|
home: <><path d="M3 11l9-8 9 8v10H3z"/><path d="M9 21v-7h6v7"/></>,
|
||||||
|
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></>,
|
||||||
|
edit: <><path d="M4 20l4-1 11-11-3-3L5 16zM14 6l3 3"/></>,
|
||||||
|
arrow: <><path d="M5 12h14M13 6l6 6-6 6"/></>,
|
||||||
|
vial: <><path d="M8 3h8M9 3v14a3 3 0 0 0 6 0V3"/><path d="M9 11h6"/></>,
|
||||||
|
capsule: <><rect x="3" y="9" width="18" height="6" rx="3"/><path d="M12 9v6"/></>,
|
||||||
|
injection: <><path d="M17 3l4 4M15 5l4 4M8 12l7-7 3 3-7 7-3.5.5zM8 12l-5 5M4 16l3 3"/></>,
|
||||||
|
trend: <><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></>,
|
||||||
|
fridge: <><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M5 10h14"/><path d="M8 6v2M8 13v4"/></>,
|
||||||
|
box: <><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 11v10"/></>,
|
||||||
|
yen: <><path d="M5 4l7 9 7-9M7 13h10M7 17h10M12 13v7"/></>,
|
||||||
|
zap: <><path d="M13 2L4 14h7l-1 8 9-12h-7z"/></>,
|
||||||
|
};
|
||||||
|
return <svg {...common} style={s}>{paths[name] || null}</svg>;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Icon = Icon;
|
||||||
518
docs/design/src/OtherPages.jsx
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
// Medium-fi placeholder pages for the remaining medicine + purchasing sections.
|
||||||
|
const { MED: M2, MEDICINES: MEDS2, CABINET: C2, REGIMENS: REG2, STORES: ST2, PRICES: PR2, PURCHASES: PU2, ACTIVITY: AC2 } = window.MESHI;
|
||||||
|
|
||||||
|
// ---- Library ----
|
||||||
|
const LibraryPage = () => {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const rows = MEDS2.filter(m => m.name.toLowerCase().includes(q.toLowerCase()));
|
||||||
|
return (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="sub-toolbar">
|
||||||
|
<div className="cab-filters__search" style={{flex:1, maxWidth: 320}}>
|
||||||
|
<Icon name="search" size={13}/>
|
||||||
|
<input placeholder="Search medicines…" value={q} onChange={e=>setQ(e.target.value)} style={{width:'100%'}}/>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', gap: 8}}>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All categories <Icon name="chevDown" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All forms <Icon name="chevDown" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Add medicine</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-card" style={{overflow:'hidden'}}>
|
||||||
|
{rows.map((m, i) => (
|
||||||
|
<div key={m.id} className="lib-row">
|
||||||
|
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
|
||||||
|
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
|
||||||
|
</div>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</div>
|
||||||
|
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{m.strength} · {m.form}</div>
|
||||||
|
</div>
|
||||||
|
<span className={'mt-pill mt-pill--' + (m.category === 'Prescription' ? 'info' : 'ghost')}>{m.category}</span>
|
||||||
|
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--subtle"><Icon name="trash" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Regimens ----
|
||||||
|
const RegimensPage = () => (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="sub-toolbar">
|
||||||
|
<div style={{display:'flex', gap:8}}>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All regimens <Icon name="chevDown" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--ghost"><Icon name="trend" size={12}/> Burn rate</button>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> New regimen</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-col">
|
||||||
|
{REG2.map(r => (
|
||||||
|
<div key={r.id} className="mt-card" style={{padding: 18}}>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap: 16}}>
|
||||||
|
<div>
|
||||||
|
<div style={{display:'flex', alignItems:'center', gap: 8, marginBottom: 4}}>
|
||||||
|
<span style={{fontFamily:'var(--font-display)', fontSize: 18, fontWeight: 500, color:'var(--ink-strong)'}}>Aeri's {r.name}</span>
|
||||||
|
<span className="mt-pill mt-pill--ok">Active</span>
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:12, color:'var(--ink-muted)'}}>{r.items.length} medication{r.items.length>1?'s':''} · {r.time} · {r.freq}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', gap: 6}}>
|
||||||
|
<button className="mt-btn mt-btn--ghost">Deactivate</button>
|
||||||
|
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--subtle"><Icon name="trash" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', flexWrap:'wrap', gap: 6, marginTop: 14}}>
|
||||||
|
{r.items.map(it => {
|
||||||
|
const m = M2[it.medId];
|
||||||
|
return (
|
||||||
|
<span key={it.medId} className="mt-pill mt-pill--outline" style={{padding: '4px 10px', fontSize: 11}}>
|
||||||
|
<span style={{background:'var(--viz-'+m.color+')', width:6, height:6, borderRadius:3, display:'inline-block', marginRight: 4}}/>
|
||||||
|
<strong style={{fontWeight: 500, color:'var(--ink-strong)'}}>{m.name}</strong>
|
||||||
|
<span style={{marginLeft:4, color:'var(--ink-muted)'}}>{it.qty} {it.unit}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Organizer ----
|
||||||
|
const OrganizerPage = () => {
|
||||||
|
const [regimenId, setRegimenId] = useState(REG2[1].id);
|
||||||
|
const [days, setDays] = useState(7);
|
||||||
|
const regimen = REG2.find(r => r.id === regimenId);
|
||||||
|
|
||||||
|
// Compute inventory impact: for each item in regimen, how many units will be consumed?
|
||||||
|
const impact = regimen.items.map(it => {
|
||||||
|
const m = M2[it.medId];
|
||||||
|
const cabEntry = C2.find(c => c.medId === it.medId);
|
||||||
|
const onHand = cabEntry ? cabEntry.qty : 0;
|
||||||
|
const needed = it.qty * days;
|
||||||
|
const after = onHand - needed;
|
||||||
|
const perDay = cabEntry ? (cabEntry.perDose || 1) / (cabEntry.daysPerUnit || 1) : 1;
|
||||||
|
const daysAfter = perDay > 0 ? Math.floor(Math.max(0, after) / perDay) : 0;
|
||||||
|
const status = after < 0 ? 'short' : after < needed * 0.5 ? 'low' : 'ok';
|
||||||
|
return { m, it, onHand, needed, after, daysAfter, status, cabEntry };
|
||||||
|
});
|
||||||
|
|
||||||
|
const anyShort = impact.some(i => i.status === 'short');
|
||||||
|
const anyLow = impact.some(i => i.status === 'low');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="org-hero">
|
||||||
|
<div className="org-hero__controls">
|
||||||
|
<div>
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>Fill from regimen</div>
|
||||||
|
<select className="org-hero__select" value={regimenId} onChange={e => setRegimenId(e.target.value)}>
|
||||||
|
{REG2.map(r => <option key={r.id} value={r.id}>Aeri's {r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<div style={{fontSize:12, color:'var(--ink-muted)', marginTop: 4}}>{regimen.time} · {regimen.items.length} medicines · {regimen.freq}</div>
|
||||||
|
</div>
|
||||||
|
<div className="org-hero__divider"/>
|
||||||
|
<div>
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500, marginBottom:8}}>Days to fill</div>
|
||||||
|
<div className="org-hero__stepper">
|
||||||
|
<button onClick={() => setDays(Math.max(1, days - 1))}>−</button>
|
||||||
|
<span className="num">{days}</span>
|
||||||
|
<button onClick={() => setDays(Math.min(30, days + 1))}>+</button>
|
||||||
|
</div>
|
||||||
|
<div className="org-hero__presets">
|
||||||
|
{[3, 7, 14, 30].map(d => (
|
||||||
|
<button key={d} className={'org-hero__preset' + (days === d ? ' is-active' : '')} onClick={() => setDays(d)}>{d}d</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="org-hero__summary">
|
||||||
|
<div className="org-hero__sumRow">
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>This fill will dispense</div>
|
||||||
|
<div style={{fontSize: 22, fontWeight: 600, color:'var(--ink-strong)', letterSpacing:'-0.01em'}} className="num">
|
||||||
|
{impact.reduce((a,b) => a + b.needed, 0)} <span style={{fontSize:13, fontWeight:400, color:'var(--ink-muted)'}}>units across {impact.length} medicines</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{anyShort ? (
|
||||||
|
<div className="org-alert org-alert--danger">
|
||||||
|
<Icon name="alert" size={14}/>
|
||||||
|
<div>
|
||||||
|
<strong>Not enough stock.</strong> {impact.filter(i => i.status === 'short').length} medicine{impact.filter(i => i.status === 'short').length > 1 ? 's are' : ' is'} short for this fill.
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--primary">Add to shopping list</button>
|
||||||
|
</div>
|
||||||
|
) : anyLow ? (
|
||||||
|
<div className="org-alert org-alert--warn">
|
||||||
|
<Icon name="alert" size={14}/>
|
||||||
|
<div><strong>Low after fill.</strong> Some meds will drop below 50% headroom.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="org-alert org-alert--ok">
|
||||||
|
<Icon name="check" size={14}/>
|
||||||
|
<div><strong>Ready to dispense.</strong> Cabinet has enough stock with comfortable headroom.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{display:'flex', gap: 8}}>
|
||||||
|
<button className="mt-btn mt-btn--primary" disabled={anyShort}><Icon name="zap" size={12}/> Dispense {days}-day fill</button>
|
||||||
|
<button className="mt-btn mt-btn--ghost">Print label</button>
|
||||||
|
<div style={{flex:1}}/>
|
||||||
|
<button className="mt-btn mt-btn--ghost">Fill history</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory impact table */}
|
||||||
|
<div className="mt-card" style={{marginTop: 16}}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Inventory impact</div>
|
||||||
|
<div className="mt-card__sub">What this fill will take from the cabinet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="org-impact">
|
||||||
|
<div className="org-impact__head">
|
||||||
|
<div>Medicine</div>
|
||||||
|
<div style={{textAlign:'right'}}>On hand</div>
|
||||||
|
<div style={{textAlign:'right'}}>Needed</div>
|
||||||
|
<div>After fill</div>
|
||||||
|
<div style={{textAlign:'right'}}>Days left</div>
|
||||||
|
</div>
|
||||||
|
{impact.map(row => {
|
||||||
|
const m = row.m;
|
||||||
|
const pct = row.onHand > 0 ? Math.max(0, Math.min(100, (row.after / row.onHand) * 100)) : 0;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={'org-impact__row org-impact__row--' + row.status}>
|
||||||
|
<div style={{display:'flex', alignItems:'center', gap: 10}}>
|
||||||
|
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
|
||||||
|
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</div>
|
||||||
|
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{row.it.qty} × {days} days = {row.needed} {m.form.toLowerCase()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{textAlign:'right'}}><span className="num" style={{fontSize:14, fontWeight:600}}>{row.onHand}</span></div>
|
||||||
|
<div style={{textAlign:'right'}}><span className="num" style={{fontSize:14, color:'var(--ink-muted)'}}>−{row.needed}</span></div>
|
||||||
|
<div style={{display:'flex', alignItems:'center', gap: 8}}>
|
||||||
|
<div className="org-impact__bar">
|
||||||
|
<div className="org-impact__barBg"/>
|
||||||
|
<div className="org-impact__barFill" style={{width: Math.max(0, pct) + '%', background: row.status === 'short' ? 'var(--danger)' : row.status === 'low' ? 'var(--warn)' : 'var(--ok)'}}/>
|
||||||
|
</div>
|
||||||
|
<span className="num" style={{fontSize:13, fontWeight:600, color: row.after < 0 ? 'var(--danger)' : 'var(--ink-strong)', minWidth: 38, textAlign:'right'}}>{row.after < 0 ? row.after : row.after}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{textAlign:'right'}}>
|
||||||
|
{row.status === 'short'
|
||||||
|
? <span className="mt-pill mt-pill--danger">Short</span>
|
||||||
|
: <span className="num" style={{fontSize:13, color: row.status === 'low' ? 'var(--warn)' : 'var(--ink-muted)'}}>{row.daysAfter}d</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Activity ----
|
||||||
|
const ActivityPage = () => {
|
||||||
|
const { SPENDING } = window.MESHI;
|
||||||
|
return (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="mt-card" style={{marginBottom: 16}}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Spending summary</div>
|
||||||
|
<div className="mt-card__sub">Last 6 months</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-row" style={{gap:6}}>
|
||||||
|
<button className="mt-btn mt-btn--ghost">This year <Icon name="chevDown" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All medicines <Icon name="chevDown" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding:'18px', display:'grid', gridTemplateColumns:'2fr 1fr', gap: 20}}>
|
||||||
|
<svg viewBox="0 0 360 180" width="100%" height="180">
|
||||||
|
{SPENDING.map((s, i) => {
|
||||||
|
const max = Math.max(...SPENDING.map(x=>x.amount));
|
||||||
|
const h = (s.amount / max) * 140;
|
||||||
|
const x = 20 + i * 56;
|
||||||
|
return (
|
||||||
|
<g key={s.month}>
|
||||||
|
<rect x={x} y={160 - h} width="36" height={h} rx="3" fill="var(--brand)" opacity={0.3 + (i/SPENDING.length)*0.7}/>
|
||||||
|
<text x={x+18} y="175" textAnchor="middle" fontSize="9" fill="var(--ink-faint)">{s.month.slice(5)}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-col">
|
||||||
|
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Total spent</div><div style={{fontFamily:'var(--font-display)', fontSize:28, fontWeight:500, color:'var(--ink-strong)'}} className="num">{fmtJPY(SPENDING.reduce((a,b)=>a+b.amount,0))}</div></div>
|
||||||
|
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Monthly average</div><div className="num" style={{fontSize:18, fontWeight:500}}>{fmtJPY(Math.round(SPENDING.reduce((a,b)=>a+b.amount,0)/SPENDING.length))}</div></div>
|
||||||
|
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Top medicine</div><div style={{fontSize:14, fontWeight:500}}>Semaglutide <span className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(56062)}</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-card">
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Cabinet activity</div>
|
||||||
|
<div className="mt-card__sub">{AC2.length} events</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-row" style={{gap:6}}>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All types <Icon name="chevDown" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All medicines <Icon name="chevDown" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding:'4px 18px 16px'}}>
|
||||||
|
{AC2.map(a => {
|
||||||
|
const m = M2[a.medId];
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="act-row">
|
||||||
|
<span className="act-rail"><span className={'act-dot act-dot--'+a.type}/></span>
|
||||||
|
<span className={'mt-pill mt-pill--' + (a.type==='consumed'?'info':a.type==='added'?'ok':'warn')} style={{minWidth: 76, justifyContent:'center'}}>{a.type}</span>
|
||||||
|
<strong style={{fontSize:13, fontWeight:500, color:'var(--ink-strong)'}}>{m.name}</strong>
|
||||||
|
<span className="num" style={{color: a.delta < 0 ? 'var(--ink-muted)' : 'var(--ok)', fontSize: 13, fontWeight:500}}>{a.delta > 0?'+':''}{a.delta}</span>
|
||||||
|
<span className="num" style={{color:'var(--ink-faint)', fontSize:12}}>{a.before}→{a.after}</span>
|
||||||
|
<span style={{flex:1}}/>
|
||||||
|
<span className="mono" style={{fontSize:11, color:'var(--ink-muted)'}}>{a.at}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Stores ----
|
||||||
|
const StoresPage = () => (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="sub-toolbar">
|
||||||
|
<div style={{display:'flex', gap:8, alignItems:'center'}}>
|
||||||
|
<div className="cab-filters__search"><Icon name="search" size={13}/><input placeholder="Search stores…"/></div>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All tags <Icon name="chevDown" size={12}/></button>
|
||||||
|
<label style={{display:'flex', gap:6, alignItems:'center', fontSize:12, color:'var(--ink-muted)'}}><input type="checkbox"/> Show inactive</label>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Add store</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-grid" style={{gridTemplateColumns:'repeat(auto-fill, minmax(280px, 1fr))'}}>
|
||||||
|
{ST2.map(s => (
|
||||||
|
<div key={s.id} className="mt-card" style={{padding: 16}}>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start'}}>
|
||||||
|
<div>
|
||||||
|
<div style={{fontWeight:500, fontSize:14, color:'var(--ink-strong)'}}>{s.name}</div>
|
||||||
|
<div style={{fontSize:11, color:'var(--brand)', marginTop:2}}>{s.url}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', gap:4}}>
|
||||||
|
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', flexWrap:'wrap', gap: 4, marginTop: 10}}>
|
||||||
|
{s.tags.map(t => <span key={t} className="mt-pill mt-pill--ghost" style={{fontSize:10}}>{t}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-divider"/>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', fontSize:11, color:'var(--ink-muted)'}}>
|
||||||
|
<span>{PR2.filter(p=>p.store===s.id).length} prices tracked</span>
|
||||||
|
<span>{PU2.filter(p=>p.store===s.id).length} orders</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Prices ----
|
||||||
|
const PricesPage = () => (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="sub-toolbar">
|
||||||
|
<div style={{display:'flex', gap: 8}}>
|
||||||
|
<button className="mt-btn mt-btn--ghost">Semaglutide (14mg) <Icon name="chevDown" size={12}/></button>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All stores <Icon name="chevDown" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Record price</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-card" style={{marginBottom: 16}}>
|
||||||
|
<div className="mt-card__head"><div><div className="mt-card__title">Store comparison</div><div className="mt-card__sub">Best price highlighted</div></div></div>
|
||||||
|
<div className="prices-table">
|
||||||
|
<div className="prices-table__head">
|
||||||
|
<div>Store</div><div className="num">Price</div><div className="num">Per unit</div><div>Date</div>
|
||||||
|
</div>
|
||||||
|
{PR2.filter(p=>p.medId==='semaglutide').map(p => {
|
||||||
|
const s = ST2.find(s=>s.id===p.store);
|
||||||
|
const isBest = p.price/p.pkg === Math.min(...PR2.filter(x=>x.medId===p.medId).map(x=>x.price/x.pkg));
|
||||||
|
return (
|
||||||
|
<div key={p.store+p.date} className={'prices-table__row' + (isBest?' is-best':'')}>
|
||||||
|
<div style={{display:'flex', alignItems:'center', gap:8}}>
|
||||||
|
<span style={{fontWeight:500}}>{s?.name}</span>
|
||||||
|
{isBest && <span className="mt-pill mt-pill--ok" style={{fontSize:10}}>Cheapest</span>}
|
||||||
|
</div>
|
||||||
|
<div className="num">{fmtJPY(p.price)}</div>
|
||||||
|
<div className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(Math.round(p.price/p.pkg))}</div>
|
||||||
|
<div className="num" style={{color:'var(--ink-muted)'}}>{p.date}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-card">
|
||||||
|
<div className="mt-card__head"><div><div className="mt-card__title">All price records</div><div className="mt-card__sub">{PR2.length} entries</div></div></div>
|
||||||
|
<div className="prices-table">
|
||||||
|
<div className="prices-table__head" style={{gridTemplateColumns:'1.5fr 1fr 0.7fr 0.8fr 0.8fr 0.8fr'}}>
|
||||||
|
<div>Medicine</div><div>Store</div><div className="num">Qty</div><div className="num">Price</div><div className="num">Per unit</div><div>Date</div>
|
||||||
|
</div>
|
||||||
|
{PR2.map(p => {
|
||||||
|
const s = ST2.find(s=>s.id===p.store);
|
||||||
|
const m = M2[p.medId];
|
||||||
|
return (
|
||||||
|
<div key={p.medId+p.store+p.date} className="prices-table__row" style={{gridTemplateColumns:'1.5fr 1fr 0.7fr 0.8fr 0.8fr 0.8fr'}}>
|
||||||
|
<div style={{fontWeight:500}}>{m.name}</div>
|
||||||
|
<div>{s?.name}</div>
|
||||||
|
<div className="num">{p.pkg} {p.unit}</div>
|
||||||
|
<div className="num">{fmtJPY(p.price)}</div>
|
||||||
|
<div className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(Math.round(p.price/p.pkg))}</div>
|
||||||
|
<div className="num" style={{color:'var(--ink-muted)'}}>{p.date}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Refills ----
|
||||||
|
const RefillsPage = () => {
|
||||||
|
const low = C2.map(r => ({r, s: cabinetStatus(r)})).filter(x => x.s.level !== 'ok');
|
||||||
|
return (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="mt-card" style={{marginBottom: 16}}>
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Refill alerts</div>
|
||||||
|
<div className="mt-card__sub">Threshold: 14 days</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Generate refill list</button>
|
||||||
|
</div>
|
||||||
|
<div style={{padding:'4px 18px 16px'}}>
|
||||||
|
{low.map(({r, s}) => {
|
||||||
|
const m = M2[r.medId];
|
||||||
|
const bestPrice = PR2.filter(p=>p.medId===r.medId).sort((a,b)=> a.price/a.pkg - b.price/b.pkg)[0];
|
||||||
|
const bestStore = bestPrice && ST2.find(st=>st.id===bestPrice.store);
|
||||||
|
return (
|
||||||
|
<div key={r.medId} className="refill-row">
|
||||||
|
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
|
||||||
|
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
|
||||||
|
</div>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
<div style={{fontWeight:500, color:'var(--ink-strong)'}}>{m.name} <span style={{color:'var(--ink-muted)', fontWeight:400}}>{m.strength}</span></div>
|
||||||
|
<div style={{fontSize:11, color: s.level==='critical' ? 'var(--danger)':'var(--warn)', fontWeight:500}}>{s.days} days left · {r.qty} {r.unit} in cabinet</div>
|
||||||
|
</div>
|
||||||
|
<div style={{textAlign:'right'}}>
|
||||||
|
<div style={{fontSize:11, color:'var(--ink-faint)'}}>Suggested</div>
|
||||||
|
<div className="num" style={{fontWeight:500}}>{bestPrice?.pkg || 30} units</div>
|
||||||
|
</div>
|
||||||
|
<div style={{textAlign:'right', minWidth: 140}}>
|
||||||
|
<div style={{fontSize:11, color:'var(--ink-faint)'}}>Best price</div>
|
||||||
|
<div style={{fontSize:12, fontWeight:500, color:'var(--brand)'}}>{bestStore?.name} · <span className="num">{bestPrice ? fmtJPY(bestPrice.price) : '—'}</span></div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--primary">Add to list</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-card">
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div><div className="mt-card__title">Refill lists</div><div className="mt-card__sub">Create from alerts or start blank</div></div>
|
||||||
|
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={12}/> New list</button>
|
||||||
|
</div>
|
||||||
|
<div style={{padding:'20px 18px', color:'var(--ink-muted)', fontSize:12, textAlign:'center'}}>No refill lists yet. Create one above or generate from alerts.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Purchases ----
|
||||||
|
const PurchasesPage = () => (
|
||||||
|
<div className="mt-page">
|
||||||
|
<div className="sub-toolbar">
|
||||||
|
<div style={{display:'flex', gap:8}}>
|
||||||
|
<button className="mt-btn mt-btn--ghost">All statuses <Icon name="chevDown" size={12}/></button>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Record purchase</button>
|
||||||
|
</div>
|
||||||
|
{['pending', 'received'].map(status => {
|
||||||
|
const items = PU2.filter(p => p.status === status);
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={status} style={{marginBottom: 20}}>
|
||||||
|
<div style={{fontSize:11, textTransform:'uppercase', letterSpacing:'.08em', color:'var(--ink-muted)', fontWeight:600, marginBottom: 8}}>{status === 'pending' ? 'Pending arrival' : 'Received'}</div>
|
||||||
|
<div className="mt-col">
|
||||||
|
{items.map(p => {
|
||||||
|
const s = ST2.find(s => s.id === p.store);
|
||||||
|
const total = p.items.reduce((a,i) => a + i.price, 0);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="mt-card" style={{padding: 16}}>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom: 10}}>
|
||||||
|
<div>
|
||||||
|
<div style={{fontWeight:500, fontSize:14, color:'var(--ink-strong)'}}>{s?.name}</div>
|
||||||
|
<div className="num" style={{fontSize:11, color:'var(--ink-muted)'}}>{p.date}</div>
|
||||||
|
</div>
|
||||||
|
<span className={'mt-pill mt-pill--' + (status==='pending'?'warn':'ok')}>{status}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', flexDirection:'column', gap:6, padding: '10px 0', borderTop:'1px dashed var(--border)'}}>
|
||||||
|
{p.items.map(i => {
|
||||||
|
const m = M2[i.medId];
|
||||||
|
return (
|
||||||
|
<div key={i.medId} style={{display:'flex', justifyContent:'space-between', fontSize:12}}>
|
||||||
|
<span>{m.name}</span>
|
||||||
|
<span style={{color:'var(--ink-muted)'}} className="num">{i.qty} {i.unit} · {fmtJPY(i.price)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', paddingTop: 10, borderTop:'1px solid var(--border)'}}>
|
||||||
|
<div className="num" style={{fontSize:15, fontWeight:600}}>{fmtJPY(total)}</div>
|
||||||
|
{status === 'pending' && <div style={{display:'flex', gap:6}}>
|
||||||
|
<button className="mt-btn mt-btn--primary">Mark as received</button>
|
||||||
|
<button className="mt-btn mt-btn--ghost">Cancel</button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Settings ----
|
||||||
|
const SettingsPage = () => (
|
||||||
|
<div className="mt-page" style={{maxWidth: 720}}>
|
||||||
|
<div className="mt-card" style={{marginBottom: 16}}>
|
||||||
|
<div className="mt-card__head"><div className="mt-card__title">Household</div></div>
|
||||||
|
<div style={{padding: 18}}>
|
||||||
|
<div className="settings-row"><span>Name</span><span style={{fontWeight:500}}>Red Panda Den</span><button className="mt-btn mt-btn--ghost">Edit</button></div>
|
||||||
|
<div className="settings-row"><span>Invite code</span><span className="mono">2496F-7CF</span><button className="mt-btn mt-btn--ghost">Regenerate</button></div>
|
||||||
|
<div className="settings-row"><span>Members</span><span>1</span><button className="mt-btn mt-btn--ghost">Invite</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-card" style={{marginBottom: 16}}>
|
||||||
|
<div className="mt-card__head"><div className="mt-card__title">Account</div></div>
|
||||||
|
<div style={{padding: 18}}>
|
||||||
|
<p style={{fontSize:12, color:'var(--ink-muted)'}}>Account settings are managed through Keycloak.</p>
|
||||||
|
<button className="mt-btn mt-btn--ghost">Manage Keycloak account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(window, { LibraryPage, RegimensPage, OrganizerPage, ActivityPage, StoresPage, PricesPage, RefillsPage, PurchasesPage, SettingsPage });
|
||||||
162
docs/design/src/Schedule.jsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// Schedule & Intake Log — slot-based, collapsed by default
|
||||||
|
const { TODAY_INTAKE: INTAKE_SEED, MED: MED_S, REGIMENS: REGS_S } = window.MESHI;
|
||||||
|
|
||||||
|
const SchedulePage = () => {
|
||||||
|
const [intake, setIntake] = useState(INTAKE_SEED);
|
||||||
|
const [day, setDay] = useState(5);
|
||||||
|
const [expanded, setExpanded] = useState(null); // slot time
|
||||||
|
|
||||||
|
const weekDays = ['Mon 13', 'Tue 14', 'Wed 15', 'Thu 16', 'Fri 17', 'Sat 18', 'Sun 19'];
|
||||||
|
const adherence = [1.0, 1.0, 1.0, 0.88, 1.0, 0.50, 0.0];
|
||||||
|
|
||||||
|
const toggle = (id) => setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i));
|
||||||
|
const markSlot = (time) => setIntake(intake.map(i => i.scheduled === time ? { ...i, status: 'taken', takenAt: time } : i));
|
||||||
|
|
||||||
|
// Group by scheduled time
|
||||||
|
const slots = [
|
||||||
|
{ time: '08:00', label: 'Morning', icon: 'sun' },
|
||||||
|
{ time: '21:00', label: 'Evening', icon: 'moon' },
|
||||||
|
].map(s => {
|
||||||
|
const items = intake.filter(i => i.scheduled === s.time);
|
||||||
|
const done = items.filter(i => i.status === 'taken').length;
|
||||||
|
return { ...s, items, done, total: items.length, complete: done === items.length && items.length > 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDone = intake.filter(i => i.status === 'taken').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-page">
|
||||||
|
{/* Week strip — adherence heatmap */}
|
||||||
|
<div className="sched-weekbar">
|
||||||
|
<div className="sched-weekbar__label">
|
||||||
|
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>This week</div>
|
||||||
|
<div style={{fontSize:13, color:'var(--ink-strong)', fontWeight:500, marginTop:2}}>
|
||||||
|
<span className="num">{Math.round(adherence.reduce((a,b)=>a+b,0)/adherence.length*100)}%</span> adherence
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sched-weekbar__days">
|
||||||
|
{weekDays.map((d, i) => {
|
||||||
|
const isToday = i === day;
|
||||||
|
const a = adherence[i];
|
||||||
|
return (
|
||||||
|
<button key={d} className={'sched-weekday' + (isToday ? ' is-active' : '')} onClick={() => setDay(i)}>
|
||||||
|
<div className="sched-weekday__label">{d.split(' ')[0]}</div>
|
||||||
|
<div className="sched-weekday__num">{d.split(' ')[1]}</div>
|
||||||
|
<div className="sched-weekday__bar">
|
||||||
|
<div className="sched-weekday__fill" style={{height: (a*100) + '%', background: a >= 1 ? 'var(--ok)' : a >= 0.5 ? 'var(--warn)' : a > 0 ? 'var(--danger)' : 'var(--border-strong)'}}/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{display:'grid', gridTemplateColumns:'2fr 1fr', gap: 16, marginTop: 16}}>
|
||||||
|
<div className="mt-card">
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Saturday, April 18</div>
|
||||||
|
<div className="mt-card__sub"><span className="num">{totalDone}</span> of <span className="num">{intake.length}</span> doses · 18:30 now</div>
|
||||||
|
</div>
|
||||||
|
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={12}/> Log off-schedule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sched-slots">
|
||||||
|
{slots.map(slot => {
|
||||||
|
const isOpen = expanded === slot.time;
|
||||||
|
const now = 18.5;
|
||||||
|
const slotHr = parseInt(slot.time);
|
||||||
|
const status = slot.complete ? 'done' : slotHr < now ? 'overdue' : 'upcoming';
|
||||||
|
return (
|
||||||
|
<div key={slot.time} className={'sched-slot sched-slot--' + status + (isOpen ? ' is-open' : '')}>
|
||||||
|
<button className="sched-slot__row" onClick={() => setExpanded(isOpen ? null : slot.time)}>
|
||||||
|
<div className="sched-slot__time">
|
||||||
|
<div className="sched-slot__hour num">{slot.time}</div>
|
||||||
|
<div className="sched-slot__phase">{slot.label}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sched-slot__main">
|
||||||
|
<div className="sched-slot__summary">
|
||||||
|
<span className="num" style={{fontSize:15, fontWeight:600, color:'var(--ink-strong)'}}>{slot.done}/{slot.total}</span>
|
||||||
|
<span style={{fontSize:13, color:'var(--ink-muted)'}}>doses</span>
|
||||||
|
<div className="sched-slot__dots">
|
||||||
|
{slot.items.map(i => {
|
||||||
|
const m = MED_S[i.medId];
|
||||||
|
return <span key={i.id} className={'sched-slot__dot' + (i.status === 'taken' ? ' is-taken' : '')} style={{background: i.status === 'taken' ? 'var(--viz-' + m.color + ')' : 'transparent', borderColor: 'var(--viz-' + m.color + ')'}}/>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status === 'done' && <span className="mt-pill mt-pill--ok">All taken</span>}
|
||||||
|
{status === 'overdue' && <span className="mt-pill mt-pill--warn">Overdue</span>}
|
||||||
|
{status === 'upcoming' && <span className="mt-pill mt-pill--ghost">Upcoming</span>}
|
||||||
|
</div>
|
||||||
|
<div className="sched-slot__actions" onClick={e => e.stopPropagation()}>
|
||||||
|
{!slot.complete && <button className="mt-btn mt-btn--primary" onClick={() => markSlot(slot.time)}><Icon name="check" size={12}/> Take all</button>}
|
||||||
|
<Icon name="chevDown" size={14} style={{transform: isOpen ? 'rotate(180deg)' : 'none', transition:'transform .2s', color:'var(--ink-faint)'}}/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="sched-slot__detail">
|
||||||
|
{slot.items.map(i => {
|
||||||
|
const m = MED_S[i.medId];
|
||||||
|
return (
|
||||||
|
<button key={i.id} className={'sched-slot__item' + (i.status === 'taken' ? ' is-taken' : '')} onClick={() => toggle(i.id)}>
|
||||||
|
<span className={'sched-check' + (i.status === 'taken' ? ' is-taken' : '')}>{i.status === 'taken' && <Icon name="check" size={10}/>}</span>
|
||||||
|
<span style={{background:'var(--viz-'+m.color+')', width:3, height:20, borderRadius:2}}/>
|
||||||
|
<span style={{flex:1, display:'flex', flexDirection:'column', alignItems:'flex-start'}}>
|
||||||
|
<span style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</span>
|
||||||
|
<span style={{fontSize:11, color:'var(--ink-muted)'}}>{i.qty} {m.form.toLowerCase()} · {m.strength}</span>
|
||||||
|
</span>
|
||||||
|
{i.status === 'taken' && <span className="mono" style={{fontSize:11, color:'var(--ink-muted)'}}>{i.takenAt}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{padding:'4px 18px 16px', display:'flex', gap: 8, alignItems:'center', color:'var(--ink-faint)', fontSize:11}}>
|
||||||
|
<Icon name="info" size={12}/> Tap a slot to see individual medicines. Off-schedule doses logged separately.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-card">
|
||||||
|
<div className="mt-card__head">
|
||||||
|
<div>
|
||||||
|
<div className="mt-card__title">Active regimens</div>
|
||||||
|
<div className="mt-card__sub"><span className="num">{REGS_S.length}</span> configured</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding:'4px 18px 16px'}}>
|
||||||
|
{REGS_S.map(r => (
|
||||||
|
<div key={r.id} className="sched-regimen">
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:8}}>
|
||||||
|
<div>
|
||||||
|
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>Aeri's {r.name}</div>
|
||||||
|
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{r.time} · {r.freq}</div>
|
||||||
|
</div>
|
||||||
|
<span className="mt-pill mt-pill--brand">Active</span>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', flexWrap:'wrap', gap: 4}}>
|
||||||
|
{r.items.map(it => {
|
||||||
|
const m = MED_S[it.medId];
|
||||||
|
return (
|
||||||
|
<span key={it.medId} className="mt-pill mt-pill--ghost" style={{fontSize:10}}>
|
||||||
|
<span style={{background:'var(--viz-'+m.color+')', width:6, height:6, borderRadius:3, display:'inline-block', marginRight:2}}/>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.SchedulePage = SchedulePage;
|
||||||
110
docs/design/src/Shell.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// App shell: sidebar + topbar + routing
|
||||||
|
const { useState, useEffect, useMemo, useRef, createContext, useContext } = React;
|
||||||
|
|
||||||
|
const AppCtx = createContext(null);
|
||||||
|
const useApp = () => useContext(AppCtx);
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
|
||||||
|
{ id: 'cabinet', label: 'Cabinet', icon: 'cabinet', section: 'Medicines', badge: 2 },
|
||||||
|
{ id: 'schedule', label: 'Schedule & Log', icon: 'clock', section: 'Medicines' },
|
||||||
|
{ id: 'regimens', label: 'Regimens', icon: 'list', section: 'Medicines' },
|
||||||
|
{ id: 'organizer', label: 'Pill Organizer', icon: 'calendar', section: 'Medicines' },
|
||||||
|
{ id: 'library', label: 'Library', icon: 'pill', section: 'Medicines' },
|
||||||
|
{ id: 'refills', label: 'Shopping list', icon: 'refresh', section: 'Medicines', badge: 1 },
|
||||||
|
{ id: 'purchases', label: 'Purchases', icon: 'truck', section: 'Medicines' },
|
||||||
|
{ id: 'prices', label: 'Prices', icon: 'tag', section: 'Medicines' },
|
||||||
|
{ id: 'stores', label: 'Stores', icon: 'store', section: 'Medicines' },
|
||||||
|
{ id: 'activity', label: 'Activity & Spend', icon: 'trend', section: 'Medicines' },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Sidebar = ({ route, onNav }) => {
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const g = {};
|
||||||
|
NAV.forEach(n => { (g[n.section] = g[n.section] || []).push(n); });
|
||||||
|
return g;
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<aside className="mt-side">
|
||||||
|
<div className="mt-side__brand">
|
||||||
|
<div className="mt-side__logo">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="5" fill="var(--brand)"/><path d="M7 15l3-6 2 4 2-3 3 5" stroke="var(--brand-ink)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div className="mt-side__wordmark">
|
||||||
|
<div className="mt-side__title">MeshiTrack</div>
|
||||||
|
<div className="mt-side__sub">Red Panda Den</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mt-side__nav">
|
||||||
|
{Object.entries(grouped).map(([section, items]) => (
|
||||||
|
<div key={section} className="mt-side__group">
|
||||||
|
<div className="mt-side__section">{section}</div>
|
||||||
|
{items.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className={'mt-side__item' + (route === item.id ? ' is-active' : '') + (item.soon ? ' is-soon' : '')}
|
||||||
|
onClick={() => !item.soon && onNav(item.id)}
|
||||||
|
disabled={item.soon}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size={16} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.badge ? <span className="mt-side__badge">{item.badge}</span> : null}
|
||||||
|
{item.soon ? <span className="mt-side__soon">Soon</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-side__foot">
|
||||||
|
<div className="mt-side__avatar">A</div>
|
||||||
|
<div className="mt-side__who">
|
||||||
|
<div className="mt-side__whoName">aerimagne</div>
|
||||||
|
<div className="mt-side__whoRole">Household owner</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Topbar = ({ title, subtitle, crumbs, onSearch, actions, theme, onToggleTheme }) => {
|
||||||
|
return (
|
||||||
|
<header className="mt-top">
|
||||||
|
<div className="mt-top__left">
|
||||||
|
{crumbs && crumbs.length > 1 && (
|
||||||
|
<div className="mt-top__crumbs">
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 && <Icon name="chev" size={12} style={{ opacity: 0.4 }} />}
|
||||||
|
<span className={i === crumbs.length - 1 ? 'is-current' : ''}>{c}</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-top__titles">
|
||||||
|
<h1 className="mt-top__title">{title}</h1>
|
||||||
|
{subtitle && <div className="mt-top__sub">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-top__right">
|
||||||
|
<div className="mt-top__search">
|
||||||
|
<Icon name="search" size={14} />
|
||||||
|
<input placeholder="Search medicines, regimens, stores…" />
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</div>
|
||||||
|
<button className="mt-iconbtn" title="Notifications">
|
||||||
|
<Icon name="bell" size={16} />
|
||||||
|
<span className="mt-iconbtn__dot"></span>
|
||||||
|
</button>
|
||||||
|
<button className="mt-iconbtn" onClick={onToggleTheme} title="Toggle theme">
|
||||||
|
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={16} />
|
||||||
|
</button>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Shell = { Sidebar, Topbar, AppCtx, useApp, NAV };
|
||||||
129
docs/design/src/data.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// Mock data for MeshiTrack. Grounded in the real household ("Red Panda Den"),
|
||||||
|
// extended with plausible timestamps, quantities, purchases and price history.
|
||||||
|
|
||||||
|
const MEDICINES = [
|
||||||
|
{ id: 'semaglutide', name: 'Semaglutide', strength: '14 mg', form: 'Tablet', category: 'Prescription', color: 1, notes: 'Weekly GLP-1. Store at room temp.' },
|
||||||
|
{ id: 'estradiol', name: 'Estradiol Valerate', strength: '10 mg', form: 'Injection', category: 'Prescription', color: 4, notes: 'Intramuscular, weekly.' },
|
||||||
|
{ id: 'spironolactone', name: 'Spironolactone', strength: '100 mg', form: 'Tablet', category: 'Prescription', color: 3 },
|
||||||
|
{ id: 'tadalafil', name: 'Tadalafil', strength: '5 mg', form: 'Tablet', category: 'Prescription', color: 3 },
|
||||||
|
{ id: 'dutasteride', name: 'Dutasteride', strength: '0.5 mg', form: 'Tablet', category: 'Prescription', color: 3 },
|
||||||
|
{ id: 'progesterone', name: 'Progesterone', strength: '200 mg', form: 'Capsule', category: 'Prescription', color: 4 },
|
||||||
|
{ id: 'magnesium', name: 'Magnesium', strength: '400 mg', form: 'Capsule', category: 'Supplement', color: 5 },
|
||||||
|
{ id: 'vitamin-d', name: 'Vitamin D', strength: '2000 IU', form: 'Capsule', category: 'Supplement', color: 2 },
|
||||||
|
{ id: 'vitamin-a', name: 'Vitamin A', strength: '5000 IU', form: 'Capsule', category: 'Supplement', color: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MED = Object.fromEntries(MEDICINES.map(m => [m.id, m]));
|
||||||
|
|
||||||
|
// Cabinet inventory — summed across item lots
|
||||||
|
const CABINET = [
|
||||||
|
{ medId: 'dutasteride', qty: 80, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-08-12', lowAt: 14 },
|
||||||
|
{ medId: 'estradiol', qty: 2, unit: 'vials', lots: 1, daysPerUnit: 14, perDose: 0.5, expiry: '2026-11-30', lowAt: 1 },
|
||||||
|
{ medId: 'magnesium', qty: 115, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-02-01', lowAt: 20 },
|
||||||
|
{ medId: 'progesterone', qty: 35, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-04-30', lowAt: 14 },
|
||||||
|
{ medId: 'semaglutide', qty: 26, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-09-15', lowAt: 7 },
|
||||||
|
{ medId: 'spironolactone', qty: 62, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 2, expiry: '2027-01-20', lowAt: 14 },
|
||||||
|
{ medId: 'tadalafil', qty: 7, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-12-01', lowAt: 7 },
|
||||||
|
{ medId: 'vitamin-a', qty: 211, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-05-05', lowAt: 30 },
|
||||||
|
{ medId: 'vitamin-d', qty: 96, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-03-10', lowAt: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Daily schedule regimens
|
||||||
|
const REGIMENS = [
|
||||||
|
{
|
||||||
|
id: 'morning', name: 'Morning Pills', time: '08:00', freq: 'daily', active: true,
|
||||||
|
items: [
|
||||||
|
{ medId: 'semaglutide', qty: 1, unit: 'tablet' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'evening', name: 'Evening Pills', time: '21:00', freq: 'daily', active: true,
|
||||||
|
items: [
|
||||||
|
{ medId: 'spironolactone', qty: 1, unit: 'tablet' },
|
||||||
|
{ medId: 'tadalafil', qty: 1, unit: 'tablet' },
|
||||||
|
{ medId: 'dutasteride', qty: 1, unit: 'tablet' },
|
||||||
|
{ medId: 'progesterone', qty: 1, unit: 'capsule' },
|
||||||
|
{ medId: 'vitamin-a', qty: 1, unit: 'capsule' },
|
||||||
|
{ medId: 'magnesium', qty: 1, unit: 'capsule' },
|
||||||
|
{ medId: 'vitamin-d', qty: 1, unit: 'capsule' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'injections', name: 'Injections', time: 'Sun 10:00', freq: 'weekly', active: true,
|
||||||
|
items: [
|
||||||
|
{ medId: 'estradiol', qty: 0.5, unit: 'vial' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Today's intake — some done, some upcoming
|
||||||
|
const TODAY_INTAKE = [
|
||||||
|
{ id: 't1', regimen: 'morning', medId: 'semaglutide', qty: 1, scheduled: '08:00', status: 'taken', takenAt: '08:12' },
|
||||||
|
{ id: 't2', regimen: 'evening', medId: 'spironolactone', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||||
|
{ id: 't3', regimen: 'evening', medId: 'tadalafil', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||||
|
{ id: 't4', regimen: 'evening', medId: 'dutasteride', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||||
|
{ id: 't5', regimen: 'evening', medId: 'progesterone', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||||
|
{ id: 't6', regimen: 'evening', medId: 'vitamin-a', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||||
|
{ id: 't7', regimen: 'evening', medId: 'magnesium', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||||
|
{ id: 't8', regimen: 'evening', medId: 'vitamin-d', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
const STORES = [
|
||||||
|
{ id: 'osakado', name: 'Osakado', url: 'osakado.clinic', tags: ['pharmacy', 'online'], currency: 'JPY', active: true },
|
||||||
|
{ id: 'inhouse', name: 'In-House Pharmacy', url: 'local', tags: ['pharmacy'], currency: 'JPY', active: true },
|
||||||
|
{ id: 'amazon-jp', name: 'Amazon JP', url: 'amazon.co.jp', tags: ['online', 'bulk'], currency: 'JPY', active: true },
|
||||||
|
{ id: 'matsumoto', name: 'Matsumoto Kiyoshi', url: 'matsukiyo.co.jp', tags: ['pharmacy', 'grocery'], currency: 'JPY', active: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Prices — multiple stores per med where it makes sense
|
||||||
|
const PRICES = [
|
||||||
|
{ medId: 'semaglutide', store: 'osakado', price: 24862, pkg: 30, unit: 'tablet', date: '2026-03-29' },
|
||||||
|
{ medId: 'semaglutide', store: 'inhouse', price: 31200, pkg: 30, unit: 'tablet', date: '2026-02-10' },
|
||||||
|
{ medId: 'tadalafil', store: 'osakado', price: 4537, pkg: 30, unit: 'tablet', date: '2026-04-02' },
|
||||||
|
{ medId: 'tadalafil', store: 'amazon-jp', price: 5180, pkg: 30, unit: 'tablet', date: '2026-03-15' },
|
||||||
|
{ medId: 'spironolactone', store: 'osakado', price: 2190, pkg: 100, unit: 'tablet', date: '2026-03-01' },
|
||||||
|
{ medId: 'dutasteride', store: 'osakado', price: 3860, pkg: 30, unit: 'tablet', date: '2026-03-20' },
|
||||||
|
{ medId: 'estradiol', store: 'osakado', price: 9800, pkg: 5, unit: 'vial', date: '2026-02-28' },
|
||||||
|
{ medId: 'progesterone', store: 'osakado', price: 2980, pkg: 30, unit: 'capsule', date: '2026-03-05' },
|
||||||
|
{ medId: 'magnesium', store: 'amazon-jp', price: 1480, pkg: 120, unit: 'capsule', date: '2026-01-22' },
|
||||||
|
{ medId: 'magnesium', store: 'matsumoto', price: 1680, pkg: 120, unit: 'capsule', date: '2026-02-14' },
|
||||||
|
{ medId: 'vitamin-d', store: 'amazon-jp', price: 890, pkg: 100, unit: 'capsule', date: '2026-02-10' },
|
||||||
|
{ medId: 'vitamin-a', store: 'amazon-jp', price: 1240, pkg: 250, unit: 'capsule', date: '2026-01-08' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Purchases
|
||||||
|
const PURCHASES = [
|
||||||
|
{ id: 'p1', store: 'osakado', date: '2026-04-02', status: 'pending', items: [{ medId: 'tadalafil', qty: 28, unit: 'tablet', price: 4537 }] },
|
||||||
|
{ id: 'p2', store: 'osakado', date: '2026-04-02', status: 'received', items: [{ medId: 'semaglutide', qty: 30, unit: 'tablet', price: 24862 }] },
|
||||||
|
{ id: 'p3', store: 'amazon-jp', date: '2026-03-22', status: 'received', items: [{ medId: 'magnesium', qty: 120, unit: 'capsule', price: 1480 }, { medId: 'vitamin-d', qty: 100, unit: 'capsule', price: 890 }] },
|
||||||
|
{ id: 'p4', store: 'osakado', date: '2026-03-20', status: 'received', items: [{ medId: 'dutasteride', qty: 30, unit: 'tablet', price: 3860 }, { medId: 'spironolactone', qty: 100, unit: 'tablet', price: 2190 }] },
|
||||||
|
{ id: 'p5', store: 'osakado', date: '2026-02-28', status: 'received', items: [{ medId: 'estradiol', qty: 5, unit: 'vial', price: 9800 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cabinet activity events (consumed / adjusted / added)
|
||||||
|
const ACTIVITY = [
|
||||||
|
{ id: 'a1', type: 'consumed', medId: 'vitamin-d', delta: -7, before: 103, after: 96, at: '2026-04-03 08:52' },
|
||||||
|
{ id: 'a2', type: 'consumed', medId: 'magnesium', delta: -7, before: 122, after: 115, at: '2026-04-03 08:52' },
|
||||||
|
{ id: 'a3', type: 'adjusted', medId: 'estradiol', delta: -1, before: 3, after: 2, at: '2026-04-03 08:57' },
|
||||||
|
{ id: 'a4', type: 'consumed', medId: 'vitamin-a', delta: -7, before: 218, after: 211, at: '2026-04-03 08:52' },
|
||||||
|
{ id: 'a5', type: 'consumed', medId: 'spironolactone', delta: -14, before: 76, after: 62, at: '2026-04-03 08:52' },
|
||||||
|
{ id: 'a6', type: 'added', medId: 'tadalafil', delta: +30, before: -23, after: 7, at: '2026-04-02 14:10', ref: 'Purchase p2' },
|
||||||
|
{ id: 'a7', type: 'consumed', medId: 'semaglutide', delta: -1, before: 27, after: 26, at: '2026-04-01 08:15' },
|
||||||
|
{ id: 'a8', type: 'consumed', medId: 'progesterone', delta: -1, before: 36, after: 35, at: '2026-04-03 21:04' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Monthly spending (last 6 months), JPY
|
||||||
|
const SPENDING = [
|
||||||
|
{ month: '2025-11', amount: 28400 },
|
||||||
|
{ month: '2025-12', amount: 41200 },
|
||||||
|
{ month: '2026-01', amount: 19600 },
|
||||||
|
{ month: '2026-02', amount: 38480 },
|
||||||
|
{ month: '2026-03', amount: 35780 },
|
||||||
|
{ month: '2026-04', amount: 9937 },
|
||||||
|
];
|
||||||
|
|
||||||
|
window.MESHI = {
|
||||||
|
MEDICINES, MED, CABINET, REGIMENS, TODAY_INTAKE,
|
||||||
|
STORES, PRICES, PURCHASES, ACTIVITY, SPENDING,
|
||||||
|
};
|
||||||
473
docs/design/styles/cabinet.css
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
/* Cabinet — hero screen */
|
||||||
|
|
||||||
|
.cab-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cab-summary__item {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.cab-summary__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cab-summary__value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.cab-summary__item--danger .cab-summary__value { color: var(--danger); }
|
||||||
|
.cab-summary__item--warn .cab-summary__value { color: var(--warn); }
|
||||||
|
.cab-summary__item--brand .cab-summary__value { color: var(--brand); }
|
||||||
|
.cab-summary__hint { font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.cab-filters {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cab-filters__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cab-filters__tab {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.cab-filters__tab:hover { color: var(--ink); }
|
||||||
|
.cab-filters__tab.is-active {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg-elev);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cab-filters__count {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.cab-filters__tab.is-active .cab-filters__count {
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
color: var(--bg-elev);
|
||||||
|
}
|
||||||
|
.cab-filters__tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.cab-filters__search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.cab-filters__search input {
|
||||||
|
border: 0; outline: 0; background: transparent; width: 120px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.cab-filters__search input::placeholder { color: var(--ink-faint); }
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.cab-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.cab-card {
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cab-card:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.cab-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--viz-1);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.cab-card:hover::before { opacity: 0.6; }
|
||||||
|
.cab-card--lvl-low::before, .cab-card--lvl-critical::before {
|
||||||
|
background: var(--danger); opacity: 1;
|
||||||
|
}
|
||||||
|
.cab-card--lvl-expiring::before { background: var(--warn); opacity: 1; }
|
||||||
|
|
||||||
|
.cab-card__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.cab-card__swatch {
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: color-mix(in oklab, var(--c) 16%, var(--bg-elev));
|
||||||
|
color: var(--c);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cab-card__swatch--sm { width: 22px; height: 22px; border-radius: 6px; }
|
||||||
|
.cab-card__meta { flex: 1; min-width: 0; }
|
||||||
|
.cab-card__name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.cab-card__strength {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.cab-card__flag { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.cab-card__qty {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.cab-card__qtyNum {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.cab-card__qtyUnit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cab-card__foot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.cab-card__footLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cab-card__footValue {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.cab-card__footValue.is-warn { color: var(--warn); }
|
||||||
|
|
||||||
|
/* Days bar */
|
||||||
|
.daysbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.daysbar__track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.daysbar__fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ok);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.daysbar--low .daysbar__fill { background: var(--warn); }
|
||||||
|
.daysbar--critical .daysbar__fill { background: var(--danger); }
|
||||||
|
.daysbar__mark {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px; bottom: -2px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--ink-faint);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.daysbar__label {
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 50px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.daysbar__num {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.daysbar__unit { color: var(--ink-muted); font-size: 10px; }
|
||||||
|
.daysbar--low .daysbar__num { color: var(--warn); }
|
||||||
|
.daysbar--critical .daysbar__num { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Table view */
|
||||||
|
.cab-table {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cab-table__head, .cab-table__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 0.7fr 1.4fr 1fr 0.8fr 0.8fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.cab-table__head {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cab-table__row {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.cab-table__row:last-child { border-bottom: 0; }
|
||||||
|
.cab-table__row:hover { background: var(--bg-inset); }
|
||||||
|
.cab-table__cellMed {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shelf view */
|
||||||
|
.cab-shelf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
padding: 20px 0 40px;
|
||||||
|
}
|
||||||
|
.cab-shelf__level {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cab-shelf__tag {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.cab-shelf__bottles {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
min-height: 160px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.cab-shelf__line {
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(180deg, var(--border-strong), var(--border));
|
||||||
|
border-radius: 1px;
|
||||||
|
margin-top: 8px;
|
||||||
|
box-shadow: 0 1px 0 var(--bg-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cab-bottle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.cab-bottle:hover { transform: translateY(-3px); }
|
||||||
|
.cab-bottle__body {
|
||||||
|
width: 46px;
|
||||||
|
height: 110px;
|
||||||
|
background: color-mix(in oklab, var(--c) 18%, var(--bg-elev));
|
||||||
|
border: 1.5px solid color-mix(in oklab, var(--c) 50%, var(--bg-elev));
|
||||||
|
border-radius: 8px 8px 6px 6px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cab-bottle__fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
background: var(--c);
|
||||||
|
transition: height 0.4s;
|
||||||
|
}
|
||||||
|
.cab-bottle__cap {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 28px; height: 8px;
|
||||||
|
background: var(--ink-muted);
|
||||||
|
border-radius: 3px 3px 2px 2px;
|
||||||
|
}
|
||||||
|
.cab-bottle--lvl-low .cab-bottle__body, .cab-bottle--lvl-critical .cab-bottle__body {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.cab-bottle__label { text-align: center; }
|
||||||
|
.cab-bottle__name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink);
|
||||||
|
max-width: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cab-bottle__qty { font-size: 10px; color: var(--ink-muted); }
|
||||||
|
|
||||||
|
/* Detail drawer */
|
||||||
|
.cab-detail {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 18, 10, 0.3);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
animation: fade-in 0.2s;
|
||||||
|
}
|
||||||
|
@keyframes fade-in { from { opacity: 0; } }
|
||||||
|
.cab-detail__panel {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slide-in 0.2s;
|
||||||
|
padding: 24px 28px 32px;
|
||||||
|
}
|
||||||
|
@keyframes slide-in { from { transform: translateX(40px); opacity: 0; } }
|
||||||
|
.cab-detail__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.cab-detail__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
}
|
||||||
|
.cab-detail__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.cab-detail__hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.cab-detail__bigLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.cab-detail__big {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.cab-detail__big span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.cab-detail__big.is-warn { color: var(--warn); }
|
||||||
|
.cab-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cab-detail__section { margin-top: 20px; }
|
||||||
|
.cab-detail__sectionTitle {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.cab-detail__prices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.cab-detail__price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
.cab-detail__price.is-best {
|
||||||
|
border-color: var(--ok);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
}
|
||||||
|
.cab-detail__priceStore { flex: 1; font-size: 13px; }
|
||||||
|
.cab-detail__priceBody { text-align: right; }
|
||||||
152
docs/design/styles/dashboard.css
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
/* Dashboard */
|
||||||
|
.dash-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 4px 4px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.dash-hero__day {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.dash-hero__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.dash-hero__sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.dash-panel { overflow: hidden; }
|
||||||
|
|
||||||
|
/* Schedule */
|
||||||
|
.dash-schedule { padding: 4px 18px 18px; }
|
||||||
|
.dash-sched__slot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.dash-sched__slot:last-child { border-bottom: 0; }
|
||||||
|
.dash-sched__slot.is-done { opacity: 0.55; }
|
||||||
|
.dash-sched__time { padding-top: 4px; }
|
||||||
|
.dash-sched__items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dash-sched__pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px 6px 8px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.dash-sched__pill:hover { border-color: var(--border-strong); }
|
||||||
|
.dash-sched__pill.is-taken { background: var(--ok-soft); border-color: transparent; }
|
||||||
|
.dash-sched__pill.is-taken .dash-sched__name { text-decoration: line-through; color: var(--ink-muted); }
|
||||||
|
.dash-sched__check {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--border-strong);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.dash-sched__check.is-taken { background: var(--ok); border-color: var(--ok); color: white; }
|
||||||
|
.dash-sched__name { font-weight: 500; color: var(--ink-strong); }
|
||||||
|
.dash-sched__dose { color: var(--ink-muted); font-size: 11px; }
|
||||||
|
|
||||||
|
/* Low */
|
||||||
|
.dash-low { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dash-low__item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.dash-low__item:last-child { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* Spend */
|
||||||
|
.dash-spend { padding: 12px 18px 18px; }
|
||||||
|
|
||||||
|
/* Supply */
|
||||||
|
.dash-supply { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.dash-supply__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr 48px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.dash-supply__name { font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dash-supply__bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dash-supply__fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
|
||||||
|
.dash-supply__fill--ok { background: var(--brand); }
|
||||||
|
.dash-supply__fill--low { background: var(--warn); }
|
||||||
|
.dash-supply__fill--critical { background: var(--danger); }
|
||||||
|
.dash-supply__days { font-weight: 600; text-align: right; }
|
||||||
|
.dash-supply__days span { color: var(--ink-faint); font-weight: 400; margin-left: 1px; font-size: 10px; }
|
||||||
|
|
||||||
|
/* Orders */
|
||||||
|
.dash-orders { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dash-order {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.dash-order:last-child { border-bottom: 0; }
|
||||||
|
.dash-order__icon {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand-soft-ink);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.dash-order.is-past .dash-order__icon { background: var(--bg-inset); color: var(--ink-muted); }
|
||||||
|
|
||||||
|
/* Activity */
|
||||||
|
.dash-activity { padding: 4px 18px 16px; }
|
||||||
|
.dash-act {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.dash-act:last-child { border-bottom: 0; }
|
||||||
199
docs/design/styles/other.css
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
/* Other pages */
|
||||||
|
.sub-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; }
|
||||||
|
.form-label { display:block; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-muted); font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.form-select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.lib-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.lib-row:last-child { border-bottom: 0; }
|
||||||
|
.lib-row:hover { background: var(--bg-inset); }
|
||||||
|
|
||||||
|
.org-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; }
|
||||||
|
.org-day { padding: 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--r-md); }
|
||||||
|
.org-day__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
||||||
|
.org-day__slots { display: flex; flex-wrap: wrap; gap: 4px; min-height: 70px; }
|
||||||
|
.org-pill {
|
||||||
|
width: 14px; height: 20px; background: var(--c); border-radius: 7px;
|
||||||
|
box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1), inset 0 3px 0 rgba(255,255,255,0.2);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.org-day__count { font-size: 10px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .06em; font-weight:600; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
|
||||||
|
|
||||||
|
/* Organizer — fill + inventory impact */
|
||||||
|
.org-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.3fr;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.org-hero__controls {
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.org-hero__divider { height: 1px; background: var(--border); }
|
||||||
|
.org-hero__select {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.org-hero__stepper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.org-hero__stepper button {
|
||||||
|
width: 32px; height: 36px;
|
||||||
|
border: none; background: transparent;
|
||||||
|
font-size: 16px; color: var(--ink-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.org-hero__stepper button:hover { background: var(--bg-inset); }
|
||||||
|
.org-hero__stepper span {
|
||||||
|
padding: 0 14px; font-size: 20px; font-weight: 600; min-width: 48px; text-align: center;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
height: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
}
|
||||||
|
.org-hero__presets { display: flex; gap: 4px; margin-top: 8px; }
|
||||||
|
.org-hero__preset {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.org-hero__preset.is-active { background: var(--ink); color: var(--bg-elev); border-color: var(--ink); }
|
||||||
|
|
||||||
|
.org-hero__summary {
|
||||||
|
padding: 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.org-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.org-alert strong { font-weight: 600; color: var(--ink-strong); }
|
||||||
|
.org-alert > div:first-of-type { flex: 1; }
|
||||||
|
.org-alert--ok { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.org-alert--ok strong { color: var(--ok); }
|
||||||
|
.org-alert--warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.org-alert--warn strong { color: var(--warn); }
|
||||||
|
.org-alert--danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.org-alert--danger strong { color: var(--danger); }
|
||||||
|
|
||||||
|
.org-impact { padding: 4px 18px 16px; }
|
||||||
|
.org-impact__head, .org-impact__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.4fr 0.8fr 0.8fr 1.6fr 0.8fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.org-impact__head {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
|
||||||
|
color: var(--ink-faint); font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.org-impact__row { border-bottom: 1px dashed var(--border); }
|
||||||
|
.org-impact__row:last-child { border-bottom: 0; }
|
||||||
|
.org-impact__row--short { background: color-mix(in oklab, var(--danger) 5%, transparent); }
|
||||||
|
.org-impact__bar {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
.org-impact__barFill {
|
||||||
|
position: absolute; top: 0; left: 0; bottom: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prices-table { padding: 0 18px 16px; }
|
||||||
|
.prices-table__head, .prices-table__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.prices-table__head {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
|
||||||
|
color: var(--ink-faint); font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.prices-table__row { border-bottom: 1px dashed var(--border); }
|
||||||
|
.prices-table__row:last-child { border-bottom: 0; }
|
||||||
|
.prices-table__row.is-best { background: var(--ok-soft); border-radius: 8px; margin: 2px 0; border-bottom: 0; }
|
||||||
|
|
||||||
|
.refill-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 0; border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.refill-row:last-child { border-bottom: 0; }
|
||||||
|
|
||||||
|
.act-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.act-rail { width: 12px; position: relative; align-self: stretch; }
|
||||||
|
.act-dot { position: absolute; top: 14px; left: 2px; width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.act-dot--consumed { background: var(--info); }
|
||||||
|
.act-dot--added { background: var(--ok); }
|
||||||
|
.act-dot--adjusted { background: var(--warn); }
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: grid; grid-template-columns: 120px 1fr auto;
|
||||||
|
gap: 16px; align-items: center;
|
||||||
|
padding: 10px 0; border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.settings-row:last-child { border-bottom: 0; }
|
||||||
|
.settings-row > span:first-child { color: var(--ink-muted); font-size: 12px; }
|
||||||
181
docs/design/styles/schedule.css
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/* Schedule page — slot-based */
|
||||||
|
|
||||||
|
/* Week bar: compact adherence heatmap */
|
||||||
|
.sched-weekbar {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.sched-weekbar__days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sched-weekday {
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 2px 8px;
|
||||||
|
align-items: center;
|
||||||
|
transition: background .12s, border-color .12s;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sched-weekday:hover { background: var(--bg-inset); }
|
||||||
|
.sched-weekday.is-active { background: var(--bg-inset); border-color: var(--border-strong); }
|
||||||
|
.sched-weekday__label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-weight: 600;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.sched-weekday__num {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
.sched-weekday__bar {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
width: 5px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sched-weekday__fill {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: height .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Today: slot rows */
|
||||||
|
.sched-slots {
|
||||||
|
padding: 4px 18px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sched-slot {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color .12s;
|
||||||
|
}
|
||||||
|
.sched-slot:hover { border-color: var(--border-strong); }
|
||||||
|
.sched-slot.is-open { border-color: var(--border-strong); }
|
||||||
|
.sched-slot--done { background: color-mix(in oklab, var(--ok) 4%, var(--bg-elev)); }
|
||||||
|
.sched-slot--overdue { border-color: color-mix(in oklab, var(--warn) 40%, var(--border)); }
|
||||||
|
|
||||||
|
.sched-slot__row {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sched-slot__time { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.sched-slot__hour {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.sched-slot__phase {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.sched-slot__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sched-slot__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sched-slot__dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.sched-slot__dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid;
|
||||||
|
background: transparent;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.sched-slot__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sched-slot__detail {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 10px 16px 14px 116px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
.sched-slot__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
transition: all 0.12s;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sched-slot__item:hover { border-color: var(--border-strong); }
|
||||||
|
.sched-slot__item.is-taken { opacity: 0.65; }
|
||||||
|
|
||||||
|
.sched-check {
|
||||||
|
width: 18px; height: 18px; border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--border-strong);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sched-check.is-taken { background: var(--ok); border-color: var(--ok); color: white; }
|
||||||
|
|
||||||
|
.sched-regimen {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
370
docs/design/styles/shell.css
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
/* Shell: sidebar + topbar + layout */
|
||||||
|
|
||||||
|
.mt-app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 248px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.mt-side {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.mt-side__brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-side__logo {
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
}
|
||||||
|
.mt-side__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
}
|
||||||
|
.mt-side__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.mt-side__nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 10px 16px;
|
||||||
|
}
|
||||||
|
.mt-side__group { margin-bottom: 14px; }
|
||||||
|
.mt-side__section {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 10px 10px 6px;
|
||||||
|
}
|
||||||
|
.mt-side__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mt-side__item > span:not(.mt-side__badge) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mt-side__item { min-width: 0; }
|
||||||
|
.mt-side__item:hover { background: var(--bg-inset); color: var(--ink); }
|
||||||
|
.mt-side__item.is-active {
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand-soft-ink);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mt-side__item svg { opacity: 0.8; }
|
||||||
|
.mt-side__item.is-active svg { opacity: 1; }
|
||||||
|
.mt-side__soon {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.mt-side__item.is-soon { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
.mt-side__item.is-soon:hover { background: transparent; color: var(--ink-muted); }
|
||||||
|
.mt-side__badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.mt-side__foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-side__avatar {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--viz-4), var(--viz-3));
|
||||||
|
color: white;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mt-side__whoName { font-size: 13px; font-weight: 500; color: var(--ink-strong); }
|
||||||
|
.mt-side__whoRole { font-size: 11px; color: var(--ink-muted); }
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.mt-main { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.mt-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 22px 32px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.mt-top__crumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mt-top__crumbs .is-current { color: var(--ink); }
|
||||||
|
.mt-top__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.mt-top__sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.mt-top__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mt-top__search {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-width: 280px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.mt-top__search:focus-within { border-color: var(--brand); }
|
||||||
|
.mt-top__search input {
|
||||||
|
border: 0; outline: 0; background: transparent;
|
||||||
|
font-size: 13px; flex: 1; color: var(--ink);
|
||||||
|
}
|
||||||
|
.mt-top__search input::placeholder { color: var(--ink-faint); }
|
||||||
|
.mt-top__search kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-iconbtn {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.mt-iconbtn:hover { color: var(--ink); border-color: var(--border-strong); }
|
||||||
|
.mt-iconbtn__dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px; right: 6px;
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--danger);
|
||||||
|
border: 1.5px solid var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page container */
|
||||||
|
.mt-page {
|
||||||
|
padding: 28px 32px 56px;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.mt-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.12s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mt-btn--primary {
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--brand-ink);
|
||||||
|
}
|
||||||
|
.mt-btn--primary:hover { background: var(--brand-deep); }
|
||||||
|
.mt-btn--ghost {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.mt-btn--ghost:hover { border-color: var(--border-strong); background: var(--bg-inset); }
|
||||||
|
.mt-btn--subtle {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.mt-btn--subtle:hover { color: var(--ink); background: var(--bg-inset); }
|
||||||
|
.mt-btn--danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger-soft);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
.mt-btn--danger:hover { background: var(--danger-soft); }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.mt-card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
.mt-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-card__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.mt-card__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.mt-card__body { padding: 16px 18px; }
|
||||||
|
|
||||||
|
/* Pill / badge */
|
||||||
|
.mt-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.6;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.mt-pill--brand { background: var(--brand-soft); color: var(--brand-soft-ink); }
|
||||||
|
.mt-pill--ok { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.mt-pill--warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.mt-pill--danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.mt-pill--info { background: var(--info-soft); color: var(--info); }
|
||||||
|
.mt-pill--ghost { background: var(--bg-inset); color: var(--ink-muted); }
|
||||||
|
.mt-pill--outline { border-color: var(--border); color: var(--ink-muted); }
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.mt-row { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.mt-col { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.mt-grid { display: grid; gap: 12px; }
|
||||||
|
.mt-divider { height: 1px; background: var(--border); margin: 12px 0; }
|
||||||
|
.mt-stack > * + * { margin-top: 12px; }
|
||||||
|
.mt-mono { font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
|
||||||
|
/* Tweaks panel */
|
||||||
|
.mt-tweaks {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 14px;
|
||||||
|
min-width: 240px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mt-tweaks.is-open { display: block; }
|
||||||
|
.mt-tweaks__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.mt-tweaks__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.mt-tweaks__row:first-of-type { border-top: 0; }
|
||||||
|
.mt-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.mt-seg button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.mt-seg button.is-active {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
123
docs/design/styles/tokens.css
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/* MeshiTrack design tokens */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Type */
|
||||||
|
--font-sans: 'Inter Tight', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||||
|
--font-display: 'Fraunces', 'Inter Tight', serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||||
|
|
||||||
|
/* Neutrals (warm off-white → near-black) */
|
||||||
|
--bg: #f6f4ef;
|
||||||
|
--bg-elev: #ffffff;
|
||||||
|
--bg-inset: #efebe3;
|
||||||
|
--border: #e4dfd4;
|
||||||
|
--border-strong: #cfc8b8;
|
||||||
|
--ink-faint: #a39b89;
|
||||||
|
--ink-muted: #6e6759;
|
||||||
|
--ink: #1d1b17;
|
||||||
|
--ink-strong: #0b0a08;
|
||||||
|
|
||||||
|
/* Brand — refined sage-leaning green (was straight green) */
|
||||||
|
--brand: #2f6b4a;
|
||||||
|
--brand-deep: #1e4a32;
|
||||||
|
--brand-ink: #ffffff;
|
||||||
|
--brand-soft: #e6efe8;
|
||||||
|
--brand-soft-ink: #1e4a32;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--danger: #b8361a;
|
||||||
|
--danger-soft: #fbe8e0;
|
||||||
|
--warn: #a66a0a;
|
||||||
|
--warn-soft: #f9ecd2;
|
||||||
|
--ok: #3e7a4c;
|
||||||
|
--ok-soft: #e3ede0;
|
||||||
|
--info: #3a5a85;
|
||||||
|
--info-soft: #e1e8f1;
|
||||||
|
|
||||||
|
/* Data viz (cabinet categories / charts) */
|
||||||
|
--viz-1: #2f6b4a;
|
||||||
|
--viz-2: #a66a0a;
|
||||||
|
--viz-3: #3a5a85;
|
||||||
|
--viz-4: #8a4c6e;
|
||||||
|
--viz-5: #6b6237;
|
||||||
|
--viz-6: #7a3a28;
|
||||||
|
|
||||||
|
/* Shape */
|
||||||
|
--r-xs: 6px;
|
||||||
|
--r-sm: 10px;
|
||||||
|
--r-md: 14px;
|
||||||
|
--r-lg: 20px;
|
||||||
|
--r-xl: 28px;
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(20, 18, 10, 0.04), 0 0 0 1px rgba(20, 18, 10, 0.04);
|
||||||
|
--shadow-md: 0 4px 16px -6px rgba(20, 18, 10, 0.08), 0 0 0 1px rgba(20, 18, 10, 0.05);
|
||||||
|
--shadow-lg: 0 20px 40px -20px rgba(20, 18, 10, 0.22), 0 0 0 1px rgba(20, 18, 10, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--bg: #141310;
|
||||||
|
--bg-elev: #1c1b17;
|
||||||
|
--bg-inset: #100f0c;
|
||||||
|
--border: #2a2823;
|
||||||
|
--border-strong: #3a372f;
|
||||||
|
--ink-faint: #6a6559;
|
||||||
|
--ink-muted: #9a9386;
|
||||||
|
--ink: #ece8de;
|
||||||
|
--ink-strong: #f8f5ec;
|
||||||
|
|
||||||
|
--brand: #5fa87b;
|
||||||
|
--brand-deep: #8fc9a4;
|
||||||
|
--brand-ink: #0b0a08;
|
||||||
|
--brand-soft: #1e2c23;
|
||||||
|
--brand-soft-ink: #8fc9a4;
|
||||||
|
|
||||||
|
--danger: #e37358;
|
||||||
|
--danger-soft: #2c1a15;
|
||||||
|
--warn: #d6a152;
|
||||||
|
--warn-soft: #2c2217;
|
||||||
|
--ok: #7aba89;
|
||||||
|
--ok-soft: #1a2820;
|
||||||
|
--info: #7ea3cf;
|
||||||
|
--info-soft: #1a2028;
|
||||||
|
|
||||||
|
--viz-1: #5fa87b;
|
||||||
|
--viz-2: #d6a152;
|
||||||
|
--viz-3: #7ea3cf;
|
||||||
|
--viz-4: #c088a5;
|
||||||
|
--viz-5: #b8ac76;
|
||||||
|
--viz-6: #d88a74;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
|
--shadow-md: 0 4px 16px -6px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
|
--shadow-lg: 0 20px 40px -20px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: 'ss01', 'cv11';
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; padding: 0; }
|
||||||
|
input, select, textarea { font: inherit; color: inherit; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
::selection { background: var(--brand); color: var(--brand-ink); }
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 2px solid var(--bg); }
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.serif { font-family: var(--font-display); }
|
||||||
|
.num { font-variant-numeric: tabular-nums; }
|
||||||
|
|
@ -472,7 +472,7 @@ All packages enforce coverage thresholds via `vitest.config.ts`. CI will fail if
|
||||||
| ----------------- | ----- | --------- | -------- | ---------- |
|
| ----------------- | ----- | --------- | -------- | ---------- |
|
||||||
| `packages/api` | 100% | 100% | 90% | 100% |
|
| `packages/api` | 100% | 100% | 90% | 100% |
|
||||||
| `packages/shared` | 100% | 100% | 90% | 100% |
|
| `packages/shared` | 100% | 100% | 90% | 100% |
|
||||||
| `packages/web` | TBD | TBD | TBD | TBD |
|
| `packages/web` | 15% | 14% | 13% | 15% |
|
||||||
|
|
||||||
### Coverage Provider
|
### Coverage Provider
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,30 @@
|
||||||
# Phase 4 — Pharmacies, Prices & Refills
|
# Phase 4 — Pharmacies, Prices, Purchases & Refills
|
||||||
|
|
||||||
**Goal**: Track where you buy medicines, compare prices across pharmacies, and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
|
**Goal**: Track store catalog prices for medicines, record purchases (which may mix medicines and food), and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
|
||||||
|
|
||||||
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate)
|
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deliverables
|
## Domain Model
|
||||||
|
|
||||||
1. `Store` MongoDB schema and CRUD API (shared infrastructure)
|
### Price Records vs Purchases
|
||||||
2. `PriceRecord` schema for medicine price tracking
|
|
||||||
3. Price history and store comparison
|
These are two distinct concepts:
|
||||||
4. Refill alerts based on burn rate
|
|
||||||
5. Refill list generation
|
- **Price Record** — a catalog observation: "Product X at Store Y is listed at $Z for a pack of N units." Nothing moves. Multiple records can exist for the same product+store (different pack sizes, bulk deals, insurance tier, etc.). This is price surveillance.
|
||||||
6. Web UI: stores, price history, refill management
|
- **Purchase** — recording that you actually acquired something. Can contain multiple line items mixing medicines and (Phase 9) food products from the same store.
|
||||||
|
|
||||||
|
### Purchase Status
|
||||||
|
|
||||||
|
Most purchases happen at a physical store and items are immediately in hand. Online purchases may take time to arrive. The status reflects this:
|
||||||
|
|
||||||
|
- **`in_cabinet`** — the default for physical store purchases. Items are available immediately and are added to the cabinet right away.
|
||||||
|
- **`ordered`** — used only for online purchases where the items have not yet arrived. Items in this state are counted toward pending stock in refill alerts (so the user is not repeatedly alerted to reorder something already purchased and on its way). Once items arrive, the purchase is moved to `in_cabinet` and items are added to the cabinet.
|
||||||
|
|
||||||
|
For food (Phase 9), purchases have no intermediate state — food items always go directly to pantry/fridge upon recording.
|
||||||
|
|
||||||
|
Refill alerts must account for both cabinet stock and purchases with status `ordered` so the alert reflects actual available stock.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -28,13 +39,10 @@ export interface Store {
|
||||||
householdId: string;
|
householdId: string;
|
||||||
name: string;
|
name: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
location?: {
|
location?: { lat: number; lng: number };
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
};
|
|
||||||
url?: string;
|
url?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags: string[]; // e.g., 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
|
tags: string[]; // e.g. 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
@ -49,25 +57,62 @@ export interface Store {
|
||||||
export interface MedicinePriceRecord {
|
export interface MedicinePriceRecord {
|
||||||
id: string;
|
id: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
medicineProductId: string; // Reference to MedicineProduct (purchasable level)
|
medicineProductId: string; // specific brand/package
|
||||||
medicineProductBrand: string; // Denormalized
|
medicineProductBrand: string; // denormalized
|
||||||
medicineId: string; // Reference to Medicine (generic level, for cross-brand comparison)
|
medicineId: string; // generic medicine, for cross-brand comparison
|
||||||
medicineName: string; // Denormalized
|
medicineName: string; // denormalized
|
||||||
storeId: string;
|
storeId: string;
|
||||||
storeName: string; // Denormalized
|
storeName: string; // denormalized
|
||||||
price: number;
|
price: number;
|
||||||
currency: string; // Default from household settings
|
currency: string;
|
||||||
quantity: number; // How many pills/units for this price (package size)
|
quantity: number; // units in this price point (package size)
|
||||||
unit: DosageUnit;
|
unit: DosageUnit;
|
||||||
pricePerUnit: number; // Computed: price / quantity
|
pricePerUnit: number; // computed: price / quantity
|
||||||
date: Date;
|
isInsurancePrice: boolean;
|
||||||
isInsurancePrice: boolean; // With insurance vs retail
|
notes?: string; // e.g. "bulk deal", "fast shipping tier"
|
||||||
notes?: string;
|
date: Date; // when price was observed
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Purchase Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/shared/src/types/purchase.ts
|
||||||
|
export type PurchaseStatus = 'ordered' | 'in_cabinet';
|
||||||
|
|
||||||
|
export interface Purchase {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string; // denormalized
|
||||||
|
status: PurchaseStatus; // 'in_cabinet' for physical; 'ordered' for online until received
|
||||||
|
items: PurchaseItem[];
|
||||||
|
notes?: string;
|
||||||
|
purchasedAt: Date; // when the purchase was made (or order placed)
|
||||||
|
receivedAt?: Date; // set when status moves to in_cabinet for online orders
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseItem {
|
||||||
|
id: string; // stable item id within the purchase
|
||||||
|
// exactly one of these is set:
|
||||||
|
medicineProductId?: string;
|
||||||
|
foodProductId?: string; // reserved for Phase 9
|
||||||
|
// denormalized display info:
|
||||||
|
name: string; // brand name / food product name
|
||||||
|
quantity: number; // units in package
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number; // what was actually paid (optional)
|
||||||
|
currency?: string;
|
||||||
|
priceRecordId?: string; // optionally links to a PriceRecord used as reference
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### RefillAlert (Computed, not stored)
|
### RefillAlert (Computed, not stored)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -77,18 +122,20 @@ export interface RefillAlert {
|
||||||
medicineName: string;
|
medicineName: string;
|
||||||
medicineStrength: number;
|
medicineStrength: number;
|
||||||
medicineStrengthUnit: StrengthUnit;
|
medicineStrengthUnit: StrengthUnit;
|
||||||
daysUntilEmpty: number;
|
daysUntilEmpty: number; // based on cabinet stock only
|
||||||
|
daysUntilEmptyWithOrders: number; // cabinet + pending ordered items
|
||||||
dailyConsumption: number;
|
dailyConsumption: number;
|
||||||
currentStock: number;
|
currentStock: number;
|
||||||
suggestedQuantity: number; // Enough for N days (configurable, default 30)
|
pendingOrderStock: number; // units in orders with status 'ordered'
|
||||||
lastKnownPrice?: {
|
suggestedQuantity: number; // enough for N days (configurable, default 30)
|
||||||
|
cheapestOption?: {
|
||||||
price: number;
|
price: number;
|
||||||
pricePerUnit: number;
|
pricePerUnit: number;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
};
|
};
|
||||||
cheapestOption?: {
|
lastKnownPrice?: {
|
||||||
price: number;
|
price: number;
|
||||||
pricePerUnit: number;
|
pricePerUnit: number;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
|
|
@ -142,9 +189,14 @@ export enum RefillListStatus {
|
||||||
|
|
||||||
// MedicinePriceRecord
|
// MedicinePriceRecord
|
||||||
{ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
|
{ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
|
||||||
{ householdId: 1, medicineId: 1, date: -1 } // Cross-brand comparison
|
{ householdId: 1, medicineId: 1, date: -1 }
|
||||||
{ householdId: 1, storeId: 1, date: -1 }
|
{ householdId: 1, storeId: 1, date: -1 }
|
||||||
|
|
||||||
|
// Purchase
|
||||||
|
{ householdId: 1, status: 1, purchasedAt: -1 }
|
||||||
|
{ householdId: 1, storeId: 1 }
|
||||||
|
{ householdId: 1, 'items.medicineProductId': 1 }
|
||||||
|
|
||||||
// RefillList
|
// RefillList
|
||||||
{ householdId: 1, status: 1 }
|
{ householdId: 1, status: 1 }
|
||||||
{ householdId: 1, createdAt: -1 }
|
{ householdId: 1, createdAt: -1 }
|
||||||
|
|
@ -167,41 +219,54 @@ export enum RefillListStatus {
|
||||||
### MedicinePricesModule
|
### MedicinePricesModule
|
||||||
|
|
||||||
| Method | Path | Description | Auth |
|
| Method | Path | Description | Auth |
|
||||||
| ------ | -------------------------------------- | --------------------------------- | ------ |
|
| ------ | -------------------------------------- | --------------------------------------- | ------ |
|
||||||
| POST | `/medicine-prices` | Record a price | member |
|
| POST | `/medicine-prices` | Record a catalog price observation | member |
|
||||||
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
|
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
|
||||||
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
|
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
|
||||||
| GET | `/medicine-prices/analytics` | Spending analytics | member |
|
| GET | `/medicine-prices/analytics` | Price trend analytics | member |
|
||||||
|
|
||||||
|
### PurchasesModule
|
||||||
|
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
| ------ | ------------------------------- | --------------------------------------------------------- | ------ |
|
||||||
|
| POST | `/purchases` | Record a purchase (physical → immediately in_cabinet; online → ordered) | member |
|
||||||
|
| GET | `/purchases` | List purchases (filterable by status) | member |
|
||||||
|
| GET | `/purchases/:id` | Get purchase detail | member |
|
||||||
|
| PATCH | `/purchases/:id` | Update purchase (notes, items) | member |
|
||||||
|
| POST | `/purchases/:id/receive` | Mark online purchase received — moves items to cabinet | member |
|
||||||
|
| DELETE | `/purchases/:id` | Delete purchase (only if status is ordered) | member |
|
||||||
|
|
||||||
### RefillsModule
|
### RefillsModule
|
||||||
|
|
||||||
| Method | Path | Description | Auth |
|
| Method | Path | Description | Auth |
|
||||||
| ------ | ----------------------------------- | ---------------------------------------- | ------ |
|
| ------ | ---------------------------------- | ----------------------------------------- | ------ |
|
||||||
| GET | `/refills/alerts` | Get refill alerts (medicines running low)| member |
|
| GET | `/refills/alerts` | Get refill alerts (accounts for pending orders) | member |
|
||||||
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
|
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
|
||||||
| GET | `/refills/lists` | List refill lists | member |
|
| GET | `/refills/lists` | List refill lists | member |
|
||||||
| GET | `/refills/lists/:id` | Get refill list | member |
|
| GET | `/refills/lists/:id` | Get refill list | member |
|
||||||
| PATCH | `/refills/lists/:id` | Update refill list | member |
|
| PATCH | `/refills/lists/:id` | Update refill list | member |
|
||||||
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
|
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
|
||||||
| POST | `/refills/lists/:id/add-to-cabinet` | Move checked items to cabinet | member |
|
|
||||||
| GET | `/refills/lists/:id/store-comparison`| Best store for this list | member |
|
|
||||||
|
|
||||||
### Query Parameters
|
### Query Parameters
|
||||||
|
|
||||||
```
|
```
|
||||||
# GET /stores
|
# GET /stores
|
||||||
?tags=pharmacy # Filter by tags
|
?tags=pharmacy
|
||||||
&search=walgreens # Name search
|
&search=walgreens
|
||||||
|
|
||||||
# GET /medicine-prices/history/:medicineId
|
# GET /medicine-prices/history/:medicineId
|
||||||
?storeId=abc123 # Filter by store
|
?storeId=abc123
|
||||||
&startDate=2026-01-01 # Date range
|
&startDate=2026-01-01
|
||||||
&endDate=2026-03-27
|
&endDate=2026-03-27
|
||||||
&limit=50
|
&limit=50
|
||||||
|
|
||||||
|
# GET /purchases
|
||||||
|
?status=ordered
|
||||||
|
&storeId=abc123
|
||||||
|
|
||||||
# GET /refills/alerts
|
# GET /refills/alerts
|
||||||
?thresholdDays=7 # Alert when <= N days of stock remain (default: 7)
|
?thresholdDays=7
|
||||||
&userId=abc123 # Filter by user's regimens
|
&userId=abc123
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -210,9 +275,10 @@ export enum RefillListStatus {
|
||||||
|
|
||||||
### 4.1 — Shared Types & Validation
|
### 4.1 — Shared Types & Validation
|
||||||
|
|
||||||
- Add store types to `packages/shared/src/types/store.ts`
|
- Store types in `packages/shared/src/types/store.ts`
|
||||||
- Add medicine price types to `packages/shared/src/types/medicine-price.ts`
|
- Medicine price types in `packages/shared/src/types/medicine-price.ts`
|
||||||
- Add refill types to `packages/shared/src/types/refill.ts`
|
- Order types in `packages/shared/src/types/order.ts`
|
||||||
|
- Refill types in `packages/shared/src/types/refill.ts`
|
||||||
- Zod schemas for all create/update operations
|
- Zod schemas for all create/update operations
|
||||||
|
|
||||||
### 4.2 — Stores CRUD (Shared Infrastructure)
|
### 4.2 — Stores CRUD (Shared Infrastructure)
|
||||||
|
|
@ -220,89 +286,85 @@ export enum RefillListStatus {
|
||||||
- `packages/api/src/modules/stores/`
|
- `packages/api/src/modules/stores/`
|
||||||
- Standard CRUD, scoped to `householdId`
|
- Standard CRUD, scoped to `householdId`
|
||||||
- Tag-based filtering (pharmacy, grocery, online, etc.)
|
- Tag-based filtering (pharmacy, grocery, online, etc.)
|
||||||
- This module is used by both medicine and food domains
|
- Reused by both medicine and food domains
|
||||||
|
|
||||||
### 4.3 — Medicine Price Service
|
### 4.3 — Medicine Price Service
|
||||||
|
|
||||||
|
Records catalog price observations. Multiple records per product+store are allowed (different pack sizes, bulk tiers, insurance pricing).
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class MedicinePriceService {
|
class MedicinePriceService {
|
||||||
/** Record a price, computing pricePerUnit */
|
recordPrice(data: CreateMedicinePriceRecord, householdId: string): Promise<MedicinePriceRecord>;
|
||||||
recordPrice(data: CreateMedicinePriceRecord): Promise<MedicinePriceRecord>;
|
getPriceHistory(medicineId: string, householdId: string, options?: { storeId?: string; startDate?: Date; endDate?: Date; limit?: number }): Promise<MedicinePriceRecord[]>;
|
||||||
|
|
||||||
/** Get price history for a medicine, optionally filtered by store */
|
|
||||||
getPriceHistory(medicineId: string, householdId: string, options?: {
|
|
||||||
storeId?: string;
|
|
||||||
startDate?: Date;
|
|
||||||
endDate?: Date;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<MedicinePriceRecord[]>;
|
|
||||||
|
|
||||||
/** Compare current prices across stores for a medicine */
|
|
||||||
compareStores(medicineId: string, householdId: string): Promise<StoreComparison[]>;
|
compareStores(medicineId: string, householdId: string): Promise<StoreComparison[]>;
|
||||||
|
|
||||||
/** Estimate price based on most recent record */
|
|
||||||
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise<number | null>;
|
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise<number | null>;
|
||||||
|
getPriceAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<PriceAnalytics>;
|
||||||
/** Spending analytics over time */
|
|
||||||
getAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<MedicineSpendingAnalytics>;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 — Refill Alert Service
|
Analytics on this module are **price trends**, not spending — they show how prices change over time, not purchases made.
|
||||||
|
|
||||||
|
### 4.4 — Purchases Module
|
||||||
|
|
||||||
|
A purchase records that items were actually acquired. A single purchase can contain any mix of medicine products and (Phase 9) food products from the same store.
|
||||||
|
|
||||||
|
**Record physical purchase** (default flow — store visit):
|
||||||
|
- Set `status: 'in_cabinet'` at creation time
|
||||||
|
- Items are added to cabinet immediately as part of the create call
|
||||||
|
- No separate receive step needed
|
||||||
|
|
||||||
|
**Record online purchase**:
|
||||||
|
- Set `status: 'ordered'` at creation time
|
||||||
|
- Items are NOT added to cabinet yet
|
||||||
|
- Items in `ordered` purchases are counted as pending stock in refill alerts
|
||||||
|
- When items arrive, call `POST /purchases/:id/receive` to add to cabinet
|
||||||
|
|
||||||
|
**Receive flow** (`POST /purchases/:id/receive`):
|
||||||
|
1. For each item with `medicineProductId` and `addedToCabinet: false`:
|
||||||
|
- Create a `CabinetItem` (status: active, purchaseDate: today)
|
||||||
|
- If `actualPrice` was recorded on the item, create a `MedicinePriceRecord`
|
||||||
|
- Mark `addedToCabinet: true`
|
||||||
|
2. Set purchase status to `in_cabinet`
|
||||||
|
3. Return summary: `{ addedCount, priceRecordsCreated }`
|
||||||
|
|
||||||
|
### 4.5 — Refill Alert Service
|
||||||
|
|
||||||
|
Refill alerts account for both current cabinet stock and pending orders (status `ordered`) so users are not prompted to reorder medicine already on its way.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class RefillAlertService {
|
class RefillAlertService {
|
||||||
/**
|
/**
|
||||||
* For each medicine in the user's active regimens:
|
* For each medicine in the user's active regimens:
|
||||||
* 1. Get burn rate from BurnRateService (Phase 3)
|
* 1. Get burn rate from BurnRateService (Phase 3)
|
||||||
* 2. If daysUntilEmpty <= thresholdDays, create alert
|
* 2. Sum cabinet stock + units in purchases with status 'ordered'
|
||||||
* 3. Attach last known price + cheapest store option
|
* 3. If daysUntilEmpty (cabinet only) <= thresholdDays, create alert
|
||||||
* 4. Calculate suggested quantity (enough for configurable days, default 30)
|
* 4. Attach pending purchase stock, cheapest price option, last known price
|
||||||
|
* 5. Calculate suggested quantity (enough for configurable days, default 30)
|
||||||
*/
|
*/
|
||||||
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
|
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.5 — Refill Lists
|
### 4.6 — Refill Lists
|
||||||
|
|
||||||
- CRUD for refill lists
|
- CRUD for refill lists
|
||||||
- `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts
|
- `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts
|
||||||
- Each item can have an estimated price (from price history) and an actual price (entered when purchased)
|
- Each item can carry an estimated price (from price history) and an actual price (entered when purchased)
|
||||||
- Store comparison: for each item, find cheapest store based on recent price records
|
- Refill lists are planning aids — actual cabinet addition goes through Purchases
|
||||||
|
|
||||||
### 4.6 — Refill to Cabinet Flow
|
|
||||||
|
|
||||||
- `POST /refills/lists/:id/add-to-cabinet`:
|
|
||||||
- For each checked item with `addedToCabinet: false`:
|
|
||||||
- Create a `CabinetItem` (status: active, purchaseDate: today)
|
|
||||||
- If `actualPrice` was entered, create a `MedicinePriceRecord`
|
|
||||||
- Mark `addedToCabinet: true`
|
|
||||||
- Return summary: `{ addedCount, priceRecordsCreated }`
|
|
||||||
|
|
||||||
### 4.7 — Price Analytics
|
### 4.7 — Price Analytics
|
||||||
|
|
||||||
```typescript
|
Analytics on the medicine-prices page show **price trends across stores over time**, not spending:
|
||||||
interface MedicineSpendingAnalytics {
|
|
||||||
/** Total spending per period */
|
|
||||||
spendingOverTime: { period: string; total: number }[];
|
|
||||||
|
|
||||||
/** Most expensive medicines */
|
```typescript
|
||||||
topBySpending: {
|
interface PriceAnalytics {
|
||||||
|
priceOverTime: { period: string; avgPricePerUnit: number; storeName: string }[];
|
||||||
|
cheapestByMedicine: {
|
||||||
medicineId: string;
|
medicineId: string;
|
||||||
medicineName: string;
|
medicineName: string;
|
||||||
totalSpent: number;
|
cheapestPricePerUnit: number;
|
||||||
avgPricePerUnit: number;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
/** Per-store spending */
|
|
||||||
spendingByStore: {
|
|
||||||
storeId: string;
|
|
||||||
storeName: string;
|
storeName: string;
|
||||||
totalSpent: number;
|
|
||||||
purchaseCount: number;
|
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
/** Price trend alerts (significant increases) */
|
|
||||||
priceAlerts: {
|
priceAlerts: {
|
||||||
medicineId: string;
|
medicineId: string;
|
||||||
medicineName: string;
|
medicineName: string;
|
||||||
|
|
@ -314,49 +376,44 @@ interface MedicineSpendingAnalytics {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.8 — Web UI: Pharmacies & Prices
|
Spending analytics (total money spent) live on the Orders page, computed from order items with `actualPrice`.
|
||||||
|
|
||||||
- `/stores` page:
|
### 4.8 — Web UI: Stores & Prices
|
||||||
- Store list with CRUD
|
|
||||||
- Filter by tags (pharmacy, grocery, etc.)
|
- `/stores` page: store list with CRUD, filter by tags
|
||||||
- Per-store summary: total spent, last visit, item count
|
|
||||||
- `/medicine-prices` page:
|
- `/medicine-prices` page:
|
||||||
- Medicine search -> price history line chart (per store, color-coded)
|
- Record a price: store → medicine → product → price + quantity + currency + insurance flag + notes
|
||||||
|
- Price history per medicine: filterable by store, shows price-per-unit over time
|
||||||
- Store comparison table for selected medicine
|
- Store comparison table for selected medicine
|
||||||
- Spending over time bar chart
|
- Price trend analytics (price changes over time, not spending)
|
||||||
- Price alert panel
|
|
||||||
|
|
||||||
### 4.9 — Web UI: Refill Management
|
### 4.9 — Web UI: Purchases
|
||||||
|
|
||||||
|
- `/purchases` page:
|
||||||
|
- List of purchases grouped by status (ordered / in cabinet)
|
||||||
|
- Record purchase: pick store, choose physical or online, add line items (medicine products)
|
||||||
|
- Physical purchases: items go to cabinet immediately on save
|
||||||
|
- Online purchases: sit in `ordered` state until received; "Mark as received" bulk-adds to cabinet
|
||||||
|
- Shows actual price paid per item (used to auto-create price records on receive)
|
||||||
|
|
||||||
|
### 4.10 — Web UI: Refill Management
|
||||||
|
|
||||||
- `/refills` page:
|
- `/refills` page:
|
||||||
- **Alerts section**: medicines running low
|
- **Alerts section**: medicines running low, with `daysUntilEmpty` and `daysUntilEmptyWithOrders` shown separately so the user can see how much the pending order helps
|
||||||
- Card per medicine: name, days remaining, suggested quantity, cheapest store
|
- **Refill lists section**: planning lists with check-off and estimated prices
|
||||||
- "Generate Refill List" button -> creates list from all alerts
|
|
||||||
- **Refill lists section**:
|
|
||||||
- Active lists at top, completed/archived below
|
|
||||||
- List detail view:
|
|
||||||
- Items with checkbox, medicine name, quantity, estimated price
|
|
||||||
- Check off: optionally enter actual price
|
|
||||||
- Store comparison panel
|
|
||||||
- "Done Shopping" -> prompts "Add items to cabinet?"
|
|
||||||
- **Dashboard widget**: refill alert count badge, medicines needing refill soon
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Can create and manage stores with tags
|
- [ ] Can create and manage stores with tags
|
||||||
- [ ] Can record medicine prices and view price history
|
- [ ] Can record catalog price observations; multiple price points per product+store allowed
|
||||||
- [ ] Store comparison shows cheapest option per medicine
|
- [ ] Store comparison shows cheapest observed price per medicine
|
||||||
- [ ] Refill alerts correctly identify medicines running low based on burn rate
|
- [ ] Can record a purchase (physical or online) mixing medicine and (future) food items
|
||||||
- [ ] Refill lists can be auto-generated from alerts
|
- [ ] Physical purchases add medicine items to cabinet immediately
|
||||||
- [ ] Checked refill items can be added to cabinet in one action
|
- [ ] Online purchases sit in ordered state; receiving them adds items to cabinet and optionally records prices
|
||||||
- [ ] Price analytics show spending trends
|
- [ ] Refill alerts correctly factor in pending purchases (status: ordered) when computing days-until-empty
|
||||||
|
- [ ] Price analytics show price trends, not spending
|
||||||
|
- [ ] Spending analytics live on the Orders page
|
||||||
- [ ] Store infrastructure is reusable for food tracking (Phase 9)
|
- [ ] Store infrastructure is reusable for food tracking (Phase 9)
|
||||||
- [ ] All queries scoped to `householdId`
|
- [ ] All queries scoped to `householdId`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estimated Effort
|
|
||||||
|
|
||||||
Medium-large. Store/price infrastructure, refill alert logic, and the refill-to-cabinet flow involve significant work. The store comparison and analytics add moderate complexity.
|
|
||||||
|
|
|
||||||
1433
package-lock.json
generated
|
|
@ -14,7 +14,8 @@
|
||||||
"test:cov": "vitest run --coverage",
|
"test:cov": "vitest run --coverage",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
||||||
"seed": "tsx --env-file ../../.env src/scripts/seed.ts"
|
"seed": "tsx --env-file ../../.env src/scripts/seed.ts",
|
||||||
|
"migrate:dosage-units": "tsx --env-file ../../.env src/scripts/migrate-dosage-units.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/awilix": "^8.2.0",
|
"@fastify/awilix": "^8.2.0",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ import cabinetRoutes from './modules/cabinet/cabinet.routes.js';
|
||||||
import cabinetEventsRoutes from './modules/cabinet-events/cabinet-events.routes.js';
|
import cabinetEventsRoutes from './modules/cabinet-events/cabinet-events.routes.js';
|
||||||
import regimensRoutes from './modules/regimens/regimens.routes.js';
|
import regimensRoutes from './modules/regimens/regimens.routes.js';
|
||||||
import organizerRoutes from './modules/organizer/organizer.routes.js';
|
import organizerRoutes from './modules/organizer/organizer.routes.js';
|
||||||
|
import storesRoutes from './modules/stores/stores.routes.js';
|
||||||
|
import medicinePricesRoutes from './modules/medicine-prices/medicine-prices.routes.js';
|
||||||
|
import refillsRoutes from './modules/refills/refills.routes.js';
|
||||||
|
import purchasesRoutes from './modules/purchases/purchases.routes.js';
|
||||||
|
|
||||||
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -109,6 +113,10 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||||
await app.register(cabinetEventsRoutes);
|
await app.register(cabinetEventsRoutes);
|
||||||
await app.register(regimensRoutes);
|
await app.register(regimensRoutes);
|
||||||
await app.register(organizerRoutes);
|
await app.register(organizerRoutes);
|
||||||
|
await app.register(storesRoutes);
|
||||||
|
await app.register(medicinePricesRoutes);
|
||||||
|
await app.register(refillsRoutes);
|
||||||
|
await app.register(purchasesRoutes);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
app.setErrorHandler((error, request, reply) => {
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { mockFind, mockFindOne, mockAggregate, mockSave } = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockAggregate: vi.fn(),
|
||||||
|
mockSave: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../schemas/medicine-price.schema.js', () => {
|
||||||
|
const chain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFind,
|
||||||
|
});
|
||||||
|
const findOneChain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFindOne,
|
||||||
|
});
|
||||||
|
const aggregateChain = () => ({ exec: mockAggregate });
|
||||||
|
|
||||||
|
class FakeModel {
|
||||||
|
data: unknown;
|
||||||
|
constructor(data: unknown) { this.data = data; }
|
||||||
|
save = mockSave;
|
||||||
|
toObject() { return this.data; }
|
||||||
|
static find = vi.fn(() => chain());
|
||||||
|
static findOne = vi.fn(() => findOneChain());
|
||||||
|
static aggregate = vi.fn(() => aggregateChain());
|
||||||
|
}
|
||||||
|
return { MedicinePriceModel: FakeModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||||
|
|
||||||
|
describe(MedicinePricesRepository.name, () => {
|
||||||
|
let repo: MedicinePricesRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new MedicinePricesRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('saves and returns price record', async () => {
|
||||||
|
const data = {
|
||||||
|
householdId: 'hh1',
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineProductBrand: 'Tylenol',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Acetaminophen',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
price: 10,
|
||||||
|
currency: 'USD',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'tablet',
|
||||||
|
pricePerUnit: 0.1,
|
||||||
|
date: new Date(),
|
||||||
|
isInsurancePrice: false,
|
||||||
|
createdBy: 'user-1',
|
||||||
|
};
|
||||||
|
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.create(data as never);
|
||||||
|
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByMedicine', () => {
|
||||||
|
it('returns paginated items', async () => {
|
||||||
|
const items = [{ _id: 'pr-1', medicineId: 'med-1' }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toEqual(items);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets hasMore when more items exist', async () => {
|
||||||
|
const items = [{ _id: 'pr-1' }, { _id: 'pr-2' }, { _id: 'pr-3' }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 2 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.pagination.hasMore).toBe(true);
|
||||||
|
expect(result.pagination.cursor).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cursor', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const cursor = Buffer.from('pr-1').toString('base64');
|
||||||
|
const result = await repo.findByMedicine('hh1', 'med-1', { cursor, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null cursor when no data', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies storeId filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await repo.findByMedicine('hh1', 'med-1', { storeId: 'st-1', limit: 20 });
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies startDate-only filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await repo.findByMedicine('hh1', 'med-1', {
|
||||||
|
startDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies endDate-only filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await repo.findByMedicine('hh1', 'med-1', {
|
||||||
|
endDate: '2026-12-31T00:00:00.000Z',
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareStores', () => {
|
||||||
|
it('returns store comparison results', async () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
_id: 'st-1',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
latestPrice: 10,
|
||||||
|
latestPricePerUnit: 0.1,
|
||||||
|
currency: 'USD',
|
||||||
|
date: new Date(),
|
||||||
|
isInsurancePrice: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockAggregate.mockResolvedValue(rows);
|
||||||
|
|
||||||
|
const result = await repo.compareStores('hh1', 'med-1');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].storeId).toBe('st-1');
|
||||||
|
expect(result[0].storeName).toBe('Walgreens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no records', async () => {
|
||||||
|
mockAggregate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.compareStores('hh1', 'med-1');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLatestForMedicine', () => {
|
||||||
|
it('returns latest record', async () => {
|
||||||
|
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
|
||||||
|
mockFindOne.mockResolvedValue(record);
|
||||||
|
|
||||||
|
const result = await repo.getLatestForMedicine('hh1', 'med-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by storeId when provided', async () => {
|
||||||
|
mockFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await repo.getLatestForMedicine('hh1', 'med-1', 'st-1');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await repo.getLatestForMedicine('hh1', 'med-1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAnalytics', () => {
|
||||||
|
it('returns analytics object with all fields', async () => {
|
||||||
|
mockAggregate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.getAnalytics('hh1', { period: 'month' });
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('spendingOverTime');
|
||||||
|
expect(result).toHaveProperty('topBySpending');
|
||||||
|
expect(result).toHaveProperty('spendingByStore');
|
||||||
|
expect(result).toHaveProperty('priceAlerts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses quarter date format', async () => {
|
||||||
|
mockAggregate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.getAnalytics('hh1', { period: 'quarter' });
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('spendingOverTime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses year date format', async () => {
|
||||||
|
mockAggregate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.getAnalytics('hh1', { period: 'year' });
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('spendingOverTime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-empty analytics results', async () => {
|
||||||
|
mockAggregate
|
||||||
|
.mockResolvedValueOnce([{ period: '2026-01', total: 50 }])
|
||||||
|
.mockResolvedValueOnce([{ medicineId: 'med-1', medicineName: 'Acetaminophen', totalSpent: 50, avgPricePerUnit: 0.1 }])
|
||||||
|
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 }])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await repo.getAnalytics('hh1', { period: 'month' });
|
||||||
|
|
||||||
|
expect(result.spendingOverTime).toHaveLength(1);
|
||||||
|
expect(result.topBySpending).toHaveLength(1);
|
||||||
|
expect(result.spendingByStore).toHaveLength(1);
|
||||||
|
expect(result.priceAlerts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
|
||||||
|
import type { MedicinePriceHistoryQueryInput, MedicinePriceAnalyticsQueryInput } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export interface CreateMedicinePriceData {
|
||||||
|
householdId: string;
|
||||||
|
medicineProductId: string;
|
||||||
|
medicineProductBrand: string;
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
pricePerUnit: number;
|
||||||
|
date: Date;
|
||||||
|
isInsurancePrice: boolean;
|
||||||
|
notes?: string;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MedicinePricesRepository {
|
||||||
|
public async create(data: CreateMedicinePriceData) {
|
||||||
|
const record = new MedicinePriceModel(data);
|
||||||
|
const saved = await record.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByMedicine(
|
||||||
|
householdId: string,
|
||||||
|
medicineId: string,
|
||||||
|
query: MedicinePriceHistoryQueryInput,
|
||||||
|
) {
|
||||||
|
const filter: Record<string, unknown> = { householdId, medicineId };
|
||||||
|
|
||||||
|
if (query.storeId) filter['storeId'] = query.storeId;
|
||||||
|
|
||||||
|
if (query.startDate || query.endDate) {
|
||||||
|
const dateFilter: Record<string, Date> = {};
|
||||||
|
if (query.startDate) dateFilter['$gte'] = new Date(query.startDate);
|
||||||
|
if (query.endDate) dateFilter['$lte'] = new Date(query.endDate);
|
||||||
|
filter['date'] = dateFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $lt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await MedicinePriceModel.find(filter)
|
||||||
|
.sort({ _id: -1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async compareStores(householdId: string, medicineId: string) {
|
||||||
|
// Get the most recent price per store for this medicine
|
||||||
|
const results = await MedicinePriceModel.aggregate([
|
||||||
|
{ $match: { householdId, medicineId } },
|
||||||
|
{ $sort: { storeId: 1, date: -1 } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$storeId',
|
||||||
|
storeName: { $first: '$storeName' },
|
||||||
|
latestPrice: { $first: '$price' },
|
||||||
|
latestPricePerUnit: { $first: '$pricePerUnit' },
|
||||||
|
currency: { $first: '$currency' },
|
||||||
|
date: { $first: '$date' },
|
||||||
|
isInsurancePrice: { $first: '$isInsurancePrice' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { latestPricePerUnit: 1 } },
|
||||||
|
]).exec();
|
||||||
|
|
||||||
|
return results.map((r) => ({
|
||||||
|
storeId: r._id as string,
|
||||||
|
storeName: r.storeName as string,
|
||||||
|
latestPrice: r.latestPrice as number,
|
||||||
|
latestPricePerUnit: r.latestPricePerUnit as number,
|
||||||
|
currency: r.currency as string,
|
||||||
|
date: r.date as Date,
|
||||||
|
isInsurancePrice: r.isInsurancePrice as boolean,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLatestForMedicine(
|
||||||
|
householdId: string,
|
||||||
|
medicineId: string,
|
||||||
|
storeId?: string,
|
||||||
|
) {
|
||||||
|
const filter: Record<string, unknown> = { householdId, medicineId };
|
||||||
|
if (storeId) filter['storeId'] = storeId;
|
||||||
|
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
|
||||||
|
const dateFormat =
|
||||||
|
query.period === 'month' ? '%Y-%m' : query.period === 'quarter' ? '%Y-Q%q' : '%Y';
|
||||||
|
|
||||||
|
const [spendingOverTime, topBySpending, spendingByStore] = await Promise.all([
|
||||||
|
MedicinePriceModel.aggregate([
|
||||||
|
{ $match: { householdId } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { $dateToString: { format: dateFormat, date: '$date' } },
|
||||||
|
total: { $sum: '$price' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { _id: 1 } },
|
||||||
|
{ $project: { _id: 0, period: '$_id', total: 1 } },
|
||||||
|
]).exec(),
|
||||||
|
|
||||||
|
MedicinePriceModel.aggregate([
|
||||||
|
{ $match: { householdId } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$medicineId',
|
||||||
|
medicineName: { $first: '$medicineName' },
|
||||||
|
totalSpent: { $sum: '$price' },
|
||||||
|
avgPricePerUnit: { $avg: '$pricePerUnit' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { totalSpent: -1 } },
|
||||||
|
{ $limit: 10 },
|
||||||
|
{ $project: { _id: 0, medicineId: '$_id', medicineName: 1, totalSpent: 1, avgPricePerUnit: 1 } },
|
||||||
|
]).exec(),
|
||||||
|
|
||||||
|
MedicinePriceModel.aggregate([
|
||||||
|
{ $match: { householdId } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$storeId',
|
||||||
|
storeName: { $first: '$storeName' },
|
||||||
|
totalSpent: { $sum: '$price' },
|
||||||
|
purchaseCount: { $sum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { totalSpent: -1 } },
|
||||||
|
{ $project: { _id: 0, storeId: '$_id', storeName: 1, totalSpent: 1, purchaseCount: 1 } },
|
||||||
|
]).exec(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Price alerts: medicines where the most recent price is >10% higher than the previous
|
||||||
|
const priceAlerts = await MedicinePriceModel.aggregate([
|
||||||
|
{ $match: { householdId } },
|
||||||
|
{ $sort: { medicineId: 1, storeId: 1, date: -1 } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { medicineId: '$medicineId', storeId: '$storeId' },
|
||||||
|
medicineName: { $first: '$medicineName' },
|
||||||
|
storeName: { $first: '$storeName' },
|
||||||
|
prices: { $push: '$pricePerUnit' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $match: { 'prices.1': { $exists: true } } },
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
|
currentPrice: { $arrayElemAt: ['$prices', 0] },
|
||||||
|
previousPrice: { $arrayElemAt: ['$prices', 1] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
|
changePercent: {
|
||||||
|
$multiply: [
|
||||||
|
{ $divide: [{ $subtract: ['$currentPrice', '$previousPrice'] }, '$previousPrice'] },
|
||||||
|
100,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $match: { changePercent: { $gt: 10 } } },
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 0,
|
||||||
|
medicineId: '$_id.medicineId',
|
||||||
|
medicineName: 1,
|
||||||
|
storeName: 1,
|
||||||
|
previousPrice: 1,
|
||||||
|
currentPrice: 1,
|
||||||
|
changePercent: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).exec();
|
||||||
|
|
||||||
|
return {
|
||||||
|
spendingOverTime: spendingOverTime as { period: string; total: number }[],
|
||||||
|
topBySpending: topBySpending as { medicineId: string; medicineName: string; totalSpent: number; avgPricePerUnit: number }[],
|
||||||
|
spendingByStore: spendingByStore as { storeId: string; storeName: string; totalSpent: number; purchaseCount: number }[],
|
||||||
|
priceAlerts: priceAlerts as { medicineId: string; medicineName: string; storeName: string; previousPrice: number; currentPrice: number; changePercent: number }[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockRecordPrice,
|
||||||
|
mockGetPriceHistory,
|
||||||
|
mockCompareStores,
|
||||||
|
mockGetAnalytics,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockRecordPrice: vi.fn(),
|
||||||
|
mockGetPriceHistory: vi.fn(),
|
||||||
|
mockCompareStores: vi.fn(),
|
||||||
|
mockGetAnalytics: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./medicine-prices.repository.js', () => ({
|
||||||
|
MedicinePricesRepository: class {
|
||||||
|
create = vi.fn();
|
||||||
|
findByMedicine = vi.fn();
|
||||||
|
compareStores = vi.fn();
|
||||||
|
getLatestForMedicine = vi.fn();
|
||||||
|
getAnalytics = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./medicine-prices.service.js', () => ({
|
||||||
|
MedicinePricesService: class {
|
||||||
|
recordPrice = mockRecordPrice;
|
||||||
|
getPriceHistory = mockGetPriceHistory;
|
||||||
|
compareStores = mockCompareStores;
|
||||||
|
getAnalytics = mockGetAnalytics;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../medicine-products/medicine-products.repository.js', () => ({
|
||||||
|
MedicineProductsRepository: class {
|
||||||
|
findById = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../stores/stores.repository.js', () => ({
|
||||||
|
StoresRepository: class {
|
||||||
|
findById = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import medicinePricesRoutes from './medicine-prices.routes.js';
|
||||||
|
|
||||||
|
function makeFakePriceRecord(overrides = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'pr-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineProductBrand: 'Tylenol',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Acetaminophen',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
price: 10,
|
||||||
|
currency: 'USD',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'tablet',
|
||||||
|
pricePerUnit: 0.1,
|
||||||
|
date: '2026-01-15T00:00:00.000Z',
|
||||||
|
isInsurancePrice: false,
|
||||||
|
createdBy: 'kc-1',
|
||||||
|
createdAt: '2026-01-15T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('medicine-prices.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(medicinePricesRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/medicine-prices', () => {
|
||||||
|
const validBody = {
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
storeId: 'st-1',
|
||||||
|
price: 10,
|
||||||
|
currency: 'USD',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'tablet',
|
||||||
|
isInsurancePrice: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('records price and returns 201', async () => {
|
||||||
|
mockRecordPrice.mockResolvedValue(makeFakePriceRecord());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: validBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body._id).toBe('pr-1');
|
||||||
|
expect(body.pricePerUnit).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes householdId and userId to service', async () => {
|
||||||
|
mockRecordPrice.mockResolvedValue(makeFakePriceRecord());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: validBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRecordPrice).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ medicineProductId: 'mp-1' }),
|
||||||
|
'hh1',
|
||||||
|
'kc-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes notes in response when present', async () => {
|
||||||
|
mockRecordPrice.mockResolvedValue(makeFakePriceRecord({ notes: 'insurance price' }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: validBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().notes).toBe('insurance price');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for missing required fields', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { price: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Date objects in response', async () => {
|
||||||
|
mockRecordPrice.mockResolvedValue(makeFakePriceRecord({
|
||||||
|
_id: { toString: () => 'pr-obj' },
|
||||||
|
date: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
|
createdAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: validBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body._id).toBe('pr-obj');
|
||||||
|
expect(body.date).toBe('2026-01-15T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/medicine-prices/history/:medicineId', () => {
|
||||||
|
it('returns paginated price history', async () => {
|
||||||
|
mockGetPriceHistory.mockResolvedValue({
|
||||||
|
data: [makeFakePriceRecord()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices/history/med-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes query params to service', async () => {
|
||||||
|
mockGetPriceHistory.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices/history/med-1?storeId=st-1&limit=10',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetPriceHistory).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'med-1',
|
||||||
|
expect.objectContaining({ storeId: 'st-1', limit: 10 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/medicine-prices/compare/:medicineId', () => {
|
||||||
|
it('returns store comparison', async () => {
|
||||||
|
mockCompareStores.mockResolvedValue([
|
||||||
|
{
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
latestPrice: 10,
|
||||||
|
latestPricePerUnit: 0.1,
|
||||||
|
currency: 'USD',
|
||||||
|
date: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
|
isInsurancePrice: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices/compare/med-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.data[0].storeId).toBe('st-1');
|
||||||
|
expect(body.data[0].date).toBe('2026-01-15T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes householdId and medicineId to service', async () => {
|
||||||
|
mockCompareStores.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices/compare/med-99',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCompareStores).toHaveBeenCalledWith('hh1', 'med-99');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/medicine-prices/analytics', () => {
|
||||||
|
it('returns analytics', async () => {
|
||||||
|
mockGetAnalytics.mockResolvedValue({
|
||||||
|
spendingOverTime: [{ period: '2026-01', total: 50 }],
|
||||||
|
topBySpending: [],
|
||||||
|
spendingByStore: [],
|
||||||
|
priceAlerts: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices/analytics',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.spendingOverTime).toHaveLength(1);
|
||||||
|
expect(body.spendingOverTime[0].total).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes period query param to service', async () => {
|
||||||
|
mockGetAnalytics.mockResolvedValue({
|
||||||
|
spendingOverTime: [],
|
||||||
|
topBySpending: [],
|
||||||
|
spendingByStore: [],
|
||||||
|
priceAlerts: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/medicine-prices/analytics?period=year',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetAnalytics).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ period: 'year' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
CreateMedicinePriceRecordSchema,
|
||||||
|
MedicinePriceHistoryQuerySchema,
|
||||||
|
MedicinePriceAnalyticsQuerySchema,
|
||||||
|
MedicinePriceRecordResponseSchema,
|
||||||
|
MedicinePriceHistoryResponseSchema,
|
||||||
|
StoreComparisonResponseSchema,
|
||||||
|
MedicineSpendingAnalyticsResponseSchema,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||||
|
import { MedicinePricesService } from './medicine-prices.service.js';
|
||||||
|
import { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||||
|
import { StoresRepository } from '../stores/stores.repository.js';
|
||||||
|
|
||||||
|
type AnyPriceDoc = {
|
||||||
|
_id: string | { toString: () => string };
|
||||||
|
householdId: string;
|
||||||
|
medicineProductId: string;
|
||||||
|
medicineProductBrand: string;
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
pricePerUnit: number;
|
||||||
|
date: Date | string | { toISOString: () => string };
|
||||||
|
isInsurancePrice: boolean;
|
||||||
|
notes?: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date | string | { toISOString: () => string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function toIso(v: Date | string | { toISOString: () => string }): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return v.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPriceRecordResponse(rawDoc: unknown) {
|
||||||
|
const doc = rawDoc as AnyPriceDoc;
|
||||||
|
return {
|
||||||
|
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||||
|
householdId: doc.householdId,
|
||||||
|
medicineProductId: doc.medicineProductId,
|
||||||
|
medicineProductBrand: doc.medicineProductBrand,
|
||||||
|
medicineId: doc.medicineId,
|
||||||
|
medicineName: doc.medicineName,
|
||||||
|
storeId: doc.storeId,
|
||||||
|
storeName: doc.storeName,
|
||||||
|
price: doc.price,
|
||||||
|
currency: doc.currency,
|
||||||
|
quantity: doc.quantity,
|
||||||
|
unit: doc.unit,
|
||||||
|
pricePerUnit: doc.pricePerUnit,
|
||||||
|
date: toIso(doc.date),
|
||||||
|
isInsurancePrice: doc.isInsurancePrice,
|
||||||
|
...(doc.notes != null ? { notes: doc.notes } : {}),
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
medicinePricesRepository: MedicinePricesRepository;
|
||||||
|
medicinePricesService: MedicinePricesService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
medicinePricesRepository: asClass(MedicinePricesRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
medicinePricesService: asClass(MedicinePricesService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
const householdParams = z.object({ householdId: z.string() });
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/medicine-prices',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: CreateMedicinePriceRecordSchema,
|
||||||
|
response: { 201: MedicinePriceRecordResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||||
|
const record = await service.recordPrice(
|
||||||
|
request.body,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(toPriceRecordResponse(record));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/medicine-prices/history/:medicineId',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ medicineId: z.string() }),
|
||||||
|
querystring: MedicinePriceHistoryQuerySchema,
|
||||||
|
response: { 200: MedicinePriceHistoryResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||||
|
const result = await service.getPriceHistory(
|
||||||
|
request.params.householdId,
|
||||||
|
request.params.medicineId,
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
return reply.send({
|
||||||
|
data: result.data.map(toPriceRecordResponse),
|
||||||
|
pagination: result.pagination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/medicine-prices/compare/:medicineId',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ medicineId: z.string() }),
|
||||||
|
response: { 200: StoreComparisonResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||||
|
const results = await service.compareStores(
|
||||||
|
request.params.householdId,
|
||||||
|
request.params.medicineId,
|
||||||
|
);
|
||||||
|
return reply.send({
|
||||||
|
data: results.map((r) => ({
|
||||||
|
...r,
|
||||||
|
date: toIso(r.date),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/medicine-prices/analytics',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
querystring: MedicinePriceAnalyticsQuerySchema,
|
||||||
|
response: { 200: MedicineSpendingAnalyticsResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||||
|
const analytics = await service.getAnalytics(request.params.householdId, request.query);
|
||||||
|
return reply.send(analytics);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'medicine-prices-routes',
|
||||||
|
dependencies: ['auth-plugin'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { MedicinePricesService } from './medicine-prices.service.js';
|
||||||
|
|
||||||
|
describe(MedicinePricesService.name, () => {
|
||||||
|
const mockPricesRepo = {
|
||||||
|
create: vi.fn(),
|
||||||
|
findByMedicine: vi.fn(),
|
||||||
|
compareStores: vi.fn(),
|
||||||
|
getLatestForMedicine: vi.fn(),
|
||||||
|
getAnalytics: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockProductsRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockStoresRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: MedicinePricesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new MedicinePricesService({
|
||||||
|
medicinePricesRepository: mockPricesRepo as never,
|
||||||
|
medicineProductsRepository: mockProductsRepo as never,
|
||||||
|
storesRepository: mockStoresRepo as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordPrice', () => {
|
||||||
|
const validInput = {
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
storeId: 'st-1',
|
||||||
|
price: 10,
|
||||||
|
currency: 'USD',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'tablet' as never,
|
||||||
|
isInsurancePrice: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates price record with computed pricePerUnit', async () => {
|
||||||
|
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
||||||
|
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
||||||
|
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
|
||||||
|
mockPricesRepo.create.mockResolvedValue(record);
|
||||||
|
|
||||||
|
const result = await service.recordPrice(validInput, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(record);
|
||||||
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ pricePerUnit: 0.1, medicineName: 'Acetaminophen', storeName: 'Walgreens' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses medicineName as brand fallback when brand is not set', async () => {
|
||||||
|
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Generic', brand: undefined });
|
||||||
|
mockStoresRepo.findById.mockResolvedValue({ name: 'CVS' });
|
||||||
|
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
|
||||||
|
|
||||||
|
await service.recordPrice(validInput, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ medicineProductBrand: 'Generic' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided date when given', async () => {
|
||||||
|
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
||||||
|
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
||||||
|
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
|
||||||
|
|
||||||
|
await service.recordPrice({ ...validInput, date: '2026-01-15T00:00:00.000Z' }, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ date: new Date('2026-01-15T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when product not found', async () => {
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Medicine product not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when store not found', async () => {
|
||||||
|
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Store not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPriceHistory', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockPricesRepo.findByMedicine.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const response = await service.getPriceHistory('hh1', 'med-1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(response).toEqual(result);
|
||||||
|
expect(mockPricesRepo.findByMedicine).toHaveBeenCalledWith('hh1', 'med-1', { limit: 20 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareStores', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const comparisons = [{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 10 }];
|
||||||
|
mockPricesRepo.compareStores.mockResolvedValue(comparisons);
|
||||||
|
|
||||||
|
const result = await service.compareStores('hh1', 'med-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(comparisons);
|
||||||
|
expect(mockPricesRepo.compareStores).toHaveBeenCalledWith('hh1', 'med-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('estimatePrice', () => {
|
||||||
|
it('returns pricePerUnit of latest record', async () => {
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.15 });
|
||||||
|
|
||||||
|
const result = await service.estimatePrice('hh1', 'med-1');
|
||||||
|
|
||||||
|
expect(result).toBe(0.15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no records', async () => {
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.estimatePrice('hh1', 'med-1');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by storeId when provided', async () => {
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.2 });
|
||||||
|
|
||||||
|
await service.estimatePrice('hh1', 'med-1', 'st-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.getLatestForMedicine).toHaveBeenCalledWith('hh1', 'med-1', 'st-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAnalytics', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const analytics = {
|
||||||
|
spendingOverTime: [],
|
||||||
|
topBySpending: [],
|
||||||
|
spendingByStore: [],
|
||||||
|
priceAlerts: [],
|
||||||
|
};
|
||||||
|
mockPricesRepo.getAnalytics.mockResolvedValue(analytics);
|
||||||
|
|
||||||
|
const result = await service.getAnalytics('hh1', { period: 'month' });
|
||||||
|
|
||||||
|
expect(result).toEqual(analytics);
|
||||||
|
expect(mockPricesRepo.getAnalytics).toHaveBeenCalledWith('hh1', { period: 'month' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import type { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||||
|
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||||
|
import type { StoresRepository } from '../stores/stores.repository.js';
|
||||||
|
import type {
|
||||||
|
CreateMedicinePriceRecordInput,
|
||||||
|
MedicinePriceHistoryQueryInput,
|
||||||
|
MedicinePriceAnalyticsQueryInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { NotFoundError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
medicinePricesRepository: MedicinePricesRepository;
|
||||||
|
medicineProductsRepository: MedicineProductsRepository;
|
||||||
|
storesRepository: StoresRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MedicinePricesService {
|
||||||
|
private readonly medicinePricesRepository: MedicinePricesRepository;
|
||||||
|
private readonly medicineProductsRepository: MedicineProductsRepository;
|
||||||
|
private readonly storesRepository: StoresRepository;
|
||||||
|
|
||||||
|
public constructor({ medicinePricesRepository, medicineProductsRepository, storesRepository }: Deps) {
|
||||||
|
this.medicinePricesRepository = medicinePricesRepository;
|
||||||
|
this.medicineProductsRepository = medicineProductsRepository;
|
||||||
|
this.storesRepository = storesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async recordPrice(
|
||||||
|
data: CreateMedicinePriceRecordInput,
|
||||||
|
householdId: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const product = await this.medicineProductsRepository.findById(
|
||||||
|
data.medicineProductId,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
|
if (!product) throw new NotFoundError('Medicine product not found');
|
||||||
|
|
||||||
|
const store = await this.storesRepository.findById(data.storeId, householdId);
|
||||||
|
if (!store) throw new NotFoundError('Store not found');
|
||||||
|
|
||||||
|
const pricePerUnit = data.price / data.quantity;
|
||||||
|
const date = data.date ? new Date(data.date) : new Date();
|
||||||
|
|
||||||
|
return this.medicinePricesRepository.create({
|
||||||
|
householdId,
|
||||||
|
medicineProductId: data.medicineProductId,
|
||||||
|
medicineProductBrand: product.brand ?? product.medicineName,
|
||||||
|
medicineId: data.medicineId,
|
||||||
|
medicineName: product.medicineName,
|
||||||
|
storeId: data.storeId,
|
||||||
|
storeName: store.name,
|
||||||
|
price: data.price,
|
||||||
|
currency: data.currency,
|
||||||
|
quantity: data.quantity,
|
||||||
|
unit: data.unit,
|
||||||
|
pricePerUnit,
|
||||||
|
date,
|
||||||
|
isInsurancePrice: data.isInsurancePrice,
|
||||||
|
notes: data.notes,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPriceHistory(
|
||||||
|
householdId: string,
|
||||||
|
medicineId: string,
|
||||||
|
query: MedicinePriceHistoryQueryInput,
|
||||||
|
) {
|
||||||
|
return this.medicinePricesRepository.findByMedicine(householdId, medicineId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async compareStores(householdId: string, medicineId: string) {
|
||||||
|
return this.medicinePricesRepository.compareStores(householdId, medicineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async estimatePrice(
|
||||||
|
householdId: string,
|
||||||
|
medicineId: string,
|
||||||
|
storeId?: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const record = await this.medicinePricesRepository.getLatestForMedicine(
|
||||||
|
householdId,
|
||||||
|
medicineId,
|
||||||
|
storeId,
|
||||||
|
);
|
||||||
|
return record ? (record.pricePerUnit as number) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
|
||||||
|
return this.medicinePricesRepository.getAnalytics(householdId, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -177,6 +177,20 @@ describe('medicine-products.routes', () => {
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.json().brand).toBe('CVS Health');
|
expect(res.json().brand).toBe('CVS Health');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes concentration fields in response when present', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/medicine-products/mp-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().concentration).toBe(5);
|
||||||
|
expect(res.json().concentrationUnit).toBe('mg/ml');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/v1/households/:householdId/medicines/:medicineId/products', () => {
|
describe('POST /api/v1/households/:householdId/medicines/:medicineId/products', () => {
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,30 @@ describe(MedicinesService.name, () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses current name when name not provided in update', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'med-1',
|
||||||
|
name: 'Aspirin',
|
||||||
|
strength: 500,
|
||||||
|
strengthUnit: StrengthUnit.MG,
|
||||||
|
form: MedicineForm.TABLET,
|
||||||
|
});
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.update.mockResolvedValue({ _id: 'med-1', name: 'Aspirin', strength: 250 });
|
||||||
|
|
||||||
|
const result = await service.update('med-1', 'hh1', { strength: 250 });
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(mockRepo.findDuplicate).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'Aspirin',
|
||||||
|
250,
|
||||||
|
StrengthUnit.MG,
|
||||||
|
MedicineForm.TABLET,
|
||||||
|
'med-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips dedup check when no identity fields change', async () => {
|
it('skips dedup check when no identity fields change', async () => {
|
||||||
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
|
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
|
||||||
mockRepo.update.mockResolvedValue({ _id: 'med-1', notes: 'Updated notes' });
|
mockRepo.update.mockResolvedValue({ _id: 'med-1', notes: 'Updated notes' });
|
||||||
|
|
|
||||||
247
packages/api/src/modules/purchases/purchases.repository.test.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockAggregate, mockSave } = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockFindOneAndUpdate: vi.fn(),
|
||||||
|
mockAggregate: vi.fn(),
|
||||||
|
mockSave: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../schemas/purchase.schema.js', () => {
|
||||||
|
const findChain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFind,
|
||||||
|
});
|
||||||
|
const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne });
|
||||||
|
const updateChain = () => ({ exec: mockFindOneAndUpdate });
|
||||||
|
const aggregateChain = () => ({ exec: mockAggregate });
|
||||||
|
|
||||||
|
class FakeModel {
|
||||||
|
data: unknown;
|
||||||
|
constructor(data: unknown) { this.data = data; }
|
||||||
|
save = mockSave;
|
||||||
|
toObject() { return this.data; }
|
||||||
|
static find = vi.fn(() => findChain());
|
||||||
|
static findOne = vi.fn(() => findOneChain());
|
||||||
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
static aggregate = vi.fn(() => aggregateChain());
|
||||||
|
}
|
||||||
|
return { PurchaseModel: FakeModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PurchasesRepository } from './purchases.repository.js';
|
||||||
|
|
||||||
|
const makeItem = (overrides = {}) => ({
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'Tylenol',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
addedToCabinet: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(PurchasesRepository.name, () => {
|
||||||
|
let repo: PurchasesRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new PurchasesRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('saves and returns plain object', async () => {
|
||||||
|
const data = { householdId: 'hh1', storeId: 'st-1', storeName: 'CVS', status: 'in_cabinet', items: [makeItem()], purchasedAt: new Date(), createdBy: 'u-1' };
|
||||||
|
mockSave.mockResolvedValue({ toObject: () => data });
|
||||||
|
|
||||||
|
const result = await repo.create(data);
|
||||||
|
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold', () => {
|
||||||
|
it('returns paginated items without hasMore', async () => {
|
||||||
|
const items = [{ _id: { toString: () => 'p-1' }, householdId: 'hh1' }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toEqual(items);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasMore and cursor when results exceed limit', async () => {
|
||||||
|
const items = Array.from({ length: 21 }, (_, i) => ({ _id: { toString: () => `p-${i}` } }));
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(20);
|
||||||
|
expect(result.pagination.hasMore).toBe(true);
|
||||||
|
expect(result.pagination.cursor).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status when provided', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, status: 'ordered' });
|
||||||
|
|
||||||
|
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'ordered' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by storeId when provided', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' });
|
||||||
|
|
||||||
|
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ storeId: 'st-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies cursor filter when provided', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
const cursor = Buffer.from('p-1').toString('base64');
|
||||||
|
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||||
|
|
||||||
|
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ _id: { $lt: 'p-1' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('returns purchase when found', async () => {
|
||||||
|
const purchase = { _id: 'p-1', householdId: 'hh1' };
|
||||||
|
mockFindOne.mockResolvedValue(purchase);
|
||||||
|
|
||||||
|
const result = await repo.findById('p-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual(purchase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await repo.findById('missing', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates notes and returns updated doc', async () => {
|
||||||
|
const updated = { _id: 'p-1', notes: 'new note' };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await repo.update('p-1', 'hh1', { notes: 'new note' });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when purchase not found', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await repo.update('missing', 'hh1', {});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes items in update set when provided', async () => {
|
||||||
|
const updated = { _id: 'p-1' };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||||
|
const items = [{ name: 'X', quantity: 1, unit: 'tablet', addedToCabinet: false }];
|
||||||
|
|
||||||
|
await repo.update('p-1', 'hh1', { items } as never);
|
||||||
|
|
||||||
|
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ $set: expect.objectContaining({ items }) }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('receiveAll', () => {
|
||||||
|
it('sets status to in_cabinet and all items addedToCabinet', async () => {
|
||||||
|
const updated = { _id: 'p-1', status: 'in_cabinet' };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||||
|
|
||||||
|
const result = await repo.receiveAll('p-1', 'hh1');
|
||||||
|
|
||||||
|
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ _id: 'p-1', householdId: 'hh1', isDeleted: false },
|
||||||
|
expect.objectContaining({
|
||||||
|
$set: expect.objectContaining({ status: 'in_cabinet' }),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markItemsAddedToCabinet', () => {
|
||||||
|
it('builds per-index update set and calls findOneAndUpdate', async () => {
|
||||||
|
const updated = { _id: 'p-1', items: [] };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||||
|
|
||||||
|
const result = await repo.markItemsAddedToCabinet('p-1', 'hh1', [0, 2]);
|
||||||
|
|
||||||
|
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ _id: 'p-1', householdId: 'hh1', isDeleted: false },
|
||||||
|
expect.objectContaining({
|
||||||
|
$set: expect.objectContaining({
|
||||||
|
'items.0.addedToCabinet': true,
|
||||||
|
'items.2.addedToCabinet': true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('softDelete', () => {
|
||||||
|
it('sets isDeleted to true', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'p-1', isDeleted: true });
|
||||||
|
|
||||||
|
const result = await repo.softDelete('p-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPendingMedicineStock', () => {
|
||||||
|
it('returns aggregated stock by medicineId', async () => {
|
||||||
|
const rows = [{ medicineId: 'med-1', totalUnits: 60 }];
|
||||||
|
mockAggregate.mockResolvedValue(rows);
|
||||||
|
|
||||||
|
const result = await repo.getPendingMedicineStock('hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no pending purchases', async () => {
|
||||||
|
mockAggregate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.getPendingMedicineStock('hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
142
packages/api/src/modules/purchases/purchases.repository.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { PurchaseModel } from '../../schemas/purchase.schema.js';
|
||||||
|
import type { PurchaseQueryInput, UpdatePurchaseInput } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export interface CreatePurchaseItemData {
|
||||||
|
medicineProductId?: string;
|
||||||
|
medicineId?: string;
|
||||||
|
foodProductId?: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
priceRecordId?: string;
|
||||||
|
addedToCabinet?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePurchaseData {
|
||||||
|
householdId: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
status: string;
|
||||||
|
items: CreatePurchaseItemData[];
|
||||||
|
notes?: string;
|
||||||
|
purchasedAt: Date;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PurchasesRepository {
|
||||||
|
public async create(data: CreatePurchaseData) {
|
||||||
|
const purchase = new PurchaseModel(data);
|
||||||
|
const saved = await purchase.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByHousehold(householdId: string, query: PurchaseQueryInput) {
|
||||||
|
const filter: Record<string, unknown> = { householdId, isDeleted: false };
|
||||||
|
|
||||||
|
if (query.status) filter['status'] = query.status;
|
||||||
|
if (query.storeId) filter['storeId'] = query.storeId;
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $lt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await PurchaseModel.find(filter)
|
||||||
|
.sort({ _id: -1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0
|
||||||
|
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string, householdId: string) {
|
||||||
|
return PurchaseModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdatePurchaseInput) {
|
||||||
|
const updateSet: Record<string, unknown> = {};
|
||||||
|
if (data.notes !== undefined) updateSet['notes'] = data.notes;
|
||||||
|
if (data.items !== undefined) updateSet['items'] = data.items;
|
||||||
|
|
||||||
|
return PurchaseModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId, isDeleted: false },
|
||||||
|
{ $set: updateSet },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markItemsAddedToCabinet(
|
||||||
|
purchaseId: string,
|
||||||
|
householdId: string,
|
||||||
|
itemIndices: number[],
|
||||||
|
) {
|
||||||
|
// Build update using positional array filters
|
||||||
|
const updateSet: Record<string, unknown> = {};
|
||||||
|
for (const idx of itemIndices) {
|
||||||
|
updateSet[`items.${idx}.addedToCabinet`] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PurchaseModel.findOneAndUpdate(
|
||||||
|
{ _id: purchaseId, householdId, isDeleted: false },
|
||||||
|
{ $set: updateSet },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async receiveAll(purchaseId: string, householdId: string) {
|
||||||
|
return PurchaseModel.findOneAndUpdate(
|
||||||
|
{ _id: purchaseId, householdId, isDeleted: false },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
status: 'in_cabinet',
|
||||||
|
receivedAt: new Date(),
|
||||||
|
'items.$[].addedToCabinet': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPendingMedicineStock(
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{ medicineId: string; totalUnits: number }[]> {
|
||||||
|
const results = await PurchaseModel.aggregate([
|
||||||
|
{ $match: { householdId, status: 'ordered', isDeleted: false } },
|
||||||
|
{ $unwind: '$items' },
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
'items.medicineId': { $exists: true, $ne: null },
|
||||||
|
'items.addedToCabinet': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$items.medicineId',
|
||||||
|
totalUnits: { $sum: '$items.quantity' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $project: { _id: 0, medicineId: '$_id', totalUnits: 1 } },
|
||||||
|
]).exec();
|
||||||
|
|
||||||
|
return results as { medicineId: string; totalUnits: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async softDelete(id: string, householdId: string) {
|
||||||
|
return PurchaseModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId, isDeleted: false, status: 'ordered' },
|
||||||
|
{ $set: { isDeleted: true } },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
476
packages/api/src/modules/purchases/purchases.routes.test.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockList,
|
||||||
|
mockGetById,
|
||||||
|
mockCreate,
|
||||||
|
mockUpdate,
|
||||||
|
mockReceive,
|
||||||
|
mockDelete,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockList: vi.fn(),
|
||||||
|
mockGetById: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockReceive: vi.fn(),
|
||||||
|
mockDelete: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./purchases.repository.js', () => ({
|
||||||
|
PurchasesRepository: class {
|
||||||
|
create = vi.fn();
|
||||||
|
findByHousehold = vi.fn();
|
||||||
|
findById = vi.fn();
|
||||||
|
update = vi.fn();
|
||||||
|
receiveAll = vi.fn();
|
||||||
|
softDelete = vi.fn();
|
||||||
|
getPendingMedicineStock = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./purchases.service.js', () => ({
|
||||||
|
PurchasesService: class {
|
||||||
|
list = mockList;
|
||||||
|
getById = mockGetById;
|
||||||
|
create = mockCreate;
|
||||||
|
update = mockUpdate;
|
||||||
|
receive = mockReceive;
|
||||||
|
delete = mockDelete;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import purchasesRoutes from './purchases.routes.js';
|
||||||
|
|
||||||
|
function makeFakePurchase(overrides = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'p-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
status: 'in_cabinet',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: 'item-1',
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'Advil',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
addedToCabinet: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
purchasedAt: '2026-01-15T00:00:00.000Z',
|
||||||
|
createdBy: 'kc-1',
|
||||||
|
createdAt: '2026-01-15T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-15T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('purchases.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(purchasesRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/purchases', () => {
|
||||||
|
it('returns paginated purchase list', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [makeFakePurchase()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.data[0].storeName).toBe('CVS');
|
||||||
|
expect(body.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes query params to service', async () => {
|
||||||
|
mockList.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases?status=ordered&limit=10',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockList).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ status: 'ordered', limit: 10 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes ObjectId _id to string', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [makeFakePurchase({ _id: { toString: () => 'p-obj' } })],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data[0]._id).toBe('p-obj');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts Date objects to ISO strings', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeFakePurchase({
|
||||||
|
purchasedAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
|
createdAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const item = res.json().data[0];
|
||||||
|
expect(item.purchasedAt).toBe('2026-01-15T00:00:00.000Z');
|
||||||
|
expect(item.createdAt).toBe('2026-01-15T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes optional fields in item response when present', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeFakePurchase({
|
||||||
|
notes: 'picked up on the way home',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: 'item-1',
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'Advil',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
actualPrice: 9.99,
|
||||||
|
currency: 'USD',
|
||||||
|
addedToCabinet: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const item = res.json().data[0].items[0];
|
||||||
|
expect(item.actualPrice).toBe(9.99);
|
||||||
|
expect(item.currency).toBe('USD');
|
||||||
|
expect(res.json().data[0].notes).toBe('picked up on the way home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles item with ObjectId _id and priceRecordId', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeFakePurchase({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: { toString: () => 'item-obj' },
|
||||||
|
name: 'Advil',
|
||||||
|
quantity: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
priceRecordId: 'pr-1',
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const item = res.json().data[0].items[0];
|
||||||
|
expect(item._id).toBe('item-obj');
|
||||||
|
expect(item.priceRecordId).toBe('pr-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles item without _id and includes receivedAt on purchase', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeFakePurchase({
|
||||||
|
status: 'in_cabinet',
|
||||||
|
receivedAt: '2026-01-20T00:00:00.000Z',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Generic',
|
||||||
|
quantity: 5,
|
||||||
|
unit: 'tablet',
|
||||||
|
addedToCabinet: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const purchase = res.json().data[0];
|
||||||
|
expect(purchase.items[0]._id).toBe('');
|
||||||
|
expect(purchase.receivedAt).toBe('2026-01-20T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/purchases/:id', () => {
|
||||||
|
it('returns single purchase', async () => {
|
||||||
|
mockGetById.mockResolvedValue(makeFakePurchase());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().storeName).toBe('CVS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id and householdId to service', async () => {
|
||||||
|
mockGetById.mockResolvedValue(makeFakePurchase());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-99',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetById).toHaveBeenCalledWith('p-99', 'hh1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/purchases', () => {
|
||||||
|
const validBody = {
|
||||||
|
storeId: 'st-1',
|
||||||
|
items: [{ name: 'Advil', quantity: 30, unit: 'tablet' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates purchase and returns 201', async () => {
|
||||||
|
mockCreate.mockResolvedValue(makeFakePurchase());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: validBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().storeName).toBe('CVS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes body, householdId, and userId to service', async () => {
|
||||||
|
mockCreate.mockResolvedValue(makeFakePurchase());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { ...validBody, status: 'ordered' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ storeId: 'st-1', status: 'ordered' }),
|
||||||
|
'hh1',
|
||||||
|
'kc-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for missing storeId', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { items: [{ name: 'X', quantity: 1, unit: 'tablet' }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for empty items array', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/purchases',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { storeId: 'st-1', items: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/purchases/:id', () => {
|
||||||
|
it('updates purchase and returns 200', async () => {
|
||||||
|
mockUpdate.mockResolvedValue(makeFakePurchase({ notes: 'updated note' }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { notes: 'updated note' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().notes).toBe('updated note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id, householdId, and body to service', async () => {
|
||||||
|
mockUpdate.mockResolvedValue(makeFakePurchase());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { notes: 'note' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
'p-1',
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ notes: 'note' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/purchases/:id/receive', () => {
|
||||||
|
it('returns addedCount and priceRecordsCreated', async () => {
|
||||||
|
mockReceive.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 1 });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-1/receive',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ addedCount: 2, priceRecordsCreated: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id, householdId, and userId to service', async () => {
|
||||||
|
mockReceive.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 });
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-1/receive',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReceive).toHaveBeenCalledWith('p-1', 'hh1', 'kc-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/households/:householdId/purchases/:id', () => {
|
||||||
|
it('deletes purchase and returns 200', async () => {
|
||||||
|
mockDelete.mockResolvedValue(makeFakePurchase());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()._id).toBe('p-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id and householdId to service', async () => {
|
||||||
|
mockDelete.mockResolvedValue(makeFakePurchase());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/purchases/p-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('p-1', 'hh1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
214
packages/api/src/modules/purchases/purchases.routes.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
CreatePurchaseSchema,
|
||||||
|
UpdatePurchaseSchema,
|
||||||
|
PurchaseQuerySchema,
|
||||||
|
PurchaseResponseSchema,
|
||||||
|
PurchaseListResponseSchema,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { PurchasesRepository } from './purchases.repository.js';
|
||||||
|
import { PurchasesService } from './purchases.service.js';
|
||||||
|
|
||||||
|
function toIso(v: Date | string | { toISOString: () => string }): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return v.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyPurchaseItem = {
|
||||||
|
_id?: string | { toString: () => string };
|
||||||
|
medicineProductId?: string;
|
||||||
|
medicineId?: string;
|
||||||
|
foodProductId?: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
priceRecordId?: string;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnyPurchase = {
|
||||||
|
_id: string | { toString: () => string };
|
||||||
|
householdId: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
status: string;
|
||||||
|
items: AnyPurchaseItem[];
|
||||||
|
notes?: string;
|
||||||
|
purchasedAt: Date | string | { toISOString: () => string };
|
||||||
|
receivedAt?: Date | string | { toISOString: () => string };
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date | string | { toISOString: () => string };
|
||||||
|
updatedAt: Date | string | { toISOString: () => string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function toItemResponse(item: AnyPurchaseItem) {
|
||||||
|
return {
|
||||||
|
_id: item._id
|
||||||
|
? typeof item._id === 'string'
|
||||||
|
? item._id
|
||||||
|
: item._id.toString()
|
||||||
|
: '',
|
||||||
|
...(item.medicineProductId ? { medicineProductId: item.medicineProductId } : {}),
|
||||||
|
...(item.medicineId ? { medicineId: item.medicineId } : {}),
|
||||||
|
...(item.foodProductId ? { foodProductId: item.foodProductId } : {}),
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
|
||||||
|
...(item.currency ? { currency: item.currency } : {}),
|
||||||
|
...(item.priceRecordId ? { priceRecordId: item.priceRecordId } : {}),
|
||||||
|
addedToCabinet: item.addedToCabinet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPurchaseResponse(doc: AnyPurchase) {
|
||||||
|
return {
|
||||||
|
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||||
|
householdId: doc.householdId,
|
||||||
|
storeId: doc.storeId,
|
||||||
|
storeName: doc.storeName,
|
||||||
|
status: doc.status as 'ordered' | 'in_cabinet',
|
||||||
|
items: doc.items.map(toItemResponse),
|
||||||
|
...(doc.notes ? { notes: doc.notes } : {}),
|
||||||
|
purchasedAt: toIso(doc.purchasedAt),
|
||||||
|
...(doc.receivedAt ? { receivedAt: toIso(doc.receivedAt) } : {}),
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
updatedAt: toIso(doc.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
purchasesRepository: PurchasesRepository;
|
||||||
|
purchasesService: PurchasesService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
purchasesRepository: asClass(PurchasesRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
purchasesService: asClass(PurchasesService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
const householdParams = z.object({ householdId: z.string() });
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/purchases',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
querystring: PurchaseQuerySchema,
|
||||||
|
response: { 200: PurchaseListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('purchasesService');
|
||||||
|
const result = await service.list(request.params.householdId, request.query);
|
||||||
|
return reply.send({
|
||||||
|
data: result.data.map(toPurchaseResponse),
|
||||||
|
pagination: result.pagination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/purchases/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: PurchaseResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('purchasesService');
|
||||||
|
const purchase = await service.getById(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toPurchaseResponse(purchase));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/purchases',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: CreatePurchaseSchema,
|
||||||
|
response: { 201: PurchaseResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('purchasesService');
|
||||||
|
const purchase = await service.create(
|
||||||
|
request.body,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(toPurchaseResponse(purchase));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/purchases/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
body: UpdatePurchaseSchema,
|
||||||
|
response: { 200: PurchaseResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('purchasesService');
|
||||||
|
const purchase = await service.update(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toPurchaseResponse(purchase));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/purchases/:id/receive',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
addedCount: z.number(),
|
||||||
|
priceRecordsCreated: z.number(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('purchasesService');
|
||||||
|
const result = await service.receive(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.send(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/:householdId/purchases/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: PurchaseResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('purchasesService');
|
||||||
|
const purchase = await service.delete(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toPurchaseResponse(purchase));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'purchases-routes',
|
||||||
|
dependencies: ['auth-plugin'],
|
||||||
|
},
|
||||||
|
);
|
||||||
418
packages/api/src/modules/purchases/purchases.service.test.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { PurchasesService } from './purchases.service.js';
|
||||||
|
|
||||||
|
describe(PurchasesService.name, () => {
|
||||||
|
const mockPurchasesRepo = {
|
||||||
|
create: vi.fn(),
|
||||||
|
findByHousehold: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
receiveAll: vi.fn(),
|
||||||
|
softDelete: vi.fn(),
|
||||||
|
getPendingMedicineStock: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockCabinetService = {
|
||||||
|
addItem: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockStoresRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockProductsRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockPricesRepo = {
|
||||||
|
create: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: PurchasesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new PurchasesService({
|
||||||
|
purchasesRepository: mockPurchasesRepo as never,
|
||||||
|
cabinetService: mockCabinetService as never,
|
||||||
|
storesRepository: mockStoresRepo as never,
|
||||||
|
medicineProductsRepository: mockProductsRepo as never,
|
||||||
|
medicinePricesRepository: mockPricesRepo as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeStore = { _id: 'st-1', name: 'CVS' };
|
||||||
|
const fakeProduct = { _id: 'mp-1', medicineId: 'med-1', medicineName: 'Ibuprofen', brand: 'Advil' };
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const validInput = {
|
||||||
|
storeId: 'st-1',
|
||||||
|
status: 'in_cabinet' as const,
|
||||||
|
items: [{ name: 'Advil', quantity: 30, unit: 'tablet', medicineProductId: 'mp-1' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('throws NotFoundError when store not found', async () => {
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow('Store not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when medicine product not found', async () => {
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Medicine product not found: mp-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates purchase with in_cabinet status and adds items to cabinet', async () => {
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
||||||
|
mockPurchasesRepo.create.mockResolvedValue(purchase);
|
||||||
|
|
||||||
|
const result = await service.create(validInput, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'in_cabinet', storeName: 'CVS' }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(purchase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records price when actualPrice is set and status is in_cabinet', async () => {
|
||||||
|
const inputWithPrice = {
|
||||||
|
...validInput,
|
||||||
|
items: [{ ...validInput.items[0], actualPrice: 9.99, currency: 'USD' }],
|
||||||
|
};
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
mockPricesRepo.create.mockResolvedValue({});
|
||||||
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||||
|
|
||||||
|
await service.create(inputWithPrice, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
price: 9.99,
|
||||||
|
medicineName: 'Ibuprofen',
|
||||||
|
storeName: 'CVS',
|
||||||
|
pricePerUnit: expect.closeTo(0.333, 2),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add to cabinet when status is ordered', async () => {
|
||||||
|
const orderedInput = { ...validInput, status: 'ordered' as const };
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||||
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1', status: 'ordered' });
|
||||||
|
|
||||||
|
await service.create(orderedInput, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||||
|
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles item without medicineProductId for in_cabinet', async () => {
|
||||||
|
const noProductInput = {
|
||||||
|
storeId: 'st-1',
|
||||||
|
status: 'in_cabinet' as const,
|
||||||
|
items: [{ name: 'Generic OTC', quantity: 1, unit: 'tablet' }],
|
||||||
|
};
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||||
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||||
|
|
||||||
|
await service.create(noProductInput, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||||
|
expect(mockPurchasesRepo.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses purchasedAt from input when provided', async () => {
|
||||||
|
const inputWithDate = { ...validInput, purchasedAt: '2026-01-15T00:00:00.000Z' };
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||||
|
|
||||||
|
await service.create(inputWithDate, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ purchasedAt: new Date('2026-01-15T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses medicineName as brand fallback when brand is undefined', async () => {
|
||||||
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
mockPricesRepo.create.mockResolvedValue({});
|
||||||
|
const inputWithPrice = {
|
||||||
|
...validInput,
|
||||||
|
items: [{ ...validInput.items[0], actualPrice: 5 }],
|
||||||
|
};
|
||||||
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||||
|
|
||||||
|
await service.create(inputWithPrice, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('receive', () => {
|
||||||
|
it('throws NotFoundError when purchase not found', async () => {
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow('Purchase not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestError when status is not ordered', async () => {
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet', items: [] });
|
||||||
|
|
||||||
|
await expect(service.receive('p-1', 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Purchase is not in ordered status',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds medicine items to cabinet and calls receiveAll', async () => {
|
||||||
|
const purchase = {
|
||||||
|
_id: 'p-1',
|
||||||
|
status: 'ordered',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'Advil',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
mockPurchasesRepo.receiveAll.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet' });
|
||||||
|
|
||||||
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPurchasesRepo.receiveAll).toHaveBeenCalledWith('p-1', 'hh1');
|
||||||
|
expect(result.addedCount).toBe(1);
|
||||||
|
expect(result.priceRecordsCreated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates price record when actualPrice is set on item', async () => {
|
||||||
|
const purchase = {
|
||||||
|
_id: 'p-1',
|
||||||
|
status: 'ordered',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'Advil',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
actualPrice: 9.99,
|
||||||
|
currency: 'USD',
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||||
|
mockPricesRepo.create.mockResolvedValue({});
|
||||||
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.create).toHaveBeenCalledOnce();
|
||||||
|
expect(result.priceRecordsCreated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses medicineName as brand fallback in price record when brand is undefined', async () => {
|
||||||
|
const purchase = {
|
||||||
|
_id: 'p-1',
|
||||||
|
status: 'ordered',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'Ibuprofen',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
actualPrice: 9.99,
|
||||||
|
currency: 'USD',
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
||||||
|
mockPricesRepo.create.mockResolvedValue({});
|
||||||
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||||
|
|
||||||
|
await service.receive('p-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips price record creation when product not found in receive', async () => {
|
||||||
|
const purchase = {
|
||||||
|
_id: 'p-1',
|
||||||
|
status: 'ordered',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
purchasedAt: new Date(),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'Advil',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
actualPrice: 9.99,
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({});
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(null);
|
||||||
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(result.priceRecordsCreated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips items already added to cabinet', async () => {
|
||||||
|
const purchase = {
|
||||||
|
_id: 'p-1',
|
||||||
|
status: 'ordered',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
purchasedAt: new Date(),
|
||||||
|
items: [
|
||||||
|
{ medicineProductId: 'mp-1', medicineId: 'med-1', name: 'X', quantity: 10, unit: 'tablet', addedToCabinet: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||||
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||||
|
expect(result.addedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockPurchasesRepo.findByHousehold.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const response = await service.list('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(response).toEqual(result);
|
||||||
|
expect(mockPurchasesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns purchase when found', async () => {
|
||||||
|
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||||
|
|
||||||
|
expect(await service.getById('p-1', 'hh1')).toEqual(purchase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Purchase not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns purchase', async () => {
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
||||||
|
const updated = { _id: 'p-1', notes: 'updated' };
|
||||||
|
mockPurchasesRepo.update.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await service.update('p-1', 'hh1', { notes: 'updated' });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when purchase does not exist', async () => {
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Purchase not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
||||||
|
mockPurchasesRepo.update.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('p-1', 'hh1', {})).rejects.toThrow('Purchase not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('soft-deletes and returns purchase', async () => {
|
||||||
|
const deleted = { _id: 'p-1', isDeleted: true };
|
||||||
|
mockPurchasesRepo.softDelete.mockResolvedValue(deleted);
|
||||||
|
|
||||||
|
const result = await service.delete('p-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual(deleted);
|
||||||
|
expect(mockPurchasesRepo.softDelete).toHaveBeenCalledWith('p-1', 'hh1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when purchase not found', async () => {
|
||||||
|
mockPurchasesRepo.softDelete.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.delete('missing', 'hh1')).rejects.toThrow(
|
||||||
|
'Purchase not found or cannot be deleted',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPendingStockByMedicine', () => {
|
||||||
|
it('returns map of medicineId to totalUnits', async () => {
|
||||||
|
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', totalUnits: 60 },
|
||||||
|
{ medicineId: 'med-2', totalUnits: 30 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getPendingStockByMedicine('hh1');
|
||||||
|
|
||||||
|
expect(result.get('med-1')).toBe(60);
|
||||||
|
expect(result.get('med-2')).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty map when no pending stock', async () => {
|
||||||
|
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.getPendingStockByMedicine('hh1');
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
272
packages/api/src/modules/purchases/purchases.service.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
import type { PurchasesRepository } from './purchases.repository.js';
|
||||||
|
import type { StoresRepository } from '../stores/stores.repository.js';
|
||||||
|
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||||
|
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
|
||||||
|
import type { CabinetService } from '../cabinet/cabinet.service.js';
|
||||||
|
import type {
|
||||||
|
CreatePurchaseInput,
|
||||||
|
UpdatePurchaseInput,
|
||||||
|
PurchaseQueryInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { DosageUnit } from '@meshitrack/shared';
|
||||||
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
purchasesRepository: PurchasesRepository;
|
||||||
|
cabinetService: CabinetService;
|
||||||
|
storesRepository: StoresRepository;
|
||||||
|
medicineProductsRepository: MedicineProductsRepository;
|
||||||
|
medicinePricesRepository: MedicinePricesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PurchasesService {
|
||||||
|
private readonly purchasesRepository: PurchasesRepository;
|
||||||
|
private readonly cabinetService: CabinetService;
|
||||||
|
private readonly storesRepository: StoresRepository;
|
||||||
|
private readonly medicineProductsRepository: MedicineProductsRepository;
|
||||||
|
private readonly medicinePricesRepository: MedicinePricesRepository;
|
||||||
|
|
||||||
|
public constructor({
|
||||||
|
purchasesRepository,
|
||||||
|
cabinetService,
|
||||||
|
storesRepository,
|
||||||
|
medicineProductsRepository,
|
||||||
|
medicinePricesRepository,
|
||||||
|
}: Deps) {
|
||||||
|
this.purchasesRepository = purchasesRepository;
|
||||||
|
this.cabinetService = cabinetService;
|
||||||
|
this.storesRepository = storesRepository;
|
||||||
|
this.medicineProductsRepository = medicineProductsRepository;
|
||||||
|
this.medicinePricesRepository = medicinePricesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreatePurchaseInput, householdId: string, userId: string) {
|
||||||
|
const store = await this.storesRepository.findById(data.storeId, householdId);
|
||||||
|
if (!store) throw new NotFoundError('Store not found');
|
||||||
|
|
||||||
|
const purchasedAt = data.purchasedAt ? new Date(data.purchasedAt) : new Date();
|
||||||
|
|
||||||
|
const items: Array<{
|
||||||
|
medicineProductId?: string;
|
||||||
|
medicineId?: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
priceRecordId?: string;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of data.items) {
|
||||||
|
let resolvedName = item.name;
|
||||||
|
let resolvedMedicineId: string | undefined;
|
||||||
|
|
||||||
|
if (item.medicineProductId) {
|
||||||
|
const product = await this.medicineProductsRepository.findById(
|
||||||
|
item.medicineProductId,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
|
if (!product) throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
|
||||||
|
if (!resolvedName || resolvedName === item.name) {
|
||||||
|
resolvedName = product.brand ?? resolvedName;
|
||||||
|
}
|
||||||
|
resolvedMedicineId = product.medicineId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
medicineProductId: item.medicineProductId,
|
||||||
|
medicineId: resolvedMedicineId,
|
||||||
|
name: resolvedName,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
actualPrice: item.actualPrice,
|
||||||
|
currency: item.currency,
|
||||||
|
addedToCabinet: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'in_cabinet') {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.medicineProductId && item.medicineId) {
|
||||||
|
await this.cabinetService.addItem(
|
||||||
|
{
|
||||||
|
medicineId: item.medicineId,
|
||||||
|
medicineProductId: item.medicineProductId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit as DosageUnit,
|
||||||
|
unitPrice:
|
||||||
|
item.actualPrice !== undefined && item.quantity > 0
|
||||||
|
? item.actualPrice / item.quantity
|
||||||
|
: undefined,
|
||||||
|
totalPrice: item.actualPrice,
|
||||||
|
storeId: data.storeId,
|
||||||
|
purchaseDate: purchasedAt.toISOString(),
|
||||||
|
},
|
||||||
|
householdId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.actualPrice !== undefined) {
|
||||||
|
const product = await this.medicineProductsRepository.findById(
|
||||||
|
item.medicineProductId,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
|
if (product) {
|
||||||
|
await this.medicinePricesRepository.create({
|
||||||
|
householdId,
|
||||||
|
medicineProductId: item.medicineProductId,
|
||||||
|
medicineProductBrand: (product.brand as string) ?? (product.medicineName as string),
|
||||||
|
medicineId: item.medicineId,
|
||||||
|
/* v8 ignore next */
|
||||||
|
medicineName: (product.medicineName as string) ?? '',
|
||||||
|
storeId: data.storeId,
|
||||||
|
storeName: store.name as string,
|
||||||
|
price: item.actualPrice,
|
||||||
|
currency: item.currency ?? 'USD',
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
/* v8 ignore next */
|
||||||
|
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
|
||||||
|
date: purchasedAt,
|
||||||
|
isInsurancePrice: false,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items[i] = { ...item, addedToCabinet: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.purchasesRepository.create({
|
||||||
|
householdId,
|
||||||
|
storeId: data.storeId,
|
||||||
|
storeName: store.name as string,
|
||||||
|
status: data.status,
|
||||||
|
items,
|
||||||
|
notes: data.notes,
|
||||||
|
purchasedAt,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async receive(id: string, householdId: string, userId: string) {
|
||||||
|
const purchase = await this.purchasesRepository.findById(id, householdId);
|
||||||
|
if (!purchase) throw new NotFoundError('Purchase not found');
|
||||||
|
if (purchase.status !== 'ordered') {
|
||||||
|
throw new BadRequestError('Purchase is not in ordered status');
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
let priceRecordsCreated = 0;
|
||||||
|
|
||||||
|
const itemsToUpdate: number[] = [];
|
||||||
|
|
||||||
|
const items = purchase.items as Array<{
|
||||||
|
medicineProductId?: string;
|
||||||
|
medicineId?: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.medicineProductId && item.medicineId && !item.addedToCabinet) {
|
||||||
|
await this.cabinetService.addItem(
|
||||||
|
{
|
||||||
|
medicineId: item.medicineId,
|
||||||
|
medicineProductId: item.medicineProductId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit as DosageUnit,
|
||||||
|
unitPrice:
|
||||||
|
item.actualPrice !== undefined && item.quantity > 0
|
||||||
|
? item.actualPrice / item.quantity
|
||||||
|
: undefined,
|
||||||
|
totalPrice: item.actualPrice,
|
||||||
|
storeId: purchase.storeId as string,
|
||||||
|
purchaseDate: (purchase.purchasedAt as Date).toISOString(),
|
||||||
|
},
|
||||||
|
householdId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
addedCount++;
|
||||||
|
|
||||||
|
if (item.actualPrice !== undefined) {
|
||||||
|
const product = await this.medicineProductsRepository.findById(
|
||||||
|
item.medicineProductId,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
|
if (product) {
|
||||||
|
await this.medicinePricesRepository.create({
|
||||||
|
householdId,
|
||||||
|
medicineProductId: item.medicineProductId,
|
||||||
|
medicineProductBrand: (product.brand as string) ?? (product.medicineName as string),
|
||||||
|
medicineId: item.medicineId,
|
||||||
|
/* v8 ignore next */
|
||||||
|
medicineName: (product.medicineName as string) ?? '',
|
||||||
|
storeId: purchase.storeId as string,
|
||||||
|
storeName: purchase.storeName as string,
|
||||||
|
price: item.actualPrice,
|
||||||
|
currency: item.currency ?? 'USD',
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
/* v8 ignore next */
|
||||||
|
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
|
||||||
|
date: purchase.purchasedAt as Date,
|
||||||
|
isInsurancePrice: false,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
priceRecordsCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsToUpdate.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.purchasesRepository.receiveAll(id, householdId);
|
||||||
|
|
||||||
|
return { addedCount, priceRecordsCreated };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(householdId: string, query: PurchaseQueryInput) {
|
||||||
|
return this.purchasesRepository.findByHousehold(householdId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string, householdId: string) {
|
||||||
|
const purchase = await this.purchasesRepository.findById(id, householdId);
|
||||||
|
if (!purchase) throw new NotFoundError('Purchase not found');
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdatePurchaseInput) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
const updated = await this.purchasesRepository.update(id, householdId, data);
|
||||||
|
if (!updated) throw new NotFoundError('Purchase not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string, householdId: string) {
|
||||||
|
const deleted = await this.purchasesRepository.softDelete(id, householdId);
|
||||||
|
if (!deleted) throw new NotFoundError('Purchase not found or cannot be deleted');
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPendingStockByMedicine(
|
||||||
|
householdId: string,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const results = await this.purchasesRepository.getPendingMedicineStock(householdId);
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of results) {
|
||||||
|
map.set(r.medicineId, r.totalUnits);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
packages/api/src/modules/refills/refills.repository.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockFindOneAndUpdate: vi.fn(),
|
||||||
|
mockSave: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../schemas/refill-list.schema.js', () => {
|
||||||
|
const chain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFind,
|
||||||
|
});
|
||||||
|
const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne });
|
||||||
|
const updateChain = () => ({ exec: mockFindOneAndUpdate });
|
||||||
|
|
||||||
|
class FakeModel {
|
||||||
|
data: unknown;
|
||||||
|
constructor(data: unknown) { this.data = data; }
|
||||||
|
save = mockSave;
|
||||||
|
toObject() { return this.data; }
|
||||||
|
static find = vi.fn(() => chain());
|
||||||
|
static findOne = vi.fn(() => findOneChain());
|
||||||
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
}
|
||||||
|
return { RefillListModel: FakeModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RefillsRepository } from './refills.repository.js';
|
||||||
|
|
||||||
|
describe(RefillsRepository.name, () => {
|
||||||
|
let repo: RefillsRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new RefillsRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('saves and returns refill list', async () => {
|
||||||
|
const data = {
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Monthly Refills',
|
||||||
|
status: 'active',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.create(data);
|
||||||
|
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold', () => {
|
||||||
|
it('returns paginated lists', async () => {
|
||||||
|
const lists = [{ _id: 'rl-1', name: 'Monthly Refills' }];
|
||||||
|
mockFind.mockResolvedValue(lists);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toEqual(lists);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets hasMore when more lists exist', async () => {
|
||||||
|
const lists = [{ _id: 'rl-1' }, { _id: 'rl-2' }, { _id: 'rl-3' }];
|
||||||
|
mockFind.mockResolvedValue(lists);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.pagination.hasMore).toBe(true);
|
||||||
|
expect(result.pagination.cursor).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cursor pagination', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const cursor = Buffer.from('rl-1').toString('base64');
|
||||||
|
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { status: 'active' as never, limit: 20 });
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null cursor when no data', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('returns list when found', async () => {
|
||||||
|
const list = { _id: 'rl-1', name: 'Monthly Refills' };
|
||||||
|
mockFindOne.mockResolvedValue(list);
|
||||||
|
|
||||||
|
const result = await repo.findById('rl-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await repo.findById('missing', 'hh1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns list', async () => {
|
||||||
|
const updated = { _id: 'rl-1', name: 'Updated' };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await repo.update('rl-1', 'hh1', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates status field', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', status: 'shopping' });
|
||||||
|
|
||||||
|
const result = await repo.update('rl-1', 'hh1', { status: 'shopping' as never });
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates preferredStoreId field', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', preferredStoreId: 'st-1' });
|
||||||
|
|
||||||
|
const result = await repo.update('rl-1', 'hh1', { preferredStoreId: 'st-1' });
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await repo.update('missing', 'hh1', {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateItem', () => {
|
||||||
|
it('updates item and returns list', async () => {
|
||||||
|
const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates actualPrice, storeId, and notes', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
|
await repo.updateItem('rl-1', 'hh1', 'item-1', {
|
||||||
|
actualPrice: 9.99,
|
||||||
|
storeId: 'st-1',
|
||||||
|
notes: 'picked up at CVS',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFindOneAndUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes checkedAt when provided', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
|
const checkedAt = new Date();
|
||||||
|
await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true, checkedAt });
|
||||||
|
|
||||||
|
expect(mockFindOneAndUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markItemsAddedToCabinet', () => {
|
||||||
|
it('marks items and returns list', async () => {
|
||||||
|
const updated = { _id: 'rl-1', items: [] };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await repo.markItemsAddedToCabinet('rl-1', 'hh1', ['item-1', 'item-2']);
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
104
packages/api/src/modules/refills/refills.repository.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { RefillListModel } from '../../schemas/refill-list.schema.js';
|
||||||
|
import type { RefillListQueryInput, UpdateRefillListInput, UpdateRefillListItemInput } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export interface CreateRefillListData {
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
preferredStoreId?: string;
|
||||||
|
totalEstimatedCost?: number;
|
||||||
|
createdBy: string;
|
||||||
|
items: Array<{
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
estimatedPrice?: number;
|
||||||
|
storeId?: string;
|
||||||
|
notes?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefillsRepository {
|
||||||
|
public async create(data: CreateRefillListData) {
|
||||||
|
const list = new RefillListModel(data);
|
||||||
|
const saved = await list.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByHousehold(householdId: string, query: RefillListQueryInput) {
|
||||||
|
const filter: Record<string, unknown> = { householdId };
|
||||||
|
|
||||||
|
if (query.status) filter['status'] = query.status;
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $lt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await RefillListModel.find(filter)
|
||||||
|
.sort({ _id: -1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0
|
||||||
|
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string, householdId: string) {
|
||||||
|
return RefillListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdateRefillListInput) {
|
||||||
|
const updateSet: Record<string, unknown> = {};
|
||||||
|
if (data.name !== undefined) updateSet['name'] = data.name;
|
||||||
|
if (data.status !== undefined) updateSet['status'] = data.status;
|
||||||
|
if (data.preferredStoreId !== undefined) updateSet['preferredStoreId'] = data.preferredStoreId;
|
||||||
|
|
||||||
|
return RefillListModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId },
|
||||||
|
{ $set: updateSet },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateItem(
|
||||||
|
listId: string,
|
||||||
|
householdId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: UpdateRefillListItemInput & { checkedAt?: Date },
|
||||||
|
) {
|
||||||
|
const updateSet: Record<string, unknown> = {};
|
||||||
|
if (data.checked !== undefined) updateSet['items.$.checked'] = data.checked;
|
||||||
|
if (data.actualPrice !== undefined) updateSet['items.$.actualPrice'] = data.actualPrice;
|
||||||
|
if (data.storeId !== undefined) updateSet['items.$.storeId'] = data.storeId;
|
||||||
|
if (data.notes !== undefined) updateSet['items.$.notes'] = data.notes;
|
||||||
|
if (data.checkedAt !== undefined) updateSet['items.$.checkedAt'] = data.checkedAt;
|
||||||
|
|
||||||
|
return RefillListModel.findOneAndUpdate(
|
||||||
|
{ _id: listId, householdId, 'items._id': itemId },
|
||||||
|
{ $set: updateSet },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markItemsAddedToCabinet(listId: string, householdId: string, itemIds: string[]) {
|
||||||
|
return RefillListModel.findOneAndUpdate(
|
||||||
|
{ _id: listId, householdId },
|
||||||
|
{ $set: { 'items.$[elem].addedToCabinet': true } },
|
||||||
|
{
|
||||||
|
arrayFilters: [{ 'elem._id': { $in: itemIds } }],
|
||||||
|
new: true,
|
||||||
|
lean: true,
|
||||||
|
},
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
506
packages/api/src/modules/refills/refills.routes.test.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockGetAlerts,
|
||||||
|
mockCreateList,
|
||||||
|
mockList,
|
||||||
|
mockGetById,
|
||||||
|
mockUpdateList,
|
||||||
|
mockUpdateItem,
|
||||||
|
mockAddToCabinet,
|
||||||
|
mockGetStoreComparison,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockGetAlerts: vi.fn(),
|
||||||
|
mockCreateList: vi.fn(),
|
||||||
|
mockList: vi.fn(),
|
||||||
|
mockGetById: vi.fn(),
|
||||||
|
mockUpdateList: vi.fn(),
|
||||||
|
mockUpdateItem: vi.fn(),
|
||||||
|
mockAddToCabinet: vi.fn(),
|
||||||
|
mockGetStoreComparison: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./refills.repository.js', () => ({
|
||||||
|
RefillsRepository: class {
|
||||||
|
create = vi.fn();
|
||||||
|
findByHousehold = vi.fn();
|
||||||
|
findById = vi.fn();
|
||||||
|
update = vi.fn();
|
||||||
|
updateItem = vi.fn();
|
||||||
|
markItemsAddedToCabinet = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./refills.service.js', () => ({
|
||||||
|
RefillsService: class {
|
||||||
|
getAlerts = mockGetAlerts;
|
||||||
|
createList = mockCreateList;
|
||||||
|
list = mockList;
|
||||||
|
getById = mockGetById;
|
||||||
|
updateList = mockUpdateList;
|
||||||
|
updateItem = mockUpdateItem;
|
||||||
|
addToCabinet = mockAddToCabinet;
|
||||||
|
getStoreComparison = mockGetStoreComparison;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import refillsRoutes from './refills.routes.js';
|
||||||
|
|
||||||
|
function makeFakeRefillList(overrides = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'rl-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Monthly Refills',
|
||||||
|
items: [],
|
||||||
|
status: 'active',
|
||||||
|
createdBy: 'kc-1',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('refills.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(refillsRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/refills/alerts', () => {
|
||||||
|
it('returns alerts with price options', async () => {
|
||||||
|
mockGetAlerts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
daysUntilEmpty: 3,
|
||||||
|
dailyConsumption: 2,
|
||||||
|
currentStock: 6,
|
||||||
|
suggestedQuantity: 60,
|
||||||
|
lastKnownPrice: { price: 10, pricePerUnit: 0.1, storeName: 'CVS', storeId: 'st-1', date: new Date('2026-01-01T00:00:00.000Z') },
|
||||||
|
cheapestOption: { price: 8, pricePerUnit: 0.08, storeName: 'Walmart', storeId: 'st-2', date: new Date('2026-01-02T00:00:00.000Z') },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/alerts',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data[0].lastKnownPrice.storeName).toBe('CVS');
|
||||||
|
expect(body.data[0].lastKnownPrice.date).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
expect(body.data[0].cheapestOption.storeName).toBe('Walmart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns alerts', async () => {
|
||||||
|
mockGetAlerts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
daysUntilEmpty: 3,
|
||||||
|
dailyConsumption: 2,
|
||||||
|
currentStock: 6,
|
||||||
|
suggestedQuantity: 60,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/alerts',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.data[0].medicineName).toBe('Aspirin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses requesting user by default', async () => {
|
||||||
|
mockGetAlerts.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/alerts',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'kc-1', 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses userId query param when provided', async () => {
|
||||||
|
mockGetAlerts.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/alerts?userId=other-user&thresholdDays=14',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'other-user', 14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/refills/lists', () => {
|
||||||
|
it('creates list and returns 201', async () => {
|
||||||
|
mockCreateList.mockResolvedValue(makeFakeRefillList());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { name: 'Monthly Refills', fromAlerts: false, thresholdDays: 7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().name).toBe('Monthly Refills');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes householdId and userId to service', async () => {
|
||||||
|
mockCreateList.mockResolvedValue(makeFakeRefillList());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { name: 'Auto List', fromAlerts: true, thresholdDays: 7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreateList).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'Auto List', fromAlerts: true }),
|
||||||
|
'hh1',
|
||||||
|
'kc-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for missing name', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/refills/lists', () => {
|
||||||
|
it('returns paginated lists', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [makeFakeRefillList()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes optional list fields in response', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [makeFakeRefillList({
|
||||||
|
preferredStoreId: 'st-1',
|
||||||
|
totalEstimatedCost: 25.5,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: 'item-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
estimatedPrice: 10,
|
||||||
|
actualPrice: 9.5,
|
||||||
|
checked: true,
|
||||||
|
checkedAt: new Date('2026-01-10T00:00:00.000Z'),
|
||||||
|
addedToCabinet: false,
|
||||||
|
storeId: 'st-1',
|
||||||
|
notes: 'generic brand',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data[0].preferredStoreId).toBe('st-1');
|
||||||
|
expect(body.data[0].totalEstimatedCost).toBe(25.5);
|
||||||
|
const item = body.data[0].items[0];
|
||||||
|
expect(item.estimatedPrice).toBe(10);
|
||||||
|
expect(item.actualPrice).toBe(9.5);
|
||||||
|
expect(item.checkedAt).toBe('2026-01-10T00:00:00.000Z');
|
||||||
|
expect(item.storeId).toBe('st-1');
|
||||||
|
expect(item.notes).toBe('generic brand');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/refills/lists/:id', () => {
|
||||||
|
it('returns single list', async () => {
|
||||||
|
mockGetById.mockResolvedValue(makeFakeRefillList());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Monthly Refills');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ObjectId-style _id in list and items', async () => {
|
||||||
|
mockGetById.mockResolvedValue(makeFakeRefillList({
|
||||||
|
_id: { toString: () => 'rl-obj' },
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: { toString: () => 'item-obj' },
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
checked: false,
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body._id).toBe('rl-obj');
|
||||||
|
expect(body.items[0]._id).toBe('item-obj');
|
||||||
|
expect(body.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id and householdId to service', async () => {
|
||||||
|
mockGetById.mockResolvedValue(makeFakeRefillList());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-99',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetById).toHaveBeenCalledWith('rl-99', 'hh1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/refills/lists/:id', () => {
|
||||||
|
it('updates list and returns 200', async () => {
|
||||||
|
mockUpdateList.mockResolvedValue(makeFakeRefillList({ name: 'Updated' }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { name: 'Updated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id, householdId, body to service', async () => {
|
||||||
|
mockUpdateList.mockResolvedValue(makeFakeRefillList());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { status: 'shopping' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateList).toHaveBeenCalledWith(
|
||||||
|
'rl-1',
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ status: 'shopping' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/refills/lists/:id/items/:itemId', () => {
|
||||||
|
it('updates item and returns 200', async () => {
|
||||||
|
mockUpdateItem.mockResolvedValue(makeFakeRefillList());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { checked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes listId, householdId, itemId, body to service', async () => {
|
||||||
|
mockUpdateItem.mockResolvedValue(makeFakeRefillList());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-99',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { actualPrice: 9.99 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateItem).toHaveBeenCalledWith(
|
||||||
|
'rl-1',
|
||||||
|
'hh1',
|
||||||
|
'item-99',
|
||||||
|
expect.objectContaining({ actualPrice: 9.99 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/refills/lists/:id/add-to-cabinet', () => {
|
||||||
|
it('adds items to cabinet and returns summary', async () => {
|
||||||
|
mockAddToCabinet.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 0 });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.addedCount).toBe(2);
|
||||||
|
expect(body.priceRecordsCreated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes listId, householdId, userId to service', async () => {
|
||||||
|
mockAddToCabinet.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 });
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAddToCabinet).toHaveBeenCalledWith('rl-1', 'hh1', 'kc-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/refills/lists/:id/store-comparison', () => {
|
||||||
|
it('returns store comparison data', async () => {
|
||||||
|
mockGetStoreComparison.mockResolvedValue([
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
storeOptions: [
|
||||||
|
{
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
latestPrice: 8,
|
||||||
|
latestPricePerUnit: 0.08,
|
||||||
|
currency: 'USD',
|
||||||
|
date: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
isInsurancePrice: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-1/store-comparison',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.data[0].storeOptions[0].date).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes listId and householdId to service', async () => {
|
||||||
|
mockGetStoreComparison.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/refills/lists/rl-99/store-comparison',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetStoreComparison).toHaveBeenCalledWith('rl-99', 'hh1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
279
packages/api/src/modules/refills/refills.routes.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
CreateRefillListSchema,
|
||||||
|
UpdateRefillListSchema,
|
||||||
|
UpdateRefillListItemSchema,
|
||||||
|
RefillListQuerySchema,
|
||||||
|
RefillAlertQuerySchema,
|
||||||
|
RefillAlertResponseSchema,
|
||||||
|
RefillListResponseSchema,
|
||||||
|
RefillListListResponseSchema,
|
||||||
|
AddToCabinetResponseSchema,
|
||||||
|
StoreComparisonItemSchema,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { RefillsRepository } from './refills.repository.js';
|
||||||
|
import { RefillsService } from './refills.service.js';
|
||||||
|
|
||||||
|
function toIso(v: Date | string | { toISOString: () => string }): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return v.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyItem = {
|
||||||
|
_id: string | { toString: () => string };
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
estimatedPrice?: number;
|
||||||
|
actualPrice?: number;
|
||||||
|
checked: boolean;
|
||||||
|
checkedAt?: Date | string;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
storeId?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnyRefillList = {
|
||||||
|
_id: string | { toString: () => string };
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
items: AnyItem[];
|
||||||
|
status: string;
|
||||||
|
preferredStoreId?: string;
|
||||||
|
totalEstimatedCost?: number;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date | string | { toISOString: () => string };
|
||||||
|
updatedAt: Date | string | { toISOString: () => string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function toItemResponse(rawItem: unknown) {
|
||||||
|
const item = rawItem as AnyItem;
|
||||||
|
return {
|
||||||
|
_id: typeof item._id === 'string' ? item._id : item._id.toString(),
|
||||||
|
medicineId: item.medicineId,
|
||||||
|
medicineName: item.medicineName,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
...(item.estimatedPrice != null ? { estimatedPrice: item.estimatedPrice } : {}),
|
||||||
|
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
|
||||||
|
checked: item.checked,
|
||||||
|
...(item.checkedAt != null ? { checkedAt: toIso(item.checkedAt) } : {}),
|
||||||
|
addedToCabinet: item.addedToCabinet,
|
||||||
|
...(item.storeId != null ? { storeId: item.storeId } : {}),
|
||||||
|
...(item.notes != null ? { notes: item.notes } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toListResponse(rawDoc: unknown) {
|
||||||
|
const doc = rawDoc as AnyRefillList;
|
||||||
|
return {
|
||||||
|
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||||
|
householdId: doc.householdId,
|
||||||
|
name: doc.name,
|
||||||
|
items: doc.items.map(toItemResponse),
|
||||||
|
status: doc.status as never,
|
||||||
|
...(doc.preferredStoreId != null ? { preferredStoreId: doc.preferredStoreId } : {}),
|
||||||
|
...(doc.totalEstimatedCost != null ? { totalEstimatedCost: doc.totalEstimatedCost } : {}),
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
updatedAt: toIso(doc.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
refillsRepository: RefillsRepository;
|
||||||
|
refillsService: RefillsService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
refillsRepository: asClass(RefillsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
refillsService: asClass(RefillsService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
const householdParams = z.object({ householdId: z.string() });
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/refills/alerts',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
querystring: RefillAlertQuerySchema,
|
||||||
|
response: { 200: z.object({ data: z.array(RefillAlertResponseSchema) }) },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const alerts = await service.getAlerts(
|
||||||
|
request.params.householdId,
|
||||||
|
request.query.userId ?? request.user.keycloakId,
|
||||||
|
request.query.thresholdDays,
|
||||||
|
);
|
||||||
|
return reply.send({
|
||||||
|
data: alerts.map((a) => ({
|
||||||
|
...a,
|
||||||
|
lastKnownPrice: a.lastKnownPrice
|
||||||
|
? { ...a.lastKnownPrice, date: toIso(a.lastKnownPrice.date) }
|
||||||
|
: undefined,
|
||||||
|
cheapestOption: a.cheapestOption
|
||||||
|
? { ...a.cheapestOption, date: toIso(a.cheapestOption.date) }
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/refills/lists',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: CreateRefillListSchema,
|
||||||
|
response: { 201: RefillListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const list = await service.createList(
|
||||||
|
request.body,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(toListResponse(list));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/refills/lists',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
querystring: RefillListQuerySchema,
|
||||||
|
response: { 200: RefillListListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const result = await service.list(request.params.householdId, request.query);
|
||||||
|
return reply.send({
|
||||||
|
data: result.data.map(toListResponse),
|
||||||
|
pagination: result.pagination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/refills/lists/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: RefillListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const list = await service.getById(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toListResponse(list));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/refills/lists/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
body: UpdateRefillListSchema,
|
||||||
|
response: { 200: RefillListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const list = await service.updateList(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toListResponse(list));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/refills/lists/:id/items/:itemId',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string(), itemId: z.string() }),
|
||||||
|
body: UpdateRefillListItemSchema,
|
||||||
|
response: { 200: RefillListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const list = await service.updateItem(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.params.itemId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toListResponse(list));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/refills/lists/:id/add-to-cabinet',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: AddToCabinetResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const result = await service.addToCabinet(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.send(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/refills/lists/:id/store-comparison',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
data: z.array(
|
||||||
|
z.object({
|
||||||
|
medicineId: z.string(),
|
||||||
|
storeOptions: z.array(StoreComparisonItemSchema),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('refillsService');
|
||||||
|
const comparisons = await service.getStoreComparison(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
);
|
||||||
|
return reply.send({
|
||||||
|
data: comparisons.map((c) => ({
|
||||||
|
medicineId: c.medicineId,
|
||||||
|
storeOptions: c.storeOptions.map((opt) => ({
|
||||||
|
...opt,
|
||||||
|
date: toIso(opt.date),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refills-routes',
|
||||||
|
dependencies: ['auth-plugin'],
|
||||||
|
},
|
||||||
|
);
|
||||||
400
packages/api/src/modules/refills/refills.service.test.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { RefillsService } from './refills.service.js';
|
||||||
|
|
||||||
|
describe(RefillsService.name, () => {
|
||||||
|
const mockRepo = {
|
||||||
|
create: vi.fn(),
|
||||||
|
findByHousehold: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
updateItem: vi.fn(),
|
||||||
|
markItemsAddedToCabinet: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockRegimensService = {
|
||||||
|
calculateBurnRates: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockCabinetRepo = {
|
||||||
|
getAggregateSummary: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockCabinetService = {
|
||||||
|
addItem: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockPricesRepo = {
|
||||||
|
getLatestForMedicine: vi.fn(),
|
||||||
|
compareStores: vi.fn(),
|
||||||
|
};
|
||||||
|
const mockPurchasesRepo = {
|
||||||
|
getPendingMedicineStock: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: RefillsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
|
||||||
|
service = new RefillsService({
|
||||||
|
refillsRepository: mockRepo as never,
|
||||||
|
regimensService: mockRegimensService as never,
|
||||||
|
cabinetRepository: mockCabinetRepo as never,
|
||||||
|
cabinetService: mockCabinetService as never,
|
||||||
|
medicinePricesRepository: mockPricesRepo as never,
|
||||||
|
purchasesRepository: mockPurchasesRepo as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAlerts', () => {
|
||||||
|
it('returns empty array when no medicines are running low', async () => {
|
||||||
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 1, totalInCabinet: 100, daysUntilEmpty: 100 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns alerts for medicines below threshold', async () => {
|
||||||
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 10, daysUntilEmpty: 5 },
|
||||||
|
]);
|
||||||
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||||
|
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
|
||||||
|
]);
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
|
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].medicineId).toBe('med-1');
|
||||||
|
expect(result[0].daysUntilEmpty).toBe(5);
|
||||||
|
expect(result[0].suggestedQuantity).toBe(60); // ceil(2 * 30)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches lastKnownPrice when available', async () => {
|
||||||
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
||||||
|
]);
|
||||||
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
|
||||||
|
price: 10,
|
||||||
|
pricePerUnit: 0.1,
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
storeId: 'st-1',
|
||||||
|
date: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
||||||
|
expect(result[0].lastKnownPrice).toBeDefined();
|
||||||
|
expect(result[0].lastKnownPrice?.storeName).toBe('Walgreens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches cheapestOption from compareStores', async () => {
|
||||||
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
||||||
|
]);
|
||||||
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
|
mockPricesRepo.compareStores.mockResolvedValue([
|
||||||
|
{ storeId: 'st-1', storeName: 'CVS', latestPrice: 8, latestPricePerUnit: 0.08, currency: 'USD', date: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
||||||
|
expect(result[0].cheapestOption).toBeDefined();
|
||||||
|
expect(result[0].cheapestOption?.storeName).toBe('CVS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
|
||||||
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 4, daysUntilEmpty: 2 },
|
||||||
|
]);
|
||||||
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
|
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||||
|
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', totalUnits: 60 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
||||||
|
expect(result[0].pendingOrderStock).toBe(60);
|
||||||
|
expect(result[0].daysUntilEmptyWithOrders).toBe(32); // (4 + 60) / 2
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes medicines with null daysUntilEmpty', async () => {
|
||||||
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 0, daysUntilEmpty: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createList', () => {
|
||||||
|
it('creates list with provided items', async () => {
|
||||||
|
const list = { _id: 'rl-1', name: 'My List', items: [], status: 'active' };
|
||||||
|
mockRepo.create.mockResolvedValue(list);
|
||||||
|
|
||||||
|
const result = await service.createList(
|
||||||
|
{
|
||||||
|
name: 'My List',
|
||||||
|
fromAlerts: false,
|
||||||
|
thresholdDays: 7,
|
||||||
|
items: [{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never }],
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(list);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'My List', householdId: 'hh1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates list with no items when neither fromAlerts nor items provided', async () => {
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
|
await service.createList({ name: 'Empty List', fromAlerts: false, thresholdDays: 7 }, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes totalEstimatedCost from items with estimatedPrice', async () => {
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
|
await service.createList(
|
||||||
|
{
|
||||||
|
name: 'Priced List',
|
||||||
|
fromAlerts: false,
|
||||||
|
thresholdDays: 7,
|
||||||
|
items: [
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never, estimatedPrice: 10 },
|
||||||
|
{ medicineId: 'med-2', medicineName: 'Ibuprofen', quantity: 20, unit: 'tablet' as never, estimatedPrice: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ totalEstimatedCost: 18 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates list from alerts when fromAlerts is true', async () => {
|
||||||
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
||||||
|
]);
|
||||||
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
|
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
|
await service.createList({ name: 'Auto List', fromAlerts: true, thresholdDays: 7 }, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ medicineId: 'med-1' }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockRepo.findByHousehold.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const response = await service.list('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(response).toEqual(result);
|
||||||
|
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns list when found', async () => {
|
||||||
|
const list = { _id: 'rl-1', name: 'My List' };
|
||||||
|
mockRepo.findById.mockResolvedValue(list);
|
||||||
|
|
||||||
|
expect(await service.getById('rl-1', 'hh1')).toEqual(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Refill list not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateList', () => {
|
||||||
|
it('updates and returns list', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||||
|
const updated = { _id: 'rl-1', name: 'Updated' };
|
||||||
|
mockRepo.update.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await service.updateList('rl-1', 'hh1', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when list not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow('Refill list not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||||
|
mockRepo.update.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.updateList('rl-1', 'hh1', {})).rejects.toThrow('Refill list not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateItem', () => {
|
||||||
|
it('updates item and returns list', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||||
|
const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] };
|
||||||
|
mockRepo.updateItem.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets checkedAt when checked is true', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||||
|
mockRepo.updateItem.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
|
await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
|
||||||
|
|
||||||
|
expect(mockRepo.updateItem).toHaveBeenCalledWith(
|
||||||
|
'rl-1',
|
||||||
|
'hh1',
|
||||||
|
'item-1',
|
||||||
|
expect.objectContaining({ checkedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when list not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow('Refill list not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when item not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||||
|
mockRepo.updateItem.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.updateItem('rl-1', 'hh1', 'bad-item', {})).rejects.toThrow(
|
||||||
|
'Refill list or item not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addToCabinet', () => {
|
||||||
|
it('adds checked items to cabinet', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'rl-1',
|
||||||
|
items: [
|
||||||
|
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
||||||
|
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
|
||||||
|
|
||||||
|
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(result.addedCount).toBe(1);
|
||||||
|
expect(mockCabinetService.addItem).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes unitPrice when actualPrice is set', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'rl-1',
|
||||||
|
items: [
|
||||||
|
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', actualPrice: 9, checked: true, addedToCabinet: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
||||||
|
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
|
||||||
|
|
||||||
|
await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockCabinetService.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ unitPrice: 0.3, totalPrice: 9 }),
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero count when no checked items', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'rl-1',
|
||||||
|
items: [
|
||||||
|
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(result.addedCount).toBe(0);
|
||||||
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips already-added items', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'rl-1',
|
||||||
|
items: [
|
||||||
|
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(result.addedCount).toBe(0);
|
||||||
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStoreComparison', () => {
|
||||||
|
it('returns store comparisons for list items', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'rl-1',
|
||||||
|
items: [{ medicineId: 'med-1' }, { medicineId: 'med-2' }],
|
||||||
|
});
|
||||||
|
mockPricesRepo.compareStores
|
||||||
|
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'CVS' }])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await service.getStoreComparison('rl-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].medicineId).toBe('med-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates medicine ids', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'rl-1',
|
||||||
|
items: [{ medicineId: 'med-1' }, { medicineId: 'med-1' }],
|
||||||
|
});
|
||||||
|
mockPricesRepo.compareStores.mockResolvedValue([{ storeId: 'st-1', storeName: 'CVS' }]);
|
||||||
|
|
||||||
|
const result = await service.getStoreComparison('rl-1', 'hh1');
|
||||||
|
|
||||||
|
expect(mockPricesRepo.compareStores).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
285
packages/api/src/modules/refills/refills.service.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
import type { RefillsRepository } from './refills.repository.js';
|
||||||
|
import type { RegimensService } from '../regimens/regimens.service.js';
|
||||||
|
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
|
||||||
|
import type { CabinetService } from '../cabinet/cabinet.service.js';
|
||||||
|
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
|
||||||
|
import type { PurchasesRepository } from '../purchases/purchases.repository.js';
|
||||||
|
import type {
|
||||||
|
CreateRefillListInput,
|
||||||
|
UpdateRefillListInput,
|
||||||
|
UpdateRefillListItemInput,
|
||||||
|
RefillListQueryInput,
|
||||||
|
RefillAlertQueryInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { RefillListStatus } from '@meshitrack/shared';
|
||||||
|
import { NotFoundError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
refillsRepository: RefillsRepository;
|
||||||
|
regimensService: RegimensService;
|
||||||
|
cabinetRepository: CabinetRepository;
|
||||||
|
cabinetService: CabinetService;
|
||||||
|
medicinePricesRepository: MedicinePricesRepository;
|
||||||
|
purchasesRepository: PurchasesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefillsService {
|
||||||
|
private readonly refillsRepository: RefillsRepository;
|
||||||
|
private readonly regimensService: RegimensService;
|
||||||
|
private readonly cabinetRepository: CabinetRepository;
|
||||||
|
private readonly cabinetService: CabinetService;
|
||||||
|
private readonly medicinePricesRepository: MedicinePricesRepository;
|
||||||
|
private readonly purchasesRepository: PurchasesRepository;
|
||||||
|
|
||||||
|
public constructor({
|
||||||
|
refillsRepository,
|
||||||
|
regimensService,
|
||||||
|
cabinetRepository,
|
||||||
|
cabinetService,
|
||||||
|
medicinePricesRepository,
|
||||||
|
purchasesRepository,
|
||||||
|
}: Deps) {
|
||||||
|
this.refillsRepository = refillsRepository;
|
||||||
|
this.regimensService = regimensService;
|
||||||
|
this.cabinetRepository = cabinetRepository;
|
||||||
|
this.cabinetService = cabinetService;
|
||||||
|
this.medicinePricesRepository = medicinePricesRepository;
|
||||||
|
this.purchasesRepository = purchasesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAlerts(householdId: string, userId: string, thresholdDays = 7) {
|
||||||
|
const burnRates = await this.regimensService.calculateBurnRates(householdId, userId);
|
||||||
|
|
||||||
|
const triggered = burnRates.filter(
|
||||||
|
(br) => br.daysUntilEmpty !== null && br.daysUntilEmpty <= thresholdDays,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (triggered.length === 0) return [];
|
||||||
|
|
||||||
|
// Get strength data from cabinet aggregate
|
||||||
|
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||||
|
const summaryMap = new Map<string, { medicineStrength: number; medicineStrengthUnit: string }>();
|
||||||
|
for (const s of summaries) {
|
||||||
|
summaryMap.set(s._id as string, {
|
||||||
|
medicineStrength: s.medicineStrength as number,
|
||||||
|
medicineStrengthUnit: s.medicineStrengthUnit as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending stock from ordered purchases
|
||||||
|
const pendingStockRows = await this.purchasesRepository.getPendingMedicineStock(householdId);
|
||||||
|
const pendingStockMap = new Map<string, number>();
|
||||||
|
for (const row of pendingStockRows) {
|
||||||
|
pendingStockMap.set(row.medicineId, row.totalUnits);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts = await Promise.all(
|
||||||
|
triggered.map(async (br) => {
|
||||||
|
const summary = summaryMap.get(br.medicineId);
|
||||||
|
const suggestedQuantity = Math.ceil(br.dailyConsumption * 30);
|
||||||
|
const pendingOrderStock = pendingStockMap.get(br.medicineId) ?? 0;
|
||||||
|
const daysUntilEmptyWithOrders =
|
||||||
|
br.dailyConsumption > 0
|
||||||
|
? (br.totalInCabinet + pendingOrderStock) / br.dailyConsumption
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [latestRecord, comparisons] = await Promise.all([
|
||||||
|
this.medicinePricesRepository.getLatestForMedicine(householdId, br.medicineId),
|
||||||
|
this.medicinePricesRepository.compareStores(householdId, br.medicineId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicineId: br.medicineId,
|
||||||
|
medicineName: br.medicineName,
|
||||||
|
medicineStrength: summary?.medicineStrength ?? 0,
|
||||||
|
medicineStrengthUnit: (summary?.medicineStrengthUnit ?? 'mg') as never,
|
||||||
|
daysUntilEmpty: br.daysUntilEmpty as number,
|
||||||
|
dailyConsumption: br.dailyConsumption,
|
||||||
|
currentStock: br.totalInCabinet,
|
||||||
|
pendingOrderStock,
|
||||||
|
daysUntilEmptyWithOrders,
|
||||||
|
suggestedQuantity,
|
||||||
|
lastKnownPrice: latestRecord
|
||||||
|
? {
|
||||||
|
price: latestRecord.price as number,
|
||||||
|
pricePerUnit: latestRecord.pricePerUnit as number,
|
||||||
|
storeName: latestRecord.storeName as string,
|
||||||
|
storeId: latestRecord.storeId as string,
|
||||||
|
date: latestRecord.date as Date,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
cheapestOption:
|
||||||
|
comparisons.length > 0
|
||||||
|
? {
|
||||||
|
price: comparisons[0].latestPrice,
|
||||||
|
pricePerUnit: comparisons[0].latestPricePerUnit,
|
||||||
|
storeName: comparisons[0].storeName,
|
||||||
|
storeId: comparisons[0].storeId,
|
||||||
|
date: comparisons[0].date,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createList(data: CreateRefillListInput, householdId: string, userId: string) {
|
||||||
|
let items: Array<{
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
estimatedPrice?: number;
|
||||||
|
storeId?: string;
|
||||||
|
notes?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (data.fromAlerts) {
|
||||||
|
const alerts = await this.getAlerts(householdId, userId, data.thresholdDays);
|
||||||
|
items = alerts.map((alert) => ({
|
||||||
|
medicineId: alert.medicineId,
|
||||||
|
medicineName: alert.medicineName,
|
||||||
|
quantity: alert.suggestedQuantity,
|
||||||
|
unit: 'tablet' as string,
|
||||||
|
estimatedPrice: alert.cheapestOption?.price ?? alert.lastKnownPrice?.price,
|
||||||
|
storeId: alert.cheapestOption?.storeId ?? alert.lastKnownPrice?.storeId,
|
||||||
|
}));
|
||||||
|
} else if (data.items) {
|
||||||
|
items = data.items.map((item) => ({
|
||||||
|
medicineId: item.medicineId,
|
||||||
|
medicineName: item.medicineName,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit as string,
|
||||||
|
estimatedPrice: item.estimatedPrice,
|
||||||
|
storeId: item.storeId,
|
||||||
|
notes: item.notes,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalEstimatedCost =
|
||||||
|
items.length > 0
|
||||||
|
? items.reduce((sum, item) => sum + (item.estimatedPrice ?? 0), 0) || undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return this.refillsRepository.create({
|
||||||
|
householdId,
|
||||||
|
name: data.name,
|
||||||
|
status: RefillListStatus.ACTIVE,
|
||||||
|
preferredStoreId: data.preferredStoreId,
|
||||||
|
totalEstimatedCost,
|
||||||
|
createdBy: userId,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(householdId: string, query: RefillListQueryInput) {
|
||||||
|
return this.refillsRepository.findByHousehold(householdId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string, householdId: string) {
|
||||||
|
const list = await this.refillsRepository.findById(id, householdId);
|
||||||
|
if (!list) throw new NotFoundError('Refill list not found');
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateList(id: string, householdId: string, data: UpdateRefillListInput) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
const updated = await this.refillsRepository.update(id, householdId, data);
|
||||||
|
if (!updated) throw new NotFoundError('Refill list not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateItem(
|
||||||
|
listId: string,
|
||||||
|
householdId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: UpdateRefillListItemInput,
|
||||||
|
) {
|
||||||
|
await this.getById(listId, householdId);
|
||||||
|
|
||||||
|
const updateData: UpdateRefillListItemInput & { checkedAt?: Date } = { ...data };
|
||||||
|
if (data.checked === true) {
|
||||||
|
updateData.checkedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.refillsRepository.updateItem(
|
||||||
|
listId,
|
||||||
|
householdId,
|
||||||
|
itemId,
|
||||||
|
updateData,
|
||||||
|
);
|
||||||
|
if (!updated) throw new NotFoundError('Refill list or item not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addToCabinet(listId: string, householdId: string, userId: string) {
|
||||||
|
const list = await this.getById(listId, householdId);
|
||||||
|
|
||||||
|
const checkedItems = (list.items as Array<{
|
||||||
|
_id: { toString: () => string };
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number;
|
||||||
|
storeId?: string;
|
||||||
|
checked: boolean;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
}>).filter((item) => item.checked && !item.addedToCabinet);
|
||||||
|
|
||||||
|
if (checkedItems.length === 0) {
|
||||||
|
return { addedCount: 0, priceRecordsCreated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
const addedItemIds: string[] = [];
|
||||||
|
|
||||||
|
for (const item of checkedItems) {
|
||||||
|
await this.cabinetService.addItem(
|
||||||
|
{
|
||||||
|
medicineId: item.medicineId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit as never,
|
||||||
|
unitPrice: item.actualPrice !== undefined && item.quantity > 0
|
||||||
|
? item.actualPrice / item.quantity
|
||||||
|
: undefined,
|
||||||
|
totalPrice: item.actualPrice,
|
||||||
|
storeId: item.storeId,
|
||||||
|
purchaseDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
householdId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
addedCount++;
|
||||||
|
addedItemIds.push(item._id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refillsRepository.markItemsAddedToCabinet(listId, householdId, addedItemIds);
|
||||||
|
|
||||||
|
return { addedCount, priceRecordsCreated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStoreComparison(listId: string, householdId: string) {
|
||||||
|
const list = await this.getById(listId, householdId);
|
||||||
|
|
||||||
|
const medicineIds = [
|
||||||
|
...new Set(
|
||||||
|
(list.items as Array<{ medicineId: string }>).map((item) => item.medicineId),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const comparisons = await Promise.all(
|
||||||
|
medicineIds.map(async (medicineId) => {
|
||||||
|
const storeOptions = await this.medicinePricesRepository.compareStores(
|
||||||
|
householdId,
|
||||||
|
medicineId,
|
||||||
|
);
|
||||||
|
return { medicineId, storeOptions };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return comparisons.filter((c) => c.storeOptions.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,6 +138,7 @@ export class RegimensService {
|
||||||
const earliestExpiry = stock?.earliestExpiry ?? null;
|
const earliestExpiry = stock?.earliestExpiry ?? null;
|
||||||
const dailyConsumption = consumption.dailyConsumption;
|
const dailyConsumption = consumption.dailyConsumption;
|
||||||
|
|
||||||
|
/* v8 ignore next */
|
||||||
const daysUntilEmpty =
|
const daysUntilEmpty =
|
||||||
dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null;
|
dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null;
|
||||||
|
|
||||||
|
|
@ -163,6 +164,7 @@ export class RegimensService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
|
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
|
||||||
|
/* v8 ignore next 6 */
|
||||||
burnRates.sort((a, b) => {
|
burnRates.sort((a, b) => {
|
||||||
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
|
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
|
||||||
if (a.daysUntilEmpty === null) return 1;
|
if (a.daysUntilEmpty === null) return 1;
|
||||||
|
|
|
||||||
171
packages/api/src/modules/stores/stores.repository.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockFindOneAndUpdate: vi.fn(),
|
||||||
|
mockSave: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../schemas/store.schema.js', () => {
|
||||||
|
const chain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFind,
|
||||||
|
});
|
||||||
|
const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne });
|
||||||
|
const updateChain = () => ({ exec: mockFindOneAndUpdate });
|
||||||
|
|
||||||
|
class FakeModel {
|
||||||
|
data: unknown;
|
||||||
|
constructor(data: unknown) { this.data = data; }
|
||||||
|
save = mockSave;
|
||||||
|
toObject() { return this.data; }
|
||||||
|
static find = vi.fn(() => chain());
|
||||||
|
static findOne = vi.fn(() => findOneChain());
|
||||||
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
}
|
||||||
|
return { StoreModel: FakeModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { StoresRepository } from './stores.repository.js';
|
||||||
|
|
||||||
|
describe(StoresRepository.name, () => {
|
||||||
|
let repo: StoresRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new StoresRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold', () => {
|
||||||
|
it('returns paginated items', async () => {
|
||||||
|
const items = [{ _id: 'st-1', name: 'Walgreens' }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toEqual(items);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets hasMore when more items exist', async () => {
|
||||||
|
const items = [{ _id: 'st-1' }, { _id: 'st-2' }, { _id: 'st-3' }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.pagination.hasMore).toBe(true);
|
||||||
|
expect(result.pagination.cursor).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cursor pagination', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const cursor = Buffer.from('st-1').toString('base64');
|
||||||
|
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by tags', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { tags: 'pharmacy,online', limit: 20 });
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by search', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { search: 'cvs', limit: 20 });
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips tag filter when tags string is empty after trim', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { tags: ' , ', limit: 20 });
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null cursor when no data', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('returns store when found', async () => {
|
||||||
|
const store = { _id: 'st-1', name: 'CVS' };
|
||||||
|
mockFindOne.mockResolvedValue(store);
|
||||||
|
|
||||||
|
const result = await repo.findById('st-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual(store);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await repo.findById('missing', 'hh1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('creates and returns store', async () => {
|
||||||
|
const data = { name: 'Walgreens', tags: [], isActive: true };
|
||||||
|
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repo.create(data as never, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns store', async () => {
|
||||||
|
const updated = { _id: 'st-1', name: 'Updated' };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await repo.update('st-1', 'hh1', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await repo.update('missing', 'hh1', {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deactivate', () => {
|
||||||
|
it('sets isActive=false and returns store', async () => {
|
||||||
|
const deactivated = { _id: 'st-1', isActive: false };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(deactivated);
|
||||||
|
|
||||||
|
const result = await repo.deactivate('st-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual(deactivated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await repo.deactivate('missing', 'hh1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
62
packages/api/src/modules/stores/stores.repository.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { StoreModel } from '../../schemas/store.schema.js';
|
||||||
|
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export class StoresRepository {
|
||||||
|
public async findByHousehold(householdId: string, query: StoreQueryInput) {
|
||||||
|
const filter: Record<string, unknown> = { householdId };
|
||||||
|
|
||||||
|
if (query.tags) {
|
||||||
|
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
if (tagList.length > 0) filter['tags'] = { $in: tagList };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
filter['name'] = { $regex: query.search, $options: 'i' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $gt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await StoreModel.find(filter)
|
||||||
|
.sort({ _id: 1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string, householdId: string) {
|
||||||
|
return StoreModel.findOne({ _id: id, householdId }).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreateStoreInput, householdId: string, createdBy: string) {
|
||||||
|
const store = new StoreModel({ ...data, householdId, createdBy });
|
||||||
|
const saved = await store.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdateStoreInput) {
|
||||||
|
return StoreModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId },
|
||||||
|
{ $set: data },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deactivate(id: string, householdId: string) {
|
||||||
|
return StoreModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId },
|
||||||
|
{ $set: { isActive: false } },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
319
packages/api/src/modules/stores/stores.routes.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockList, mockGetById, mockCreate, mockUpdate, mockDeactivate } = vi.hoisted(() => ({
|
||||||
|
mockList: vi.fn(),
|
||||||
|
mockGetById: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockDeactivate: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./stores.repository.js', () => ({
|
||||||
|
StoresRepository: class {
|
||||||
|
findByHousehold = vi.fn();
|
||||||
|
findById = vi.fn();
|
||||||
|
create = vi.fn();
|
||||||
|
update = vi.fn();
|
||||||
|
deactivate = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./stores.service.js', () => ({
|
||||||
|
StoresService: class {
|
||||||
|
list = mockList;
|
||||||
|
getById = mockGetById;
|
||||||
|
create = mockCreate;
|
||||||
|
update = mockUpdate;
|
||||||
|
deactivate = mockDeactivate;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import storesRoutes from './stores.routes.js';
|
||||||
|
|
||||||
|
function makeFakeStore(overrides = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'st-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Walgreens',
|
||||||
|
tags: ['pharmacy'],
|
||||||
|
isActive: true,
|
||||||
|
createdBy: 'kc-1',
|
||||||
|
createdAt: '2024-06-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-06-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stores.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(storesRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/stores', () => {
|
||||||
|
it('returns paginated store list', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [makeFakeStore()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/stores',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.data[0].name).toBe('Walgreens');
|
||||||
|
expect(body.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes query params to service', async () => {
|
||||||
|
mockList.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/stores?tags=pharmacy&search=cvs&limit=5',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockList).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ tags: 'pharmacy', search: 'cvs', limit: 5 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ObjectId and Date in response', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [makeFakeStore({
|
||||||
|
_id: { toString: () => 'st-obj' },
|
||||||
|
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||||
|
})],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/stores',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data[0]._id).toBe('st-obj');
|
||||||
|
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes optional fields in response when present', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
data: [makeFakeStore({
|
||||||
|
address: '123 Main St',
|
||||||
|
location: { lat: 40.7128, lng: -74.006 },
|
||||||
|
url: 'https://walgreens.com',
|
||||||
|
notes: 'Open 24h',
|
||||||
|
})],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/stores',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data[0].address).toBe('123 Main St');
|
||||||
|
expect(body.data[0].location).toEqual({ lat: 40.7128, lng: -74.006 });
|
||||||
|
expect(body.data[0].url).toBe('https://walgreens.com');
|
||||||
|
expect(body.data[0].notes).toBe('Open 24h');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/stores/:id', () => {
|
||||||
|
it('returns single store', async () => {
|
||||||
|
mockGetById.mockResolvedValue(makeFakeStore());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/stores/st-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Walgreens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id and householdId to service', async () => {
|
||||||
|
mockGetById.mockResolvedValue(makeFakeStore());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/stores/st-99',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetById).toHaveBeenCalledWith('st-99', 'hh1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/stores', () => {
|
||||||
|
it('creates store and returns 201', async () => {
|
||||||
|
mockCreate.mockResolvedValue(makeFakeStore());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/stores',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { name: 'Walgreens' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().name).toBe('Walgreens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes userId to service', async () => {
|
||||||
|
mockCreate.mockResolvedValue(makeFakeStore());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/stores',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { name: 'CVS' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'CVS' }),
|
||||||
|
'hh1',
|
||||||
|
'kc-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for missing name', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/stores',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/stores/:id', () => {
|
||||||
|
it('updates store and returns 200', async () => {
|
||||||
|
mockUpdate.mockResolvedValue(makeFakeStore({ name: 'CVS' }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/stores/st-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { name: 'CVS' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('CVS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id, householdId, body to service', async () => {
|
||||||
|
mockUpdate.mockResolvedValue(makeFakeStore());
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/stores/st-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
payload: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
'st-1',
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ isActive: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/households/:householdId/stores/:id', () => {
|
||||||
|
it('deactivates store and returns 200', async () => {
|
||||||
|
mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/stores/st-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes id and householdId to service', async () => {
|
||||||
|
mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false }));
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/stores/st-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDeactivate).toHaveBeenCalledWith('st-1', 'hh1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
158
packages/api/src/modules/stores/stores.routes.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
CreateStoreSchema,
|
||||||
|
UpdateStoreSchema,
|
||||||
|
StoreQuerySchema,
|
||||||
|
StoreResponseSchema,
|
||||||
|
StoreListResponseSchema,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { StoresRepository } from './stores.repository.js';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
type AnyStoreDoc = {
|
||||||
|
_id: string | { toString: () => string };
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
location?: { lat: number; lng: number };
|
||||||
|
url?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string | Date | { toISOString: () => string };
|
||||||
|
updatedAt: string | Date | { toISOString: () => string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return v.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStoreResponse(rawDoc: unknown) {
|
||||||
|
const doc = rawDoc as AnyStoreDoc;
|
||||||
|
return {
|
||||||
|
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||||
|
householdId: doc.householdId,
|
||||||
|
name: doc.name,
|
||||||
|
...(doc.address != null ? { address: doc.address } : {}),
|
||||||
|
...(doc.location != null ? { location: doc.location } : {}),
|
||||||
|
...(doc.url != null ? { url: doc.url } : {}),
|
||||||
|
...(doc.notes != null ? { notes: doc.notes } : {}),
|
||||||
|
tags: doc.tags,
|
||||||
|
isActive: doc.isActive,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
updatedAt: toIso(doc.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
storesRepository: StoresRepository;
|
||||||
|
storesService: StoresService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
storesRepository: asClass(StoresRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
storesService: asClass(StoresService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
const householdParams = z.object({ householdId: z.string() });
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/stores',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
querystring: StoreQuerySchema,
|
||||||
|
response: { 200: StoreListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('storesService');
|
||||||
|
const result = await service.list(request.params.householdId, request.query);
|
||||||
|
return reply.send({
|
||||||
|
data: result.data.map(toStoreResponse),
|
||||||
|
pagination: result.pagination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/stores/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: StoreResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('storesService');
|
||||||
|
const store = await service.getById(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toStoreResponse(store));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/stores',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: CreateStoreSchema,
|
||||||
|
response: { 201: StoreResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('storesService');
|
||||||
|
const store = await service.create(
|
||||||
|
request.body,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(toStoreResponse(store));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/stores/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
body: UpdateStoreSchema,
|
||||||
|
response: { 200: StoreResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('storesService');
|
||||||
|
const store = await service.update(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toStoreResponse(store));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/:householdId/stores/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: StoreResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('storesService');
|
||||||
|
const store = await service.deactivate(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toStoreResponse(store));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stores-routes',
|
||||||
|
dependencies: ['auth-plugin'],
|
||||||
|
},
|
||||||
|
);
|
||||||
109
packages/api/src/modules/stores/stores.service.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
describe(StoresService.name, () => {
|
||||||
|
const mockRepo = {
|
||||||
|
findByHousehold: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
deactivate: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: StoresService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new StoresService({ storesRepository: mockRepo as never });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockRepo.findByHousehold.mockResolvedValue(result);
|
||||||
|
|
||||||
|
const response = await service.list('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(response).toEqual(result);
|
||||||
|
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns store when found', async () => {
|
||||||
|
const store = { _id: 'st-1', name: 'Walgreens' };
|
||||||
|
mockRepo.findById.mockResolvedValue(store);
|
||||||
|
|
||||||
|
expect(await service.getById('st-1', 'hh1')).toEqual(store);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Store not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const store = { _id: 'st-1', name: 'CVS' };
|
||||||
|
mockRepo.create.mockResolvedValue(store);
|
||||||
|
|
||||||
|
const result = await service.create({ name: 'CVS', tags: [], isActive: true } as never, 'hh1', 'user-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(store);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.anything(), 'hh1', 'user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns store', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||||
|
const updated = { _id: 'st-1', name: 'CVS' };
|
||||||
|
mockRepo.update.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await service.update('st-1', 'hh1', { name: 'CVS' });
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError on initial lookup failure', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Store not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||||
|
mockRepo.update.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('st-1', 'hh1', {})).rejects.toThrow('Store not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deactivate', () => {
|
||||||
|
it('deactivates and returns store', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||||
|
const deactivated = { _id: 'st-1', isActive: false };
|
||||||
|
mockRepo.deactivate.mockResolvedValue(deactivated);
|
||||||
|
|
||||||
|
const result = await service.deactivate('st-1', 'hh1');
|
||||||
|
|
||||||
|
expect(result).toEqual(deactivated);
|
||||||
|
expect(mockRepo.deactivate).toHaveBeenCalledWith('st-1', 'hh1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when store not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.deactivate('missing', 'hh1')).rejects.toThrow('Store not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when deactivate returns null', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||||
|
mockRepo.deactivate.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.deactivate('st-1', 'hh1')).rejects.toThrow('Store not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
packages/api/src/modules/stores/stores.service.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { StoresRepository } from './stores.repository.js';
|
||||||
|
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
|
||||||
|
import { NotFoundError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
storesRepository: StoresRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoresService {
|
||||||
|
private readonly storesRepository: StoresRepository;
|
||||||
|
|
||||||
|
public constructor({ storesRepository }: Deps) {
|
||||||
|
this.storesRepository = storesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(householdId: string, query: StoreQueryInput) {
|
||||||
|
return this.storesRepository.findByHousehold(householdId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string, householdId: string) {
|
||||||
|
const store = await this.storesRepository.findById(id, householdId);
|
||||||
|
if (!store) throw new NotFoundError('Store not found');
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreateStoreInput, householdId: string, userId: string) {
|
||||||
|
return this.storesRepository.create(data, householdId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdateStoreInput) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
const updated = await this.storesRepository.update(id, householdId, data);
|
||||||
|
if (!updated) throw new NotFoundError('Store not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deactivate(id: string, householdId: string) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
const updated = await this.storesRepository.deactivate(id, householdId);
|
||||||
|
if (!updated) throw new NotFoundError('Store not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/api/src/schemas/medicine-price.schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const medicinePriceSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String, required: true },
|
||||||
|
medicineProductId: { type: String, required: true },
|
||||||
|
medicineProductBrand: { type: String, required: true },
|
||||||
|
medicineId: { type: String, required: true },
|
||||||
|
medicineName: { type: String, required: true },
|
||||||
|
storeId: { type: String, required: true },
|
||||||
|
storeName: { type: String, required: true },
|
||||||
|
price: { type: Number, required: true },
|
||||||
|
currency: { type: String, required: true },
|
||||||
|
quantity: { type: Number, required: true },
|
||||||
|
unit: { type: String, required: true },
|
||||||
|
pricePerUnit: { type: Number, required: true },
|
||||||
|
date: { type: Date, required: true },
|
||||||
|
isInsurancePrice: { type: Boolean, required: true, default: false },
|
||||||
|
notes: { type: String },
|
||||||
|
createdBy: { type: String, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: { createdAt: true, updatedAt: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
medicinePriceSchema.index({ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 });
|
||||||
|
medicinePriceSchema.index({ householdId: 1, medicineId: 1, date: -1 });
|
||||||
|
medicinePriceSchema.index({ householdId: 1, storeId: 1, date: -1 });
|
||||||
|
|
||||||
|
export const MedicinePriceModel = mongoose.model('MedicinePrice', medicinePriceSchema);
|
||||||
|
export type MedicinePriceDocument = mongoose.InferSchemaType<typeof medicinePriceSchema> & {
|
||||||
|
_id: mongoose.Types.ObjectId;
|
||||||
|
};
|
||||||
67
packages/api/src/schemas/purchase.schema.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import mongoose, { type Document } from 'mongoose';
|
||||||
|
|
||||||
|
const { Schema, model } = mongoose;
|
||||||
|
|
||||||
|
const purchaseItemSchema = new Schema(
|
||||||
|
{
|
||||||
|
medicineProductId: { type: String },
|
||||||
|
medicineId: { type: String },
|
||||||
|
foodProductId: { type: String },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
quantity: { type: Number, required: true },
|
||||||
|
unit: { type: String, required: true },
|
||||||
|
actualPrice: { type: Number },
|
||||||
|
currency: { type: String },
|
||||||
|
priceRecordId: { type: String },
|
||||||
|
addedToCabinet: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchaseSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String, required: true },
|
||||||
|
storeId: { type: String, required: true },
|
||||||
|
storeName: { type: String, required: true },
|
||||||
|
status: { type: String, enum: ['ordered', 'in_cabinet'], required: true },
|
||||||
|
items: { type: [purchaseItemSchema], required: true },
|
||||||
|
notes: { type: String },
|
||||||
|
purchasedAt: { type: Date, required: true },
|
||||||
|
receivedAt: { type: Date },
|
||||||
|
createdBy: { type: String, required: true },
|
||||||
|
isDeleted: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
purchaseSchema.index({ householdId: 1, status: 1, purchasedAt: -1 });
|
||||||
|
purchaseSchema.index({ householdId: 1, storeId: 1 });
|
||||||
|
purchaseSchema.index({ householdId: 1, 'items.medicineProductId': 1 });
|
||||||
|
|
||||||
|
export type PurchaseDocument = Document & {
|
||||||
|
householdId: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
status: string;
|
||||||
|
items: Array<{
|
||||||
|
medicineProductId?: string;
|
||||||
|
medicineId?: string;
|
||||||
|
foodProductId?: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
priceRecordId?: string;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
}>;
|
||||||
|
notes?: string;
|
||||||
|
purchasedAt: Date;
|
||||||
|
receivedAt?: Date;
|
||||||
|
createdBy: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PurchaseModel = model<PurchaseDocument>('Purchase', purchaseSchema);
|
||||||
46
packages/api/src/schemas/refill-list.schema.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { DosageUnit } from '@meshitrack/shared';
|
||||||
|
import { RefillListStatus } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const refillListItemSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
medicineId: { type: String, required: true },
|
||||||
|
medicineName: { type: String, required: true },
|
||||||
|
quantity: { type: Number, required: true, min: 0 },
|
||||||
|
unit: { type: String, enum: Object.values(DosageUnit), required: true },
|
||||||
|
estimatedPrice: { type: Number, min: 0 },
|
||||||
|
actualPrice: { type: Number, min: 0 },
|
||||||
|
checked: { type: Boolean, required: true, default: false },
|
||||||
|
checkedAt: { type: Date },
|
||||||
|
addedToCabinet: { type: Boolean, required: true, default: false },
|
||||||
|
storeId: { type: String },
|
||||||
|
notes: { type: String },
|
||||||
|
},
|
||||||
|
{ _id: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const refillListSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String, required: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
items: { type: [refillListItemSchema], required: true, default: [] },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(RefillListStatus),
|
||||||
|
required: true,
|
||||||
|
default: RefillListStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
preferredStoreId: { type: String },
|
||||||
|
totalEstimatedCost: { type: Number },
|
||||||
|
createdBy: { type: String, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
refillListSchema.index({ householdId: 1, status: 1 });
|
||||||
|
refillListSchema.index({ householdId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
export const RefillListModel = mongoose.model('RefillList', refillListSchema);
|
||||||
|
export type RefillListDocument = mongoose.InferSchemaType<typeof refillListSchema> & {
|
||||||
|
_id: mongoose.Types.ObjectId;
|
||||||
|
};
|
||||||
32
packages/api/src/schemas/store.schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const locationSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
lat: { type: Number, required: true },
|
||||||
|
lng: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const storeSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String, required: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
address: { type: String },
|
||||||
|
location: { type: locationSchema },
|
||||||
|
url: { type: String },
|
||||||
|
notes: { type: String },
|
||||||
|
tags: { type: [String], default: [] },
|
||||||
|
isActive: { type: Boolean, required: true, default: true },
|
||||||
|
createdBy: { type: String, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
storeSchema.index({ householdId: 1, name: 1 });
|
||||||
|
storeSchema.index({ householdId: 1, tags: 1 });
|
||||||
|
|
||||||
|
export const StoreModel = mongoose.model('Store', storeSchema);
|
||||||
|
export type StoreDocument = mongoose.InferSchemaType<typeof storeSchema> & {
|
||||||
|
_id: mongoose.Types.ObjectId;
|
||||||
|
};
|
||||||
107
packages/api/src/scripts/migrate-dosage-units.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* Migration: normalize legacy dosage unit values to current DosageUnit enum.
|
||||||
|
*
|
||||||
|
* Known renames:
|
||||||
|
* pill → tablet
|
||||||
|
* pills → tablet
|
||||||
|
*
|
||||||
|
* Collections updated:
|
||||||
|
* medicineproducts packageUnit
|
||||||
|
* cabinetitems unit
|
||||||
|
* regimens medications[].dosageUnit
|
||||||
|
* refillists items[].unit
|
||||||
|
*/
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const MONGODB_URI =
|
||||||
|
process.env.SEED_MONGODB_URI ||
|
||||||
|
process.env.MONGODB_URI ||
|
||||||
|
'mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin&replicaSet=rs0';
|
||||||
|
|
||||||
|
const UNIT_MAP: Record<string, string> = {
|
||||||
|
pill: 'tablet',
|
||||||
|
pills: 'tablet',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Connecting...');
|
||||||
|
await mongoose.connect(MONGODB_URI);
|
||||||
|
const db = mongoose.connection.db!;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
// --- medicineproducts.packageUnit ---
|
||||||
|
for (const [old, next] of Object.entries(UNIT_MAP)) {
|
||||||
|
const result = await db
|
||||||
|
.collection('medicineproducts')
|
||||||
|
.updateMany({ packageUnit: old }, { $set: { packageUnit: next } });
|
||||||
|
if (result.modifiedCount > 0) {
|
||||||
|
console.log(`medicineproducts.packageUnit: ${old} → ${next} (${result.modifiedCount})`);
|
||||||
|
total += result.modifiedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cabinetitems.unit ---
|
||||||
|
for (const [old, next] of Object.entries(UNIT_MAP)) {
|
||||||
|
const result = await db
|
||||||
|
.collection('cabinetitems')
|
||||||
|
.updateMany({ unit: old }, { $set: { unit: next } });
|
||||||
|
if (result.modifiedCount > 0) {
|
||||||
|
console.log(`cabinetitems.unit: ${old} → ${next} (${result.modifiedCount})`);
|
||||||
|
total += result.modifiedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- regimens: medications[].dosageUnit ---
|
||||||
|
// MongoDB cannot target a specific array element value with updateMany + $set on nested
|
||||||
|
// fields matching a condition, so we load and rewrite affected documents.
|
||||||
|
const regimens = await db.collection('regimens').find({}).toArray();
|
||||||
|
for (const regimen of regimens) {
|
||||||
|
const medications: Array<Record<string, unknown>> = regimen.medications ?? [];
|
||||||
|
let dirty = false;
|
||||||
|
for (const med of medications) {
|
||||||
|
const mapped = UNIT_MAP[med.dosageUnit as string];
|
||||||
|
if (mapped) {
|
||||||
|
med.dosageUnit = mapped;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
await db
|
||||||
|
.collection('regimens')
|
||||||
|
.updateOne({ _id: regimen._id }, { $set: { medications } });
|
||||||
|
console.log(`regimens: updated medications in regimen ${regimen._id}`);
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- refillists: items[].unit ---
|
||||||
|
const refillLists = await db.collection('refillists').find({}).toArray();
|
||||||
|
for (const list of refillLists) {
|
||||||
|
const items: Array<Record<string, unknown>> = list.items ?? [];
|
||||||
|
let dirty = false;
|
||||||
|
for (const item of items) {
|
||||||
|
const mapped = UNIT_MAP[item.unit as string];
|
||||||
|
if (mapped) {
|
||||||
|
item.unit = mapped;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
await db
|
||||||
|
.collection('refillists')
|
||||||
|
.updateOne({ _id: list._id }, { $set: { items } });
|
||||||
|
console.log(`refillists: updated items in list ${list._id}`);
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`Migration complete. ${total} document(s) updated.`);
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -3,3 +3,5 @@ export * from './medicine.enums.js';
|
||||||
export * from './cabinet.enums.js';
|
export * from './cabinet.enums.js';
|
||||||
export * from './cabinet-event.enums.js';
|
export * from './cabinet-event.enums.js';
|
||||||
export * from './regimen.enums.js';
|
export * from './regimen.enums.js';
|
||||||
|
export * from './refill.enums.js';
|
||||||
|
export * from './purchase.enums.js';
|
||||||
|
|
|
||||||
15
packages/shared/src/enums/purchase.enums.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PurchaseStatus } from './purchase.enums.js';
|
||||||
|
|
||||||
|
describe(PurchaseStatus.name, () => {
|
||||||
|
it('has exactly 2 values', () => {
|
||||||
|
expect(Object.values(PurchaseStatus)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ORDERED', 'ordered'],
|
||||||
|
['IN_CABINET', 'in_cabinet'],
|
||||||
|
])('%s = %s', (key, value) => {
|
||||||
|
expect(PurchaseStatus[key as keyof typeof PurchaseStatus]).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
packages/shared/src/enums/purchase.enums.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export enum PurchaseStatus {
|
||||||
|
ORDERED = 'ordered',
|
||||||
|
IN_CABINET = 'in_cabinet',
|
||||||
|
}
|
||||||
17
packages/shared/src/enums/refill.enums.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RefillListStatus } from './refill.enums.js';
|
||||||
|
|
||||||
|
describe(RefillListStatus.name, () => {
|
||||||
|
it('has exactly 4 values', () => {
|
||||||
|
expect(Object.values(RefillListStatus)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ACTIVE', 'active'],
|
||||||
|
['SHOPPING', 'shopping'],
|
||||||
|
['COMPLETED', 'completed'],
|
||||||
|
['ARCHIVED', 'archived'],
|
||||||
|
])('%s = %s', (key, value) => {
|
||||||
|
expect(RefillListStatus[key as keyof typeof RefillListStatus]).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
6
packages/shared/src/enums/refill.enums.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export enum RefillListStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
SHOPPING = 'shopping',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,7 @@ export * from './cabinet-event.js';
|
||||||
export * from './regimen.js';
|
export * from './regimen.js';
|
||||||
export * from './organizer-fill.js';
|
export * from './organizer-fill.js';
|
||||||
export * from './burn-rate.js';
|
export * from './burn-rate.js';
|
||||||
|
export * from './store.js';
|
||||||
|
export * from './medicine-price.js';
|
||||||
|
export * from './refill.js';
|
||||||
|
export * from './purchase.js';
|
||||||
|
|
|
||||||
56
packages/shared/src/types/medicine-price.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { DosageUnit } from '../enums/medicine.enums.js';
|
||||||
|
|
||||||
|
export interface MedicinePriceRecord {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
medicineProductId: string;
|
||||||
|
medicineProductBrand: string;
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: DosageUnit;
|
||||||
|
pricePerUnit: number;
|
||||||
|
date: Date;
|
||||||
|
isInsurancePrice: boolean;
|
||||||
|
notes?: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreComparison {
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
latestPrice: number;
|
||||||
|
latestPricePerUnit: number;
|
||||||
|
currency: string;
|
||||||
|
date: Date;
|
||||||
|
isInsurancePrice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedicineSpendingAnalytics {
|
||||||
|
spendingOverTime: { period: string; total: number }[];
|
||||||
|
topBySpending: {
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
totalSpent: number;
|
||||||
|
avgPricePerUnit: number;
|
||||||
|
}[];
|
||||||
|
spendingByStore: {
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
totalSpent: number;
|
||||||
|
purchaseCount: number;
|
||||||
|
}[];
|
||||||
|
priceAlerts: {
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
storeName: string;
|
||||||
|
previousPrice: number;
|
||||||
|
currentPrice: number;
|
||||||
|
changePercent: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
30
packages/shared/src/types/purchase.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { PurchaseStatus } from '../enums/purchase.enums.js';
|
||||||
|
|
||||||
|
export interface PurchaseItem {
|
||||||
|
id: string;
|
||||||
|
medicineProductId?: string;
|
||||||
|
medicineId?: string;
|
||||||
|
foodProductId?: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
actualPrice?: number;
|
||||||
|
currency?: string;
|
||||||
|
priceRecordId?: string;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Purchase {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
status: PurchaseStatus;
|
||||||
|
items: PurchaseItem[];
|
||||||
|
notes?: string;
|
||||||
|
purchasedAt: Date;
|
||||||
|
receivedAt?: Date;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
57
packages/shared/src/types/refill.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js';
|
||||||
|
import type { RefillListStatus } from '../enums/refill.enums.js';
|
||||||
|
|
||||||
|
export interface RefillAlert {
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
medicineStrength: number;
|
||||||
|
medicineStrengthUnit: StrengthUnit;
|
||||||
|
daysUntilEmpty: number;
|
||||||
|
dailyConsumption: number;
|
||||||
|
currentStock: number;
|
||||||
|
pendingOrderStock: number;
|
||||||
|
daysUntilEmptyWithOrders: number | null;
|
||||||
|
suggestedQuantity: number;
|
||||||
|
lastKnownPrice?: {
|
||||||
|
price: number;
|
||||||
|
pricePerUnit: number;
|
||||||
|
storeName: string;
|
||||||
|
storeId: string;
|
||||||
|
date: Date;
|
||||||
|
};
|
||||||
|
cheapestOption?: {
|
||||||
|
price: number;
|
||||||
|
pricePerUnit: number;
|
||||||
|
storeName: string;
|
||||||
|
storeId: string;
|
||||||
|
date: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefillListItem {
|
||||||
|
id: string;
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: DosageUnit;
|
||||||
|
estimatedPrice?: number;
|
||||||
|
actualPrice?: number;
|
||||||
|
checked: boolean;
|
||||||
|
checkedAt?: Date;
|
||||||
|
addedToCabinet: boolean;
|
||||||
|
storeId?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefillList {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
items: RefillListItem[];
|
||||||
|
status: RefillListStatus;
|
||||||
|
preferredStoreId?: string;
|
||||||
|
totalEstimatedCost?: number;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
17
packages/shared/src/types/store.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export interface Store {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
location?: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
};
|
||||||
|
url?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -5,3 +5,7 @@ export * from './cabinet.schemas.js';
|
||||||
export * from './cabinet-event.schemas.js';
|
export * from './cabinet-event.schemas.js';
|
||||||
export * from './regimen.schemas.js';
|
export * from './regimen.schemas.js';
|
||||||
export * from './organizer.schemas.js';
|
export * from './organizer.schemas.js';
|
||||||
|
export * from './store.schemas.js';
|
||||||
|
export * from './medicine-price.schemas.js';
|
||||||
|
export * from './refill.schemas.js';
|
||||||
|
export * from './purchase.schemas.js';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||||
|
import {
|
||||||
|
CreateMedicinePriceRecordSchema,
|
||||||
|
MedicinePriceHistoryQuerySchema,
|
||||||
|
MedicinePriceAnalyticsQuerySchema,
|
||||||
|
} from './medicine-price.schemas.js';
|
||||||
|
|
||||||
|
const validInput = {
|
||||||
|
medicineProductId: 'prod-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
storeId: 'store-1',
|
||||||
|
price: 9.99,
|
||||||
|
currency: 'USD',
|
||||||
|
quantity: 30,
|
||||||
|
unit: DosageUnit.TABLET,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CreateMedicinePriceRecordSchema', () => {
|
||||||
|
it('accepts valid input', () => {
|
||||||
|
expect(CreateMedicinePriceRecordSchema.safeParse(validInput).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults isInsurancePrice to false', () => {
|
||||||
|
const result = CreateMedicinePriceRecordSchema.parse(validInput);
|
||||||
|
expect(result.isInsurancePrice).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts optional date as ISO datetime', () => {
|
||||||
|
const result = CreateMedicinePriceRecordSchema.safeParse({
|
||||||
|
...validInput,
|
||||||
|
date: '2026-01-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-positive price', () => {
|
||||||
|
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-positive quantity', () => {
|
||||||
|
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid unit', () => {
|
||||||
|
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MedicinePriceHistoryQuerySchema', () => {
|
||||||
|
it('applies default limit', () => {
|
||||||
|
const result = MedicinePriceHistoryQuerySchema.parse({});
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts date range filters', () => {
|
||||||
|
const result = MedicinePriceHistoryQuerySchema.parse({
|
||||||
|
startDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
endDate: '2026-03-31T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(result.startDate).toBeTruthy();
|
||||||
|
expect(result.endDate).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MedicinePriceAnalyticsQuerySchema', () => {
|
||||||
|
it('defaults to month period', () => {
|
||||||
|
const result = MedicinePriceAnalyticsQuerySchema.parse({});
|
||||||
|
expect(result.period).toBe('month');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['month', 'quarter', 'year'])('accepts period %s', (period) => {
|
||||||
|
expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid period', () => {
|
||||||
|
expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period: 'week' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
packages/shared/src/validation/medicine-price.schemas.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||||
|
|
||||||
|
export const CreateMedicinePriceRecordSchema = z.object({
|
||||||
|
medicineProductId: z.string().min(1),
|
||||||
|
medicineId: z.string().min(1),
|
||||||
|
storeId: z.string().min(1),
|
||||||
|
price: z.number().positive(),
|
||||||
|
currency: z.string().min(1).max(10),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unit: z.nativeEnum(DosageUnit),
|
||||||
|
date: z.iso.datetime().optional(),
|
||||||
|
isInsurancePrice: z.boolean().default(false),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MedicinePriceHistoryQuerySchema = z.object({
|
||||||
|
storeId: z.string().optional(),
|
||||||
|
startDate: z.iso.datetime().optional(),
|
||||||
|
endDate: z.iso.datetime().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MedicinePriceAnalyticsQuerySchema = z.object({
|
||||||
|
period: z.enum(['month', 'quarter', 'year']).default('month'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MedicinePriceRecordResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string(),
|
||||||
|
medicineProductId: z.string(),
|
||||||
|
medicineProductBrand: z.string(),
|
||||||
|
medicineId: z.string(),
|
||||||
|
medicineName: z.string(),
|
||||||
|
storeId: z.string(),
|
||||||
|
storeName: z.string(),
|
||||||
|
price: z.number(),
|
||||||
|
currency: z.string(),
|
||||||
|
quantity: z.number(),
|
||||||
|
unit: z.string(),
|
||||||
|
pricePerUnit: z.number(),
|
||||||
|
date: z.string(),
|
||||||
|
isInsurancePrice: z.boolean(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MedicinePriceHistoryResponseSchema = z.object({
|
||||||
|
data: z.array(MedicinePriceRecordResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StoreComparisonItemSchema = z.object({
|
||||||
|
storeId: z.string(),
|
||||||
|
storeName: z.string(),
|
||||||
|
latestPrice: z.number(),
|
||||||
|
latestPricePerUnit: z.number(),
|
||||||
|
currency: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
isInsurancePrice: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StoreComparisonResponseSchema = z.object({
|
||||||
|
data: z.array(StoreComparisonItemSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MedicineSpendingAnalyticsResponseSchema = z.object({
|
||||||
|
spendingOverTime: z.array(z.object({ period: z.string(), total: z.number() })),
|
||||||
|
topBySpending: z.array(z.object({
|
||||||
|
medicineId: z.string(),
|
||||||
|
medicineName: z.string(),
|
||||||
|
totalSpent: z.number(),
|
||||||
|
avgPricePerUnit: z.number(),
|
||||||
|
})),
|
||||||
|
spendingByStore: z.array(z.object({
|
||||||
|
storeId: z.string(),
|
||||||
|
storeName: z.string(),
|
||||||
|
totalSpent: z.number(),
|
||||||
|
purchaseCount: z.number(),
|
||||||
|
})),
|
||||||
|
priceAlerts: z.array(z.object({
|
||||||
|
medicineId: z.string(),
|
||||||
|
medicineName: z.string(),
|
||||||
|
storeName: z.string(),
|
||||||
|
previousPrice: z.number(),
|
||||||
|
currentPrice: z.number(),
|
||||||
|
changePercent: z.number(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateMedicinePriceRecordInput = z.infer<typeof CreateMedicinePriceRecordSchema>;
|
||||||
|
export type MedicinePriceHistoryQueryInput = z.infer<typeof MedicinePriceHistoryQuerySchema>;
|
||||||
|
export type MedicinePriceAnalyticsQueryInput = z.infer<typeof MedicinePriceAnalyticsQuerySchema>;
|
||||||
120
packages/shared/src/validation/purchase.schemas.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
CreatePurchaseItemSchema,
|
||||||
|
CreatePurchaseSchema,
|
||||||
|
UpdatePurchaseSchema,
|
||||||
|
PurchaseQuerySchema,
|
||||||
|
} from './purchase.schemas.js';
|
||||||
|
|
||||||
|
describe('CreatePurchaseItemSchema', () => {
|
||||||
|
it('accepts minimal valid item', () => {
|
||||||
|
const result = CreatePurchaseItemSchema.safeParse({ name: 'Tylenol 30ct', quantity: 30, unit: 'tablet' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.addedToCabinet).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts item with all fields', () => {
|
||||||
|
const result = CreatePurchaseItemSchema.safeParse({
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
name: 'Tylenol 30ct',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
actualPrice: 9.99,
|
||||||
|
currency: 'USD',
|
||||||
|
priceRecordId: 'pr-1',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty name', () => {
|
||||||
|
expect(CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-positive quantity', () => {
|
||||||
|
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty unit', () => {
|
||||||
|
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims name whitespace', () => {
|
||||||
|
const result = CreatePurchaseItemSchema.parse({ name: ' Tylenol ', quantity: 1, unit: 'tablet' });
|
||||||
|
expect(result.name).toBe('Tylenol');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CreatePurchaseSchema', () => {
|
||||||
|
const validItem = { name: 'Tylenol', quantity: 30, unit: 'tablet' };
|
||||||
|
|
||||||
|
it('defaults status to in_cabinet', () => {
|
||||||
|
const result = CreatePurchaseSchema.parse({ storeId: 'st-1', items: [validItem] });
|
||||||
|
expect(result.status).toBe('in_cabinet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts ordered status', () => {
|
||||||
|
const result = CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'ordered', items: [validItem] });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty storeId', () => {
|
||||||
|
expect(CreatePurchaseSchema.safeParse({ storeId: '', items: [validItem] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty items array', () => {
|
||||||
|
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid status', () => {
|
||||||
|
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts optional purchasedAt datetime', () => {
|
||||||
|
const result = CreatePurchaseSchema.safeParse({
|
||||||
|
storeId: 'st-1',
|
||||||
|
items: [validItem],
|
||||||
|
purchasedAt: '2026-01-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid purchasedAt', () => {
|
||||||
|
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [validItem], purchasedAt: 'not-a-date' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdatePurchaseSchema', () => {
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
expect(UpdatePurchaseSchema.safeParse({}).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts notes only', () => {
|
||||||
|
expect(UpdatePurchaseSchema.safeParse({ notes: 'updated' }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims notes whitespace', () => {
|
||||||
|
const result = UpdatePurchaseSchema.parse({ notes: ' hello ' });
|
||||||
|
expect(result.notes).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PurchaseQuerySchema', () => {
|
||||||
|
it('defaults limit to 20', () => {
|
||||||
|
const result = PurchaseQuerySchema.parse({});
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts status filter', () => {
|
||||||
|
expect(PurchaseQuerySchema.safeParse({ status: 'ordered' }).success).toBe(true);
|
||||||
|
expect(PurchaseQuerySchema.safeParse({ status: 'in_cabinet' }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid status', () => {
|
||||||
|
expect(PurchaseQuerySchema.safeParse({ status: 'unknown' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces string limit', () => {
|
||||||
|
const result = PurchaseQuerySchema.parse({ limit: '50' });
|
||||||
|
expect(result.limit).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/shared/src/validation/purchase.schemas.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
export const CreatePurchaseItemSchema = z.object({
|
||||||
|
medicineProductId: z.string().optional(),
|
||||||
|
name: z.string().min(1).max(200).trim(),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unit: z.string().min(1),
|
||||||
|
actualPrice: z.number().positive().optional(),
|
||||||
|
currency: z.string().min(1).max(10).optional(),
|
||||||
|
priceRecordId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreatePurchaseSchema = z.object({
|
||||||
|
storeId: z.string().min(1),
|
||||||
|
status: z.enum(['ordered', 'in_cabinet']).default('in_cabinet'),
|
||||||
|
items: z.array(CreatePurchaseItemSchema).min(1),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
purchasedAt: z.iso.datetime().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdatePurchaseSchema = z.object({
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
items: z.array(CreatePurchaseItemSchema).min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PurchaseItemResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
medicineProductId: z.string().optional(),
|
||||||
|
medicineId: z.string().optional(),
|
||||||
|
foodProductId: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
quantity: z.number(),
|
||||||
|
unit: z.string(),
|
||||||
|
actualPrice: z.number().optional(),
|
||||||
|
currency: z.string().optional(),
|
||||||
|
priceRecordId: z.string().optional(),
|
||||||
|
addedToCabinet: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PurchaseResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string(),
|
||||||
|
storeId: z.string(),
|
||||||
|
storeName: z.string(),
|
||||||
|
status: z.enum(['ordered', 'in_cabinet']),
|
||||||
|
items: z.array(PurchaseItemResponseSchema),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
purchasedAt: z.string(),
|
||||||
|
receivedAt: z.string().optional(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PurchaseListResponseSchema = z.object({
|
||||||
|
data: z.array(PurchaseResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
total: z.number().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PurchaseQuerySchema = z.object({
|
||||||
|
status: z.enum(['ordered', 'in_cabinet']).optional(),
|
||||||
|
storeId: z.string().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreatePurchaseItemInput = z.infer<typeof CreatePurchaseItemSchema>;
|
||||||
|
export type CreatePurchaseInput = z.infer<typeof CreatePurchaseSchema>;
|
||||||
|
export type UpdatePurchaseInput = z.infer<typeof UpdatePurchaseSchema>;
|
||||||
|
export type PurchaseQueryInput = z.infer<typeof PurchaseQuerySchema>;
|
||||||
112
packages/shared/src/validation/refill.schemas.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||||
|
import { RefillListStatus } from '../enums/refill.enums.js';
|
||||||
|
import {
|
||||||
|
CreateRefillListSchema,
|
||||||
|
UpdateRefillListSchema,
|
||||||
|
UpdateRefillListItemSchema,
|
||||||
|
RefillListQuerySchema,
|
||||||
|
RefillAlertQuerySchema,
|
||||||
|
} from './refill.schemas.js';
|
||||||
|
|
||||||
|
describe('CreateRefillListSchema', () => {
|
||||||
|
it('accepts minimal valid input', () => {
|
||||||
|
const result = CreateRefillListSchema.parse({ name: 'Weekly Refills' });
|
||||||
|
expect(result.fromAlerts).toBe(false);
|
||||||
|
expect(result.thresholdDays).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts fromAlerts with threshold', () => {
|
||||||
|
const result = CreateRefillListSchema.parse({
|
||||||
|
name: 'Auto List',
|
||||||
|
fromAlerts: true,
|
||||||
|
thresholdDays: 14,
|
||||||
|
});
|
||||||
|
expect(result.fromAlerts).toBe(true);
|
||||||
|
expect(result.thresholdDays).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts items array', () => {
|
||||||
|
const result = CreateRefillListSchema.safeParse({
|
||||||
|
name: 'Manual List',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: DosageUnit.TABLET,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty name', () => {
|
||||||
|
expect(CreateRefillListSchema.safeParse({ name: '' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects item with non-positive quantity', () => {
|
||||||
|
expect(
|
||||||
|
CreateRefillListSchema.safeParse({
|
||||||
|
name: 'List',
|
||||||
|
items: [{ medicineId: 'med-1', medicineName: 'X', quantity: 0, unit: DosageUnit.TABLET }],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims name whitespace', () => {
|
||||||
|
const result = CreateRefillListSchema.parse({ name: ' My List ' });
|
||||||
|
expect(result.name).toBe('My List');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateRefillListSchema', () => {
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
expect(UpdateRefillListSchema.safeParse({}).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts status update', () => {
|
||||||
|
const result = UpdateRefillListSchema.parse({ status: RefillListStatus.SHOPPING });
|
||||||
|
expect(result.status).toBe(RefillListStatus.SHOPPING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid status', () => {
|
||||||
|
expect(UpdateRefillListSchema.safeParse({ status: 'invalid' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateRefillListItemSchema', () => {
|
||||||
|
it('accepts checked=true', () => {
|
||||||
|
expect(UpdateRefillListItemSchema.safeParse({ checked: true }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts actualPrice', () => {
|
||||||
|
const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.50 });
|
||||||
|
expect(result.actualPrice).toBe(12.50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative actualPrice', () => {
|
||||||
|
expect(UpdateRefillListItemSchema.safeParse({ actualPrice: -1 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RefillListQuerySchema', () => {
|
||||||
|
it('applies default limit', () => {
|
||||||
|
expect(RefillListQuerySchema.parse({}).limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts status filter', () => {
|
||||||
|
const result = RefillListQuerySchema.parse({ status: RefillListStatus.ACTIVE });
|
||||||
|
expect(result.status).toBe(RefillListStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RefillAlertQuerySchema', () => {
|
||||||
|
it('defaults thresholdDays to 7', () => {
|
||||||
|
expect(RefillAlertQuerySchema.parse({}).thresholdDays).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces thresholdDays from string', () => {
|
||||||
|
expect(RefillAlertQuerySchema.parse({ thresholdDays: '14' }).thresholdDays).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
packages/shared/src/validation/refill.schemas.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js';
|
||||||
|
import { RefillListStatus } from '../enums/refill.enums.js';
|
||||||
|
|
||||||
|
export const CreateRefillListItemSchema = z.object({
|
||||||
|
medicineId: z.string().min(1),
|
||||||
|
medicineName: z.string().min(1),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unit: z.nativeEnum(DosageUnit),
|
||||||
|
estimatedPrice: z.number().nonnegative().optional(),
|
||||||
|
storeId: z.string().optional(),
|
||||||
|
notes: z.string().max(500).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateRefillListSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).trim(),
|
||||||
|
preferredStoreId: z.string().optional(),
|
||||||
|
fromAlerts: z.boolean().default(false),
|
||||||
|
thresholdDays: z.number().int().min(1).max(365).default(7),
|
||||||
|
items: z.array(CreateRefillListItemSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateRefillListSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).trim().optional(),
|
||||||
|
status: z.nativeEnum(RefillListStatus).optional(),
|
||||||
|
preferredStoreId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateRefillListItemSchema = z.object({
|
||||||
|
checked: z.boolean().optional(),
|
||||||
|
actualPrice: z.number().nonnegative().optional(),
|
||||||
|
storeId: z.string().optional(),
|
||||||
|
notes: z.string().max(500).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefillListQuerySchema = z.object({
|
||||||
|
status: z.nativeEnum(RefillListStatus).optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefillAlertQuerySchema = z.object({
|
||||||
|
thresholdDays: z.coerce.number().int().min(1).max(365).default(7),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PriceOptionSchema = z.object({
|
||||||
|
price: z.number(),
|
||||||
|
pricePerUnit: z.number(),
|
||||||
|
storeName: z.string(),
|
||||||
|
storeId: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefillAlertResponseSchema = z.object({
|
||||||
|
medicineId: z.string(),
|
||||||
|
medicineName: z.string(),
|
||||||
|
medicineStrength: z.number(),
|
||||||
|
medicineStrengthUnit: z.nativeEnum(StrengthUnit),
|
||||||
|
daysUntilEmpty: z.number(),
|
||||||
|
dailyConsumption: z.number(),
|
||||||
|
currentStock: z.number(),
|
||||||
|
pendingOrderStock: z.number().optional(),
|
||||||
|
daysUntilEmptyWithOrders: z.number().nullable().optional(),
|
||||||
|
suggestedQuantity: z.number(),
|
||||||
|
lastKnownPrice: PriceOptionSchema.optional(),
|
||||||
|
cheapestOption: PriceOptionSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefillListItemResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
medicineId: z.string(),
|
||||||
|
medicineName: z.string(),
|
||||||
|
quantity: z.number(),
|
||||||
|
unit: z.string(),
|
||||||
|
estimatedPrice: z.number().optional(),
|
||||||
|
actualPrice: z.number().optional(),
|
||||||
|
checked: z.boolean(),
|
||||||
|
checkedAt: z.string().optional(),
|
||||||
|
addedToCabinet: z.boolean(),
|
||||||
|
storeId: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefillListResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
items: z.array(RefillListItemResponseSchema),
|
||||||
|
status: z.nativeEnum(RefillListStatus),
|
||||||
|
preferredStoreId: z.string().optional(),
|
||||||
|
totalEstimatedCost: z.number().optional(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefillListListResponseSchema = z.object({
|
||||||
|
data: z.array(RefillListResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddToCabinetResponseSchema = z.object({
|
||||||
|
addedCount: z.number(),
|
||||||
|
priceRecordsCreated: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateRefillListInput = z.infer<typeof CreateRefillListSchema>;
|
||||||
|
export type UpdateRefillListInput = z.infer<typeof UpdateRefillListSchema>;
|
||||||
|
export type UpdateRefillListItemInput = z.infer<typeof UpdateRefillListItemSchema>;
|
||||||
|
export type RefillListQueryInput = z.infer<typeof RefillListQuerySchema>;
|
||||||
|
export type RefillAlertQueryInput = z.infer<typeof RefillAlertQuerySchema>;
|
||||||
81
packages/shared/src/validation/store.schemas.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CreateStoreSchema, UpdateStoreSchema, StoreQuerySchema } from './store.schemas.js';
|
||||||
|
|
||||||
|
describe('CreateStoreSchema', () => {
|
||||||
|
it('accepts minimal valid input', () => {
|
||||||
|
const result = CreateStoreSchema.safeParse({ name: 'Walgreens' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.tags).toEqual([]);
|
||||||
|
expect(result.data.isActive).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts full valid input', () => {
|
||||||
|
const result = CreateStoreSchema.safeParse({
|
||||||
|
name: 'CVS Pharmacy',
|
||||||
|
address: '123 Main St',
|
||||||
|
location: { lat: 40.7128, lng: -74.006 },
|
||||||
|
url: 'https://cvs.com',
|
||||||
|
notes: 'Open 24h',
|
||||||
|
tags: ['pharmacy', 'retail'],
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty name', () => {
|
||||||
|
expect(CreateStoreSchema.safeParse({ name: '' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid location lat', () => {
|
||||||
|
expect(
|
||||||
|
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid location lng', () => {
|
||||||
|
expect(
|
||||||
|
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims name whitespace', () => {
|
||||||
|
const result = CreateStoreSchema.parse({ name: ' Rite Aid ' });
|
||||||
|
expect(result.name).toBe('Rite Aid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateStoreSchema', () => {
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
expect(UpdateStoreSchema.safeParse({}).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts partial update', () => {
|
||||||
|
const result = UpdateStoreSchema.safeParse({ isActive: false });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tags update', () => {
|
||||||
|
const result = UpdateStoreSchema.safeParse({ tags: ['grocery'] });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StoreQuerySchema', () => {
|
||||||
|
it('applies default limit', () => {
|
||||||
|
const result = StoreQuerySchema.parse({});
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces limit from string', () => {
|
||||||
|
const result = StoreQuerySchema.parse({ limit: '5' });
|
||||||
|
expect(result.limit).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tags and search filters', () => {
|
||||||
|
const result = StoreQuerySchema.parse({ tags: 'pharmacy', search: 'walgreens' });
|
||||||
|
expect(result.tags).toBe('pharmacy');
|
||||||
|
expect(result.search).toBe('walgreens');
|
||||||
|
});
|
||||||
|
});
|
||||||
60
packages/shared/src/validation/store.schemas.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
const locationSchema = z.object({
|
||||||
|
lat: z.number().min(-90).max(90),
|
||||||
|
lng: z.number().min(-180).max(180),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateStoreSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).trim(),
|
||||||
|
address: z.string().max(500).trim().optional(),
|
||||||
|
location: locationSchema.optional(),
|
||||||
|
url: z.url().optional(),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
tags: z.array(z.string().min(1).max(50).trim()).default([]),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateStoreSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).trim().optional(),
|
||||||
|
address: z.string().max(500).trim().optional(),
|
||||||
|
location: locationSchema.optional(),
|
||||||
|
url: z.url().optional(),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
tags: z.array(z.string().min(1).max(50).trim()).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StoreQuerySchema = z.object({
|
||||||
|
tags: z.string().optional(),
|
||||||
|
search: z.string().max(200).optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StoreResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
location: locationSchema.optional(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StoreListResponseSchema = z.object({
|
||||||
|
data: z.array(StoreResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
|
||||||
|
export type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
|
||||||
|
export type StoreQueryInput = z.infer<typeof StoreQuerySchema>;
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"lint-fix": "eslint src --fix",
|
"lint-fix": "eslint src --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "echo 'no tests yet'"
|
"test": "vitest run",
|
||||||
|
"test:cov": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@meshitrack/shared": "*",
|
"@meshitrack/shared": "*",
|
||||||
|
|
@ -23,12 +24,21 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/eslint-plugin-next": "^16.2.1",
|
"@next/eslint-plugin-next": "^16.2.1",
|
||||||
"@tailwindcss/postcss": "^4.2.0",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.0.0",
|
"@types/node": "^25.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"msw": "^2.12.14",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
"typescript": "^6.0.0"
|
"typescript": "^6.0.0",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
packages/web/src/app/(dashboard)/__tests__/layout.test.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('@/components/layout/Sidebar', () => ({
|
||||||
|
Sidebar: () => <nav data-testid="sidebar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/layout/TopBar', () => ({
|
||||||
|
TopBar: () => <div data-testid="topbar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DashboardLayout from '../layout';
|
||||||
|
|
||||||
|
describe('DashboardLayout', () => {
|
||||||
|
it('renders sidebar, topbar and children', () => {
|
||||||
|
render(<DashboardLayout>
|
||||||
|
<div data-testid="child">content</div>
|
||||||
|
</DashboardLayout>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
packages/web/src/app/(dashboard)/__tests__/loading.test.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, container } from '@testing-library/react';
|
||||||
|
|
||||||
|
import DashboardLoading from '../loading';
|
||||||
|
|
||||||
|
describe('DashboardLoading', () => {
|
||||||
|
it('renders a loading spinner', () => {
|
||||||
|
const { container: c } = render(<DashboardLoading />);
|
||||||
|
expect(c.querySelector('.animate-spin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DashboardPage from '../page';
|
||||||
|
|
||||||
|
describe('DashboardPage', () => {
|
||||||
|
it('renders heading', () => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Medicines card linking to /medicines', () => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
const link = screen.getByRole('link', { name: /medicines/i });
|
||||||
|
expect(link).toHaveAttribute('href', '/medicines');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Settings card linking to /settings', () => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
const link = screen.getByRole('link', { name: /settings/i });
|
||||||
|
expect(link).toHaveAttribute('href', '/settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
|
const { mockRecordPrice, mockGetPriceHistory, mockCompareStores } = vi.hoisted(() => ({
|
||||||
|
mockRecordPrice: vi.fn(),
|
||||||
|
mockGetPriceHistory: vi.fn(),
|
||||||
|
mockCompareStores: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListMedicines, mockListMedicineProducts } = vi.hoisted(() => ({
|
||||||
|
mockListMedicines: vi.fn(),
|
||||||
|
mockListMedicineProducts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListStores } = vi.hoisted(() => ({ mockListStores: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
vi.mock('@/services/medicine-prices', () => ({
|
||||||
|
recordPrice: mockRecordPrice,
|
||||||
|
getPriceHistory: mockGetPriceHistory,
|
||||||
|
compareStores: mockCompareStores,
|
||||||
|
}));
|
||||||
|
vi.mock('@/services/medicines', () => ({
|
||||||
|
listMedicines: mockListMedicines,
|
||||||
|
listMedicineProducts: mockListMedicineProducts,
|
||||||
|
}));
|
||||||
|
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
|
||||||
|
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||||
|
|
||||||
|
import MedicinePricesPage from '../page';
|
||||||
|
|
||||||
|
const emptyResponse = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||||
|
mockListStores.mockResolvedValue(emptyResponse);
|
||||||
|
mockGetPriceHistory.mockResolvedValue(emptyResponse);
|
||||||
|
mockCompareStores.mockResolvedValue({ data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MedicinePricesPage', () => {
|
||||||
|
it('shows loading skeleton when session loading', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
expect(screen.getByText('Medicine Prices')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Record Price')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows household prompt when no householdId', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Medicine Prices heading when householdId exists', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
expect(screen.getByText('Medicine Prices')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Record Price')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty prompt to select a medicine', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Select a medicine to view price history.')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads price history when medicine selected', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockGetPriceHistory.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'pr-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
price: 15.99,
|
||||||
|
pricePerUnit: 0.18,
|
||||||
|
currency: 'USD',
|
||||||
|
quantity: 90,
|
||||||
|
unit: 'tablet',
|
||||||
|
date: '2026-01-01T00:00:00.000Z',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Select a medicine'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
||||||
|
// Wait for price records to render
|
||||||
|
await waitFor(() => expect(screen.getByText('Walgreens')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows validation error in form when required fields missing', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Record Price'));
|
||||||
|
await waitFor(() => screen.getByRole('button', { name: 'Record Price', hidden: false }));
|
||||||
|
|
||||||
|
// The submit button inside the form also has text 'Record Price'
|
||||||
|
const submitBtn = screen.getAllByRole('button', { name: 'Record Price' }).find(
|
||||||
|
(b) => b.getAttribute('type') === 'submit',
|
||||||
|
);
|
||||||
|
if (submitBtn) {
|
||||||
|
fireEvent.submit(submitBtn.closest('form')!);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Please select a medicine, a product, and a store.')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when price history fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Select a medicine'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('History load failed')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a price successfully', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockListStores.mockResolvedValue({
|
||||||
|
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [{ _id: 'prod-1', brand: 'Generic', packageSize: 90, packageUnit: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockRecordPrice.mockResolvedValue({});
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Record Price'));
|
||||||
|
await userEvent.click(screen.getByText('Record Price'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Record Price', { selector: 'h2' }));
|
||||||
|
|
||||||
|
// Select store
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
|
||||||
|
// Select medicine
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
// Wait for products to load and select product
|
||||||
|
await waitFor(() => screen.getByText(/Generic.*90/));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
fireEvent.submit(screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockRecordPrice).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })));
|
||||||
|
// Form should close after success
|
||||||
|
await waitFor(() => expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Load more button in price history', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockGetPriceHistory.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'pr-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
price: 15.99,
|
||||||
|
pricePerUnit: 0.18,
|
||||||
|
currency: 'USD',
|
||||||
|
quantity: 90,
|
||||||
|
unit: 'tablet',
|
||||||
|
date: '2026-01-01T00:00:00.000Z',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: 'cur1', hasMore: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Select a medicine'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Load more')).toBeInTheDocument());
|
||||||
|
await userEvent.click(screen.getByText('Load more'));
|
||||||
|
|
||||||
|
expect(mockGetPriceHistory).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows store filter when medicine selected and changes it', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockListStores.mockResolvedValue({
|
||||||
|
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Select a medicine'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByDisplayValue('All stores'));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('All stores'), { target: { value: 'st-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGetPriceHistory).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses price history error', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Select a medicine'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('History load failed'));
|
||||||
|
await userEvent.click(screen.getByText('Dismiss'));
|
||||||
|
expect(screen.queryByText('History load failed')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows store comparison table', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockCompareStores.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 12.99, latestPricePerUnit: 0.14, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
||||||
|
{ storeId: 'st-2', storeName: 'CVS', latestPrice: 14.99, latestPricePerUnit: 0.17, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Select a medicine'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Store comparison')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText('Walgreens')).toBeInTheDocument();
|
||||||
|
// First row (cheapest) should be highlighted green
|
||||||
|
expect(screen.getByText('cheapest')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes price, currency, quantity, and unit fields in form', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Record Price'));
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('9.99'));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('9.99'), { target: { value: '15.99' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('USD'), { target: { value: 'EUR' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||||
|
fireEvent.change(screen.getByDisplayValue('tablet'), { target: { value: 'capsule' } });
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('9.99')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes medicine search, notes and insurance price fields in form', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Record Price'));
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'Met' } });
|
||||||
|
|
||||||
|
// Notes field (no placeholder, but maxLength 1000)
|
||||||
|
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||||
|
if (notesInput) {
|
||||||
|
fireEvent.change(notesInput, { target: { value: 'Some notes' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insurance price checkbox
|
||||||
|
fireEvent.click(screen.getByLabelText('Insurance price'));
|
||||||
|
expect(screen.getByLabelText('Insurance price')).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles Record Price form', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
render(<MedicinePricesPage />);
|
||||||
|
await userEvent.click(screen.getByText('Record Price'));
|
||||||
|
// Both the header toggle and the form show "Cancel" when open
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Cancel' }).length).toBeGreaterThan(0);
|
||||||
|
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[0]);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
618
packages/web/src/app/(dashboard)/medicine-prices/page.tsx
Normal file
|
|
@ -0,0 +1,618 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import {
|
||||||
|
recordPrice,
|
||||||
|
getPriceHistory,
|
||||||
|
compareStores,
|
||||||
|
} from '@/services/medicine-prices';
|
||||||
|
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
||||||
|
import { listStores } from '@/services/stores';
|
||||||
|
import { DosageUnit } from '@meshitrack/shared';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type {
|
||||||
|
MedicinePriceRecordResponseSchema,
|
||||||
|
StoreComparisonItemSchema,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type PriceRecord = z.infer<typeof MedicinePriceRecordResponseSchema>;
|
||||||
|
type StoreComparisonItem = z.infer<typeof StoreComparisonItemSchema>;
|
||||||
|
|
||||||
|
type MedicineOption = { _id: string; name: string; strength: number; strengthUnit: string };
|
||||||
|
type ProductOption = { _id: string; brand?: string; packageSize: number; packageUnit: string };
|
||||||
|
type StoreOption = { _id: string; name: string };
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, currency?: string): string {
|
||||||
|
return currency ? `${currency} ${amount.toFixed(2)}` : amount.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Record price form ---
|
||||||
|
|
||||||
|
function RecordPriceForm({
|
||||||
|
householdId,
|
||||||
|
medicines,
|
||||||
|
stores,
|
||||||
|
onRecorded,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
householdId: string;
|
||||||
|
medicines: MedicineOption[];
|
||||||
|
stores: StoreOption[];
|
||||||
|
onRecorded: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [medicineId, setMedicineId] = useState('');
|
||||||
|
const [medicineSearch, setMedicineSearch] = useState('');
|
||||||
|
const [products, setProducts] = useState<ProductOption[]>([]);
|
||||||
|
const [productsLoading, setProductsLoading] = useState(false);
|
||||||
|
const [medicineProductId, setMedicineProductId] = useState('');
|
||||||
|
const [storeId, setStoreId] = useState('');
|
||||||
|
const [price, setPrice] = useState('');
|
||||||
|
const [currency, setCurrency] = useState('USD');
|
||||||
|
const [quantity, setQuantity] = useState('');
|
||||||
|
const [unit, setUnit] = useState<DosageUnit>(DosageUnit.TABLET);
|
||||||
|
const [isInsurancePrice, setIsInsurancePrice] = useState(false);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const filteredMedicines = medicineSearch
|
||||||
|
? medicines.filter((m) => m.name.toLowerCase().includes(medicineSearch.toLowerCase()))
|
||||||
|
: medicines;
|
||||||
|
|
||||||
|
async function handleMedicineChange(id: string) {
|
||||||
|
setMedicineId(id);
|
||||||
|
setMedicineProductId('');
|
||||||
|
setProducts([]);
|
||||||
|
if (!id) return;
|
||||||
|
setProductsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await listMedicineProducts(householdId, id, { limit: 50 });
|
||||||
|
setProducts(result.data as ProductOption[]);
|
||||||
|
} catch {
|
||||||
|
/* non-fatal */
|
||||||
|
} finally {
|
||||||
|
setProductsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProductChange(productId: string) {
|
||||||
|
setMedicineProductId(productId);
|
||||||
|
const product = products.find((p) => p._id === productId);
|
||||||
|
if (product) {
|
||||||
|
setQuantity(String(product.packageSize));
|
||||||
|
setUnit(product.packageUnit as DosageUnit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!medicineId || !medicineProductId || !storeId) {
|
||||||
|
setError('Please select a medicine, a product, and a store.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await recordPrice(householdId, {
|
||||||
|
medicineId,
|
||||||
|
medicineProductId,
|
||||||
|
storeId,
|
||||||
|
price: Number(price),
|
||||||
|
currency: currency.trim(),
|
||||||
|
quantity: Number(quantity),
|
||||||
|
unit,
|
||||||
|
isInsurancePrice,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
onRecorded();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to record price');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Record Price</h2>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
|
||||||
|
<select
|
||||||
|
value={storeId}
|
||||||
|
onChange={(e) => setStoreId(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select store</option>
|
||||||
|
{stores.map((s) => (
|
||||||
|
<option key={s._id} value={s._id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{stores.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
No stores yet.{' '}
|
||||||
|
<Link href="/stores" className="text-primary-600 underline">
|
||||||
|
Add a store first
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Medicine</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={medicineSearch}
|
||||||
|
onChange={(e) => setMedicineSearch(e.target.value)}
|
||||||
|
placeholder="Search medicines..."
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none mb-2"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={medicineId}
|
||||||
|
onChange={(e) => handleMedicineChange(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select medicine</option>
|
||||||
|
{filteredMedicines.map((m) => (
|
||||||
|
<option key={m._id} value={m._id}>
|
||||||
|
{m.name} ({m.strength} {m.strengthUnit})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Product</label>
|
||||||
|
{productsLoading ? (
|
||||||
|
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={medicineProductId}
|
||||||
|
onChange={(e) => handleProductChange(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={!medicineId}
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
||||||
|
>
|
||||||
|
<option value="">{medicineId ? 'Select product' : 'Select a medicine first'}</option>
|
||||||
|
{products.map((p) => (
|
||||||
|
<option key={p._id} value={p._id}>
|
||||||
|
{p.brand ?? 'Generic'} — {p.packageSize} {p.packageUnit}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{medicineId && !productsLoading && products.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
No products for this medicine.{' '}
|
||||||
|
<Link href={`/medicines/${medicineId}`} className="text-primary-600 underline">
|
||||||
|
Add a product first
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Price</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={0.01}
|
||||||
|
step="any"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
placeholder="9.99"
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Currency</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={10}
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
|
placeholder="USD"
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Package size</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
placeholder="90"
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
||||||
|
<select
|
||||||
|
value={unit}
|
||||||
|
onChange={(e) => setUnit(e.target.value as DosageUnit)}
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{Object.values(DosageUnit).map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Notes (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={1000}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isInsurancePrice"
|
||||||
|
checked={isInsurancePrice}
|
||||||
|
onChange={(e) => setIsInsurancePrice(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isInsurancePrice" className="text-sm font-medium text-gray-700">
|
||||||
|
Insurance price
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? 'Recording...' : 'Record Price'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Price history & store comparison ---
|
||||||
|
|
||||||
|
function PriceHistory({
|
||||||
|
householdId,
|
||||||
|
medicines,
|
||||||
|
stores,
|
||||||
|
}: {
|
||||||
|
householdId: string;
|
||||||
|
medicines: MedicineOption[];
|
||||||
|
stores: StoreOption[];
|
||||||
|
}) {
|
||||||
|
const [selectedMedicineId, setSelectedMedicineId] = useState('');
|
||||||
|
const [selectedStoreId, setSelectedStoreId] = useState('');
|
||||||
|
const [records, setRecords] = useState<PriceRecord[]>([]);
|
||||||
|
const [comparison, setComparison] = useState<StoreComparisonItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(
|
||||||
|
async (append = false) => {
|
||||||
|
if (!selectedMedicineId) return;
|
||||||
|
if (!append) setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const [histResult, compResult] = await Promise.all([
|
||||||
|
getPriceHistory(householdId, selectedMedicineId, {
|
||||||
|
storeId: selectedStoreId || undefined,
|
||||||
|
cursor: append ? (cursor ?? undefined) : undefined,
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
!append ? compareStores(householdId, selectedMedicineId) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
setRecords((prev) => (append ? [...prev, ...histResult.data] : histResult.data));
|
||||||
|
setCursor(histResult.pagination.cursor);
|
||||||
|
setHasMore(histResult.pagination.hasMore);
|
||||||
|
if (compResult) setComparison(compResult.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load price history');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[householdId, selectedMedicineId, selectedStoreId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCursor(null);
|
||||||
|
setRecords([]);
|
||||||
|
setComparison([]);
|
||||||
|
if (selectedMedicineId) fetchHistory(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [householdId, selectedMedicineId, selectedStoreId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Price History</h2>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={selectedMedicineId}
|
||||||
|
onChange={(e) => setSelectedMedicineId(e.target.value)}
|
||||||
|
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select a medicine</option>
|
||||||
|
{medicines.map((m) => (
|
||||||
|
<option key={m._id} value={m._id}>
|
||||||
|
{m.name} ({m.strength} {m.strengthUnit})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedMedicineId && (
|
||||||
|
<select
|
||||||
|
value={selectedStoreId}
|
||||||
|
onChange={(e) => setSelectedStoreId(e.target.value)}
|
||||||
|
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">All stores</option>
|
||||||
|
{stores.map((s) => (
|
||||||
|
<option key={s._id} value={s._id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedMedicineId ? (
|
||||||
|
<p className="text-sm text-gray-500 py-4 text-center">
|
||||||
|
Select a medicine to view price history.
|
||||||
|
</p>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse h-12 rounded-lg bg-gray-200" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{comparison.length > 0 && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-2">Store comparison</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-gray-500">
|
||||||
|
<th className="pb-2 font-medium">Store</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Price</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Per unit</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{comparison.map((item, i) => (
|
||||||
|
<tr key={item.storeId} className={i === 0 ? 'text-green-700 font-medium' : ''}>
|
||||||
|
<td className="py-2">
|
||||||
|
{item.storeName}
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs">
|
||||||
|
cheapest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.isInsurancePrice && (
|
||||||
|
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
||||||
|
insurance
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{formatCurrency(item.latestPrice, item.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-gray-500">
|
||||||
|
{formatCurrency(item.latestPricePerUnit, item.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-gray-400">{formatDate(item.date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 py-4 text-center">No price records found.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-2">All records</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-gray-500">
|
||||||
|
<th className="pb-2 font-medium">Store</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Price</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Qty</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Per unit</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{records.map((r) => (
|
||||||
|
<tr key={r._id}>
|
||||||
|
<td className="py-2">
|
||||||
|
{r.storeName}
|
||||||
|
{r.isInsurancePrice && (
|
||||||
|
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
||||||
|
ins
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.notes && (
|
||||||
|
<span className="ml-1 text-xs text-gray-400"> — {r.notes}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-medium">
|
||||||
|
{formatCurrency(r.price, r.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-gray-500">
|
||||||
|
{r.quantity} {r.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-gray-500">
|
||||||
|
{formatCurrency(r.pricePerUnit, r.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-gray-400">{formatDate(r.date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => fetchHistory(true)}
|
||||||
|
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main page ---
|
||||||
|
|
||||||
|
function MedicinePricesContent({ householdId }: { householdId: string }) {
|
||||||
|
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
|
||||||
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [historyKey, setHistoryKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
|
||||||
|
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
|
||||||
|
}, [householdId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Medicine Prices</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
{showForm ? 'Cancel' : 'Record Price'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<RecordPriceForm
|
||||||
|
householdId={householdId}
|
||||||
|
medicines={medicines}
|
||||||
|
stores={stores}
|
||||||
|
onRecorded={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setHistoryKey((k) => k + 1);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PriceHistory
|
||||||
|
key={historyKey}
|
||||||
|
householdId={householdId}
|
||||||
|
medicines={medicines}
|
||||||
|
stores={stores}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MedicinePricesPage() {
|
||||||
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
|
if (sessionLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
||||||
|
<div className="h-40 rounded-xl bg-gray-200" />
|
||||||
|
<div className="h-40 rounded-xl bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!householdId) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
|
||||||
|
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
You need to{' '}
|
||||||
|
<Link href="/settings" className="text-primary-600 underline">
|
||||||
|
create or join a household
|
||||||
|
</Link>{' '}
|
||||||
|
before tracking prices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MedicinePricesContent householdId={householdId} />;
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ function formatDateTime(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v8 ignore next 4 */
|
||||||
function formatQuantityChange(event: CabinetEvent): string {
|
function formatQuantityChange(event: CabinetEvent): string {
|
||||||
const sign = event.quantity > 0 ? '+' : '';
|
const sign = event.quantity > 0 ? '+' : '';
|
||||||
return `${sign}${event.quantity}`;
|
return `${sign}${event.quantity}`;
|
||||||
|
|
@ -267,7 +268,7 @@ function EventTimeline({
|
||||||
<option value="">All event types</option>
|
<option value="">All event types</option>
|
||||||
{Object.values(CabinetEventType).map((t) => (
|
{Object.values(CabinetEventType).map((t) => (
|
||||||
<option key={t} value={t}>
|
<option key={t} value={t}>
|
||||||
{EVENT_TYPE_LABELS[t] ?? t}
|
{/* v8 ignore next */ EVENT_TYPE_LABELS[t] ?? t}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -348,9 +349,9 @@ function EventTimeline({
|
||||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${/* v8 ignore next */ EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
|
||||||
>
|
>
|
||||||
{EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
{/* v8 ignore next */ EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<span className="text-sm font-medium text-gray-900">
|
||||||
{event.medicineName}
|
{event.medicineName}
|
||||||
|
|
|
||||||
|
|
@ -456,9 +456,9 @@ function CabinetItemCard({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-3">
|
<div className="flex items-center gap-2 ml-3">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[item.status] ?? STATUS_COLORS['active']}`}
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${/* v8 ignore next */ STATUS_COLORS[item.status] ?? STATUS_COLORS['active']}`}
|
||||||
>
|
>
|
||||||
{STATUS_LABELS[item.status] ?? item.status}
|
{/* v8 ignore next */ STATUS_LABELS[item.status] ?? item.status}
|
||||||
</span>
|
</span>
|
||||||
{item.status === 'active' && (
|
{item.status === 'active' && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -594,7 +594,7 @@ function AddToCabinetForm({
|
||||||
<option value="">Select a medicine</option>
|
<option value="">Select a medicine</option>
|
||||||
{medicines.map((med) => (
|
{medicines.map((med) => (
|
||||||
<option key={med._id} value={med._id}>
|
<option key={med._id} value={med._id}>
|
||||||
{med.name} ({med.strength} {med.strengthUnit}, {FORM_LABELS[med.form] ?? med.form})
|
{med.name} ({med.strength} {med.strengthUnit}, {/* v8 ignore next */ FORM_LABELS[med.form] ?? med.form})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -637,7 +637,7 @@ function AddToCabinetForm({
|
||||||
: Object.values(DosageUnit);
|
: Object.values(DosageUnit);
|
||||||
return units.map((u) => (
|
return units.map((u) => (
|
||||||
<option key={u} value={u}>
|
<option key={u} value={u}>
|
||||||
{UNIT_LABELS[u] ?? u}
|
{/* v8 ignore next */ UNIT_LABELS[u] ?? u}
|
||||||
</option>
|
</option>
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,7 @@ function CreateMedicineForm({
|
||||||
>
|
>
|
||||||
{Object.values(MedicineForm).map((f) => (
|
{Object.values(MedicineForm).map((f) => (
|
||||||
<option key={f} value={f}>
|
<option key={f} value={f}>
|
||||||
{FORM_LABELS[f] ?? f}
|
{/* v8 ignore next */ FORM_LABELS[f] ?? f}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -319,7 +319,7 @@ function CreateMedicineForm({
|
||||||
>
|
>
|
||||||
{Object.values(MedicineCategory).map((c) => (
|
{Object.values(MedicineCategory).map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
{CATEGORY_LABELS[c] ?? c}
|
{/* v8 ignore next */ CATEGORY_LABELS[c] ?? c}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,777 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
const { mockUseParams } = vi.hoisted(() => ({ mockUseParams: vi.fn() }));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockGetMedicine,
|
||||||
|
mockListMedicineProducts,
|
||||||
|
mockCreateMedicineProduct,
|
||||||
|
mockDeleteMedicineProduct,
|
||||||
|
mockUpdateMedicine,
|
||||||
|
mockUpdateMedicineProduct,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockGetMedicine: vi.fn(),
|
||||||
|
mockListMedicineProducts: vi.fn(),
|
||||||
|
mockCreateMedicineProduct: vi.fn(),
|
||||||
|
mockDeleteMedicineProduct: vi.fn(),
|
||||||
|
mockUpdateMedicine: vi.fn(),
|
||||||
|
mockUpdateMedicineProduct: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
mockListCabinetItems: vi.fn(),
|
||||||
|
mockAdjustCabinetItemQuantity: vi.fn(),
|
||||||
|
mockDeleteCabinetItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
vi.mock('next/navigation', () => ({ useParams: mockUseParams }));
|
||||||
|
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||||
|
|
||||||
|
vi.mock('@/services/medicines', () => ({
|
||||||
|
getMedicine: mockGetMedicine,
|
||||||
|
listMedicineProducts: mockListMedicineProducts,
|
||||||
|
createMedicineProduct: mockCreateMedicineProduct,
|
||||||
|
deleteMedicineProduct: mockDeleteMedicineProduct,
|
||||||
|
updateMedicine: mockUpdateMedicine,
|
||||||
|
updateMedicineProduct: mockUpdateMedicineProduct,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/cabinet', () => ({
|
||||||
|
listCabinetItems: mockListCabinetItems,
|
||||||
|
adjustCabinetItemQuantity: mockAdjustCabinetItemQuantity,
|
||||||
|
deleteCabinetItem: mockDeleteCabinetItem,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MedicineDetailPage from '../page';
|
||||||
|
|
||||||
|
const medicine = {
|
||||||
|
_id: 'med-1',
|
||||||
|
name: 'Metformin',
|
||||||
|
form: 'tablet',
|
||||||
|
strength: 500,
|
||||||
|
strengthUnit: 'mg',
|
||||||
|
category: 'prescription',
|
||||||
|
notes: '',
|
||||||
|
tags: [],
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyProducts = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
const emptyItems = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseParams.mockReturnValue({ id: 'med-1' });
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetMedicine.mockResolvedValue(medicine);
|
||||||
|
mockListMedicineProducts.mockResolvedValue(emptyProducts);
|
||||||
|
mockListCabinetItems.mockResolvedValue(emptyItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MedicineDetailPage', () => {
|
||||||
|
it('shows loading skeleton when session is loading', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Metformin')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders medicine name and sections after load', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText('Inventory')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Products (Brands/Packages)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when medicine fails to load', async () => {
|
||||||
|
mockGetMedicine.mockRejectedValue(new Error('Not found'));
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Not found')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty inventory state', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/No inventory items/)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty products state', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/No products yet/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles Add Product form', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product'));
|
||||||
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
expect(screen.getByText('Add Product', { selector: 'h3' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByText('Cancel')[0]);
|
||||||
|
expect(screen.queryByText('Add Product', { selector: 'h3' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles edit medicine form', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByTitle('Edit'));
|
||||||
|
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Cancel'));
|
||||||
|
expect(screen.queryByText('Edit Medicine')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves medicine edit', async () => {
|
||||||
|
mockUpdateMedicine.mockResolvedValue({ ...medicine, name: 'Metformin XR' });
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByTitle('Edit'));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a product after confirmation', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Glucophage',
|
||||||
|
packageSize: 60,
|
||||||
|
packageUnit: 'tablet',
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteMedicineProduct.mockResolvedValue({});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Glucophage'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeleteMedicineProduct).toHaveBeenCalledWith('hh1', 'prod-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits CreateProductForm', async () => {
|
||||||
|
mockCreateMedicineProduct.mockResolvedValue({
|
||||||
|
_id: 'prod-new',
|
||||||
|
brand: 'NewBrand',
|
||||||
|
packageSize: 30,
|
||||||
|
packageUnit: 'tablet',
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product'));
|
||||||
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
|
||||||
|
target: { value: 'NewBrand' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('90'), {
|
||||||
|
target: { value: '30' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockCreateMedicineProduct).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'med-1',
|
||||||
|
expect.objectContaining({ brand: 'NewBrand' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows create product error on failure', async () => {
|
||||||
|
mockCreateMedicineProduct.mockRejectedValue(new Error('Duplicate brand'));
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product'));
|
||||||
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
|
||||||
|
target: { value: 'Brand' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Duplicate brand')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens and saves product edit form', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Glucophage',
|
||||||
|
packageSize: 60,
|
||||||
|
packageUnit: 'tablet',
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockUpdateMedicineProduct.mockResolvedValue({
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Glucophage XR',
|
||||||
|
packageSize: 60,
|
||||||
|
packageUnit: 'tablet',
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Glucophage'));
|
||||||
|
// First Edit title is the medicine edit, second is the product edit
|
||||||
|
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Glucophage'), { target: { value: 'Glucophage XR' } });
|
||||||
|
fireEvent.submit(screen.getByDisplayValue('Glucophage XR').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockUpdateMedicineProduct).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'prod-1',
|
||||||
|
expect.objectContaining({ brand: 'Glucophage XR' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows concentration fields when injection medicine with ml unit', async () => {
|
||||||
|
mockGetMedicine.mockResolvedValue({
|
||||||
|
...medicine,
|
||||||
|
form: 'injection',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product'));
|
||||||
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||||
|
// Change package unit to ml to show concentration fields
|
||||||
|
const unitSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).value === 'vial',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByPlaceholderText('e.g. 100')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a product after confirmation', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Glucophage',
|
||||||
|
packageSize: 60,
|
||||||
|
packageUnit: 'tablet',
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteMedicineProduct.mockResolvedValue({});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Glucophage'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeleteMedicineProduct).toHaveBeenCalledWith('hh1', 'prod-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits injection product and changes concentration fields', async () => {
|
||||||
|
mockGetMedicine.mockResolvedValue({
|
||||||
|
...medicine,
|
||||||
|
form: 'injection',
|
||||||
|
});
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Insulin',
|
||||||
|
packageSize: 10,
|
||||||
|
packageUnit: 'ml',
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Insulin'));
|
||||||
|
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Insulin'));
|
||||||
|
|
||||||
|
// concentration field should be visible since form=injection, unit=ml
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
|
||||||
|
|
||||||
|
// Change concentration unit
|
||||||
|
const concUnitSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).options[0]?.text === '--',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
if (concUnitSelect) {
|
||||||
|
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Insulin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes fields in the product edit inline form', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Glucophage',
|
||||||
|
packageSize: 60,
|
||||||
|
packageUnit: 'tablet',
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Glucophage'));
|
||||||
|
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||||
|
|
||||||
|
// Change package size
|
||||||
|
fireEvent.change(screen.getByDisplayValue('60'), { target: { value: '90' } });
|
||||||
|
|
||||||
|
// Change package unit
|
||||||
|
const unitSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).value === 'tablet',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes manufacturer, concentration and notes fields in product form', async () => {
|
||||||
|
mockGetMedicine.mockResolvedValue({
|
||||||
|
...medicine,
|
||||||
|
form: 'injection',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product'));
|
||||||
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. Pfizer'));
|
||||||
|
|
||||||
|
// Change manufacturer (truthy) then clear (falsy → undefined)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: 'Pfizer' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: '' } });
|
||||||
|
|
||||||
|
// Change notes (truthy) then clear (falsy → undefined)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'Store in fridge' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
||||||
|
|
||||||
|
// Change unit to ml to show concentration fields
|
||||||
|
const unitSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).value === 'vial',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||||
|
// Set concentration (truthy) then clear (falsy → undefined)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
|
||||||
|
|
||||||
|
// Change concentration unit
|
||||||
|
const concUnitSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).value === '',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
if (concUnitSelect) {
|
||||||
|
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('e.g. Pfizer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes fields in the product edit inline form (manufacturer, notes)', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Glucophage',
|
||||||
|
packageSize: 60,
|
||||||
|
packageUnit: 'tablet',
|
||||||
|
source: 'manual',
|
||||||
|
manufacturer: '',
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Glucophage'));
|
||||||
|
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||||
|
|
||||||
|
// Find the manufacturer input (empty string value, maxLength 200)
|
||||||
|
const allInputs = document.querySelectorAll('input[maxLength="200"]');
|
||||||
|
// First is brand (Glucophage), second is manufacturer
|
||||||
|
if (allInputs.length > 1) {
|
||||||
|
fireEvent.change(allInputs[1]!, { target: { value: 'Pfizer' } });
|
||||||
|
// Clear manufacturer to cover the || undefined false branch
|
||||||
|
fireEvent.change(allInputs[1]!, { target: { value: '' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change notes (maxLength 1000 input)
|
||||||
|
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||||
|
if (notesInput) {
|
||||||
|
fireEvent.change(notesInput, { target: { value: 'Keep refrigerated' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjusts cabinet item quantity', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockAdjustCabinetItemQuantity.mockResolvedValue({});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Add 1'));
|
||||||
|
await userEvent.click(screen.getByTitle('Add 1'));
|
||||||
|
|
||||||
|
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes 1 from cabinet item', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockAdjustCabinetItemQuantity.mockResolvedValue({});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Take 1'));
|
||||||
|
await userEvent.click(screen.getByTitle('Take 1'));
|
||||||
|
|
||||||
|
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: -1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes cabinet item after confirmation', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteCabinetItem.mockResolvedValue({});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Delete'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeleteCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels CreateProductForm with internal Cancel button', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product'));
|
||||||
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product', { selector: 'h3' }));
|
||||||
|
// Click the Cancel button inside the form (last Cancel button in DOM)
|
||||||
|
const cancelButtons = screen.getAllByText('Cancel');
|
||||||
|
await userEvent.click(cancelButtons[cancelButtons.length - 1]!);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Add Product', { selector: 'h3' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes all fields in edit medicine form', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByTitle('Edit'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. daily, morning'));
|
||||||
|
|
||||||
|
// Change name
|
||||||
|
const nameInput = document.querySelector('input[maxLength="200"]') as HTMLElement;
|
||||||
|
if (nameInput) fireEvent.change(nameInput, { target: { value: 'Metformin XR' } });
|
||||||
|
|
||||||
|
// Change form select
|
||||||
|
const formSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).options[0]?.value === 'tablet',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
if (formSelect) fireEvent.change(formSelect, { target: { value: 'capsule' } });
|
||||||
|
|
||||||
|
// Change strength
|
||||||
|
const strengthInput = document.querySelector('input[min="0.01"]') as HTMLElement;
|
||||||
|
if (strengthInput) fireEvent.change(strengthInput, { target: { value: '250' } });
|
||||||
|
|
||||||
|
// Change category select
|
||||||
|
const catSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
if (catSelect) fireEvent.change(catSelect, { target: { value: 'otc' } });
|
||||||
|
|
||||||
|
// Change notes (truthy value)
|
||||||
|
const notesInputs = document.querySelectorAll('input[maxLength="1000"]');
|
||||||
|
if (notesInputs[0]) fireEvent.change(notesInputs[0]!, { target: { value: 'Take with food' } });
|
||||||
|
// Also clear notes (covers the || undefined false branch)
|
||||||
|
if (notesInputs[0]) fireEvent.change(notesInputs[0]!, { target: { value: '' } });
|
||||||
|
|
||||||
|
// Change tags
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. daily, morning'), {
|
||||||
|
target: { value: 'morning, daily' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('e.g. daily, morning')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when update medicine fails', async () => {
|
||||||
|
mockUpdateMedicine.mockRejectedValue(new Error('Update failed'));
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Update failed')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses error in detail page', async () => {
|
||||||
|
mockUpdateMedicine.mockRejectedValue(new Error('Update failed'));
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Update failed'));
|
||||||
|
await userEvent.click(screen.getByText('Dismiss'));
|
||||||
|
expect(screen.queryByText('Update failed')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes strength unit in edit medicine form', async () => {
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getByTitle('Edit'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Edit Medicine'));
|
||||||
|
// Change strength unit select (the one with 'mg' options)
|
||||||
|
const unitSelect = screen.getAllByRole('combobox').find(
|
||||||
|
(s) => (s as HTMLSelectElement).value === 'mg',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
if (unitSelect) fireEvent.change(unitSelect, { target: { value: 'mcg' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels product edit form', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [{ _id: 'mp-1', brand: 'Bayer', packageSize: 100, unit: 'tablet', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Bayer'));
|
||||||
|
const editButtons = screen.getAllByTitle('Edit');
|
||||||
|
await userEvent.click(editButtons[editButtons.length - 1]!);
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Bayer'));
|
||||||
|
|
||||||
|
// Click Cancel to close product edit form
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
|
||||||
|
expect(screen.queryByDisplayValue('Bayer')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error thrown on create product', async () => {
|
||||||
|
mockCreateMedicineProduct.mockRejectedValue('create failed');
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Product'));
|
||||||
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), { target: { value: 'Brand X' } });
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to create product')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error thrown on update product', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [{ _id: 'mp-1', brand: 'Bayer', packageSize: 100, unit: 'tablet', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockUpdateMedicineProduct.mockRejectedValue('update failed');
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Bayer'));
|
||||||
|
// Click the product Edit button (last Edit button, not the medicine one)
|
||||||
|
const editButtons = screen.getAllByTitle('Edit');
|
||||||
|
await userEvent.click(editButtons[editButtons.length - 1]!);
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Bayer'));
|
||||||
|
fireEvent.submit(screen.getByDisplayValue('Bayer').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to update product')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error thrown on delete product', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [{ _id: 'mp-1', brand: 'Bayer', packageSize: 100, unit: 'tablet', form: 'tablet' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteMedicineProduct.mockRejectedValue('delete failed');
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Bayer'));
|
||||||
|
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to delete product')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error thrown on adjust cabinet', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockAdjustCabinetItemQuantity.mockRejectedValue('adjust failed');
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Take 1'));
|
||||||
|
await userEvent.click(screen.getByTitle('Take 1'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to adjust quantity')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows product concentration and manufacturer in display', async () => {
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'mp-1',
|
||||||
|
brand: 'Lantus',
|
||||||
|
packageSize: 10,
|
||||||
|
packageUnit: 'ml',
|
||||||
|
concentration: 100,
|
||||||
|
concentrationUnit: 'IU/ml',
|
||||||
|
manufacturer: 'Sanofi',
|
||||||
|
notes: 'Refrigerate after opening',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Lantus'));
|
||||||
|
expect(screen.getByText(/100 IU\/ml/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Sanofi/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Refrigerate after opening')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears concentration and notes in edit inline form for injection', async () => {
|
||||||
|
mockGetMedicine.mockResolvedValue({ ...medicine, form: 'injection' });
|
||||||
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'prod-1',
|
||||||
|
brand: 'Lantus',
|
||||||
|
packageSize: 10,
|
||||||
|
packageUnit: 'ml',
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Lantus'));
|
||||||
|
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Lantus'));
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||||
|
|
||||||
|
// Set concentration then clear (covers undefined branch at line 675)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
|
||||||
|
|
||||||
|
// Set notes then clear (covers undefined branch at line 720)
|
||||||
|
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||||
|
if (notesInput) fireEvent.change(notesInput, { target: { value: 'Refrigerate' } });
|
||||||
|
if (notesInput) fireEvent.change(notesInput, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Lantus')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error thrown on delete cabinet', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [{ _id: 'ci-1', quantity: 10, unit: 'tablet', status: 'active' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteCabinetItem.mockRejectedValue('delete failed');
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getAllByTitle('Delete'));
|
||||||
|
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -161,11 +161,17 @@ export default function MedicineDetailPage() {
|
||||||
|
|
||||||
function startEditProduct(product: MedicineProduct) {
|
function startEditProduct(product: MedicineProduct) {
|
||||||
setEditingProductId(product._id);
|
setEditingProductId(product._id);
|
||||||
|
const validUnits = Object.values(DosageUnit) as string[];
|
||||||
|
const allowedUnits = allowedUnitsForForm((medicine?.form as MedicineForm) ?? MedicineForm.OTHER);
|
||||||
|
const storedUnit = product.packageUnit;
|
||||||
|
const packageUnit = validUnits.includes(storedUnit)
|
||||||
|
? (storedUnit as DosageUnit)
|
||||||
|
: allowedUnits[0];
|
||||||
setProductForm({
|
setProductForm({
|
||||||
brand: product.brand,
|
brand: product.brand,
|
||||||
manufacturer: product.manufacturer ?? undefined,
|
manufacturer: product.manufacturer ?? undefined,
|
||||||
packageSize: product.packageSize,
|
packageSize: product.packageSize,
|
||||||
packageUnit: product.packageUnit as DosageUnit,
|
packageUnit,
|
||||||
concentration: product.concentration ?? undefined,
|
concentration: product.concentration ?? undefined,
|
||||||
concentrationUnit: product.concentrationUnit as ConcentrationUnit | undefined,
|
concentrationUnit: product.concentrationUnit as ConcentrationUnit | undefined,
|
||||||
notes: product.notes ?? undefined,
|
notes: product.notes ?? undefined,
|
||||||
|
|
@ -561,6 +567,7 @@ export default function MedicineDetailPage() {
|
||||||
<CreateProductForm
|
<CreateProductForm
|
||||||
householdId={householdId}
|
householdId={householdId}
|
||||||
medicineId={params.id}
|
medicineId={params.id}
|
||||||
|
/* v8 ignore next */
|
||||||
medicineForm={(medicine?.form as MedicineForm) ?? MedicineForm.OTHER}
|
medicineForm={(medicine?.form as MedicineForm) ?? MedicineForm.OTHER}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setShowProductForm(false);
|
setShowProductForm(false);
|
||||||
|
|
@ -587,6 +594,7 @@ export default function MedicineDetailPage() {
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
|
/* v8 ignore next */
|
||||||
value={productForm.brand ?? ''}
|
value={productForm.brand ?? ''}
|
||||||
onChange={(e) => setProductForm({ ...productForm, brand: e.target.value })}
|
onChange={(e) => setProductForm({ ...productForm, brand: e.target.value })}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
|
|
@ -619,7 +627,7 @@ export default function MedicineDetailPage() {
|
||||||
required
|
required
|
||||||
min={1}
|
min={1}
|
||||||
step="any"
|
step="any"
|
||||||
value={productForm.packageSize ?? ''}
|
value={/* v8 ignore next */ productForm.packageSize ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setProductForm({
|
setProductForm({
|
||||||
...productForm,
|
...productForm,
|
||||||
|
|
@ -632,7 +640,7 @@ export default function MedicineDetailPage() {
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
||||||
<select
|
<select
|
||||||
value={productForm.packageUnit ?? ''}
|
value={/* v8 ignore next */ productForm.packageUnit ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setProductForm({
|
setProductForm({
|
||||||
...productForm,
|
...productForm,
|
||||||
|
|
@ -642,7 +650,7 @@ export default function MedicineDetailPage() {
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
{allowedUnitsForForm(
|
{allowedUnitsForForm(
|
||||||
(medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
/* v8 ignore next */ (medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
||||||
).map((u) => (
|
).map((u) => (
|
||||||
<option key={u} value={u}>
|
<option key={u} value={u}>
|
||||||
{u}
|
{u}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
const { mockListCabinetEvents, mockGetSpendingSummary } = vi.hoisted(() => ({
|
||||||
|
mockListCabinetEvents: vi.fn(),
|
||||||
|
mockGetSpendingSummary: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('@/services/cabinet-events', () => ({
|
||||||
|
listCabinetEvents: mockListCabinetEvents,
|
||||||
|
getSpendingSummary: mockGetSpendingSummary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
||||||
|
|
||||||
|
import { ActivityTab } from '../ActivityTab';
|
||||||
|
|
||||||
|
const emptyMeds = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
const emptyEvents = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
const emptySummary = {
|
||||||
|
totalSpent: 0,
|
||||||
|
currency: null,
|
||||||
|
byMedicine: [],
|
||||||
|
byPeriod: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockListMedicines.mockResolvedValue(emptyMeds);
|
||||||
|
mockListCabinetEvents.mockResolvedValue(emptyEvents);
|
||||||
|
mockGetSpendingSummary.mockResolvedValue(emptySummary);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ActivityTab', () => {
|
||||||
|
it('renders Spending Summary and Cabinet Activity sections', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Spending Summary')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText('Cabinet Activity')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches spending summary on mount', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches events on mount', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no events', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('No events found for the selected filters.')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when events fail to load', async () => {
|
||||||
|
mockListCabinetEvents.mockRejectedValue(new Error('Events error'));
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Events error')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when spending summary fails', async () => {
|
||||||
|
mockGetSpendingSummary.mockRejectedValue(new Error('Spending error'));
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Spending error')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders event entries', async () => {
|
||||||
|
mockListCabinetEvents.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'ev-1',
|
||||||
|
eventType: 'purchased',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
quantity: 10,
|
||||||
|
quantityBefore: 0,
|
||||||
|
quantityAfter: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows spending summary with data', async () => {
|
||||||
|
mockGetSpendingSummary.mockResolvedValue({
|
||||||
|
totalSpent: 125.50,
|
||||||
|
currency: 'USD',
|
||||||
|
byMedicine: [
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
totalSpent: 125.50,
|
||||||
|
purchaseCount: 2,
|
||||||
|
avgUnitPrice: 0.69,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
byPeriod: [
|
||||||
|
{ period: '2026-01', totalSpent: 125.50 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByText(/125.50/).length).toBeGreaterThan(0));
|
||||||
|
expect(screen.getByText('Metformin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('By medicine')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no purchase data message when total is zero', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('No purchase data found for this period.')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters events by event type', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters events by medicine', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1));
|
||||||
|
const allMedSelects = screen.getAllByDisplayValue('All medicines');
|
||||||
|
// The last select is the cabinet events medicine filter
|
||||||
|
fireEvent.change(allMedSelects[allMedSelects.length - 1]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows clear filters button when filter applied and clears them', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalled());
|
||||||
|
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Clear filters'));
|
||||||
|
await userEvent.click(screen.getByText('Clear filters'));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Clear filters')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses cabinet activity error', async () => {
|
||||||
|
mockListCabinetEvents.mockRejectedValue(new Error('Events error'));
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Events error'));
|
||||||
|
await userEvent.click(screen.getByText('Dismiss'));
|
||||||
|
expect(screen.queryByText('Events error')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes spending summary period and medicine filters', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// Change period
|
||||||
|
fireEvent.change(screen.getByDisplayValue('This month'), { target: { value: 'quarter' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
// Change medicine filter in spending summary (first "All medicines" select)
|
||||||
|
const medSelects = screen.getAllByDisplayValue('All medicines');
|
||||||
|
fireEvent.change(medSelects[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledTimes(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes start and end date filters', async () => {
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const dateInputs = document.querySelectorAll('input[type="date"]');
|
||||||
|
fireEvent.change(dateInputs[0]!, { target: { value: '2026-01-01' } });
|
||||||
|
fireEvent.change(dateInputs[1]!, { target: { value: '2026-03-31' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads more events when Load more is clicked', async () => {
|
||||||
|
mockListCabinetEvents.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'ev-1',
|
||||||
|
eventType: 'purchased',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
quantity: 10,
|
||||||
|
quantityBefore: 0,
|
||||||
|
quantityAfter: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: 'cursor-1', hasMore: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Load more'));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
|
||||||
|
|
||||||
|
expect(mockListCabinetEvents).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders event with negative quantity (red dot)', async () => {
|
||||||
|
mockListCabinetEvents.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'ev-1',
|
||||||
|
eventType: 'taken',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
quantity: -1,
|
||||||
|
quantityBefore: 10,
|
||||||
|
quantityAfter: 9,
|
||||||
|
unit: 'tablet',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders event with reason, storeName, totalPrice, and notes', async () => {
|
||||||
|
mockListCabinetEvents.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'ev-1',
|
||||||
|
eventType: 'purchased',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
quantity: 30,
|
||||||
|
quantityBefore: 0,
|
||||||
|
quantityAfter: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
reason: 'Monthly refill',
|
||||||
|
storeName: 'Pharmacy',
|
||||||
|
totalPrice: 25.5,
|
||||||
|
currency: 'USD',
|
||||||
|
notes: 'On sale',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/Monthly refill/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Pharmacy/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/25.50/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/On sale/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,667 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockListCabinetItems,
|
||||||
|
mockGetCabinetSummary,
|
||||||
|
mockCreateCabinetItem,
|
||||||
|
mockAdjustCabinetItemQuantity,
|
||||||
|
mockDeleteCabinetItem,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockListCabinetItems: vi.fn(),
|
||||||
|
mockGetCabinetSummary: vi.fn(),
|
||||||
|
mockCreateCabinetItem: vi.fn(),
|
||||||
|
mockAdjustCabinetItemQuantity: vi.fn(),
|
||||||
|
mockDeleteCabinetItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('@/services/cabinet', () => ({
|
||||||
|
listCabinetItems: mockListCabinetItems,
|
||||||
|
getCabinetSummary: mockGetCabinetSummary,
|
||||||
|
createCabinetItem: mockCreateCabinetItem,
|
||||||
|
adjustCabinetItemQuantity: mockAdjustCabinetItemQuantity,
|
||||||
|
deleteCabinetItem: mockDeleteCabinetItem,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||||
|
|
||||||
|
import { CabinetTab } from '../CabinetTab';
|
||||||
|
|
||||||
|
const emptySummary = { data: [] };
|
||||||
|
const emptyItems = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
const emptyMeds = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
|
||||||
|
const cabinetItem = {
|
||||||
|
_id: 'ci-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
quantity: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
status: 'active',
|
||||||
|
expirationDate: null,
|
||||||
|
unitPrice: null,
|
||||||
|
totalPrice: null,
|
||||||
|
storeId: null,
|
||||||
|
notes: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||||
|
mockListCabinetItems.mockResolvedValue(emptyItems);
|
||||||
|
mockListMedicines.mockResolvedValue(emptyMeds);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CabinetTab', () => {
|
||||||
|
it('shows empty state when no items', async () => {
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/cabinet is empty/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when list fails', async () => {
|
||||||
|
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses error on Dismiss click', async () => {
|
||||||
|
mockGetCabinetSummary.mockRejectedValue(new Error('Server error'));
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Server error'));
|
||||||
|
await userEvent.click(screen.getByText('Dismiss'));
|
||||||
|
expect(screen.queryByText('Server error')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles Add to Cabinet form', async () => {
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByText('Cancel')[0]);
|
||||||
|
expect(screen.queryByPlaceholderText('Search medicines...')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches between Summary and All Items views', async () => {
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
expect(mockListCabinetItems).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cabinet items in detail view', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjusts item quantity', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 11 });
|
||||||
|
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Add 1'));
|
||||||
|
await userEvent.click(screen.getByTitle('Add 1'));
|
||||||
|
|
||||||
|
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows summary view with medicine data', async () => {
|
||||||
|
mockGetCabinetSummary.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
medicineForm: 'tablet',
|
||||||
|
totalQuantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
itemCount: 2,
|
||||||
|
earliestExpiry: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/2 items/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands medicine in summary view', async () => {
|
||||||
|
mockGetCabinetSummary.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
medicineForm: 'tablet',
|
||||||
|
totalQuantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
itemCount: 1,
|
||||||
|
earliestExpiry: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockListCabinetItems).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ medicineId: 'med-1' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits AddToCabinetForm with medicine selection validation', async () => {
|
||||||
|
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
|
||||||
|
// Submit without selecting a medicine - should show error
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Please select a medicine')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits AddToCabinetForm successfully', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'med-1',
|
||||||
|
name: 'Metformin',
|
||||||
|
form: 'tablet',
|
||||||
|
strength: 500,
|
||||||
|
strengthUnit: 'mg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
|
||||||
|
// Select a medicine (first combobox is the medicine select)
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, {
|
||||||
|
target: { value: 'med-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockCreateCabinetItem).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits AddToCabinetForm with expiration date and notes', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'med-1',
|
||||||
|
name: 'Metformin',
|
||||||
|
form: 'tablet',
|
||||||
|
strength: 500,
|
||||||
|
strengthUnit: 'mg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockCreateCabinetItem.mockResolvedValue(cabinetItem);
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
|
||||||
|
// Select a medicine
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, {
|
||||||
|
target: { value: 'med-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set expiration date (type="date" input, no label association)
|
||||||
|
const dateInput = document.querySelector('input[type="date"]') as HTMLElement;
|
||||||
|
if (dateInput) fireEvent.change(dateInput, { target: { value: '2027-12-31' } });
|
||||||
|
|
||||||
|
// Set notes
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Any notes about this item'), {
|
||||||
|
target: { value: 'Store in fridge' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockCreateCabinetItem).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({
|
||||||
|
medicineId: 'med-1',
|
||||||
|
notes: 'Store in fridge',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes quantity and unit in AddToCabinetForm', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
|
||||||
|
// Search for medicine
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
|
||||||
|
target: { value: 'Met' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change quantity
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('30'), { target: { value: '60' } });
|
||||||
|
|
||||||
|
// Change unit
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[1]!, { target: { value: 'capsule' } });
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches from detail view back to summary view', async () => {
|
||||||
|
mockGetCabinetSummary.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
medicineForm: 'tablet',
|
||||||
|
totalQuantity: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
itemCount: 1,
|
||||||
|
earliestExpiry: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
// Now in detail view
|
||||||
|
await waitFor(() => screen.getByText('Summary'));
|
||||||
|
await userEvent.click(screen.getByText('Summary'));
|
||||||
|
|
||||||
|
// Back in summary view — Metformin should show
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows expiry date for cabinet items', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
...cabinetItem,
|
||||||
|
expirationDate: '2099-12-31T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
expect(screen.getByText(/days\)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters cabinet items by status in detail view', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByDisplayValue('All Statuses'));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('All Statuses'), { target: { value: 'active' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes 1 from cabinet item', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockAdjustCabinetItemQuantity.mockResolvedValue({ ...cabinetItem, quantity: 9 });
|
||||||
|
mockGetCabinetSummary.mockResolvedValue(emptySummary);
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Take 1'));
|
||||||
|
await userEvent.click(screen.getByTitle('Take 1'));
|
||||||
|
|
||||||
|
expect(mockAdjustCabinetItemQuantity).toHaveBeenCalledWith('hh1', 'ci-1', { delta: -1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses expanded medicine when it returns 0 items', async () => {
|
||||||
|
mockGetCabinetSummary.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
medicineForm: 'tablet',
|
||||||
|
totalQuantity: 0,
|
||||||
|
unit: 'tablet',
|
||||||
|
itemCount: 0,
|
||||||
|
earliestExpiry: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockListCabinetItems).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ medicineId: 'med-1' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when createCabinetItem fails', async () => {
|
||||||
|
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
|
// Should show 'Please select a medicine' validation first (since no medicine selected)
|
||||||
|
await waitFor(() => expect(screen.getByText('Please select a medicine')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes cabinet item after confirmation', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteCabinetItem.mockResolvedValue({});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Delete'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeleteCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when adjust fails', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockAdjustCabinetItemQuantity.mockRejectedValue(new Error('Adjust failed'));
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Add 1'));
|
||||||
|
await userEvent.click(screen.getByTitle('Add 1'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Adjust failed')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when delete fails', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteCabinetItem.mockRejectedValue(new Error('Delete failed'));
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTitle('Delete'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Delete failed')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels AddToCabinetForm with internal Cancel button', async () => {
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
// Click the Cancel button inside the form (not the toggle button)
|
||||||
|
const cancelButtons = screen.getAllByText('Cancel');
|
||||||
|
await userEvent.click(cancelButtons[cancelButtons.length - 1]!);
|
||||||
|
|
||||||
|
expect(screen.queryByPlaceholderText('Search medicines...')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses expanded medicine when clicked again', async () => {
|
||||||
|
const summary = {
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
medicineForm: 'tablet',
|
||||||
|
totalQuantity: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
itemCount: 1,
|
||||||
|
earliestExpiry: null,
|
||||||
|
};
|
||||||
|
mockGetCabinetSummary.mockResolvedValue({ data: [summary] });
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [cabinetItem],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
// First click expands — handleExpand + useEffect both call listCabinetItems
|
||||||
|
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||||
|
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
|
||||||
|
const callsAfterExpand = mockListCabinetItems.mock.calls.length;
|
||||||
|
|
||||||
|
// Second click collapses — no additional listCabinetItems calls
|
||||||
|
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||||
|
await waitFor(() => expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(mockListCabinetItems.mock.calls.length).toBe(callsAfterExpand);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty items when expand fails', async () => {
|
||||||
|
mockGetCabinetSummary.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
medicineStrength: 500,
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
medicineForm: 'tablet',
|
||||||
|
totalQuantity: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
itemCount: 1,
|
||||||
|
earliestExpiry: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockListCabinetItems.mockRejectedValue(new Error('Expand failed'));
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
await userEvent.click(screen.getByText('Metformin').closest('button')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListCabinetItems).toHaveBeenCalledTimes(2));
|
||||||
|
// No items should be shown (empty after error)
|
||||||
|
expect(screen.queryByTitle('Take 1')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows create error when medicine is selected and create fails', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Create failed')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows item notes in detail view', async () => {
|
||||||
|
mockListCabinetItems.mockResolvedValue({
|
||||||
|
data: [{ ...cabinetItem, notes: 'Store in fridge' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('All Items'));
|
||||||
|
await userEvent.click(screen.getByText('All Items'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Store in fridge')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for medicines to load then selects medicine in form', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
// Wait for medicine options to load (covers medicines.map callback)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(screen.getAllByRole('combobox')[0] as HTMLSelectElement).options.length,
|
||||||
|
).toBeGreaterThan(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select a valid medicine (true branch: medForm is set → defaultUnitForForm called)
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
// Select empty value (false branch: selectedMed undefined → DosageUnit.TABLET fallback)
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error is thrown during create', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockCreateCabinetItem.mockRejectedValue('unexpected');
|
||||||
|
|
||||||
|
render(<CabinetTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add to Cabinet'));
|
||||||
|
await userEvent.click(screen.getByText('Add to Cabinet'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to add item')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
const { mockListMedicines, mockCreateMedicine, mockDeleteMedicine } = vi.hoisted(() => ({
|
||||||
|
mockListMedicines: vi.fn(),
|
||||||
|
mockCreateMedicine: vi.fn(),
|
||||||
|
mockDeleteMedicine: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/medicines', () => ({
|
||||||
|
listMedicines: mockListMedicines,
|
||||||
|
createMedicine: mockCreateMedicine,
|
||||||
|
deleteMedicine: mockDeleteMedicine,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||||
|
|
||||||
|
import { LibraryTab } from '../LibraryTab';
|
||||||
|
|
||||||
|
const emptyResponse = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
|
||||||
|
const medicine = {
|
||||||
|
_id: 'med-1',
|
||||||
|
name: 'Metformin',
|
||||||
|
form: 'tablet',
|
||||||
|
strength: 500,
|
||||||
|
strengthUnit: 'mg',
|
||||||
|
category: 'prescription',
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('LibraryTab', () => {
|
||||||
|
it('shows loading skeleton initially', () => {
|
||||||
|
mockListMedicines.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
expect(screen.queryByText('Metformin')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders medicine list after load', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Metformin')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no medicines', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/No medicines yet/)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when list fails', async () => {
|
||||||
|
mockListMedicines.mockRejectedValue(new Error('Failed to load'));
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to load')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses error on Dismiss click', async () => {
|
||||||
|
mockListMedicines.mockRejectedValue(new Error('Failed to load'));
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Failed to load'));
|
||||||
|
await userEvent.click(screen.getByText('Dismiss'));
|
||||||
|
expect(screen.queryByText('Failed to load')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles Add Medicine form', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Medicine'));
|
||||||
|
await userEvent.click(screen.getByText('Add Medicine'));
|
||||||
|
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByText('Cancel')[0]);
|
||||||
|
expect(screen.queryByPlaceholderText('e.g. Metformin')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates medicine and refreshes list', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||||
|
mockCreateMedicine.mockResolvedValue({ _id: 'med-2', name: 'Aspirin' });
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Add Medicine'));
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('e.g. Metformin'), 'Aspirin');
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('500'), '100');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockCreateMedicine).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Aspirin' })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes medicine after confirmation', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||||
|
mockDeleteMedicine.mockResolvedValue({});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeleteMedicine).toHaveBeenCalledWith('hh1', 'med-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes form fields in add medicine form', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Medicine'));
|
||||||
|
await userEvent.click(screen.getByText('Add Medicine'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. Metformin'));
|
||||||
|
|
||||||
|
// Change form type
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Tablet'), { target: { value: 'capsule' } });
|
||||||
|
// Change strength unit
|
||||||
|
fireEvent.change(screen.getByDisplayValue('mg'), { target: { value: 'mcg' } });
|
||||||
|
// Change category
|
||||||
|
fireEvent.change(screen.getByDisplayValue('OTC'), { target: { value: 'prescription' } });
|
||||||
|
// Change notes (covers truthy branch)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'test notes' } });
|
||||||
|
// Clear notes (covers falsy branch → undefined)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
||||||
|
|
||||||
|
// Verify form is still visible
|
||||||
|
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters medicines by search, category, and form', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [medicine],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'met' } });
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
fireEvent.change(screen.getByDisplayValue('All Categories'), { target: { value: 'prescription' } });
|
||||||
|
|
||||||
|
// Form filter
|
||||||
|
fireEvent.change(screen.getByDisplayValue('All Forms'), { target: { value: 'tablet' } });
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Search medicines...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error is thrown on delete', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||||
|
mockDeleteMedicine.mockRejectedValue('oops');
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to delete')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels CreateMedicineForm with internal Cancel button', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Medicine'));
|
||||||
|
await userEvent.click(screen.getByText('Add Medicine'));
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. Metformin'));
|
||||||
|
|
||||||
|
const cancelButtons = screen.getAllByText('Cancel');
|
||||||
|
await userEvent.click(cancelButtons[cancelButtons.length - 1]!);
|
||||||
|
|
||||||
|
expect(screen.queryByPlaceholderText('e.g. Metformin')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when non-Error is thrown on create medicine', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue(emptyResponse);
|
||||||
|
mockCreateMedicine.mockRejectedValue('create failed');
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Add Medicine'));
|
||||||
|
await userEvent.click(screen.getByText('Add Medicine'));
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByPlaceholderText('e.g. Metformin'));
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('e.g. Metformin'), 'Aspirin');
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('500'), '100');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Failed to create medicine')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete medicine if confirmation cancelled', async () => {
|
||||||
|
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
await userEvent.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeleteMedicine).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||