wrolpi/modules/inventory/defaults.py
Roland ee0b827c71
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) <noreply@anthropic.com>
2026-06-19 12:40:30 -06:00

168 lines
7 KiB
Python

"""Default field schemas and category lists for the inventory module.
An inventory's ``type`` only seeds its initial ``fields`` — afterwards the user owns the schema and may
add/remove/reorder/retype fields freely. Quantity-field default units use mathjs-compatible names (e.g. ``lb``,
not ``pound``); all unit math happens in the frontend via mathjs.
"""
import re
# Supported field types. The backend treats every value as an opaque string/number; only the frontend interprets
# `quantity` (magnitude + `<key>_unit`), `date`, `select` (enum `options`), `location`, and `calories` (a number of
# kcal-per-unit that the Summary's ration estimate detects by type).
FIELD_TYPES = ('text', 'number', 'quantity', 'date', 'select', 'location', 'calories')
def _field(key, label, type_, order, **kwargs):
d = dict(key=key, label=label, type=type_, order=order)
d.update({k: v for k, v in kwargs.items() if v is not None})
return d
# Food storage categories (subcategory, category). Used to seed the `category` select field's options and for
# category suggestions in the UI.
DEFAULT_FOOD_CATEGORIES = [
('salt', 'cooking ingredients'),
('dry milk', 'dairy'),
('evaporated milk', 'dairy'),
('freeze dried', 'dairy'),
('shortening', 'fats'),
('vegetable oil', 'fats'),
('canned', 'fruits'),
('dehydrated', 'fruits'),
('freeze-dried', 'fruits'),
('corn meal', 'grains'),
('flour', 'grains'),
('oats', 'grains'),
('pasta', 'grains'),
('rice', 'grains'),
('wheat', 'grains'),
('dry beans', 'legumes'),
('lentils', 'legumes'),
('canned', 'meats'),
('dried', 'meats'),
('freeze dried', 'meals'),
('brown sugar', 'sugars'),
('corn syrup', 'sugars'),
('honey', 'sugars'),
('juice mix', 'sugars'),
('white sugar', 'sugars'),
('canned', 'vegetables'),
('dehydrated', 'vegetables'),
('freeze dried', 'vegetables'),
('water', 'water'),
('bottled', 'water'),
('barrel', 'water'),
]
_FOOD_CATEGORY_OPTIONS = sorted({category for _, category in DEFAULT_FOOD_CATEGORIES})
DEFAULT_FIELD_SETS = {
# `mobile=True` marks the columns shown in the condensed, read-only portrait-mobile view; the user can change
# which fields are mobile in the field-schema editor.
'food': [
_field('brand', 'Brand', 'text', 0),
_field('name', 'Name', 'text', 1, required=True, mobile=True),
_field('category', 'Category', 'select', 2, options=_FOOD_CATEGORY_OPTIONS),
_field('subcategory', 'Subcategory', 'text', 3, mobile=True),
_field('item_size', 'Size', 'quantity', 4, unit='lb', mobile=True),
_field('count', 'Count', 'number', 5, mobile=True),
_field('calories', 'kcal per unit', 'calories', 6),
_field('expiration_date', 'Expires', 'date', 7),
],
'fuel': [
_field('name', 'Name', 'text', 0, required=True, mobile=True),
_field('fuel_type', 'Fuel Type', 'select', 1,
options=['gasoline', 'diesel', 'propane', 'kerosene', 'oil'], mobile=True),
_field('container', 'Container', 'text', 2),
_field('item_size', 'Size', 'quantity', 3, unit='gallon', mobile=True),
_field('count', 'Count', 'number', 4, mobile=True),
_field('purchase_date', 'Purchased', 'date', 5),
_field('location', 'Location', 'location', 6),
],
'tool': [
_field('name', 'Name', 'text', 0, required=True, mobile=True),
_field('brand', 'Brand', 'text', 1),
_field('category', 'Category', 'select', 2,
options=['hand', 'power', 'garden', 'automotive', 'measuring', 'safety'], mobile=True),
_field('count', 'Count', 'number', 3, mobile=True),
_field('condition', 'Condition', 'select', 4, options=['new', 'good', 'worn', 'broken']),
_field('location', 'Location', 'location', 5, mobile=True),
_field('notes', 'Notes', 'text', 6),
],
}
# The first/default type used when a type is not specified (e.g. legacy migration).
DEFAULT_TYPE = 'food'
INVENTORY_TYPES = tuple(DEFAULT_FIELD_SETS.keys())
# 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', items_factory=recommended_food_storage_items),
]
def default_fields(inventory_type: str) -> list:
"""Return a fresh copy of the default field set for the given type (falls back to food)."""
from copy import deepcopy
return deepcopy(DEFAULT_FIELD_SETS.get(inventory_type, DEFAULT_FIELD_SETS[DEFAULT_TYPE]))
def slugify(name: str) -> str:
"""Convert an inventory name to a filesystem-safe slug used as its config file stem."""
slug = re.sub(r'[^a-z0-9]+', '-', (name or '').strip().lower()).strip('-')
return slug or 'inventory'