/* ============================================================================
MOWDEW — Water chemistry across all three personas
The pool's chemistry doubles as a HOMEOWNER TRUST signal: competitors keep
it pro-side; Mowdew shows the homeowner a plain, reassuring verdict.
• PRO ProLogChem — fast numeric entry inside a visit (mobile)
• HOME ChemVerdictHero — "Water: Balanced ✓" star card (used by h_chem)
• OWNER OwnerChemHistory — per-property reading timeline + chronic flag
• OWNER OwnerConfigReadings — toggle tracked readings / units / ideal ranges
Extends the chemistry primitives in mowdew-kit.jsx (READINGS, computeLSI…).
========================================================================= */
/* 8 verified visits for 88 Crestline Ave (Emma) — newest first. Two early
blips the pro already handled; CYA quietly creeping high (chronic, owner-side). */
const POOL_CHEM_LOG = [
{ id: 'k1', date: 'Today · May 12', short: 'May 12', visit: 'v1', live: true,
ph: 7.4, cl: 1.8, alk: 100, cya: 58, salt: 3200, ch: 300, temp: 80 },
{ id: 'k2', date: 'Mon · May 5', short: 'May 5', visit: 'v2',
ph: 7.5, cl: 2.1, alk: 110, cya: 55, salt: 3150, ch: 290, temp: 79 },
{ id: 'k3', date: 'Mon · Apr 28', short: 'Apr 28', visit: 'v3', handled: 'cl',
ph: 7.2, cl: 0.8, alk: 95, cya: 53, salt: 3100, ch: 295, temp: 78 },
{ id: 'k4', date: 'Mon · Apr 21', short: 'Apr 21', visit: 'v4', handled: 'ph',
ph: 7.8, cl: 2.6, alk: 120, cya: 50, salt: 3250, ch: 310, temp: 77 },
{ id: 'k5', date: 'Mon · Apr 14', short: 'Apr 14', visit: 'v4',
ph: 7.6, cl: 1.9, alk: 105, cya: 47, salt: 3200, ch: 300, temp: 76 },
{ id: 'k6', date: 'Mon · Apr 7', short: 'Apr 7', visit: 'v4',
ph: 7.3, cl: 1.2, alk: 90, cya: 45, salt: 3050, ch: 285, temp: 75 },
{ id: 'k7', date: 'Mon · Mar 31', short: 'Mar 31', visit: 'v4',
ph: 7.5, cl: 2.0, alk: 100, cya: 46, salt: 3180, ch: 305, temp: 74 },
{ id: 'k8', date: 'Mon · Mar 24', short: 'Mar 24', visit: 'v4',
ph: 7.4, cl: 1.6, alk: 108, cya: 44, salt: 3120, ch: 300, temp: 73 },
];
/* default tracked set + which readings the homeowner verdict leans on */
const CHEM_TRACKED = ['ph', 'cl', 'alk', 'cya', 'salt', 'ch', 'temp'];
const CHEM_SANITIZER = ['ph', 'cl', 'alk']; // the readings that drive the homeowner verdict
function chemTone(key, val) {
const st = chemState(key, val);
return st === 'ideal' ? 'ideal' : st === 'low' ? 'low' : st === 'high' ? 'high' : 'empty';
}
const CHEM_STATE_C = {
ideal: { bg: 'var(--green-50)', bd: 'var(--green-100)', fg: 'var(--green-700)' },
low: { bg: 'var(--yellow-50)', bd: 'var(--yellow-100)', fg: 'var(--yellow-700)' },
high: { bg: '#FBEAE3', bd: '#F6D6C8', fg: 'var(--coral-700,#C2410C)' },
empty: { bg: 'var(--ink-50)', bd: 'var(--ink-100)', fg: 'var(--ink-500)' },
};
/* ─── LSI chip — balance verdict, labelled an estimate ──────────────── */
function LsiChip({ lsi, size = 'md' }) {
const v = lsiVerdict(lsi);
const c = v.key === 'balanced' ? ['var(--green-50)', 'var(--green-100)', 'var(--green-700)']
: v.key === 'corrosive' ? ['var(--yellow-50)', 'var(--yellow-100)', 'var(--yellow-700)']
: v.key === 'scaling' ? ['var(--chem-50)', '#DDD7FA', '#5847d6']
: ['var(--ink-50)', 'var(--ink-100)', 'var(--ink-500)'];
const big = size === 'lg';
return (
{v.label}
{lsi != null && LSI {lsi >= 0 ? '+' : ''}{lsi.toFixed(2)} }
EST
);
}
/* ─── HOMEOWNER verdict hero — the star. Plain, reassuring, never scary. ─ */
function ChemVerdictHero({ reading, size = 'lg' }) {
const lsi = computeLSI(reading);
const v = lsiVerdict(lsi);
// any sanitizer reading out of range?
const outs = CHEM_SANITIZER.filter(k => reading[k] != null && chemState(k, reading[k]) !== 'ideal');
const handledKey = reading.handled;
const allGood = outs.length === 0 && v.key === 'balanced';
const headline = allGood ? 'Water: Balanced' : v.key === 'balanced' ? 'Water: Looked after' : `Water: ${v.label}`;
let line;
if (allGood) line = "Everything's in range — your pool's healthy.";
else if (handledKey) {
const r = READINGS[handledKey];
const word = handledKey === 'cl' ? 'Chlorine was a touch low today' : handledKey === 'ph' ? 'pH ran a little high today' : `${r.label} was off today`;
line = `${word} — Diego ${handledKey === 'cl' ? 'added tablets' : 'balanced it'} on the visit. All set now.`;
} else if (outs.length) {
line = "A couple of levels are settling in — your pro's on it. Nothing for you to do.";
} else line = "Your pool's in good shape.";
return (
{headline}
{allGood && }
{(reading.date || 'TODAY').toUpperCase()} · VERIFIED ON-SITE
{line}
Water-balance estimate · your pro confirms it
);
}
/* ─── Reading display row — used by homeowner detail + owner timeline ── */
function ChemRow({ rkey, value, note, showBand = true }) {
const r = READINGS[rkey];
const st = chemState(rkey, value);
const c = CHEM_STATE_C[st];
const txt = st === 'ideal' ? 'In range' : st === 'low' ? 'Low' : st === 'high' ? 'High' : '—';
return (
{r.label}
{showBand ? `IDEAL ${r.band}` : ''}{note ? (showBand ? ' · ' : '') + note : ''}
{value == null ? '—' : value.toFixed(r.dp)}{r.unit && value != null ? {r.unit} : null}
{txt.toUpperCase()}
);
}
/* ============================================================================
PRO — Log water chemistry (mobile, inside a visit)
========================================================================= */
function ProLogChem({ go, params, toast }) {
const stop = (window.PRO_STOPS || []).find(s => s.id === params.id) || { label: 'Crestline Ave', vert: 'pool', in: '10:12 AM' };
const tracked = CHEM_TRACKED;
const [vals, setVals] = React.useState(() => Object.fromEntries(tracked.map(k => [k, ''])));
const [photo, setPhoto] = React.useState(false);
const [added, setAdded] = React.useState({}); // dosing keys marked done
const set = (k, v) => setVals(s => ({ ...s, [k]: v }));
const num = (k) => { const n = parseFloat(vals[k]); return isNaN(n) ? null : n; };
const reading = Object.fromEntries(tracked.map(k => [k, num(k)]));
const entered = tracked.filter(k => num(k) != null);
const filled = entered.length;
const lsi = reading.ph != null ? computeLSI(reading) : null;
const outKeys = tracked.filter(k => num(k) != null && chemState(k, num(k)) !== 'ideal');
const dosing = dosingFor(reading).filter(d => !added[d.key]);
const allIn = filled === tracked.length && outKeys.length === 0;
const scan = () => {
// "snap test kit" autofill — one reading comes in a touch low to show the calm out-of-range + dosing flow
setVals({ ph: '7.4', cl: '0.8', alk: '100', cya: '45', salt: '3200', ch: '300', temp: '80' });
setPhoto(true);
toast('Test kit read · check the values', 'blue');
};
const markAdded = (d) => {
setAdded(a => ({ ...a, [d.key]: true }));
// optimistic: bring that reading into range so the celebratory state can show
if (d.key === 'cl') set('cl', '2.0');
if (d.key === 'ph') set('ph', '7.4');
if (d.key === 'alk') set('alk', '100');
toast('Marked as added · re-test after circulation', 'green');
};
const save = () => { toast('Readings saved · shared with the homeowner', 'green'); go('pro_visit', { id: params.id }); };
return (
{filled === 0 ? 'Enter a reading to save' : `Save ${filled} reading${filled > 1 ? 's' : ''}`}
}>
go('pro_visit', { id: params.id })}
right={ Private } />
{/* scan / photo */}
{photo ? 'Test kit photo attached' : 'Snap the test kit'}
{photo ? 'Values pre-filled — confirm below' : 'Auto-fills the readings · or enter by hand'}
{photo ?
:
}
{/* numeric entry list */}
Readings
{tracked.map((k, i) => set(k, v)} top={i > 0} />)}
Saved readings are shared with the homeowner as a plain verdict.
{/* state banner */}
{filled === 0 ? (
Enter readings as you test — or snap the kit to fill them in. The balance verdict appears here.
) : (
<>
{/* LSI balance verdict */}
Water balance
LANGELIER INDEX · ESTIMATE
{/* all-in-range celebration OR calm out-of-range */}
{allIn ? (
All readings in range
Nice — this pool's dialed in. Save and the homeowner sees a clean bill.
) : outKeys.length > 0 && (
{outKeys.length} TO ADJUST · NOT URGENT
{dosing.length > 0 ? (
{dosing.map(d => (
{d.text}
{d.why} · EST for ~15k gal
markAdded(d)} className="btn btn-secondary btn-block" style={{ marginTop: 11, minHeight: 42 }}> Mark as added
))}
) : (
Treatments marked added. Re-test after the water circulates.
)}
)}
>
)}
);
}
/* fast numeric entry row — label + big value + unit + ideal band + live tint */
function ChemEntryRow({ rkey, value, onChange, top }) {
const r = READINGS[rkey];
const n = value === '' || value == null ? null : parseFloat(value);
const st = n == null || isNaN(n) ? 'empty' : chemState(rkey, n);
const c = CHEM_STATE_C[st];
return (
{r.label}
{st === 'empty' ? `IDEAL ${r.band}` : st === 'ideal' ? 'IN RANGE' : st === 'low' ? `LOW · IDEAL ${r.band}` : `HIGH · IDEAL ${r.band}`}
onChange(e.target.value.replace(/[^0-9.]/g, ''))}
placeholder="—" aria-label={r.label}
style={{ width: 74, textAlign: 'right', border: `1.5px solid ${st === 'empty' ? 'var(--ink-200)' : c.bd}`, borderRadius: 11, padding: '9px 10px', fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 20, letterSpacing: '-0.02em', color: st === 'empty' ? 'var(--ink-900)' : c.fg, background: '#fff', outline: 'none', fontVariantNumeric: 'tabular-nums' }}
/>
{r.unit ? {r.unit} : }
);
}
/* ============================================================================
OWNER — Chemistry history per property (web + mobile)
========================================================================= */
function chronicFlags(log) {
const recent = log.slice(0, 6);
const flags = {};
CHEM_TRACKED.forEach(k => {
if (READINGS[k].soft) return;
const out = recent.filter(r => r[k] != null && chemState(k, r[k]) !== 'ideal').length;
if (out >= 3) flags[k] = out;
});
return flags;
}
function OwnerChemHistory({ go, params, toast }) {
const log = POOL_CHEM_LOG;
const cols = ['ph', 'cl', 'alk', 'cya']; // compact grid columns; full set in expand
const [openId, setOpenId] = React.useState(null);
const flags = chronicFlags(log);
const flagKeys = Object.keys(flags);
const latest = log[0];
return (
go('o_chem_config')} className="btn btn-secondary" style={{ flex: 1 }}> Configure readings
toast('Chemistry report exported', 'green')} className="btn btn-primary" style={{ flex: 1 }}> Export
}>
go('o_customer', { id: params.id || 'c2' })} />
{/* latest snapshot */}
Latest · {latest.date.replace('Today · ', '')}
Verified on-site
{cols.map(k => {
const st = chemState(k, latest[k]); const c = CHEM_STATE_C[st];
return (
{READINGS[k].abbr}
{latest[k].toFixed(READINGS[k].dp)}
);
})}
{/* chronic warning */}
{flagKeys.length > 0 && (
{flagKeys.map(k => READINGS[k].label).join(' & ')} chronically out of range
{flagKeys.includes('cya') ? 'CYA has crept above 50 ppm for three visits running — stabilizer builds up over time. Plan a partial drain & refill to reset it.' : 'Out of range on 3+ recent visits — worth a closer look on the next service.'}
)}
{/* per-visit timeline */}
Every visit, on the record
{log.map((r, i) => {
const open = openId === r.id;
const outs = cols.filter(k => chemState(k, r[k]) !== 'ideal');
return (
setOpenId(open ? null : r.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', cursor: 'pointer', background: open ? 'var(--ink-50)' : '#fff' }}>
{r.short}
{r.live &&
TODAY
}
{/* compact cells */}
{cols.map(k => {
const st = chemState(k, r[k]); const c = CHEM_STATE_C[st];
return (
{READINGS[k].abbr}
{r[k].toFixed(READINGS[k].dp)}
);
})}
{open && (
{CHEM_TRACKED.map((k, j) => )}
{r.handled && (
Handled on-site — {READINGS[r.handled].label} brought back in range that visit.
)}
)}
);
})}
);
}
/* ============================================================================
OWNER — Configure readings (setting): toggle tracked, units, ideal ranges
========================================================================= */
function OwnerConfigReadings({ go, toast }) {
const POOL_DEFAULT = ['ph', 'cl', 'alk', 'cya', 'salt', 'ch', 'temp'];
const [on, setOn] = React.useState(() => Object.fromEntries(CHEM_TRACKED.map(k => [k, true])));
const toggle = (k) => setOn(s => ({ ...s, [k]: !s[k] }));
const count = Object.values(on).filter(Boolean).length;
return (
{ toast('Reading set saved · applies to new pool visits', 'green'); go('o_settings'); }} className="btn btn-primary btn-lg btn-block"> Save reading set
}>
go('o_settings')} />
{count} readings tracked. Sensible pool defaults are on. Toggle what your crew measures, with ideal ranges.
Tracked readings
{CHEM_TRACKED.map((k, i) => {
const r = READINGS[k];
const active = on[k];
return (
{r.abbr}
{r.label}
{r.band}{r.soft ? ' · reference only' : ''}
toggle(k)} tone="green" />
);
})}
Ranges follow industry pool norms. The homeowner only ever sees a plain verdict — not the raw set.
);
}
Object.assign(window, {
POOL_CHEM_LOG, CHEM_TRACKED, CHEM_SANITIZER, chemTone, CHEM_STATE_C,
LsiChip, ChemVerdictHero, ChemRow, ChemEntryRow,
ProLogChem, OwnerChemHistory, OwnerConfigReadings, chronicFlags,
});