+ );
+};
+
+// ---- Activity ----
+const ActivityPage = () => {
+ const { SPENDING } = window.MESHI;
+ return (
+
+);
+
+// ---- Refills ----
+const RefillsPage = () => {
+ const low = C2.map(r => ({r, s: cabinetStatus(r)})).filter(x => x.s.level !== 'ok');
+ return (
+
+);
+
+Object.assign(window, { LibraryPage, RegimensPage, OrganizerPage, ActivityPage, StoresPage, PricesPage, RefillsPage, PurchasesPage, SettingsPage });
diff --git a/docs/design/src/Schedule.jsx b/docs/design/src/Schedule.jsx
new file mode 100644
index 0000000..d97243f
--- /dev/null
+++ b/docs/design/src/Schedule.jsx
@@ -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 (
+
+ {/* Week strip — adherence heatmap */}
+
+
+
This week
+
+ {Math.round(adherence.reduce((a,b)=>a+b,0)/adherence.length*100)}% adherence
+
+
+
+ {weekDays.map((d, i) => {
+ const isToday = i === day;
+ const a = adherence[i];
+ return (
+
+
+
+
+
+
+
Saturday, April 18
+
{totalDone} of {intake.length} doses · 18:30 now
+
+
+
+
+
+ {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 (
+
+
+ {isOpen && (
+
+ {slot.items.map(i => {
+ const m = MED_S[i.medId];
+ return (
+ toggle(i.id)}>
+ {i.status === 'taken' && }
+
+
+ {m.name}
+ {i.qty} {m.form.toLowerCase()} · {m.strength}
+
+ {i.status === 'taken' && {i.takenAt}}
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+
+ Tap a slot to see individual medicines. Off-schedule doses logged separately.
+
+
+
+
+
+
+
Active regimens
+
{REGS_S.length} configured
+
+
+
+ {REGS_S.map(r => (
+
+
+
+
Aeri's {r.name}
+
{r.time} · {r.freq}
+
+
Active
+
+
+ {r.items.map(it => {
+ const m = MED_S[it.medId];
+ return (
+
+
+ {m.name}
+
+ );
+ })}
+
+
+ ))}
+
+
+
+
+ );
+};
+
+window.SchedulePage = SchedulePage;
diff --git a/docs/design/src/Shell.jsx b/docs/design/src/Shell.jsx
new file mode 100644
index 0000000..ac11d8d
--- /dev/null
+++ b/docs/design/src/Shell.jsx
@@ -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 (
+
+ );
+};
+
+const Topbar = ({ title, subtitle, crumbs, onSearch, actions, theme, onToggleTheme }) => {
+ return (
+
+ );
+};
+
+window.Shell = { Sidebar, Topbar, AppCtx, useApp, NAV };
diff --git a/docs/design/src/data.jsx b/docs/design/src/data.jsx
new file mode 100644
index 0000000..7320fdc
--- /dev/null
+++ b/docs/design/src/data.jsx
@@ -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,
+};
diff --git a/docs/design/styles/cabinet.css b/docs/design/styles/cabinet.css
new file mode 100644
index 0000000..64d437c
--- /dev/null
+++ b/docs/design/styles/cabinet.css
@@ -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; }
diff --git a/docs/design/styles/dashboard.css b/docs/design/styles/dashboard.css
new file mode 100644
index 0000000..5e559be
--- /dev/null
+++ b/docs/design/styles/dashboard.css
@@ -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; }
diff --git a/docs/design/styles/other.css b/docs/design/styles/other.css
new file mode 100644
index 0000000..e46b681
--- /dev/null
+++ b/docs/design/styles/other.css
@@ -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; }
diff --git a/docs/design/styles/schedule.css b/docs/design/styles/schedule.css
new file mode 100644
index 0000000..889fc88
--- /dev/null
+++ b/docs/design/styles/schedule.css
@@ -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;
+}
diff --git a/docs/design/styles/shell.css b/docs/design/styles/shell.css
new file mode 100644
index 0000000..652a58b
--- /dev/null
+++ b/docs/design/styles/shell.css
@@ -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);
+}
diff --git a/docs/design/styles/tokens.css b/docs/design/styles/tokens.css
new file mode 100644
index 0000000..973a56f
--- /dev/null
+++ b/docs/design/styles/tokens.css
@@ -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; }
diff --git a/docs/instructions/testing.md b/docs/instructions/testing.md
index 841a961..e728053 100644
--- a/docs/instructions/testing.md
+++ b/docs/instructions/testing.md
@@ -472,7 +472,7 @@ All packages enforce coverage thresholds via `vitest.config.ts`. CI will fail if
| ----------------- | ----- | --------- | -------- | ---------- |
| `packages/api` | 100% | 100% | 90% | 100% |
| `packages/shared` | 100% | 100% | 90% | 100% |
-| `packages/web` | TBD | TBD | TBD | TBD |
+| `packages/web` | 15% | 14% | 13% | 15% |
### Coverage Provider
diff --git a/docs/phases/phase-4-pharmacies-prices-refills.md b/docs/phases/phase-4-pharmacies-prices-refills.md
index 7c8c256..dfbac63 100644
--- a/docs/phases/phase-4-pharmacies-prices-refills.md
+++ b/docs/phases/phase-4-pharmacies-prices-refills.md
@@ -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)
---
-## Deliverables
+## Domain Model
-1. `Store` MongoDB schema and CRUD API (shared infrastructure)
-2. `PriceRecord` schema for medicine price tracking
-3. Price history and store comparison
-4. Refill alerts based on burn rate
-5. Refill list generation
-6. Web UI: stores, price history, refill management
+### Price Records vs Purchases
+
+These are two distinct concepts:
+
+- **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.
+- **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;
name: string;
address?: string;
- location?: {
- lat: number;
- lng: number;
- };
+ location?: { lat: number; lng: number };
url?: string;
notes?: string;
- tags: string[]; // e.g., 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
+ tags: string[]; // e.g. 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
isActive: boolean;
createdBy: string;
createdAt: Date;
@@ -49,25 +57,62 @@ export interface Store {
export interface MedicinePriceRecord {
id: string;
householdId: string;
- medicineProductId: string; // Reference to MedicineProduct (purchasable level)
- medicineProductBrand: string; // Denormalized
- medicineId: string; // Reference to Medicine (generic level, for cross-brand comparison)
- medicineName: string; // Denormalized
+ medicineProductId: string; // specific brand/package
+ medicineProductBrand: string; // denormalized
+ medicineId: string; // generic medicine, for cross-brand comparison
+ medicineName: string; // denormalized
storeId: string;
- storeName: string; // Denormalized
+ storeName: string; // denormalized
price: number;
- currency: string; // Default from household settings
- quantity: number; // How many pills/units for this price (package size)
+ currency: string;
+ quantity: number; // units in this price point (package size)
unit: DosageUnit;
- pricePerUnit: number; // Computed: price / quantity
- date: Date;
- isInsurancePrice: boolean; // With insurance vs retail
- notes?: string;
+ pricePerUnit: number; // computed: price / quantity
+ isInsurancePrice: boolean;
+ notes?: string; // e.g. "bulk deal", "fast shipping tier"
+ date: Date; // when price was observed
createdBy: string;
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)
```typescript
@@ -77,18 +122,20 @@ export interface RefillAlert {
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
- daysUntilEmpty: number;
+ daysUntilEmpty: number; // based on cabinet stock only
+ daysUntilEmptyWithOrders: number; // cabinet + pending ordered items
dailyConsumption: number;
currentStock: number;
- suggestedQuantity: number; // Enough for N days (configurable, default 30)
- lastKnownPrice?: {
+ pendingOrderStock: number; // units in orders with status 'ordered'
+ suggestedQuantity: number; // enough for N days (configurable, default 30)
+ cheapestOption?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
- cheapestOption?: {
+ lastKnownPrice?: {
price: number;
pricePerUnit: number;
storeName: string;
@@ -142,9 +189,14 @@ export enum RefillListStatus {
// MedicinePriceRecord
{ 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 }
+// Purchase
+{ householdId: 1, status: 1, purchasedAt: -1 }
+{ householdId: 1, storeId: 1 }
+{ householdId: 1, 'items.medicineProductId': 1 }
+
// RefillList
{ householdId: 1, status: 1 }
{ householdId: 1, createdAt: -1 }
@@ -166,42 +218,55 @@ export enum RefillListStatus {
### MedicinePricesModule
-| Method | Path | Description | Auth |
-| ------ | -------------------------------------- | --------------------------------- | ------ |
-| POST | `/medicine-prices` | Record a price | 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/analytics` | Spending analytics | member |
+| Method | Path | Description | Auth |
+| ------ | -------------------------------------- | --------------------------------------- | ------ |
+| POST | `/medicine-prices` | Record a catalog price observation | 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/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
-| Method | Path | Description | Auth |
-| ------ | ----------------------------------- | ---------------------------------------- | ------ |
-| GET | `/refills/alerts` | Get refill alerts (medicines running low)| member |
-| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
-| GET | `/refills/lists` | List refill lists | member |
-| GET | `/refills/lists/:id` | Get refill list | member |
-| PATCH | `/refills/lists/:id` | Update refill list | 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 |
+| Method | Path | Description | Auth |
+| ------ | ---------------------------------- | ----------------------------------------- | ------ |
+| GET | `/refills/alerts` | Get refill alerts (accounts for pending orders) | member |
+| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
+| GET | `/refills/lists` | List refill lists | member |
+| GET | `/refills/lists/:id` | Get refill list | member |
+| PATCH | `/refills/lists/:id` | Update refill list | member |
+| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
### Query Parameters
```
# GET /stores
-?tags=pharmacy # Filter by tags
-&search=walgreens # Name search
+?tags=pharmacy
+&search=walgreens
# GET /medicine-prices/history/:medicineId
-?storeId=abc123 # Filter by store
-&startDate=2026-01-01 # Date range
+?storeId=abc123
+&startDate=2026-01-01
&endDate=2026-03-27
&limit=50
+# GET /purchases
+?status=ordered
+&storeId=abc123
+
# GET /refills/alerts
-?thresholdDays=7 # Alert when <= N days of stock remain (default: 7)
-&userId=abc123 # Filter by user's regimens
+?thresholdDays=7
+&userId=abc123
```
---
@@ -210,9 +275,10 @@ export enum RefillListStatus {
### 4.1 — Shared Types & Validation
-- Add store types to `packages/shared/src/types/store.ts`
-- Add medicine price types to `packages/shared/src/types/medicine-price.ts`
-- Add refill types to `packages/shared/src/types/refill.ts`
+- Store types in `packages/shared/src/types/store.ts`
+- Medicine price types in `packages/shared/src/types/medicine-price.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
### 4.2 — Stores CRUD (Shared Infrastructure)
@@ -220,89 +286,85 @@ export enum RefillListStatus {
- `packages/api/src/modules/stores/`
- Standard CRUD, scoped to `householdId`
- 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
+Records catalog price observations. Multiple records per product+store are allowed (different pack sizes, bulk tiers, insurance pricing).
+
```typescript
class MedicinePriceService {
- /** Record a price, computing pricePerUnit */
- recordPrice(data: CreateMedicinePriceRecord): Promise
;
-
- /** 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;
-
- /** Compare current prices across stores for a medicine */
+ recordPrice(data: CreateMedicinePriceRecord, householdId: string): Promise;
+ getPriceHistory(medicineId: string, householdId: string, options?: { storeId?: string; startDate?: Date; endDate?: Date; limit?: number }): Promise;
compareStores(medicineId: string, householdId: string): Promise;
-
- /** Estimate price based on most recent record */
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise;
-
- /** Spending analytics over time */
- getAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise;
+ getPriceAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise;
}
```
-### 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
class RefillAlertService {
/**
* For each medicine in the user's active regimens:
* 1. Get burn rate from BurnRateService (Phase 3)
- * 2. If daysUntilEmpty <= thresholdDays, create alert
- * 3. Attach last known price + cheapest store option
- * 4. Calculate suggested quantity (enough for configurable days, default 30)
+ * 2. Sum cabinet stock + units in purchases with status 'ordered'
+ * 3. If daysUntilEmpty (cabinet only) <= thresholdDays, create alert
+ * 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;
}
```
-### 4.5 — Refill Lists
+### 4.6 — Refill Lists
- CRUD for refill lists
- `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)
-- Store comparison: for each item, find cheapest store based on recent price records
-
-### 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 }`
+- Each item can carry an estimated price (from price history) and an actual price (entered when purchased)
+- Refill lists are planning aids — actual cabinet addition goes through Purchases
### 4.7 — Price Analytics
-```typescript
-interface MedicineSpendingAnalytics {
- /** Total spending per period */
- spendingOverTime: { period: string; total: number }[];
+Analytics on the medicine-prices page show **price trends across stores over time**, not spending:
- /** Most expensive medicines */
- topBySpending: {
+```typescript
+interface PriceAnalytics {
+ priceOverTime: { period: string; avgPricePerUnit: number; storeName: string }[];
+ cheapestByMedicine: {
medicineId: string;
medicineName: string;
- totalSpent: number;
- avgPricePerUnit: number;
- }[];
-
- /** Per-store spending */
- spendingByStore: {
- storeId: string;
+ cheapestPricePerUnit: number;
storeName: string;
- totalSpent: number;
- purchaseCount: number;
}[];
-
- /** Price trend alerts (significant increases) */
priceAlerts: {
medicineId: 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:
- - Store list with CRUD
- - Filter by tags (pharmacy, grocery, etc.)
- - Per-store summary: total spent, last visit, item count
+### 4.8 — Web UI: Stores & Prices
+
+- `/stores` page: store list with CRUD, filter by tags
- `/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
- - Spending over time bar chart
- - Price alert panel
+ - Price trend analytics (price changes over time, not spending)
-### 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:
- - **Alerts section**: medicines running low
- - Card per medicine: name, days remaining, suggested quantity, cheapest store
- - "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
+ - **Alerts section**: medicines running low, with `daysUntilEmpty` and `daysUntilEmptyWithOrders` shown separately so the user can see how much the pending order helps
+ - **Refill lists section**: planning lists with check-off and estimated prices
---
## Acceptance Criteria
- [ ] Can create and manage stores with tags
-- [ ] Can record medicine prices and view price history
-- [ ] Store comparison shows cheapest option per medicine
-- [ ] Refill alerts correctly identify medicines running low based on burn rate
-- [ ] Refill lists can be auto-generated from alerts
-- [ ] Checked refill items can be added to cabinet in one action
-- [ ] Price analytics show spending trends
+- [ ] Can record catalog price observations; multiple price points per product+store allowed
+- [ ] Store comparison shows cheapest observed price per medicine
+- [ ] Can record a purchase (physical or online) mixing medicine and (future) food items
+- [ ] Physical purchases add medicine items to cabinet immediately
+- [ ] Online purchases sit in ordered state; receiving them adds items to cabinet and optionally records prices
+- [ ] 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)
- [ ] 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.
diff --git a/package-lock.json b/package-lock.json
index ff93f4c..8dea230 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,13 @@
"typescript-eslint": "^8.57.2"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -31,6 +38,47 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
+ "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.1.1",
+ "@csstools/css-color-parser": "^4.0.2",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
+ "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.7"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@auth/core": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
@@ -60,6 +108,28 @@
}
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -96,6 +166,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -120,6 +200,159 @@
"node": ">=18"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
+ "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz",
+ "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -739,6 +972,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
+ "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
@@ -1630,6 +1881,94 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@inquirer/ansi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
+ "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "5.1.21",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz",
+ "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "10.3.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz",
+ "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "cli-width": "^4.1.0",
+ "mute-stream": "^2.0.0",
+ "signal-exit": "^4.1.0",
+ "wrap-ansi": "^6.2.0",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
+ "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz",
+ "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1710,6 +2049,24 @@
"sparse-bitfield": "^3.0.3"
}
},
+ "node_modules/@mswjs/interceptors": {
+ "version": "0.41.3",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
+ "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/logger": "^0.3.0",
+ "@open-draft/until": "^2.0.0",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "strict-event-emitter": "^0.5.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -1906,6 +2263,31 @@
"node": ">= 8"
}
},
+ "node_modules/@open-draft/deferred-promise": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/logger": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+ "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.0"
+ }
+ },
+ "node_modules/@open-draft/until": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@oxc-project/types": {
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
@@ -2493,6 +2875,105 @@
"tailwindcss": "4.2.2"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@turbo/darwin-64": {
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.8.20.tgz",
@@ -2588,6 +3069,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -2650,6 +3138,13 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/statuses": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -2947,15 +3442,48 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
+ "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.7"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
+ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@vitest/coverage-v8": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz",
- "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz",
+ "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.1",
+ "@vitest/utils": "4.1.2",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -2963,14 +3491,14 @@
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
- "tinyrainbow": "^3.0.3"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.1.1",
- "vitest": "4.1.1"
+ "@vitest/browser": "4.1.2",
+ "vitest": "4.1.2"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2979,31 +3507,31 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
- "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
+ "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.1",
- "@vitest/utils": "4.1.1",
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
"chai": "^6.2.2",
- "tinyrainbow": "^3.0.3"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
- "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
+ "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.1",
+ "@vitest/spy": "4.1.2",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3024,26 +3552,26 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
- "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
+ "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^3.0.3"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
- "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
+ "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.1",
+ "@vitest/utils": "4.1.2",
"pathe": "^2.0.3"
},
"funding": {
@@ -3051,14 +3579,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
- "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
+ "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.1",
- "@vitest/utils": "4.1.1",
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/utils": "4.1.2",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3067,9 +3595,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
- "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
+ "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3077,15 +3605,15 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
- "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
+ "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.1",
+ "@vitest/pretty-format": "4.1.2",
"convert-source-map": "^2.0.0",
- "tinyrainbow": "^3.0.3"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -3188,6 +3716,16 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3211,6 +3749,16 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -3516,6 +4064,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3685,12 +4243,55 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3779,6 +4380,27 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3786,6 +4408,58 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3867,6 +4541,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3951,6 +4632,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4014,6 +4702,13 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -4037,6 +4732,19 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -4264,6 +4972,16 @@
"@esbuild/win32-x64": "0.27.4"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -4976,6 +5694,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -5162,6 +5890,16 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/graphql": {
+ "version": "16.13.2",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
+ "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -5256,6 +5994,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/headers-polyfill": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
+ "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
@@ -5272,6 +6017,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -5356,6 +6114,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -5546,6 +6314,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -5604,6 +6382,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -5630,6 +6415,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5895,6 +6687,85 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "29.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
+ "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.0.1",
+ "@asamuzakjp/dom-selector": "^7.0.3",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.1",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.7",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.24.5",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -6351,6 +7222,16 @@
"node": "20 || >=22"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -6399,6 +7280,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
@@ -6460,6 +7348,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -6596,6 +7494,61 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/msw": {
+ "version": "2.12.14",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz",
+ "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/confirm": "^5.0.0",
+ "@mswjs/interceptors": "^0.41.2",
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@types/statuses": "^2.0.6",
+ "cookie": "^1.0.2",
+ "graphql": "^16.12.0",
+ "headers-polyfill": "^4.0.2",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "path-to-regexp": "^6.3.0",
+ "picocolors": "^1.1.1",
+ "rettime": "^0.10.1",
+ "statuses": "^2.0.2",
+ "strict-event-emitter": "^0.5.1",
+ "tough-cookie": "^6.0.0",
+ "type-fest": "^5.2.0",
+ "until-async": "^3.0.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "msw": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mswjs"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.8.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mute-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
+ "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -6928,6 +7881,13 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/outvariant": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -6998,6 +7958,19 @@
"node": ">=6"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7041,6 +8014,13 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -7250,6 +8230,41 @@
"node": ">=6.0.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -7428,6 +8443,20 @@
"node": ">= 12.13.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7472,6 +8501,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -7534,6 +8573,13 @@
"node": ">=10"
}
},
+ "node_modules/rettime": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
+ "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -7733,6 +8779,19 @@
"node": ">=10"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -7985,6 +9044,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -8064,6 +9136,13 @@
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
+ "node_modules/strict-event-emitter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -8073,6 +9152,21 @@
"safe-buffer": "~5.2.0"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -8171,6 +9265,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -8246,6 +9366,13 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
@@ -8262,6 +9389,19 @@
"url": "https://opencollective.com/synckit"
}
},
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -8385,6 +9525,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
+ "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.27"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
+ "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8415,6 +9575,19 @@
"node": ">=0.6"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
@@ -8497,6 +9670,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
+ "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -8632,6 +9821,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
+ "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -8639,6 +9838,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/until-async": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+ "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/kettanaito"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -8756,19 +9965,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
- "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
+ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.1",
- "@vitest/mocker": "4.1.1",
- "@vitest/pretty-format": "4.1.1",
- "@vitest/runner": "4.1.1",
- "@vitest/snapshot": "4.1.1",
- "@vitest/spy": "4.1.1",
- "@vitest/utils": "4.1.1",
+ "@vitest/expect": "4.1.2",
+ "@vitest/mocker": "4.1.2",
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/runner": "4.1.2",
+ "@vitest/snapshot": "4.1.2",
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8779,7 +9988,7 @@
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.0.3",
+ "tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
@@ -8796,10 +10005,10 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.1",
- "@vitest/browser-preview": "4.1.1",
- "@vitest/browser-webdriverio": "4.1.1",
- "@vitest/ui": "4.1.1",
+ "@vitest/browser-playwright": "4.1.2",
+ "@vitest/browser-preview": "4.1.2",
+ "@vitest/browser-webdriverio": "4.1.2",
+ "@vitest/ui": "4.1.2",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -8837,6 +10046,19 @@
}
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -8846,6 +10068,16 @@
"node": ">=12"
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
@@ -8991,12 +10223,44 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -9006,6 +10270,16 @@
"node": ">=0.4"
}
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@@ -9021,6 +10295,35 @@
"url": "https://github.com/sponsors/eemeli"
}
},
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -9034,6 +10337,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/yoctocolors-cjs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
+ "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
@@ -9102,13 +10418,22 @@
"devDependencies": {
"@next/eslint-plugin-next": "^16.2.1",
"@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/react": "^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-hooks": "^5.2.0",
+ "jsdom": "^29.0.1",
+ "msw": "^2.12.14",
"tailwindcss": "^4.2.0",
- "typescript": "^6.0.0"
+ "typescript": "^6.0.0",
+ "vitest": "^4.1.2"
}
}
}
diff --git a/packages/api/package.json b/packages/api/package.json
index f30a838..f71b4e5 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -14,7 +14,8 @@
"test:cov": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"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": {
"@fastify/awilix": "^8.2.0",
diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts
index 27ad80e..60386c0 100644
--- a/packages/api/src/main.ts
+++ b/packages/api/src/main.ts
@@ -34,6 +34,10 @@ import cabinetRoutes from './modules/cabinet/cabinet.routes.js';
import cabinetEventsRoutes from './modules/cabinet-events/cabinet-events.routes.js';
import regimensRoutes from './modules/regimens/regimens.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 } = {}) {
const app = Fastify({
@@ -109,6 +113,10 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
await app.register(cabinetEventsRoutes);
await app.register(regimensRoutes);
await app.register(organizerRoutes);
+ await app.register(storesRoutes);
+ await app.register(medicinePricesRoutes);
+ await app.register(refillsRoutes);
+ await app.register(purchasesRoutes);
// Global error handler
app.setErrorHandler((error, request, reply) => {
diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.repository.test.ts b/packages/api/src/modules/medicine-prices/medicine-prices.repository.test.ts
new file mode 100644
index 0000000..5ed159d
--- /dev/null
+++ b/packages/api/src/modules/medicine-prices/medicine-prices.repository.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts b/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts
new file mode 100644
index 0000000..80e2f9c
--- /dev/null
+++ b/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts
@@ -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 = { householdId, medicineId };
+
+ if (query.storeId) filter['storeId'] = query.storeId;
+
+ if (query.startDate || query.endDate) {
+ const dateFilter: Record = {};
+ 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 = { 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 }[],
+ };
+ }
+}
diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.routes.test.ts b/packages/api/src/modules/medicine-prices/medicine-prices.routes.test.ts
new file mode 100644
index 0000000..4982517
--- /dev/null
+++ b/packages/api/src/modules/medicine-prices/medicine-prices.routes.test.ts
@@ -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>;
+
+ 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' }),
+ );
+ });
+ });
+});
diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts b/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts
new file mode 100644
index 0000000..ccaac30
--- /dev/null
+++ b/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts
@@ -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();
+ 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'],
+ },
+);
diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.service.test.ts b/packages/api/src/modules/medicine-prices/medicine-prices.service.test.ts
new file mode 100644
index 0000000..d53a82c
--- /dev/null
+++ b/packages/api/src/modules/medicine-prices/medicine-prices.service.test.ts
@@ -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' });
+ });
+ });
+});
diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.service.ts b/packages/api/src/modules/medicine-prices/medicine-prices.service.ts
new file mode 100644
index 0000000..68a8c56
--- /dev/null
+++ b/packages/api/src/modules/medicine-prices/medicine-prices.service.ts
@@ -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 {
+ 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);
+ }
+}
diff --git a/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts b/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts
index 879c199..83f9239 100644
--- a/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts
+++ b/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts
@@ -177,6 +177,20 @@ describe('medicine-products.routes', () => {
expect(res.statusCode).toBe(200);
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', () => {
diff --git a/packages/api/src/modules/medicines/medicines.service.test.ts b/packages/api/src/modules/medicines/medicines.service.test.ts
index ac7f51b..e1ad191 100644
--- a/packages/api/src/modules/medicines/medicines.service.test.ts
+++ b/packages/api/src/modules/medicines/medicines.service.test.ts
@@ -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 () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
mockRepo.update.mockResolvedValue({ _id: 'med-1', notes: 'Updated notes' });
diff --git a/packages/api/src/modules/purchases/purchases.repository.test.ts b/packages/api/src/modules/purchases/purchases.repository.test.ts
new file mode 100644
index 0000000..c57dfef
--- /dev/null
+++ b/packages/api/src/modules/purchases/purchases.repository.test.ts
@@ -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([]);
+ });
+ });
+});
diff --git a/packages/api/src/modules/purchases/purchases.repository.ts b/packages/api/src/modules/purchases/purchases.repository.ts
new file mode 100644
index 0000000..8adbf43
--- /dev/null
+++ b/packages/api/src/modules/purchases/purchases.repository.ts
@@ -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 = { 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 = {};
+ 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 = {};
+ 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();
+ }
+}
diff --git a/packages/api/src/modules/purchases/purchases.routes.test.ts b/packages/api/src/modules/purchases/purchases.routes.test.ts
new file mode 100644
index 0000000..f757fb0
--- /dev/null
+++ b/packages/api/src/modules/purchases/purchases.routes.test.ts
@@ -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>;
+
+ 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');
+ });
+ });
+});
diff --git a/packages/api/src/modules/purchases/purchases.routes.ts b/packages/api/src/modules/purchases/purchases.routes.ts
new file mode 100644
index 0000000..7dbc2d4
--- /dev/null
+++ b/packages/api/src/modules/purchases/purchases.routes.ts
@@ -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();
+ 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'],
+ },
+);
diff --git a/packages/api/src/modules/purchases/purchases.service.test.ts b/packages/api/src/modules/purchases/purchases.service.test.ts
new file mode 100644
index 0000000..7aee85b
--- /dev/null
+++ b/packages/api/src/modules/purchases/purchases.service.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/packages/api/src/modules/purchases/purchases.service.ts b/packages/api/src/modules/purchases/purchases.service.ts
new file mode 100644
index 0000000..06add7c
--- /dev/null
+++ b/packages/api/src/modules/purchases/purchases.service.ts
@@ -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