1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299 | // ==================== 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
};
})();
|