From ee0b827c71da3de05dfd0bb151369e00c0445218 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 19 Jun 2026 12:39:58 -0600 Subject: [PATCH] Add a recommended Food Storage example, a Ration tab, and a supply planner Build on the rebuilt config-only Inventory with three related additions. Recommended example inventory: - A fresh install now seeds the "Food Storage" example with the recommended one-adult / one-year (~3,000 kcal/day) emergency food supply (60 items, ~1.12M kcal), instead of an empty inventory. - The counts are layered on top of the shipped food catalog (single source of per-package nutrition) via RECOMMENDED_ONE_YEAR_FOOD_COUNTS, joined at seed time by recommended_food_storage_items(). - Genericized the two remaining brand names in the catalog defaults (Velveeta -> Processed Cheese, Spam -> Canned Lunch Meat) so both the catalog and the seeded inventory stay brand-free. Ration tab (split out of Summary): - Moved the Ration Estimate (People / Daily Calories / estimate table) out of the increasingly long Summary tab into its own "Ration" tab, shown only for inventories that have a calories field. Supply planner ("Plan More Supply"): - A slider picks a target duration longer than the current estimate; the whole inventory is scaled up proportionally (every item by the same factor, mix kept balanced) into a sortable shopping list of what to buy. - Any split package rounds UP to a whole one (ceil), matching the "1.6 buckets -> buy 1 more" rule. - The shopping list is exportable as CSV (reusing toCSV) and PDF (reusing the print-to-PDF path, scoped via body.printing-shopping so only the list prints -- no inventory or summary). Search filter: - Moved the items search out of the top toolbar (which implied it filtered every tab) into the Items tab, so it clearly narrows Items only; Summary/Ration/Export/print always use the full inventory. Aggregation/role helpers (findNameKey, planSupplyPurchase) live in summarize.js, the single source of truth shared by Summary, export, and the ration views. All new pure functions and UI flows are covered by tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.css | 25 +++ .../calculators/RationCalculator.js | 168 +++++++++++++++++- .../calculators/RationCalculator.test.js | 79 +++++++- .../components/inventory/InventoryRoute.js | 36 ++-- .../components/inventory/InventorySummary.js | 13 +- .../components/inventory/inventoryExport.js | 15 ++ .../inventory/inventoryExport.test.js | 22 ++- app/src/components/inventory/summarize.js | 48 +++++ .../components/inventory/summarize.test.js | 57 +++++- modules/inventory/catalog_defaults.yaml | 10 +- modules/inventory/common.py | 7 +- modules/inventory/defaults.py | 58 +++++- modules/inventory/test/test_catalog.py | 4 +- modules/inventory/test/test_inventory.py | 41 ++++- 14 files changed, 545 insertions(+), 38 deletions(-) diff --git a/app/src/App.css b/app/src/App.css index 6f949ac5..6ca76aae 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -543,4 +543,29 @@ tr.drag-deselecting td { .inventory-print tr { page-break-inside: avoid; } + + /* The supply-plan shopping list reuses the .inventory-print element styling (h1/table/etc.) but is a separate + printable block. By default (e.g. the Export tab's whole-inventory PDF) it is hidden; it becomes the ONLY + printed block when the supply plan toggles `body.printing-shopping` just before window.print(). */ + .shopping-print, + .shopping-print * { + visibility: hidden; + } + .shopping-print { + display: none; + } + body.printing-shopping .inventory-print:not(.shopping-print), + body.printing-shopping .inventory-print:not(.shopping-print) * { + visibility: hidden; + } + body.printing-shopping .inventory-print:not(.shopping-print) { + display: none; + } + body.printing-shopping .shopping-print, + body.printing-shopping .shopping-print * { + visibility: visible; + } + body.printing-shopping .shopping-print { + display: block; + } } diff --git a/app/src/components/calculators/RationCalculator.js b/app/src/components/calculators/RationCalculator.js index 448ce2cf..5360f478 100644 --- a/app/src/components/calculators/RationCalculator.js +++ b/app/src/components/calculators/RationCalculator.js @@ -1,9 +1,12 @@ import React from "react"; -import {GridColumn, Input, Label, TableBody, TableCell, TableRow} from "semantic-ui-react"; +import {GridColumn, Input, Label, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; -import {Form, Header, Table} from "../Theme"; +import {Button, Form, Header, Icon, Table} from "../Theme"; import {InfoPopup, roundDigits, useLocalStorage} from "../Common"; import {formatDuration} from "./WaterCalculator"; +import {findNameKey, planSupplyPurchase} from "../inventory/summarize"; +import {downloadCSV, inventoryExportFilename, shoppingListCSV} from "../inventory/inventoryExport"; +import {ThemeContext} from "../../contexts/contexts"; // Field-role detection lives in inventory/summarize.js (single source of truth); re-exported here for back-compat. export {findCaloriesKey, findCountKey} from "../inventory/summarize"; @@ -109,7 +112,7 @@ function fmt(value, unit) { * inventory Summary (when the inventory has a `calories` field). Household/preset selections are persisted under * shared localStorage keys, so adjusting them in one place carries to the other. */ -export function RationEstimatePanel({items, caloriesKey, countKey}) { +export function RationEstimatePanel({name, items, fields, caloriesKey, countKey}) { const [preset, setPreset] = useLocalStorage('ration_preset', DEFAULT_PRESET); const [counts, setCounts] = useLocalStorage('ration_counts', {men: '1', women: '1', children: ''}); const [rates, setRates] = React.useState({...RATION_PRESETS[DEFAULT_PRESET].rates}); @@ -178,8 +181,167 @@ export function RationEstimatePanel({items, caloriesKey, countKey}) { + + ; } +// Print only the supply-plan shopping list (not the whole-inventory print block): toggle a body class that the +// `@media print` rules use to pick which printable block is visible, then open the print dialog. +function printShoppingList() { + document.body.classList.add('printing-shopping'); + let timer; + const cleanup = () => { + clearTimeout(timer); // ensure the backstop can't fire later and clear the class during a new print + window.removeEventListener('afterprint', cleanup); + document.body.classList.remove('printing-shopping'); + }; + window.addEventListener('afterprint', cleanup, {once: true}); + window.print(); + // Backstop in case afterprint never fires (cancelled above once afterprint runs), so a later whole-inventory + // print isn't stuck in shopping mode. + timer = setTimeout(cleanup, 1000); +} + +// Length of a "month" used for the supply plan, matching formatDuration()'s 30-day months so the slider label and +// the duration text agree. +const PLAN_MONTH_DAYS = 30; + +const PLAN_INFO = 'Drag the slider to a target duration longer than your current estimate to see what to buy. ' + + 'Your whole inventory is scaled up proportionally — every item grows by the same factor so the mix stays ' + + 'balanced — and any split package is rounded up to a whole one. The projection assumes you keep the same ' + + 'variety of food and the same household/activity settings above.'; + +/** + * Extrapolation: a slider to pick a target duration longer than the current ration estimate, and a shopping list of + * the additional packages to buy to reach it (the inventory scaled up proportionally — see planSupplyPurchase). + */ +function SupplyPlan({name, items, fields, caloriesKey, countKey, currentDays, total, daily}) { + const {t} = React.useContext(ThemeContext); + const nameKey = findNameKey(fields); + + const currentMonths = (currentDays || 0) / PLAN_MONTH_DAYS; + const minMonths = Math.max(1, Math.ceil(currentMonths)); + const maxMonths = minMonths + 24; + const [targetMonths, setTargetMonths] = React.useState(minMonths); + // Keep the target within range as the estimate shifts (editing the household above moves the current duration). + React.useEffect(() => { + setTargetMonths(m => Math.min(Math.max(m, minMonths), maxMonths)); + }, [minMonths, maxMonths]); + // Default to largest purchase first; clicking a header re-sorts. + const [sort, setSort] = React.useState({key: 'additional', dir: 'desc'}); + const toggleSort = (key) => setSort(prev => + prev.key === key + ? {key, dir: prev.dir === 'asc' ? 'desc' : 'asc'} + : {key, dir: key === 'name' ? 'asc' : 'desc'}); + + if (!(currentDays > 0)) { + return null; // No usable estimate yet (no calories or no household) — nothing to extrapolate from. + } + + const targetDays = targetMonths * PLAN_MONTH_DAYS; + const scale = targetDays / currentDays; + const {rows, addedCalories} = planSupplyPurchase(items, {countKey, nameKey, caloriesKey, scale}); + const projectedDays = daily > 0 ? (total + addedCalories) / daily : null; + const totalToBuy = rows.reduce((sum, r) => sum + r.additional, 0); + + const sortedRows = [...rows].sort((a, b) => { + const r = sort.key === 'name' ? a.name.localeCompare(b.name) : a[sort.key] - b[sort.key]; + return sort.dir === 'desc' ? -r : r; + }); + const sortDir = (key) => sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined; + const headerCell = (key, label) => + toggleSort(key)} style={{cursor: 'pointer'}}> + {label} + ; + + return
+
Plan More Supply
+ {!countKey + ?

Add a Count field to this inventory to plan purchases.

+ : <> +
+ setTargetMonths(Number(e.target.value))}/> +
+ Target: {formatDuration(targetDays)} +
+

+ Currently {formatDuration(currentDays)} +

+
+ + {rows.length === 0 + ?

Drag the slider above your current estimate to see a shopping list.

+ : <> + + + + {headerCell('name', 'Item')} + {headerCell('current', 'Have')} + {headerCell('additional', 'Buy')} + {headerCell('target', 'New Total')} + + + + {sortedRows.map((r, i) => + {r.name} + {r.current} + +{r.additional} + {r.target} + )} + +
+

+ Buy {totalToBuy.toLocaleString()} additional package{totalToBuy === 1 ? '' : 's'} + {' '}across {rows.length} item{rows.length === 1 ? '' : 's'} + {projectedDays ? <> to reach about {formatDuration(projectedDays)} of food. : '.'} +

+ + + + + {/* Hidden printable block — `printShoppingList` makes this the only thing printed. */} + + } + } +
; +} + +// Printable rendering of just the shopping list (no inventory or summary), shown only when printing via the +// `body.printing-shopping` toggle. Reuses the `.inventory-print` styling for headings and the table. +function ShoppingListPrint({name, rows, targetText, currentText}) { + return
+

