// ==================== Baby Tracker Utilities ==================== window.BabyUtils = (() => { 'use strict'; // --- Constants --- const TYPE_LABELS = { milk: '喝奶', food: '辅食', poo: '便便', meds: '药品' }; const TYPE_ICONS = { milk: '🍼', food: '🥣', poo: '💩', meds: '💊' }; const DAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const CN_OFFSET_MS = 8 * 60 * 60 * 1000; const AVG_DAYS_PER_MONTH = 30.4375; // --- Standard Growth Data (WS/T 423-2022 Boy) --- // Each row: [P3, P15, P50, P85, P97] const STANDARD_DATA_BOY = { months: [0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 18, 21, 24], weight: [ [2.58, 2.96, 3.38, 3.84, 4.31], [3.58, 4.04, 4.54, 5.08, 5.61], [4.46, 5.03, 5.64, 6.30, 6.94], [5.17, 5.82, 6.51, 7.25, 7.96], [5.73, 6.44, 7.19, 7.99, 8.76], [6.17, 6.93, 7.73, 8.58, 9.39], [6.53, 7.33, 8.17, 9.06, 9.91], [7.09, 7.95, 8.86, 9.81, 10.73], [7.53, 8.44, 9.40, 10.42, 11.39], [7.90, 8.86, 9.86, 10.93, 11.96], [8.40, 9.41, 10.48, 11.64, 12.75], [8.88, 9.96, 11.09, 12.33, 13.53], [9.35, 10.51, 11.71, 13.04, 14.32], [9.83, 11.06, 12.33, 13.74, 15.12] ], height: [ [46.6, 48.2, 50.0, 51.8, 53.3], [50.9, 52.6, 54.5, 56.4, 58.0], [54.3, 56.1, 58.1, 60.1, 61.8], [57.0, 58.9, 61.0, 63.1, 64.9], [59.2, 61.2, 63.4, 65.5, 67.4], [61.1, 63.1, 65.4, 67.6, 69.5], [62.7, 64.8, 67.1, 69.4, 71.3], [65.5, 67.7, 70.1, 72.5, 74.5], [67.9, 70.1, 72.6, 75.1, 77.3], [69.9, 72.3, 74.9, 77.5, 79.9], [72.7, 75.2, 78.0, 80.8, 83.4], [75.2, 77.9, 80.9, 83.9, 86.6], [77.5, 80.4, 83.6, 86.8, 89.7], [79.8, 82.8, 86.2, 89.6, 92.7] ], head: [ [32.3, 33.1, 34.0, 34.9, 35.7], [35.3, 36.1, 37.0, 37.9, 38.8], [37.1, 38.0, 38.9, 39.8, 40.7], [38.4, 39.3, 40.3, 41.2, 42.1], [39.4, 40.3, 41.3, 42.3, 43.2], [40.2, 41.2, 42.2, 43.2, 44.1], [40.9, 41.9, 42.9, 43.9, 44.9], [42.0, 43.0, 44.1, 45.1, 46.1], [42.8, 43.9, 45.0, 46.1, 47.1], [43.5, 44.6, 45.7, 46.9, 47.9], [44.3, 45.4, 46.6, 47.8, 48.9], [45.0, 46.1, 47.3, 48.5, 49.7], [45.5, 46.7, 47.9, 49.1, 50.3], [46.0, 47.2, 48.4, 49.7, 50.9] ], bmi: [ [12.2, 13.4, 14.6, 15.9, 17.3], [12.4, 13.6, 14.9, 16.3, 17.8], [13.7, 15.0, 16.3, 17.8, 19.4], [14.3, 15.5, 16.9, 18.4, 20.0], [14.5, 15.8, 17.2, 18.7, 20.3], [14.7, 15.9, 17.3, 18.8, 20.5], [14.7, 16.0, 17.3, 18.8, 20.5], [14.7, 15.9, 17.3, 18.7, 20.4], [14.6, 15.7, 17.0, 18.5, 20.1], [14.4, 15.5, 16.8, 18.2, 19.8], [14.1, 15.2, 16.4, 17.8, 19.4], [13.9, 14.9, 16.1, 17.5, 19.0], [13.7, 14.7, 15.9, 17.2, 18.7], [13.6, 14.6, 15.7, 17.0, 18.5] ] }; // --- Date / Time (always Asia/Shanghai) --- /** Current time as ISO string shifted to UTC+8 wall-clock representation */ function getNowCNStr() { return new Date(Date.now() + CN_OFFSET_MS).toISOString(); } /** YYYY-MM-DD in Asia/Shanghai */ function getTodayCN() { return getNowCNStr().slice(0, 10); } /** YYYY-MM-DD of yesterday in Asia/Shanghai */ function getYesterdayCN() { const d = new Date(Date.now() + CN_OFFSET_MS); d.setUTCDate(d.getUTCDate() - 1); return d.toISOString().slice(0, 10); } /** Format ISO timestamp → HH:mm in Asia/Shanghai */ function formatTimeCN(iso) { return new Date(iso).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Shanghai' }); } /** datetime-local value (YYYY-MM-DDTHH:mm) → UTC ISO string (assumes +08:00) */ function toISOFromCNLocal(datetimeLocal) { if (!datetimeLocal) return new Date().toISOString(); // Ensure seconds are present const withSec = datetimeLocal.length === 16 ? `${datetimeLocal}:00` : datetimeLocal; return new Date(`${withSec}+08:00`).toISOString(); } /** Day-of-week label for a YYYY-MM-DD string (no timezone ambiguity) */ function getDayLabel(dateStr) { // Append T12:00:00 to avoid DST/UTC edge cases around midnight const d = new Date(`${dateStr}T12:00:00+08:00`); return DAY_LABELS[d.getDay()] || ''; } /** Add N calendar days to a YYYY-MM-DD string, return YYYY-MM-DD */ function addDays(dateStr, days) { const d = new Date(`${dateStr}T12:00:00+08:00`); d.setDate(d.getDate() + days); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } /** Age in months between two Date objects */ function getMonthDiff(dateFrom, dateTo) { return parseFloat(((dateTo - dateFrom) / (1000 * 60 * 60 * 24 * AVG_DAYS_PER_MONTH)).toFixed(2)); } /** * Human-readable age string relative to a date string (YYYY-MM-DD). * e.g. "1岁2个月5天" */ function getAgeDisplay(birthDateStr, currentDateStr) { if (!birthDateStr) return ''; const birth = parseFlexibleDate(birthDateStr); const current = new Date(`${currentDateStr}T12:00:00+08:00`); birth.setHours(0, 0, 0, 0); current.setHours(0, 0, 0, 0); if (current < birth) return '👶 等待出生'; if (current.getTime() === birth.getTime()) return '🎂 出生当天'; let years = current.getFullYear() - birth.getFullYear(); let months = current.getMonth() - birth.getMonth(); let days = current.getDate() - birth.getDate(); if (days < 0) { months--; const lastMonth = new Date(current.getFullYear(), current.getMonth(), 0); days += lastMonth.getDate(); } if (months < 0) { years--; months += 12; } let str = ''; if (years > 0) str += years + '岁'; if (months > 0) str += months + '个月'; str += days + '天'; return str; } /** Parse 'YYYY/MM/DD' or 'YYYY-MM-DD' into a local Date at midnight */ function parseFlexibleDate(str) { const normalized = String(str).replace(/\//g, '-'); const [y, m, d] = normalized.split('-').map(Number); return new Date(y, m - 1, d); } // --- Display helpers --- function formatType(t) { return TYPE_LABELS[t] || t; } function getIcon(t) { return TYPE_ICONS[t] || ''; } function isVitDRecord(row) { if (!row || row.type !== 'meds') return false; const sub = row.sub_type || ''; return sub.includes('维D') || sub.includes('Vitamin') || sub.includes('VD'); } /** Human-readable elapsed time since an ISO timestamp */ function getTimeDiff(recordTime, nowMs = Date.now()) { const diff = nowMs - new Date(recordTime).getTime(); if (diff < 0) return '刚刚'; const totalMinutes = Math.floor(diff / 60000); if (totalMinutes < 1) return '刚刚'; if (totalMinutes < 60) return `${totalMinutes}分钟`; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours < 24) return minutes > 0 ? `${hours}小时${minutes}分` : `${hours}小时`; const days = Math.floor(hours / 24); return `${days}天${hours % 24}小时`; } // --- Growth chart standard series --- function generateStandardSeries(dataKey) { // Recessive neutral greys — percentile bands stay behind baby data const labels = ['P3', 'P15', 'P50', 'P85', 'P97']; const lineStyles = ['dashed', 'solid', 'solid', 'solid', 'dashed']; const widths = [1, 1, 1.5, 1, 1]; const colors = ['#D9D3CA', '#C4BDB4', '#9A938A', '#C4BDB4', '#D9D3CA']; const data = STANDARD_DATA_BOY[dataKey]; if (!data) return []; return labels.map((name, i) => ({ name, type: 'line', data: STANDARD_DATA_BOY.months.map((m, idx) => [m, data[idx][i]]), smooth: true, showSymbol: false, lineStyle: { color: colors[i], width: widths[i], type: lineStyles[i] }, animation: false, emphasis: { disabled: true }, silent: true })); } // --- Misc utilities --- function debounce(fn, delay = 300) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } async function copyToClipboard(text) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch { // fall through to legacy path } // Fallback for older browsers / non-HTTPS try { const textArea = document.createElement('textarea'); textArea.value = text; textArea.setAttribute('readonly', ''); textArea.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const ok = document.execCommand('copy'); document.body.removeChild(textArea); return ok; } catch { return false; } } function toast(message, type = 'info') { try { const store = window.Alpine && Alpine.store && Alpine.store('toast'); if (store) { store.show(message, type); return; } } catch { // Alpine not ready } // Ultimate fallback console.log(`[${type}] ${message}`); } /** Revoke a blob: URL if present */ function revokeObjectURL(url) { if (url && typeof url === 'string' && url.startsWith('blob:')) { try { URL.revokeObjectURL(url); } catch { /* ignore */ } } } /** Calculate BMI from weight(kg) and height(cm) */ function calcBMI(weight, height) { if (!weight || !height || height <= 0) return null; return parseFloat((weight / Math.pow(height / 100, 2)).toFixed(2)); } return { STANDARD_DATA_BOY, TYPE_LABELS, TYPE_ICONS, getMonthDiff, generateStandardSeries, getNowCNStr, getTodayCN, getYesterdayCN, formatTimeCN, toISOFromCNLocal, formatType, getIcon, getDayLabel, addDays, getAgeDisplay, parseFlexibleDate, isVitDRecord, getTimeDiff, debounce, copyToClipboard, toast, revokeObjectURL, calcBMI }; })();