diff --git a/app/src/components/Calculators.js b/app/src/components/Calculators.js index 933f2e3e..2c0665bf 100644 --- a/app/src/components/Calculators.js +++ b/app/src/components/Calculators.js @@ -9,6 +9,7 @@ import {Link} from "react-router"; import {RatioCalculators} from "./calculators/RatioCalculator"; import {QRCodeCalculator} from "./calculators/QRCodeCalculator"; import {DriveCalculator} from "./calculators/DriveCalculator"; +import {WaterCalculator} from "./calculators/WaterCalculator"; export const useCalculators = () => { const [calc, setCalc] = useCalcQuery(); @@ -17,6 +18,7 @@ export const useCalculators = () => { {key: 'ratio', icon: 'th large', button: 'Ratio', contents: }, {key: 'electrical', icon: 'lightning', button: 'Electrical', contents: }, {key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: }, + {key: 'water', icon: 'tint', button: 'Water Storage', contents: }, {key: 'antenna', icon: 'signal', button: 'Antenna', contents: }, {key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: }, {key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: }, diff --git a/app/src/components/Search.js b/app/src/components/Search.js index 58cac17b..a57efd7d 100644 --- a/app/src/components/Search.js +++ b/app/src/components/Search.js @@ -56,6 +56,11 @@ const SUGGESTED_APPS = [ title: 'Drive Ratio Calculator', description: 'Calculate pulley, gear, and sprocket ratios, RPM, torque, belt and chain length.' }, + { + location: '/more/calculators?calc=water', + title: 'Water Storage Calculator', + description: 'Calculate water storage for your household: drinking and hygiene needs, how much to store, and how many drums, buckets, or bottles you need.' + }, ]; export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false, model) => { diff --git a/app/src/components/calculators/WaterCalculator.js b/app/src/components/calculators/WaterCalculator.js new file mode 100644 index 00000000..e63d2146 --- /dev/null +++ b/app/src/components/calculators/WaterCalculator.js @@ -0,0 +1,310 @@ +import React from "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, Segment, Table} from "../Theme"; +import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common"; +import {Media} from "../../contexts/contexts"; + +// Pure calculation functions for the Water Storage calculator. +// +// The model is deliberately simple: a household of some men, women, and children each +// consume a per-day amount of water (drinking plus hygiene/dish use). Over a chosen +// duration that becomes a total volume, which the user stores in some number of a chosen +// container (55-gallon drums, 5-gallon buckets, 2-liter bottles, ...). +// +// Functions are unit-agnostic: pass consistent units (all gallons, or all liters) and the +// result comes back in those same units. The React component picks imperial vs metric and +// supplies the matching per-category rates and container capacities. + +// Divide `a` by `b`, returning null when the result would not be a finite number. +const safeDiv = (a, b) => { + const result = a / b; + return Number.isFinite(result) ? result : null; +}; + +// Total water the household needs per day: sum of (count * rate) for each category. +// Missing/blank/non-positive counts or rates contribute nothing rather than NaN. +export function dailyDemand(counts, rates) { + const term = (count, rate) => { + const c = Number(count), r = Number(rate); + return (c > 0 && r > 0) ? c * r : 0; + }; + return term(counts.men, rates.men) + + term(counts.women, rates.women) + + term(counts.children, rates.children) + + term(counts.pregnant, rates.pregnant); +} + +// Total volume to store for `days` days at the given daily demand. +export function totalWater(daily, days) { + if (!(daily > 0) || !(days > 0)) { + return null; + } + return daily * days; +} + +// Number of containers of `capacity` needed to hold `totalVolume`, rounded up because a +// partial container still has to be a whole container. `leftover` is the unused capacity +// in the last container. Returns null for impossible inputs. +export function containersNeeded(totalVolume, capacity) { + if (!(totalVolume > 0) || !(capacity > 0)) { + return null; + } + const count = Math.ceil(totalVolume / capacity); + return {count, leftover: count * capacity - totalVolume}; +} + +// Approximate weight of a volume of water: ~8.345 lb per US gallon, or 1 kg per liter. +// Useful for shelving/vehicle planning. Returns null when the volume is not positive. +export function waterWeight(volume, metric) { + if (!(volume > 0)) { + return null; + } + return metric ? volume * 1 : volume * 8.345; +} + +// Human-friendly label for a duration in days: days up to a week, then weeks, then months, +// then whole years. Keeps the slider from showing awkward values like "68 days". +export function formatDuration(days) { + const d = Math.round(Number(days)); + if (!(d > 0)) { + return '—'; + } + if (d <= 7) { + return `${d} day${d === 1 ? '' : 's'}`; + } + if (d < 30) { + const weeks = Math.round(d / 7); + return `${weeks} week${weeks === 1 ? '' : 's'}`; + } + if (d < 360) { + const months = Math.round(d / 30); + return `${months} month${months === 1 ? '' : 's'}`; + } + const years = d / 360; + if (Number.isInteger(years)) { + return `${years} year${years === 1 ? '' : 's'}`; + } + const months = Math.round(d / 30); + return `${months} months`; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Per-category daily water need, including drinking plus hygiene and dish use. These are +// editable starting points. "Minimum" sits at the CDC/FEMA emergency floor of ~1 gallon +// per person per day (half drinking, half hygiene/cooking); men/women/children are scaled +// by the drinking portion using the National Academies adequate-intake figures (men ~3.7 L, +// women ~2.7 L of total water/day), while the hygiene/cooking half is shared. Pregnant and +// nursing women get the extra CDC/Ready.gov calls out (~+0.5 gal pregnant, ~+1 gal nursing; +// NAS lactating ~3.8 L). "Comfortable" roughly doubles the floor to allow real washing, +// cooking, and dishes. Metric values are the imperial values converted to liters and +// rounded; the component swaps tables on toggle. +export const WATER_PRESETS = { + minimum: { + label: 'Minimum', + description: 'CDC emergency floor (~1 gal/person/day)', + imperial: {men: 1.0, women: 0.9, children: 0.8, pregnant: 1.6}, // gallons/day + metric: {men: 3.8, women: 3.4, children: 3.0, pregnant: 6.1}, // liters/day + }, + comfortable: { + label: 'Comfortable', + description: 'Adds real washing, cooking, and dishes', + imperial: {men: 2.0, women: 1.8, children: 1.5, pregnant: 2.8}, + metric: {men: 7.6, women: 6.8, children: 5.7, pregnant: 10.6}, + }, +}; + +// Common water-storage containers, capacity in both gallons and liters. Ordered large to +// small so the dropdown reads from bulk storage down to single bottles. +export const CONTAINERS = [ + {key: 'ibc', label: 'IBC tote', gallons: 275, liters: 1041}, + {key: 'drum55', label: '55-gallon drum', gallons: 55, liters: 208}, + {key: 'drum30', label: '30-gallon drum', gallons: 30, liters: 114}, + {key: 'jug7', label: '7-gallon jug', gallons: 7, liters: 26.5}, + {key: 'bucket5', label: '5-gallon bucket', gallons: 5, liters: 18.9}, + {key: 'waterbrick', label: 'WaterBrick', gallons: 3.5, liters: 13.2}, + {key: 'jug1', label: '1-gallon jug', gallons: 1, liters: 3.785}, + {key: 'bottle2l', label: '2-liter soda bottle', gallons: 0.528, liters: 2}, + {key: 'bottle1l', label: '1-liter bottle', gallons: 0.264, liters: 1}, + {key: 'bottle500', label: '500 mL water bottle', gallons: 0.132, liters: 0.5}, +]; + +// Slider stops in days: daily for the first week, then weekly, then monthly (grouped by 30 +// per the "no awkward 68-day values" rule), out to a couple of years. +export const DURATION_STOPS = [ + 1, 2, 3, 4, 5, 6, 7, + 14, 21, + 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330, 360, + 540, 720, +]; + +// Explanation shown in the "Daily Use" info popup. Cites the figures the presets are +// built from so the numbers are traceable offline. +const DEMAND_INFO = 'Per-person daily water includes drinking plus basic hygiene and dish ' + + 'washing — not just drinking. The Minimum preset follows the CDC/FEMA emergency floor ' + + 'of about 1 gallon (3.8 L) per person per day (roughly half for drinking, half for ' + + 'hygiene and cooking), and both agencies recommend storing a 2-week supply. The ' + + 'men/women/children split scales the drinking portion using the U.S. National Academies ' + + 'adequate-intake figures (men ~3.7 L, women ~2.7 L of total water per day). The ' + + 'Pregnant/Nursing category adds the extra CDC/Ready.gov calls out — about +0.5 gallon ' + + 'for pregnant and +1 gallon for nursing women. CDC also advises storing more for sick ' + + 'people, pets, and hot climates — edit the rates to match your household.'; + +// Per-category colors (Paul Tol bright palette) so the count inputs read at a glance. +const CATEGORY_META = [ + {key: 'men', label: 'Men', icon: 'man', color: '#4477aa'}, + {key: 'women', label: 'Women', icon: 'woman', color: '#aa3377'}, + {key: 'children', label: 'Children', icon: 'child', color: '#228833'}, + {key: 'pregnant', label: 'Pregnant/Nursing', icon: 'heart', color: '#ee6677'}, +]; + +// Pick black or white label text for legibility on a given background color. +function textColorFor(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.6 ? '#000000' : '#ffffff'; +} + +// Format a positive number with a unit, or an em-dash when unavailable. +function fmt(value, unit) { + if (!Number.isFinite(value) || value <= 0) { + return '—'; + } + return unit ? `${roundDigits(value, 2)} ${unit}` : `${roundDigits(value, 2)}`; +} + +export function WaterCalculator() { + const [metric, setMetric] = useLocalStorage('water_calculator_metric', false); + const [preset, setPreset] = useLocalStorage('water_calculator_preset', 'minimum'); + const [counts, setCounts] = useLocalStorage('water_calculator_counts', + {men: '1', women: '1', children: '', pregnant: ''}); + const [rates, setRates] = React.useState({...WATER_PRESETS.minimum.imperial}); + const [durationIndex, setDurationIndex] = React.useState(DURATION_STOPS.indexOf(14)); + + // Reset the editable rates to the preset's defaults whenever the preset or unit changes. + React.useEffect(() => { + const p = WATER_PRESETS[preset]; + setRates(metric ? {...p.metric} : {...p.imperial}); + }, [preset, metric]); + + const volumeUnit = metric ? 'L' : 'gal'; + const rateUnit = metric ? 'L/day' : 'gal/day'; + const weightUnit = metric ? 'kg' : 'lb'; + const days = DURATION_STOPS[durationIndex]; + + const daily = dailyDemand(counts, rates); + const total = totalWater(daily, days); + const weight = waterWeight(total, metric); + + const inputProps = {fluid: true, type: 'number', onSelect: e => e.target.select(), autoComplete: 'off'}; + + // One count input per category, label colored to match. + const countInput = ({key, label, icon, color}) => + setCounts({...counts, [key]: e.target.value === '' ? '' : String(Math.max(0, Math.round(Number(e.target.value))))})} + label={}/>; + + // One editable rate input per category, sharing the category color. + const rateInput = ({key, label, color}) => + setRates({...rates, [key]: e.target.value})} + label={}/>; + + const presetButtons = setPreset(preset === 'comfortable' ? 'minimum' : 'comfortable')}/>; + + // One row per container: how many of it the total water would fill, and the spare + // capacity in the last one. Shown for every container so the user can compare. + const containerRows = CONTAINERS.map(c => { + const capacity = metric ? c.liters : c.gallons; + const need = total !== null ? containersNeeded(total, capacity) : null; + return + {c.label} + {capacity} {volumeUnit} + + {need === null + ? '—' + : {need.count} + {need.leftover > 0 && + ({fmt(need.leftover, volumeUnit)} spare)} + } + + ; + }); + + return
+
Water Storage
+ +
+ setMetric(!metric)}/> +
+
{presetButtons}
+ +
People
+ + {CATEGORY_META.map(meta => + {countInput(meta)})} + + +
+ Daily Use ({rateUnit} per person) +
+ + {CATEGORY_META.map(meta => + {rateInput(meta)})} + + +
How long?
+
+ setDurationIndex(Number(e.target.value))} + style={{width: '100%'}}/> +
+ {formatDuration(days)} +
+
+ + + + + + Daily Use + {fmt(daily, `${volumeUnit}/day`)} + + + Total Water for {formatDuration(days)} + {fmt(total, volumeUnit)} + + + Total Weight + {fmt(weight, weightUnit)} + + +
+ +
Containers Needed
+ + + + Container + Capacity + Needed + + + {containerRows} +
+
+
; +} diff --git a/app/src/components/calculators/WaterCalculator.test.js b/app/src/components/calculators/WaterCalculator.test.js new file mode 100644 index 00000000..9d84ad05 --- /dev/null +++ b/app/src/components/calculators/WaterCalculator.test.js @@ -0,0 +1,157 @@ +import { + CONTAINERS, + containersNeeded, + dailyDemand, + DURATION_STOPS, + formatDuration, + totalWater, + WATER_PRESETS, + waterWeight, +} from "./WaterCalculator"; + +describe('dailyDemand', () => { + test('sums each category count times its rate', () => { + // 2 men * 1 + 1 woman * 0.9 + 3 children * 0.5 = 2 + 0.9 + 1.5 = 4.4 + const demand = dailyDemand({men: 2, women: 1, children: 3}, {men: 1, women: 0.9, children: 0.5}); + expect(demand).toBeCloseTo(4.4, 5); + }); + + test('includes the pregnant/nursing category', () => { + // 1 man * 1 + 1 pregnant * 1.6 = 2.6 + const demand = dailyDemand({men: 1, women: 0, children: 0, pregnant: 1}, + {men: 1, women: 0.9, children: 0.8, pregnant: 1.6}); + expect(demand).toBeCloseTo(2.6, 5); + }); + + test('ignores blank, zero, and negative counts/rates', () => { + expect(dailyDemand({men: '', women: 0, children: -2}, {men: 1, women: 1, children: 1})).toBe(0); + // A category with a count but no rate contributes nothing. + expect(dailyDemand({men: 5, women: 1, children: 0}, {men: '', women: 2, children: 5})).toBe(2); + }); + + test('handles string inputs from form fields', () => { + expect(dailyDemand({men: '1', women: '1', children: ''}, {men: '1.0', women: '0.9', children: '0.6'})) + .toBeCloseTo(1.9, 5); + }); +}); + +describe('totalWater', () => { + test('multiplies daily demand by the number of days', () => { + expect(totalWater(4, 14)).toBe(56); + }); + + test('returns null when demand or days is not positive', () => { + expect(totalWater(0, 14)).toBeNull(); + expect(totalWater(4, 0)).toBeNull(); + expect(totalWater(NaN, 14)).toBeNull(); + }); +}); + +describe('containersNeeded', () => { + test('rounds up to a whole number of containers and reports the spare capacity', () => { + // 56 gallons into 5-gallon buckets -> 12 buckets (60 gal), 4 gal spare. + expect(containersNeeded(56, 5)).toEqual({count: 12, leftover: 4}); + }); + + test('an exact fit has no leftover', () => { + expect(containersNeeded(110, 55)).toEqual({count: 2, leftover: 0}); + }); + + test('any positive volume needs at least one container', () => { + expect(containersNeeded(0.1, 55)).toEqual({count: 1, leftover: 54.9}); + }); + + test('returns null for impossible inputs', () => { + expect(containersNeeded(0, 5)).toBeNull(); + expect(containersNeeded(10, 0)).toBeNull(); + }); +}); + +describe('waterWeight', () => { + test('imperial uses ~8.345 lb per gallon', () => { + expect(waterWeight(10, false)).toBeCloseTo(83.45, 2); + }); + + test('metric uses 1 kg per liter', () => { + expect(waterWeight(10, true)).toBe(10); + }); + + test('returns null for non-positive volume', () => { + expect(waterWeight(0, false)).toBeNull(); + }); +}); + +describe('formatDuration', () => { + test('labels by the appropriate unit and groups cleanly', () => { + expect(formatDuration(1)).toBe('1 day'); + expect(formatDuration(5)).toBe('5 days'); + expect(formatDuration(7)).toBe('7 days'); + expect(formatDuration(14)).toBe('2 weeks'); + expect(formatDuration(21)).toBe('3 weeks'); + expect(formatDuration(30)).toBe('1 month'); + expect(formatDuration(90)).toBe('3 months'); + expect(formatDuration(360)).toBe('1 year'); + expect(formatDuration(540)).toBe('18 months'); + expect(formatDuration(720)).toBe('2 years'); + }); + + test('non-positive durations show an em-dash', () => { + expect(formatDuration(0)).toBe('—'); + }); + + test('every slider stop produces a label (never an awkward day count past a month)', () => { + DURATION_STOPS.forEach(days => { + const label = formatDuration(days); + expect(label).not.toBe('—'); + // Beyond a month we should never show a raw "N days" label. + if (days > 30) { + expect(label).not.toMatch(/day/); + } + }); + }); +}); + +describe('constants', () => { + const CATEGORIES = ['men', 'women', 'children', 'pregnant']; + + test('presets define imperial and metric rates for every category', () => { + for (const preset of Object.values(WATER_PRESETS)) { + for (const system of [preset.imperial, preset.metric]) { + for (const cat of CATEGORIES) { + expect(system[cat]).toBeGreaterThan(0); + } + } + } + }); + + test('comfortable preset is wetter than the minimum for every category', () => { + const {minimum, comfortable} = WATER_PRESETS; + for (const cat of CATEGORIES) { + expect(comfortable.imperial[cat]).toBeGreaterThan(minimum.imperial[cat]); + expect(comfortable.metric[cat]).toBeGreaterThan(minimum.metric[cat]); + } + }); + + test('pregnant/nursing rate exceeds the baseline woman rate', () => { + for (const preset of Object.values(WATER_PRESETS)) { + expect(preset.imperial.pregnant).toBeGreaterThan(preset.imperial.women); + expect(preset.metric.pregnant).toBeGreaterThan(preset.metric.women); + } + }); + + test('every container has a unique key and positive capacities', () => { + const keys = CONTAINERS.map(c => c.key); + expect(new Set(keys).size).toBe(keys.length); + CONTAINERS.forEach(c => { + expect(c.gallons).toBeGreaterThan(0); + expect(c.liters).toBeGreaterThan(0); + }); + }); + + test('duration stops are strictly increasing and start at one day', () => { + expect(DURATION_STOPS[0]).toBe(1); + for (let i = 1; i < DURATION_STOPS.length; i++) { + expect(DURATION_STOPS[i]).toBeGreaterThan(DURATION_STOPS[i - 1]); + } + }); +});