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