diff --git a/app/src/components/inventory/FieldSchemaEditor.js b/app/src/components/inventory/FieldSchemaEditor.js index 2a9f7a67..c48b3d20 100644 --- a/app/src/components/inventory/FieldSchemaEditor.js +++ b/app/src/components/inventory/FieldSchemaEditor.js @@ -4,6 +4,7 @@ import { } from "semantic-ui-react"; import {Button, Icon, Modal, Table} from "../Theme"; import {ALL_UNITS} from "./units"; +import {addCountByWeightFields, isCountByWeight} from "./computeFields"; const FIELD_TYPE_OPTIONS = ['text', 'number', 'quantity', 'date', 'select', 'location', 'calories'] .map(t => ({key: t, value: t, text: t})); @@ -39,6 +40,8 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) { return next; }); const add = () => setDraft(prev => [...prev, {key: '', label: '', type: 'text'}]); + // Add the "count by weight" fields (Unit Weight, Total Weight, computed Count); links an existing Count field. + const addCountByWeight = () => setDraft(prev => addCountByWeightFields(prev)); const save = async () => { // Fill any missing keys from labels and normalize order. @@ -108,6 +111,10 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) { update(index, {options: e.target.value.split(',')})}/>} + {isCountByWeight(f) && + + Auto-counted: Total Weight ÷ Unit Weight + } + diff --git a/app/src/components/inventory/FieldSchemaEditor.test.js b/app/src/components/inventory/FieldSchemaEditor.test.js index 98253ee7..4646b926 100644 --- a/app/src/components/inventory/FieldSchemaEditor.test.js +++ b/app/src/components/inventory/FieldSchemaEditor.test.js @@ -20,6 +20,24 @@ describe('FieldSchemaEditor select options', () => { expect(optionsInput.value).toBe('screws,nails'); }); + test('"Count by Weight" adds the linked weight + computed-count fields and they persist on save', async () => { + const onSave = jest.fn(); + render(); + + fireEvent.click(screen.getByText('Count by Weight')); + // The note appears on the computed Count row. + expect(screen.getByText(/Total Weight ÷ Unit Weight/)).toBeTruthy(); + + fireEvent.click(screen.getByText('Save Fields')); + const saved = onSave.mock.calls[0][0]; + const byKey = Object.fromEntries(saved.map(f => [f.key, f])); + expect(byKey.unit_weight).toMatchObject({type: 'quantity', unit: 'g'}); + expect(byKey.total_weight).toMatchObject({type: 'quantity', unit: 'g'}); + // The compute metadata survives save() so the table can auto-fill the count. + expect(byKey.count.compute).toEqual({kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}); + }); + test('options are trimmed and emptied-out on save', async () => { const onSave = jest.fn(); render(); diff --git a/app/src/components/inventory/InventoryTable.js b/app/src/components/inventory/InventoryTable.js index 45a17b75..ae3802ee 100644 --- a/app/src/components/inventory/InventoryTable.js +++ b/app/src/components/inventory/InventoryTable.js @@ -2,6 +2,7 @@ import React, {useMemo, useRef, useState} from "react"; import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react"; import {Button, Icon, Table} from "../Theme"; import {FieldCell} from "./FieldCell"; +import {applyComputedCounts, computedCountConfigs} from "./computeFields"; const LOCATION_LIST_ID = 'inventory-location-suggestions'; const CATALOG_LIST_ID = 'inventory-catalog-names'; @@ -80,6 +81,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, search, }); return keys; }, [fields]); + // "Count by weight" configs: a Count field auto-filled from a Total Weight ÷ Unit Weight. + const countConfigs = useMemo(() => computedCountConfigs(fields), [fields]); const listIdFor = (field) => { if (field.type === 'location') { @@ -147,6 +150,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, search, }); } } + // Auto-fill any "count by weight" field when a weight (or its unit) changed. + applyComputedCounts(next, countConfigs, key); return next; }); @@ -165,7 +170,11 @@ export function InventoryTable({slug, fields, items, locations, catalog, search, setFocusCell({id: item.id, key}); // the FieldCell for this key auto-focuses and selects its contents }; const setEditValue = (id, key, value) => - setEdits(prev => ({...prev, [id]: {...prev[id], [key]: value}})); + setEdits(prev => { + const updated = {...prev[id], [key]: value}; + applyComputedCounts(updated, countConfigs, key); + return {...prev, [id]: updated}; + }); const saveEdit = (id) => { onChange(items.map(i => i.id === id ? edits[id] : i)); diff --git a/app/src/components/inventory/InventoryTable.test.js b/app/src/components/inventory/InventoryTable.test.js index 77fe3a0c..27b91452 100644 --- a/app/src/components/inventory/InventoryTable.test.js +++ b/app/src/components/inventory/InventoryTable.test.js @@ -1,5 +1,5 @@ import React from "react"; -import {fireEvent, render, screen} from "@testing-library/react"; +import {fireEvent, render, screen, within} from "@testing-library/react"; import {filterItems, InventoryTable} from "./InventoryTable"; const FIELDS = [ @@ -78,6 +78,64 @@ describe('InventoryTable keyboard entry', () => { }); }); +describe('InventoryTable count by weight', () => { + const FIELDS = [ + {key: 'name', label: 'Name', type: 'text', order: 0}, + {key: 'unit_weight', label: 'Unit Weight', type: 'quantity', unit: 'g', order: 1}, + {key: 'total_weight', label: 'Total Weight', type: 'quantity', unit: 'g', order: 2}, + {key: 'count', label: 'Count', type: 'number', order: 3, + compute: {kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}}, + ]; + + test('entering the two weights in the draft row auto-fills the count', () => { + const onChange = jest.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Unit Weight'), {target: {value: '5'}}); + fireEvent.change(screen.getByLabelText('Total Weight'), {target: {value: '1000'}}); + + // The draft Count input is now pre-filled with 200 (1000 / 5). + expect(screen.getByLabelText('Count').value).toBe('200'); + }); + + test('editing an existing item recomputes its count from the weights', () => { + const onChange = jest.fn(); + const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g', + total_weight: '1000', total_weight_unit: 'g', count: '200'}]; + render(); + + fireEvent.click(screen.getByText('Screws')); // enter edit mode + const totalInput = screen.getAllByLabelText('Total Weight').find(i => i.value === '1000'); + fireEvent.change(totalInput, {target: {value: '2000'}}); + + // The edited row's Count is recomputed to 400 (2000 / 5). + expect(screen.getAllByLabelText('Count').some(i => i.value === '400')).toBe(true); + }); + + test('clearing a weight in an existing row blanks the count (no stale value)', () => { + const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g', + total_weight: '1000', total_weight_unit: 'g', count: '200'}]; + render(); + + fireEvent.click(screen.getByText('Screws')); // enter edit mode + const row = screen.getByDisplayValue('Screws').closest('tr'); + // Clearing the total weight (as a real keyboard delete does) fires onChange with '' and blanks the count. + fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: ''}}); + expect(within(row).getByLabelText('Count').value).toBe(''); + }); + + test('reducing a weight below one item shows 0, not the stale count', () => { + const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g', + total_weight: '1000', total_weight_unit: 'g', count: '200'}]; + render(); + + fireEvent.click(screen.getByText('Screws')); + const row = screen.getByDisplayValue('Screws').closest('tr'); + fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: '1'}}); + expect(within(row).getByLabelText('Count').value).toBe('0'); + }); +}); + describe('InventoryTable sorting', () => { const items = [ {id: 1, name: 'Rice', count: '5'}, diff --git a/app/src/components/inventory/computeFields.js b/app/src/components/inventory/computeFields.js new file mode 100644 index 00000000..496f2639 --- /dev/null +++ b/app/src/components/inventory/computeFields.js @@ -0,0 +1,91 @@ +import {countByWeight} from "./units"; + +// "Count by weight" computed fields. A Count (number) field can carry metadata linking it to a Total Weight and a +// Unit Weight field; the table then fills the count automatically from the two weights (count = round(total / unit)). +// The metadata is a plain property on the field dict, so it round-trips through the config/API unchanged. + +export const COUNT_BY_WEIGHT = 'count_by_weight'; + +// The fields the "Count by weight" preset creates. +const UNIT_WEIGHT = {key: 'unit_weight', label: 'Unit Weight', type: 'quantity', unit: 'g'}; +const TOTAL_WEIGHT = {key: 'total_weight', label: 'Total Weight', type: 'quantity', unit: 'g'}; +const COUNT_COMPUTE = {kind: COUNT_BY_WEIGHT, total: 'total_weight', unit: 'unit_weight'}; + +// True when a field is a Count computed from weights. +export function isCountByWeight(field) { + return !!field && field.compute && field.compute.kind === COUNT_BY_WEIGHT; +} + +/** + * Add the "Count by weight" fields to a schema: Unit Weight and Total Weight (quantity, grams), plus a Count that + * is tagged as computed. An existing `count` field is linked in place (so a Tool/Fastener inventory's Count just + * becomes computed) rather than duplicated; existing weight fields are left untouched. Returns a new field list. + */ +export function addCountByWeightFields(fields) { + const list = (fields || []).map(f => ({...f})); + const has = (key) => list.some(f => f.key === key); + + if (!has(UNIT_WEIGHT.key)) { + list.push({...UNIT_WEIGHT}); + } + if (!has(TOTAL_WEIGHT.key)) { + list.push({...TOTAL_WEIGHT}); + } + const existingCount = list.find(f => f.key === 'count'); + if (existingCount) { + existingCount.compute = {...COUNT_COMPUTE}; + if (existingCount.type !== 'number') { + existingCount.type = 'number'; + } + } else { + list.push({key: 'count', label: 'Count', type: 'number', compute: {...COUNT_COMPUTE}}); + } + return list; +} + +/** + * The count-by-weight configs present in a schema, each resolved to the weight fields' keys and default units: + * [{countKey, totalKey, unitKey, totalUnit, unitUnit}]. Skips configs whose referenced weight fields are missing. + */ +export function computedCountConfigs(fields) { + return (fields || []) + .filter(isCountByWeight) + .map(f => { + const totalField = (fields || []).find(x => x.key === f.compute.total); + const unitField = (fields || []).find(x => x.key === f.compute.unit); + if (!totalField || !unitField) { + return null; + } + return { + countKey: f.key, + totalKey: totalField.key, + unitKey: unitField.key, + totalUnit: totalField.unit || '', + unitUnit: unitField.unit || '', + }; + }) + .filter(Boolean); +} + +/** + * Recompute computed counts on an item after its field `changedKey` changed, mutating and returning the item. + * Only acts when the change was to one of a count's weight inputs (value or unit), so a hand-typed count survives + * edits to unrelated fields and is only touched when a weight actually changes. When a weight changes, the count + * always reflects the new reality: it is set to the computed value (0 included) when both weights are present, or + * cleared when they can't produce one (a weight blanked, incompatible units) — so a stale count can never persist + * against edited weights. + */ +export function applyComputedCounts(item, configs, changedKey) { + (configs || []).forEach(c => { + const triggers = [c.totalKey, c.unitKey, `${c.totalKey}_unit`, `${c.unitKey}_unit`]; + if (!triggers.includes(changedKey)) { + return; + } + const count = countByWeight( + item[c.totalKey], item[`${c.totalKey}_unit`] || c.totalUnit, + item[c.unitKey], item[`${c.unitKey}_unit`] || c.unitUnit, + ); + item[c.countKey] = count === null ? '' : String(count); + }); + return item; +} diff --git a/app/src/components/inventory/computeFields.test.js b/app/src/components/inventory/computeFields.test.js new file mode 100644 index 00000000..bece3cc8 --- /dev/null +++ b/app/src/components/inventory/computeFields.test.js @@ -0,0 +1,92 @@ +import {addCountByWeightFields, applyComputedCounts, computedCountConfigs, isCountByWeight} from "./computeFields"; + +describe('addCountByWeightFields', () => { + test('adds Unit Weight, Total Weight, and a computed Count to a schema with no count', () => { + const result = addCountByWeightFields([{key: 'name', label: 'Name', type: 'text'}]); + const byKey = Object.fromEntries(result.map(f => [f.key, f])); + expect(byKey.unit_weight).toMatchObject({type: 'quantity', unit: 'g'}); + expect(byKey.total_weight).toMatchObject({type: 'quantity', unit: 'g'}); + expect(byKey.count.type).toBe('number'); + expect(byKey.count.compute).toEqual({kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}); + }); + + test('links an existing count field instead of duplicating it', () => { + const fields = [ + {key: 'name', label: 'Name', type: 'text'}, + {key: 'count', label: 'Count', type: 'number'}, + ]; + const result = addCountByWeightFields(fields); + expect(result.filter(f => f.key === 'count')).toHaveLength(1); + expect(isCountByWeight(result.find(f => f.key === 'count'))).toBe(true); + // The two weight fields were appended. + expect(result.map(f => f.key)).toEqual(['name', 'count', 'unit_weight', 'total_weight']); + }); + + test('does not duplicate existing weight fields', () => { + const fields = [{key: 'unit_weight', type: 'quantity', unit: 'oz'}]; + const result = addCountByWeightFields(fields); + expect(result.filter(f => f.key === 'unit_weight')).toHaveLength(1); + expect(result.find(f => f.key === 'unit_weight').unit).toBe('oz'); // left untouched + }); +}); + +describe('computedCountConfigs', () => { + const FIELDS = [ + {key: 'unit_weight', type: 'quantity', unit: 'g'}, + {key: 'total_weight', type: 'quantity', unit: 'g'}, + {key: 'count', type: 'number', compute: {kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}}, + ]; + + test('resolves the weight keys and their default units', () => { + expect(computedCountConfigs(FIELDS)).toEqual([ + {countKey: 'count', totalKey: 'total_weight', unitKey: 'unit_weight', totalUnit: 'g', unitUnit: 'g'}, + ]); + }); + + test('skips a config whose referenced weight field is missing', () => { + const broken = [{key: 'count', type: 'number', + compute: {kind: 'count_by_weight', total: 'gone', unit: 'unit_weight'}}]; + expect(computedCountConfigs(broken)).toEqual([]); + }); +}); + +describe('applyComputedCounts', () => { + const configs = [{countKey: 'count', totalKey: 'total_weight', unitKey: 'unit_weight', + totalUnit: 'g', unitUnit: 'g'}]; + + test('fills the count when a weight changes', () => { + const item = {total_weight: '1000', unit_weight: '5'}; + applyComputedCounts(item, configs, 'total_weight'); + expect(item.count).toBe('200'); + }); + + test('uses the per-item unit override when present', () => { + const item = {total_weight: '2', total_weight_unit: 'kg', unit_weight: '5', unit_weight_unit: 'g'}; + applyComputedCounts(item, configs, 'unit_weight'); + expect(item.count).toBe('400'); + }); + + test('does not recompute when an unrelated field changes (manual count survives)', () => { + const item = {total_weight: '1000', unit_weight: '5', count: '999', name: 'Screws'}; + applyComputedCounts(item, configs, 'name'); + expect(item.count).toBe('999'); + }); + + test('reducing a weight to a sub-unit total recomputes the count to 0 (no stale value)', () => { + const item = {total_weight: '1', unit_weight: '5', count: '200'}; + applyComputedCounts(item, configs, 'total_weight'); + expect(item.count).toBe('0'); + }); + + test('clearing a weight clears the count so a stale value cannot persist', () => { + const item = {total_weight: '', unit_weight: '5', count: '200'}; + applyComputedCounts(item, configs, 'total_weight'); + expect(item.count).toBe(''); + }); + + test('a fresh draft (no count yet) stays blank while only one weight is entered', () => { + const item = {total_weight: '', unit_weight: '5', count: ''}; + applyComputedCounts(item, configs, 'unit_weight'); + expect(item.count).toBe(''); + }); +}); diff --git a/app/src/components/inventory/units.js b/app/src/components/inventory/units.js index 357412aa..53859650 100644 --- a/app/src/components/inventory/units.js +++ b/app/src/components/inventory/units.js @@ -54,6 +54,35 @@ export function convert(value, fromUnit, toUnitName) { } } +/** + * Estimate how many items a lot contains from a total weight and a single item's weight: + * count = round(total / unit), converting `total` into the unit-weight's unit first (so e.g. a 2 kg total and a + * 5 g unit weight give 400). The result is rounded to a whole number — you can't have 403.7 nails. A total below + * one item (or zero) gives 0, not null, so a corrected-down weight doesn't leave a stale count behind. + * Returns null when a weight is blank, the unit weight is non-positive, or the units are incompatible. + */ +export function countByWeight(total, totalUnit, unitWeight, unitWeightUnit) { + const each = Number(unitWeight); + if (!(each > 0) || total === '' || total === null || total === undefined) { + return null; + } + let totalInEachUnit; + if (totalUnit && unitWeightUnit && totalUnit !== unitWeightUnit) { + totalInEachUnit = convert(total, totalUnit, unitWeightUnit); // null if incompatible + } else if ((totalUnit || '') === (unitWeightUnit || '')) { + // Same unit (or both unitless) — divide the raw magnitudes. + const t = Number(total); + totalInEachUnit = Number.isFinite(t) ? t : null; + } else { + // One side has a unit and the other doesn't — can't safely divide, so refuse to guess. + return null; + } + if (totalInEachUnit === null || !Number.isFinite(totalInEachUnit) || totalInEachUnit < 0) { + return null; + } + return Math.round(totalInEachUnit / each); // 0 for a sub-unit (or zero) total +} + function compact(unitValue) { // unitValue is a mathjs Unit. On a compatible ladder, pick the largest unit where the value is still >= 1. for (const ladder of COMPACTION_LADDERS) { diff --git a/app/src/components/inventory/units.test.js b/app/src/components/inventory/units.test.js index 8ff7f9a6..473a724d 100644 --- a/app/src/components/inventory/units.test.js +++ b/app/src/components/inventory/units.test.js @@ -1,4 +1,36 @@ -import {convert, formatTotals, sumQuantities} from "./units"; +import {convert, countByWeight, formatTotals, sumQuantities} from "./units"; + +describe('countByWeight', () => { + test('divides total by unit weight and rounds to a whole number', () => { + expect(countByWeight('1000', 'g', '5', 'g')).toBe(200); + // 403.7 nails -> 404 (rounded to a whole number). + expect(countByWeight('1009.25', 'g', '2.5', 'g')).toBe(404); + }); + + test('converts mismatched units before dividing', () => { + expect(countByWeight('2', 'kg', '5', 'g')).toBe(400); // 2 kg / 5 g + expect(countByWeight('1', 'lb', '1', 'oz')).toBe(16); + }); + + test('a sub-unit or zero total yields a count of 0 (not null)', () => { + expect(countByWeight('1', 'g', '5', 'g')).toBe(0); // round(0.2) = 0 + expect(countByWeight('0', 'g', '5', 'g')).toBe(0); + }); + + test('returns null when a weight is missing, zero, or incompatible', () => { + expect(countByWeight('', 'g', '5', 'g')).toBeNull(); + expect(countByWeight('1000', 'g', '', 'g')).toBeNull(); + expect(countByWeight('1000', 'g', '0', 'g')).toBeNull(); + expect(countByWeight('1000', 'lb', '5', 'gallon')).toBeNull(); // incompatible dimensions + }); + + test('refuses to divide when only one side has a unit', () => { + expect(countByWeight('2', '', '1', 'oz')).toBeNull(); + expect(countByWeight('2', 'oz', '1', '')).toBeNull(); + // Both unitless divides the raw magnitudes. + expect(countByWeight('100', '', '4', '')).toBe(25); + }); +}); describe('convert', () => { test('converts within mass', () => { diff --git a/modules/inventory/test/test_inventory.py b/modules/inventory/test/test_inventory.py index b16388ca..a27a7d5d 100644 --- a/modules/inventory/test/test_inventory.py +++ b/modules/inventory/test/test_inventory.py @@ -81,6 +81,26 @@ def test_save_inventory_fields(food_inventory_factory, test_inventory_configs): assert [f['order'] for f in saved['fields']] == [0, 1] +def test_save_inventory_fields_preserve_compute_metadata(food_inventory_factory, test_inventory_configs): + """A field's extra `compute` metadata (e.g. count-by-weight) must round-trip through save and re-import.""" + config = test_inventory_configs + slug = food_inventory_factory() + compute = {'kind': 'count_by_weight', 'total': 'total_weight', 'unit': 'unit_weight'} + saved = config.save_inventory(slug, dict(fields=[ + dict(key='name', label='Name', type='text'), + dict(key='unit_weight', label='Unit Weight', type='quantity', unit='g'), + dict(key='total_weight', label='Total Weight', type='quantity', unit='g'), + dict(key='count', label='Count', type='number', compute=compute), + ])) + assert next(f for f in saved['fields'] if f['key'] == 'count')['compute'] == compute + + # A fresh config instance re-reads from disk; the metadata persists. + fresh = inventory_common.InventoriesConfig() + fresh.initialize() + restored = fresh.get_inventory(slug) + assert next(f for f in restored['fields'] if f['key'] == 'count')['compute'] == compute + + def test_save_inventory_rename_keeps_slug(food_inventory_factory, test_inventory_configs): config = test_inventory_configs slug = food_inventory_factory(name='Food Storage')