{name || 'Inventory'} — Shopping List

+

+ Target {targetText} (currently {currentText}) · {rows.length} item{rows.length === 1 ? '' : 's'} to buy +

+ + + + + + {rows.map((r, i) => + + + + + )} + +
ItemHaveBuyNew Total
{r.name}{r.current}+{r.additional}{r.target}
+
; +} + // NOTE: the inventory-based "how long does my food last" estimate now lives only inside the inventory Summary // (via RationEstimatePanel). The standalone Calculators page is the generic FoodStorageCalculator instead. diff --git a/app/src/components/calculators/RationCalculator.test.js b/app/src/components/calculators/RationCalculator.test.js index 2bd1985d..f6953fc3 100644 --- a/app/src/components/calculators/RationCalculator.test.js +++ b/app/src/components/calculators/RationCalculator.test.js @@ -1,4 +1,8 @@ -import {dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, totalCalories} from "./RationCalculator"; +import React from "react"; +import {fireEvent, render, screen, within} from "@testing-library/react"; +import { + dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, RationEstimatePanel, totalCalories, +} from "./RationCalculator"; describe('findCaloriesKey', () => { test('detects a calories-type field', () => { @@ -71,3 +75,76 @@ describe('daysOfFood', () => { expect(daysOfFood(14000, 0)).toBeNull(); }); }); + +describe('RationEstimatePanel supply plan', () => { + const FIELDS = [ + {key: 'name', label: 'Name', type: 'text', order: 0}, + {key: 'count', label: 'Count', type: 'number', order: 1}, + {key: 'calories', label: 'kcal', type: 'calories', order: 2}, + ]; + // 100 cans × 450 kcal = 45,000 kcal; with a survival ration for one man (1,500/day) that lasts exactly 30 days. + const ITEMS = [{name: 'Beans', count: '100', calories: '450'}]; + + beforeEach(() => { + window.localStorage.clear(); + // Deterministic household: one man on the Survival preset → 1,500 kcal/day. + window.localStorage.setItem('ration_preset', JSON.stringify('survival')); + window.localStorage.setItem('ration_counts', JSON.stringify({men: '1', women: '', children: ''})); + }); + + test('dragging the target slider produces a round-up shopping list', () => { + render(); + + // Current estimate is 1 month, so the slider starts there with no purchases yet. + expect(screen.getByText(/drag the slider/i)).toBeTruthy(); + + // Drag to a 2-month target: scale ×2 → 100 → 200 cans, buy 100. + fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}}); + + // Scope to the on-screen table (the hidden print block duplicates these texts in the DOM). + const plan = within(document.querySelector('table.sortable')); + expect(plan.getByText('Beans')).toBeTruthy(); + expect(plan.getByText('+100')).toBeTruthy(); + // Summary line reports the total to buy. + expect(screen.getByText(/additional packages/).textContent).toMatch(/100 additional packages/); + }); + + test('clicking a column header re-sorts the shopping list', () => { + const items = [ + {name: 'Apples', count: '10', calories: '100'}, + {name: 'Beans', count: '200', calories: '100'}, + ]; + render(); + fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}}); + + const itemNames = () => + [...document.querySelectorAll('table.sortable tbody tr td:first-child')].map(td => td.textContent); + // Default sort is largest purchase first (Beans buys far more than Apples). + expect(itemNames()).toEqual(['Beans', 'Apples']); + + // Sorting by Item ascending flips to alphabetical (scope to the sortable table; the print block also has an + // "Item" header). + fireEvent.click(within(document.querySelector('table.sortable')).getByText('Item')); + expect(itemNames()).toEqual(['Apples', 'Beans']); + }); + + test('a populated plan offers CSV and PDF export, and printing scopes to the shopping list', () => { + const printSpy = jest.spyOn(window, 'print').mockImplementation(() => {}); + render(); + fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}}); + + expect(screen.getByText(/Download CSV/i)).toBeTruthy(); + + // Printing toggles the body class the @media print rules use to show only the shopping list. + fireEvent.click(screen.getByText(/Print/i)); + expect(printSpy).toHaveBeenCalled(); + expect(document.body.classList.contains('printing-shopping')).toBe(true); + printSpy.mockRestore(); + }); + + test('without a count field, the plan asks for one instead of a list', () => { + render(); + expect(screen.getByText(/add a/i).textContent.toLowerCase()).toContain('count'); + }); +}); diff --git a/app/src/components/inventory/InventoryRoute.js b/app/src/components/inventory/InventoryRoute.js index 9e8bd96f..ce3a60bc 100644 --- a/app/src/components/inventory/InventoryRoute.js +++ b/app/src/components/inventory/InventoryRoute.js @@ -8,7 +8,8 @@ import {InventoryItemsMobile} from "./InventoryItemsMobile"; import {InventorySummary} from "./InventorySummary"; import {InventoryExportPanel} from "./InventoryExportPanel"; import {InventoryPrint} from "./InventoryPrint"; -import {defaultGroupKey, defaultSumKey} from "./summarize"; +import {RationEstimatePanel} from "../calculators/RationCalculator"; +import {defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey} from "./summarize"; import {FieldSchemaEditor} from "./FieldSchemaEditor"; import {CatalogEditor} from "./CatalogEditor"; import {Media} from "../../contexts/contexts"; @@ -82,6 +83,9 @@ export function InventoryRoute() { const current = inventories?.find(i => i.slug === slug); const fields = useMemo(() => (current ? current.fields : []), [current]); const items = useMemo(() => (current ? current.items : []), [current]); + // The Ration tab (estimate + supply plan) only applies to inventories with a calories field. + const caloriesKey = useMemo(() => findCaloriesKey(fields), [fields]); + const countKey = useMemo(() => findCountKey(fields), [fields]); // Re-seed the export grouping when the selected inventory (or its schema) changes. React.useEffect(() => { @@ -90,9 +94,16 @@ export function InventoryRoute() { }, [fields]); // Clear the search when switching inventories. React.useEffect(() => setSearch(''), [slug]); + // The Ration tab disappears for inventories without a calories field; fall back to Items if it was active. + React.useEffect(() => { + if (tab === 'ration' && !caloriesKey) { + setTab('items'); + } + }, [tab, caloriesKey]); - // The search narrows the whole inventory view (Items display, Summary, Export); edits/adds in InventoryTable - // still operate on the full `items`, so filtering never drops data. + // The search lives in (and only narrows) the Items tab. It feeds the read-only mobile list here; the desktop + // table filters its own display from the full `items` it receives. Summary/Ration/Export always use the full + // inventory, so the filter never silently hides data on those tabs. const filteredItems = useMemo(() => filterItems(items, fields, search), [items, fields, search]); // Location suggestions are pooled across every inventory. @@ -155,20 +166,22 @@ export function InventoryRoute() { } - {current && - setSearch(data.value)}/>} {current ? <> setTab('items')}/> setTab('summary')}/> + {caloriesKey && + setTab('ration')}/>} setTab('export')}/> {tab === 'items' && <> + {/* The search filter lives here so it clearly applies to the Items tab only, not Summary/Ration/Export. */} + setSearch(data.value)}/> {/* Portrait mobile: condensed, read-only. Rotate to landscape (tablet+) for the full editor. */} @@ -181,14 +194,17 @@ export function InventoryRoute() { onChange={newItems => persistInventory(slug, {items: newItems})}/> } - {tab === 'summary' && } + {tab === 'summary' && } + {tab === 'ration' && caloriesKey && + } {tab === 'export' && - } {/* Always mounted (hidden on screen) so the browser print dialog has the full table + summary to render. */} - )} - - {caloriesKey && - -
Ration Estimate
- -
} ; } diff --git a/app/src/components/inventory/inventoryExport.js b/app/src/components/inventory/inventoryExport.js index ba3c804d..123d39ea 100644 --- a/app/src/components/inventory/inventoryExport.js +++ b/app/src/components/inventory/inventoryExport.js @@ -23,6 +23,21 @@ export function toCSV(fields, items) { return [header, ...rows].map(row => row.join(',')).join('\r\n'); } +// Column layout for an exported supply-plan shopping list, mirroring the on-screen table. No `type` is set, so +// the shared CSV writer formats every value as plain text/number. +const SHOPPING_LIST_FIELDS = [ + {key: 'name', label: 'Item'}, + {key: 'current', label: 'Have'}, + {key: 'additional', label: 'Buy'}, + {key: 'target', label: 'New Total'}, +]; + +// Build a CSV of a supply-plan shopping list (rows from planSupplyPurchase: {name, current, additional, target}), +// reusing the inventory CSV writer so quoting/escaping stays identical. +export function shoppingListCSV(rows) { + return toCSV(SHOPPING_LIST_FIELDS, rows || []); +} + // Filesystem-safe export filename derived from the inventory name (e.g. "Food Storage" -> "food-storage.csv"). export function inventoryExportFilename(name, ext) { const base = (name || 'inventory').trim().toLowerCase() diff --git a/app/src/components/inventory/inventoryExport.test.js b/app/src/components/inventory/inventoryExport.test.js index 09d94164..09d020f4 100644 --- a/app/src/components/inventory/inventoryExport.test.js +++ b/app/src/components/inventory/inventoryExport.test.js @@ -1,4 +1,4 @@ -import {inventoryExportFilename, toCSV} from "./inventoryExport"; +import {inventoryExportFilename, shoppingListCSV, toCSV} from "./inventoryExport"; const FIELDS = [ {key: 'name', label: 'Name', type: 'text', order: 0}, @@ -48,6 +48,26 @@ describe('toCSV', () => { }); }); +describe('shoppingListCSV', () => { + test('emits the Item/Have/Buy/New Total columns for each plan row', () => { + const rows = [ + {name: 'Canned Beans', current: 96, additional: 29, target: 125}, + {name: 'Mixed Vegetables', current: 180, additional: 55, target: 235}, + ]; + expect(shoppingListCSV(rows)).toBe( + 'Item,Have,Buy,New Total\r\n' + + 'Canned Beans,96,29,125\r\n' + + 'Mixed Vegetables,180,55,235' + ); + }); + + test('quotes a name containing a comma, and handles no rows', () => { + expect(shoppingListCSV([{name: 'Beans, dried', current: 1, additional: 1, target: 2}]).split('\r\n')[1]) + .toBe('"Beans, dried",1,1,2'); + expect(shoppingListCSV([])).toBe('Item,Have,Buy,New Total'); + }); +}); + describe('inventoryExportFilename', () => { test('slugifies the inventory name with the given extension', () => { expect(inventoryExportFilename('Food Storage', 'csv')).toBe('food-storage.csv'); diff --git a/app/src/components/inventory/summarize.js b/app/src/components/inventory/summarize.js index 63844ab9..0b44970a 100644 --- a/app/src/components/inventory/summarize.js +++ b/app/src/components/inventory/summarize.js @@ -52,6 +52,54 @@ export function findCountKey(fields) { return firstNumber ? firstNumber.key : null; } +// The field whose value names each item (for the shopping-list label): prefer a field keyed "name", else the first +// text field, else none. +export function findNameKey(fields) { + const named = (fields || []).find(f => f.key === 'name'); + if (named) { + return named.key; + } + const firstText = (fields || []).find(f => f.type === 'text'); + return firstText ? firstText.key : null; +} + +/** + * Build a proportional "buy more" shopping list to grow the whole inventory by `scale` (the target duration divided + * by the current duration). Every item is scaled by the same factor so the mix stays balanced — matching the + * user's mental model ("100 cans lasting 2 months, extend to 3 → buy 50 more"). + * + * Each item's target count = ceil(currentCount × scale) — any fractional package is rounded UP to a whole one (a + * bucket of wheat that extrapolates to 1.6 buckets means buying 1 more, not 0). `additional` is the positive + * shortfall (items already at or above target are omitted). Items with no positive count have no basis to scale + * from and are skipped. Returns `{rows, addedCalories}` where rows = [{name, current, target, additional}] sorted + * by largest purchase first, and addedCalories is the calories the purchases would add (to project the new duration). + */ +export function planSupplyPurchase(items, {countKey, nameKey, caloriesKey, scale}) { + const rows = []; + let addedCalories = 0; + if (scale > 1) { + (items || []).forEach(item => { + const current = countKey ? Number(item[countKey]) : NaN; + if (!(current > 0)) { + return; + } + const target = Math.ceil(current * scale); + const additional = target - current; + if (additional <= 0) { + return; + } + const name = (nameKey && item[nameKey]) || '(unnamed)'; + rows.push({name, current, target, additional}); + const calories = caloriesKey ? Number(item[caloriesKey]) : 0; + if (calories > 0) { + addedCalories += calories * additional; + } + }); + } + rows.sort((a, b) => b.additional - a.additional || a.name.localeCompare(b.name)); + return {rows, addedCalories}; +} + // Parse an item's field value into a finite number, or null when blank/non-numeric. function numericValue(item, key) { const raw = item[key]; diff --git a/app/src/components/inventory/summarize.test.js b/app/src/components/inventory/summarize.test.js index 9445879c..364a55ca 100644 --- a/app/src/components/inventory/summarize.test.js +++ b/app/src/components/inventory/summarize.test.js @@ -1,6 +1,6 @@ import { - defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey, groupFieldsOf, sortSummaryRows, - summableFieldsOf, summarizeInventory, + defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey, findNameKey, groupFieldsOf, planSupplyPurchase, + sortSummaryRows, summableFieldsOf, summarizeInventory, } from "./summarize"; const FIELDS = [ @@ -45,6 +45,59 @@ describe('field-role helpers', () => { expect(findCaloriesKey(FIELDS)).toBe('calories'); expect(findCountKey(FIELDS)).toBe('count'); }); + + test('findNameKey prefers a "name" field, else the first text field', () => { + expect(findNameKey(FIELDS)).toBe('name'); + expect(findNameKey([{key: 'label', type: 'text'}, {key: 'note', type: 'text'}])).toBe('label'); + expect(findNameKey([{key: 'qty', type: 'number'}])).toBe(null); + }); +}); + +describe('planSupplyPurchase', () => { + const opts = (scale) => ({countKey: 'count', nameKey: 'name', caloriesKey: 'calories', scale}); + + test('the canonical example: 100 cans lasting 2 months, extend to 3 → buy 50 more', () => { + const items = [{name: 'Beans', count: '100', calories: '370'}]; + const {rows} = planSupplyPurchase(items, opts(3 / 2)); + expect(rows).toEqual([{name: 'Beans', current: 100, target: 150, additional: 50}]); + }); + + test('rounds a split package UP to a whole one', () => { + // One 50 lb bucket extrapolating to ~1.6 buckets → recommend buying 1 (not 0). + const items = [{name: 'Wheat', count: '1', calories: '7500'}]; + expect(planSupplyPurchase(items, opts(1.6)).rows).toEqual( + [{name: 'Wheat', current: 1, target: 2, additional: 1}]); + // Even a small fraction rounds up. + expect(planSupplyPurchase(items, opts(1.05)).rows[0].additional).toBe(1); + }); + + test('scales every item proportionally (balanced restock) and reports added calories', () => { + const items = [ + {name: 'Rice', count: '14', calories: '8040'}, + {name: 'Beans', count: '96', calories: '370'}, + {name: 'Salt', count: '6', calories: '0'}, // zero-calorie items still scale + ]; + const {rows, addedCalories} = planSupplyPurchase(items, opts(1.25)); + const byName = Object.fromEntries(rows.map(r => [r.name, r])); + expect(byName.Rice.additional).toBe(4); // ceil(14×1.25)=18 + expect(byName.Beans.additional).toBe(24); // ceil(96×1.25)=120 + expect(byName.Salt.additional).toBe(2); // ceil(6×1.25)=8, still listed + // Added calories ignore the zero-calorie salt: 8040×4 + 370×24. + expect(addedCalories).toBe(8040 * 4 + 370 * 24); + // Sorted by largest purchase first. + expect(rows.map(r => r.name)).toEqual(['Beans', 'Rice', 'Salt']); + }); + + test('a scale of 1 or less yields no purchases', () => { + const items = [{name: 'Rice', count: '14', calories: '8040'}]; + expect(planSupplyPurchase(items, opts(1)).rows).toEqual([]); + expect(planSupplyPurchase(items, opts(0.5)).rows).toEqual([]); + }); + + test('items with no positive count are skipped (no basis to scale)', () => { + const items = [{name: 'Mystery', count: '', calories: '500'}, {name: 'Rice', count: '0', calories: '8040'}]; + expect(planSupplyPurchase(items, opts(2)).rows).toEqual([]); + }); }); describe('summarizeInventory', () => { diff --git a/modules/inventory/catalog_defaults.yaml b/modules/inventory/catalog_defaults.yaml index 3a4a0075..25b3e75a 100644 --- a/modules/inventory/catalog_defaults.yaml +++ b/modules/inventory/catalog_defaults.yaml @@ -21,12 +21,12 @@ items: # --- Dairy --- - {id: 9, name: Nonfat Dry Milk, category: dairy, subcategory: dry milk, item_size: '64', item_size_unit: oz, calories: '6400'} - {id: 10, name: Evaporated Milk, category: dairy, subcategory: evaporated milk, item_size: '12', item_size_unit: oz, calories: '480'} - - {id: 11, name: Velveeta Cheese, category: dairy, subcategory: cheese, item_size: '32', item_size_unit: oz, calories: '2560'} + - {id: 11, name: Processed Cheese, category: dairy, subcategory: cheese, item_size: '32', item_size_unit: oz, calories: '2560'} # --- Fats --- - {id: 12, name: Butter, category: fats, subcategory: butter, item_size: '1', item_size_unit: lb, calories: '3200'} - {id: 13, name: Extra-Virgin Olive Oil, category: fats, subcategory: oil, item_size: '50.7', item_size_unit: oz, calories: '12000'} - - {id: 14, name: Shortening, category: fats, subcategory: shortening, item_size: '3', item_size_unit: lb, calories: '12000'} + - {id: 14, name: Shortening, category: fats, subcategory: shortening, item_size: '3', item_size_unit: lb, calories: '12430'} - {id: 15, name: Peanut Butter, category: fats, subcategory: peanut butter, item_size: '18', item_size_unit: oz, calories: '3040'} # --- Sugars --- @@ -35,14 +35,14 @@ items: - {id: 18, name: Confectioners Sugar, category: sugars, subcategory: powdered sugar, item_size: '1', item_size_unit: lb, calories: '1800'} - {id: 19, name: Light Corn Syrup, category: sugars, subcategory: corn syrup, item_size: '16', item_size_unit: oz, calories: '1920'} - {id: 20, name: Maple Syrup, category: sugars, subcategory: syrup, item_size: '12.5', item_size_unit: oz, calories: '1200'} - - {id: 21, name: Clover Honey, category: sugars, subcategory: honey, item_size: '16', item_size_unit: oz, calories: '1280'} + - {id: 21, name: Honey, category: sugars, subcategory: honey, item_size: '16', item_size_unit: oz, calories: '1280'} - {id: 22, name: Cocoa Syrup, category: sugars, subcategory: syrup, item_size: '16', item_size_unit: oz, calories: '1200'} - {id: 23, name: Jelly or Preserves, category: sugars, subcategory: jelly, item_size: '16', item_size_unit: oz, calories: '1300'} # --- Meats --- - {id: 24, name: Tuna in Oil, category: meats, subcategory: canned, item_size: '5', item_size_unit: oz, calories: '187'} - {id: 25, name: Canned Ham, category: meats, subcategory: canned, item_size: '16', item_size_unit: oz, calories: '800'} - - {id: 26, name: Spam, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '1020'} + - {id: 26, name: Canned Lunch Meat, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '1020'} - {id: 27, name: Vienna Sausage, category: meats, subcategory: canned, item_size: '5', item_size_unit: oz, calories: '375'} - {id: 28, name: Roast Beef, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '375'} @@ -50,7 +50,7 @@ items: - {id: 29, name: Cream of Chicken Soup, category: meals, subcategory: canned, item_size: '10.75', item_size_unit: oz, calories: '300'} - {id: 30, name: Canned Macaroni and Cheese, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '480'} - {id: 31, name: Canned Pasta, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '500'} - - {id: 32, name: Chunky Soup, category: meals, subcategory: canned, item_size: '18.8', item_size_unit: oz, calories: '500'} + - {id: 32, name: Chunky Soup, category: meals, subcategory: canned, item_size: '18.8', item_size_unit: oz, calories: '360'} - {id: 33, name: Roast Beef Hash, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '780'} - {id: 34, name: Beef Stew, category: meals, subcategory: canned, item_size: '24', item_size_unit: oz, calories: '630'} - {id: 35, name: Chili with Beans, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '700'} diff --git a/modules/inventory/common.py b/modules/inventory/common.py index bb5d0b7f..fd30f52a 100644 --- a/modules/inventory/common.py +++ b/modules/inventory/common.py @@ -184,7 +184,12 @@ class InventoriesConfig(MultiFileConfig): return for default in DEFAULT_INVENTORIES: try: - self.create_inventory(default['name'], default['type']) + inventory = self.create_inventory(default['name'], default['type']) + # Optionally populate the example with seed items (e.g. the recommended one-year food supply). + items_factory = default.get('items_factory') + items = items_factory() if items_factory else None + if items: + self.save_inventory(inventory['slug'], dict(items=items)) except Exception as e: logger.warning(f'Failed to seed default inventory {default}: {e}') diff --git a/modules/inventory/defaults.py b/modules/inventory/defaults.py index da75f6da..d99b1420 100644 --- a/modules/inventory/defaults.py +++ b/modules/inventory/defaults.py @@ -96,9 +96,63 @@ DEFAULT_TYPE = 'food' INVENTORY_TYPES = tuple(DEFAULT_FIELD_SETS.keys()) -# A brand-new install seeds one example inventory of each kind so the feature is discoverable. +# Recommended package counts for a one-adult, one-year (~3,000 kcal/day) food supply, keyed by the shipped +# food-catalog id (see catalog_defaults.yaml). Transcribed from the same "One-Year Emergency Food Supply for One +# Adult" source list. `seed_defaults()` joins these counts with the catalog's per-package data to populate the +# example "Food Storage" inventory on a fresh install (e.g. id 1 = white rice: 70 lb stored as 14 x 5 lb bags). +RECOMMENDED_ONE_YEAR_FOOD_COUNTS = { + # Grains + 1: 14, 2: 14, 3: 6, 4: 4, 5: 4, 6: 36, 7: 4, 8: 12, + # Dairy + 9: 12, 10: 24, 11: 3, + # Fats + 12: 12, 13: 5, 14: 2, 15: 12, + # Sugars + 16: 5, 17: 12, 18: 12, 19: 6, 20: 6, 21: 9, 22: 8, 23: 12, + # Meats + 24: 48, 25: 12, 26: 24, 27: 24, 28: 24, + # Meals (prepared / canned) + 29: 12, 30: 24, 31: 24, 32: 24, 33: 24, 34: 18, 35: 24, + # Legumes + 36: 96, + # Vegetables + 37: 24, 38: 36, 39: 12, 40: 180, 41: 12, + # Fruits + 42: 48, + # Cooking ingredients + 43: 12, 44: 12, 45: 24, 46: 6, 47: 12, 48: 24, 49: 6, 50: 12, 51: 12, 52: 12, + 53: 2, 54: 2, 55: 2, 56: 1, 57: 2, 58: 1, 59: 48, 60: 48, +} + +# Per-package fields copied from a food-catalog entry onto a seeded inventory item (its `count` is added from +# RECOMMENDED_ONE_YEAR_FOOD_COUNTS). +_RECOMMENDED_ITEM_KEYS = ('name', 'category', 'subcategory', 'item_size', 'item_size_unit', 'calories') + + +def recommended_food_storage_items() -> list: + """Build the example one-adult/one-year Food Storage items. + + Joins the shipped food catalog (per-package nutrition in ``catalog_defaults.yaml``) with + ``RECOMMENDED_ONE_YEAR_FOOD_COUNTS`` (how many packages to store), so the per-package nutritional data keeps a + single source of truth and this layer only adds quantities. Items are kept generic (no brand). + """ + from .catalog import load_catalog_defaults + by_id = {e.get('id'): e for e in load_catalog_defaults()} + items = [] + for catalog_id, count in RECOMMENDED_ONE_YEAR_FOOD_COUNTS.items(): + entry = by_id.get(catalog_id) + if not entry: + continue + item = {key: entry.get(key, '') for key in _RECOMMENDED_ITEM_KEYS} + item['count'] = str(count) + items.append(item) + return items + + +# A brand-new install seeds one example inventory of each kind so the feature is discoverable. The food example +# is populated with the recommended one-adult/one-year supply; `items_factory` is called lazily at seed time. DEFAULT_INVENTORIES = [ - dict(name='Food Storage', type='food'), + dict(name='Food Storage', type='food', items_factory=recommended_food_storage_items), ] diff --git a/modules/inventory/test/test_catalog.py b/modules/inventory/test/test_catalog.py index 2087c59e..71ba3ea9 100644 --- a/modules/inventory/test/test_catalog.py +++ b/modules/inventory/test/test_catalog.py @@ -30,10 +30,10 @@ def test_seed_catalog_defaults_and_tombstone(test_catalog_config): assert len(config.items) == seeded # A user-deleted default is NOT re-added on the next seed (tombstoned via merged_default_ids). - remaining = [i for i in config.items if i['name'] != 'Spam'] + remaining = [i for i in config.items if i['name'] != 'Canned Lunch Meat'] catalog_module.save_catalog_items(remaining) catalog_module.seed_catalog_defaults(config) - assert not any(i['name'] == 'Spam' for i in config.items) + assert not any(i['name'] == 'Canned Lunch Meat' for i in config.items) def test_catalog_file_is_not_loaded_as_an_inventory(test_inventory_configs, test_catalog_config): diff --git a/modules/inventory/test/test_inventory.py b/modules/inventory/test/test_inventory.py index e4c17510..b16388ca 100644 --- a/modules/inventory/test/test_inventory.py +++ b/modules/inventory/test/test_inventory.py @@ -1,7 +1,8 @@ import pytest from modules.inventory import common as inventory_common -from modules.inventory.defaults import slugify, default_fields +from modules.inventory.defaults import (slugify, default_fields, recommended_food_storage_items, + RECOMMENDED_ONE_YEAR_FOOD_COUNTS) from modules.inventory.errors import InventoryConflict @@ -114,6 +115,44 @@ def test_delete_inventory(food_inventory_factory, test_inventory_configs): assert config.all_inventories() == [] +def test_recommended_food_storage_items(): + """The example supply joins the shipped food catalog (per-package nutrition) with the recommended counts.""" + items = recommended_food_storage_items() + # One item per recommended count, all resolved against the catalog. + assert len(items) == len(RECOMMENDED_ONE_YEAR_FOOD_COUNTS) + # Kept generic: no brand names. + assert all(not item.get('brand') for item in items) + # Counts are positive strings; the per-package calorie basis comes from the catalog. + assert all(item['count'] and int(item['count']) > 0 for item in items) + rice = next(i for i in items if i['name'] == 'Long Grain White Rice') + assert rice['count'] == '14' + assert rice['calories'] == '8040' + assert rice['item_size'] == '5' and rice['item_size_unit'] == 'lb' + # Total stored calories approximate one adult-year at ~3,000 kcal/day. + total = sum(int(i['calories']) * int(i['count']) for i in items if i.get('calories')) + assert 1_000_000 < total < 1_300_000 + + +def test_seed_defaults_populates_food_storage(test_inventory_configs): + config = test_inventory_configs + config.seed_defaults() + inventories = config.all_inventories() + assert len(inventories) == 1 + inv = inventories[0] + assert inv['name'] == 'Food Storage' + assert inv['type'] == 'food' + # Seeded with the full recommended one-adult/one-year supply, with server-assigned ids. + assert len(inv['items']) == len(RECOMMENDED_ONE_YEAR_FOOD_COUNTS) + assert all(isinstance(i['id'], int) for i in inv['items']) + + +def test_seed_defaults_idempotent(test_inventory_configs): + config = test_inventory_configs + config.seed_defaults() + config.seed_defaults() + assert len(config.all_inventories()) == 1 + + def test_config_round_trip(food_inventory_factory, test_inventory_configs): """Saving to per-inventory YAML and re-importing preserves fields and items.""" config = test_inventory_configs