Growth chart: flexible age input with smart parser

Replace [years] + [months dropdown] with a single text field that
accepts:
  3y / 3 years / 3 yr
  29m / 29 months / 29 mo
  2y5m / 2 years 5 months / 2 yr 5 mo
  3.5 years / 36 (plain number = months)
  15 days / 2 weeks / 3y 2m 10d

Enables fractional ages so newborns can be plotted accurately
(WHO/CDC growth curves are continuous — "0 months" means at birth,
not a 0-27 day bucket, so a 15-day-old should plot at ~0.5 months).

Live hint below the field shows how the input was interpreted
("= 2 yr 5 mo (29 mo total)").
This commit is contained in:
Daniel 2026-04-14 03:17:16 +02:00
parent 3e05d8eec9
commit 42984e355b
2 changed files with 67 additions and 14 deletions

View file

@ -216,14 +216,8 @@
</div>
<div class="calc-field" id="growth-age-field">
<label>Age</label>
<div style="display:flex;gap:4px;align-items:center;">
<input type="number" id="growth-age-yr" min="0" max="20" step="1" placeholder="yr" style="width:40%;">
<select id="growth-age-mo" style="width:55%;padding:8px 4px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;">
<option value="0">0 mo</option><option value="1">1 mo</option><option value="2">2 mo</option><option value="3">3 mo</option>
<option value="4">4 mo</option><option value="5">5 mo</option><option value="6">6 mo</option><option value="7">7 mo</option>
<option value="8">8 mo</option><option value="9">9 mo</option><option value="10">10 mo</option><option value="11">11 mo</option>
</select>
</div>
<input type="text" id="growth-age" placeholder="e.g. 2y 5m, 29 months, 3 years, 15 days" autocomplete="off">
<div id="growth-age-parsed" style="font-size:11px;color:var(--g500);margin-top:4px;min-height:14px;"></div>
</div>
<div class="calc-field" id="growth-ga-field" style="display:none;">
<label>Gestational Age (weeks)</label>

View file

@ -10,6 +10,52 @@
initCalculators();
});
// ── Flexible age parser ─────────────────────────────────────────────
// Accepts: "3y", "3 years", "29m", "29 months", "2y5m", "2 years 5 months",
// "15d", "15 days", "3y 2m 10d", "36" (months), "3.5 years", etc.
// Returns age in months (may be fractional), or null if unparseable.
function parseAgeMonths(input) {
if (input == null) return null;
var s = String(input).toLowerCase().trim();
if (!s) return null;
// Plain number with no unit: if >= 24 assume years, else months. Matches
// common shorthand ("36" for 3y, "6" for 6 months).
if (/^[\d.]+$/.test(s)) {
var n = parseFloat(s);
if (isNaN(n)) return null;
return n >= 24 ? n : n; // keep as months; user can add unit if they meant years
}
var total = 0;
var matched = false;
// Use (?![a-z]) instead of \b so "2y5m" terminates "y" correctly.
// Order matters: longest-first alternations (years before yrs before y, etc.).
var my = s.match(/([\d.]+)\s*(?:years|year|yrs|yr|y)(?![a-z])/);
if (my) { total += parseFloat(my[1]) * 12; matched = true; }
var mm = s.match(/([\d.]+)\s*(?:months|month|mos|mo|m)(?![a-z])/);
if (mm) { total += parseFloat(mm[1]); matched = true; }
var mw = s.match(/([\d.]+)\s*(?:weeks|week|wks|wk|w)(?![a-z])/);
if (mw) { total += parseFloat(mw[1]) * 7 / 30.4375; matched = true; }
var md = s.match(/([\d.]+)\s*(?:days|day|d)(?![a-z])/);
if (md) { total += parseFloat(md[1]) / 30.4375; matched = true; }
return matched ? total : null;
}
function formatParsedAge(months) {
if (months < 1) {
var d = Math.round(months * 30.4375);
return '= ' + d + ' day' + (d === 1 ? '' : 's') + ' (' + months.toFixed(2) + ' mo)';
}
if (months < 24) {
return '= ' + (Math.round(months * 10) / 10) + ' months';
}
var yr = Math.floor(months / 12);
var mo = Math.round(months - yr * 12);
if (mo === 12) { yr += 1; mo = 0; }
return '= ' + yr + ' yr' + (mo ? ' ' + mo + ' mo' : '') + ' (' + Math.round(months) + ' mo total)';
}
// Expose for debugging / other modules
window._parseAgeMonths = parseAgeMonths;
function initCalculators() {
// ── Top-level pill navigation (scoped to .calc-nav-pill only) ──
document.querySelectorAll('.calc-nav-pill').forEach(function(pill) {
@ -1105,10 +1151,9 @@
return;
}
var growthYr = parseFloat(document.getElementById('growth-age-yr').value) || 0;
var growthMo = parseInt(document.getElementById('growth-age-mo').value) || 0;
var age = growthYr * 12 + growthMo; // age in months
if (isNaN(age)) { showToast('Enter age in months', 'error'); return; }
var rawAge = (document.getElementById('growth-age').value || '').trim();
var age = parseAgeMonths(rawAge); // age in months, may be fractional
if (age == null || isNaN(age) || age < 0) { showToast('Enter age (e.g. 2y 5m, 29 months, 15 days)', 'error'); return; }
if (currentGrowthType === 'wfa') {
var wt = parseFloat(document.getElementById('growth-weight').value);
@ -1207,13 +1252,27 @@
});
document.getElementById('btn-clear-growth').addEventListener('click', function() {
['growth-sex', 'growth-age-yr', 'growth-ga', 'growth-weight', 'growth-length', 'growth-hc'].forEach(function(id) {
['growth-sex', 'growth-age', 'growth-ga', 'growth-weight', 'growth-length', 'growth-hc'].forEach(function(id) {
var el = document.getElementById(id); if (el) el.value = '';
});
var gaMo = document.getElementById('growth-age-mo'); if (gaMo) gaMo.value = '0';
var hint = document.getElementById('growth-age-parsed'); if (hint) hint.textContent = '';
document.getElementById('growth-result').classList.add('hidden');
});
// Live feedback as user types the age
var ageEl = document.getElementById('growth-age');
var ageHint = document.getElementById('growth-age-parsed');
if (ageEl && ageHint) {
ageEl.addEventListener('input', function() {
var v = ageEl.value.trim();
if (!v) { ageHint.textContent = ''; return; }
var m = parseAgeMonths(v);
if (m == null || isNaN(m) || m < 0) { ageHint.textContent = 'Unrecognized format'; ageHint.style.color = '#ef4444'; return; }
ageHint.style.color = 'var(--g500)';
ageHint.textContent = formatParsedAge(m);
});
}
// ═══════════════════════════════════════════════════════════
// BILIRUBIN — AAP 2022 Phototherapy + Bhutani Nomogram
// ═══════════════════════════════════════════════════════════