| author | hui <hao263@outlook.com> | 2026-10-09 06:35:47 +0800 |
|---|---|---|
| committer | hui <hao263@outlook.com> | 2026-10-09 06:35:47 +0800 |
| commit | e66ffea0e82569509c46ef3eaaf5595d3fd9cc55 (patch) | |
| tree | ece9457704df251b29f5ae0b701bd1ee7f48ebfc | |
| download | baby-e66ffea0e8.tar.gz zip | |
init: baby projectmain
Diffstat
| .env.example (new) | +18 | -0 |
| .gitignore (new) | +10 | -0 |
| .mcp.json (new) | +8 | -0 |
| README.md (new) | +159 | -0 |
| icons/apple-touch-icon.svg (new) | +8 | -0 |
| icons/icon.svg (new) | +19 | -0 |
| index.html (new) | +713 | -0 |
| manifest.webmanifest (new) | +33 | -0 |
| src/js/app.js (new) | +1172 | -0 |
| src/js/charts.js (new) | +294 | -0 |
| src/js/config.js (new) | +77 | -0 |
| src/js/db.js (new) | +174 | -0 |
| src/js/toast.js (new) | +37 | -0 |
| src/js/utils.js (new) | +299 | -0 |
| src/styles/main.css (new) | +1418 | -0 |
| sw.js (new) | +87 | -0 |
16 files changed, 4526 insertions(+), 0 deletions(-)
diff --git a/.env.example b/.env.example @@ -0,0 +1,18 @@ +# Environment Variables for Baby Tracker App +# Copy this file to .env or set in Cloudflare Pages > Settings > Environment Variables + +# Supabase Configuration +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_KEY=your-anon-key-here + +# Image Upload API (Cloudflare Images or similar) +IMGBED_API=https://your-imgbed-api.com/upload?authCode=your-auth-code + +# Growth Data API (returns JSON with checkDate, weight, height, headCircumference) +GROW_API_URL=https://your-grow-api.com/grow + +# AI Assessment API (POST request with {query: string}) +AI_API_URL=https://your-ai-api.com + +# Baby Birth Date (format: YYYY/MM/DD) +BIRTH_DATE=2025/03/09 diff --git a/.gitignore b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.wrangler/ +dist/ +.env +.dev.vars +.access_password +*.zip +*.rar +.DS_Store +.claude/ diff --git a/.mcp.json b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"] + } + } +} diff --git a/README.md b/README.md @@ -0,0 +1,159 @@ +# 宝宝日记 App + +一个用于记录宝宝日常喂养、生长数据的静态 Web 应用,可直接部署到 Cloudflare Pages,支持添加到手机主屏幕。 + +## 功能特性 + +- **记录功能**: 奶量、辅食、便便、维生素 D +- **历史查询**: 按日期查看记录、日统计(总奶量 / 辅食顿数 / 维 D) +- **生长监测**: 体重 / 身长 / 头围 / BMI 曲线,基于 WS/T 423-2022 男童标准 +- **AI 智能评估**: 自动分析最新生长数据(可关闭) +- **图片上传**: 便便照片记录 +- **数据导出**: 近 30 天饮食数据一键复制,方便发给 AI 做饮食建议 +- **维 D 提醒**: 昨天漏打卡时首页温馨提示 +- **深浅色主题**: 跟随系统或手动切换 +- **PWA**: 可「添加到主屏幕」,离线打开壳层页面 + +## 项目结构 + +``` +baby/ +├── index.html # 主页面 +├── manifest.webmanifest # PWA 清单 +├── sw.js # 壳层 Service Worker(仅缓存静态资源) +├── icons/ +│ ├── icon.svg # 应用图标 +│ └── apple-touch-icon.svg # iOS 主屏幕图标 +├── src/ +│ ├── js/ +│ │ ├── config.js # 环境配置(远程 + 本地兜底) +│ │ ├── utils.js # 工具函数 / 生长标准数据 +│ │ ├── db.js # Supabase 数据访问层 +│ │ ├── charts.js # ECharts 图表管理 +│ │ ├── toast.js # Toast 通知 +│ │ └── app.js # Alpine.js 主应用 +│ └── styles/ +│ └── main.css # 主题样式(Warm Minimal + 暗色) +├── .env.example # 环境变量模板 +└── README.md +``` + +## 本地开发 + +1. 用本地静态服务器打开项目根目录(推荐,避免 `file://` 限制剪贴板等 API): + +```bash +# Python +python -m http.server 8080 + +# 或 Node +npx serve . +``` + +2. 浏览器访问 `http://localhost:8080` + +> Service Worker 需要 HTTP(S) 源;`file://` 下不会注册。 + +## 添加到主屏幕 + +- **iOS Safari**: 分享 → 添加到主屏幕 +- **Android Chrome**: 菜单 → 安装应用 / 添加到主屏幕 +- 独立窗口打开时使用 `standalone` 显示模式,主题色随浅/深色模式切换 + +## Cloudflare Pages 部署 + +### 方式一: 直接上传 + +1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) +2. **Workers & Pages** → **Create** → **Pages** → **Upload assets** +3. 上传项目文件夹(或 zip) +4. 部署完成即可访问 + +### 方式二: Git 连接 + +1. 推送到 GitHub / GitLab +2. Cloudflare Pages 连接仓库 +3. 构建设置: + - Build command: 留空 + - Build output directory: `/` + - Root directory: `/` + +## 配置说明 + +应用启动时会先请求远程配置,失败则使用 `src/js/config.js` 中的本地默认值。 + +可覆盖的配置项: + +| 键 | 说明 | +|----|------| +| `SUPABASE_URL` | Supabase 项目 URL | +| `SUPABASE_KEY` | Supabase anon key | +| `IMGBED_API` | 图片上传 API | +| `GROW_API_URL` | 生长数据 JSON API | +| `AI_API_URL` | AI 评估 API(设为 `''` 可禁用) | +| `BIRTH_DATE` | 宝宝生日,格式 `YYYY/MM/DD` | + +修改默认值: 编辑 `src/js/config.js` 中的 `window.env`。 + +### Supabase 数据表 + +```sql +CREATE TABLE baby_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recorded_at TIMESTAMPTZ NOT NULL, + type TEXT NOT NULL, -- 'milk' | 'food' | 'poo' | 'meds' + sub_type TEXT, + amount INTEGER, + is_new_food BOOLEAN DEFAULT FALSE, + tags TEXT, + notes TEXT, + image_url TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_recorded_at ON baby_records(recorded_at); +CREATE INDEX idx_type ON baby_records(type); +CREATE INDEX idx_type_recorded ON baby_records(type, recorded_at); +``` + +建议开启 RLS,并按你的使用场景配置策略(家庭私用可暂时允许 anon 读写)。 + +### 生长数据 API + +`GROW_API_URL` 需返回: + +```json +{ + "data": [ + { + "checkDate": "2025-04-09", + "weight": 5.2, + "height": 58.5, + "headCircumference": 39.2 + } + ] +} +``` + +### AI 评估 API + +`POST` JSON body: `{ "query": "..." }`,响应为纯文本评估结果。 +将 `AI_API_URL` 设为空字符串可完全关闭该功能。 + +## 外部依赖 + +| 库 / 服务 | 用途 | 来源 | +|-----------|------|------| +| Tailwind CSS | 工具类样式 | CDN | +| Alpine.js | 响应式交互 | CDN | +| ECharts | 生长曲线 | CDN(首次打开生长/报告时加载) | +| Supabase | 后端数据库 | supabase.com | +| 图床 API | 图片存储 | 自建 / Cloudflare | + +## 时区说明 + +应用统一按 **Asia/Shanghai (UTC+8)** 处理日期与时间显示;写入数据库时转为标准 UTC ISO 字符串。 + +## 许可 + +MIT diff --git a/icons/apple-touch-icon.svg b/icons/apple-touch-icon.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="宝宝日记"> + <rect width="180" height="180" rx="40" fill="#D4896A"/> + <circle cx="90" cy="90" r="58" fill="#FAF7F2"/> + <rect x="74" y="58" width="32" height="52" rx="12" fill="#6B9AC4"/> + <rect x="80" y="46" width="20" height="16" rx="4" fill="#3D6488"/> + <rect x="84" y="38" width="12" height="12" rx="3" fill="#FAF7F2"/> + <rect x="79" y="76" width="22" height="28" rx="6" fill="#E8F1F8"/> +</svg> diff --git a/icons/icon.svg b/icons/icon.svg @@ -0,0 +1,19 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="宝宝日记"> + <defs> + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0%" stop-color="#F6E4DB"/> + <stop offset="100%" stop-color="#E8C9B8"/> + </linearGradient> + </defs> + <rect width="512" height="512" rx="112" fill="url(#bg)"/> + <circle cx="256" cy="256" r="168" fill="#D4896A"/> + <circle cx="256" cy="256" r="132" fill="#FAF7F2"/> + <!-- bottle body --> + <rect x="214" y="176" width="84" height="140" rx="28" fill="#6B9AC4"/> + <rect x="228" y="148" width="56" height="36" rx="10" fill="#3D6488"/> + <rect x="240" y="128" width="32" height="28" rx="8" fill="#D4896A"/> + <!-- milk level --> + <rect x="226" y="220" width="60" height="80" rx="16" fill="#E8F1F8" opacity="0.95"/> + <!-- smile --> + <path d="M230 340c14 18 38 18 52 0" fill="none" stroke="#8B4F35" stroke-width="10" stroke-linecap="round"/> +</svg> diff --git a/index.html b/index.html @@ -0,0 +1,713 @@ +<!DOCTYPE html> +<html lang="zh-CN" data-theme=""> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> + <meta id="theme-color-meta" name="theme-color" content="#D4896A"> + <meta name="color-scheme" content="light dark"> + <meta name="apple-mobile-web-app-capable" content="yes"> + <meta name="apple-mobile-web-app-status-bar-style" content="default"> + <meta name="apple-mobile-web-app-title" content="宝宝日记"> + <meta name="mobile-web-app-capable" content="yes"> + <meta name="description" content="记录宝宝喝奶、辅食、便便与生长的温暖日记"> + <title>宝宝日记 - 记录成长每一天</title> + <link rel="manifest" href="./manifest.webmanifest"> + <link rel="icon" href="./icons/icon.svg" type="image/svg+xml"> + <link rel="apple-touch-icon" href="./icons/apple-touch-icon.svg"> + <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> + <link rel="preconnect" href="https://cdn.tailwindcss.com" crossorigin> + <script src="https://cdn.tailwindcss.com"></script> + <!-- ECharts 延迟加载:切换到"生长"Tab 时动态引入 --> + <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script> + <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script> + <link rel="stylesheet" href="./src/styles/main.css"> + <style> + /* Early theme paint — avoid flash before Alpine init */ + html { color-scheme: light dark; background: #FAF7F2; } + html[data-theme="dark"] { background: #1A1816; } + @media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) { background: #1A1816; } + } + </style> + <script> + // Apply stored / system theme before first paint + (function () { + try { + var stored = localStorage.getItem('baby_theme'); + var theme = (stored === 'dark' || stored === 'light') + ? stored + : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + document.documentElement.dataset.theme = theme; + var meta = document.getElementById('theme-color-meta'); + if (meta) meta.content = theme === 'dark' ? '#1A1816' : '#D4896A'; + } catch (e) { /* ignore */ } + })(); + </script> +</head> +<body x-data="app" x-cloak> + + <!-- Boot splash --> + <div x-show="!ready" class="boot-splash"> + <div class="boot-mark" aria-hidden="true">🍼</div> + <div class="boot-brand">宝宝日记</div> + <div class="boot-text">加载中</div> + </div> + + <div class="main-container" x-show="ready" x-transition.opacity> + + <header class="modern-header"> + <div class="flex justify-between items-center"> + <h1 class="header-title"> + <a href="https://zelinlife2025.888653.xyz" target="_blank" rel="noopener noreferrer" + title="进入相册" class="avatar-ring" aria-label="进入相册"> + <img src="https://ii99photoshow.888653.xyz/file/1764031994825_Generated_Image_November_25__2025_-_8_52AM.png" + class="w-10 h-10 object-cover" + alt="宝宝头像" + width="40" height="40"> + </a> + <span class="flex flex-col"> + <span>宝宝日记</span> + <span class="subtitle">记录成长的每一天</span> + </span> + </h1> + <button type="button" class="theme-toggle" + @click="toggleTheme()" + :aria-label="theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" + x-text="theme === 'dark' ? '☀️' : '🌙'"></button> + </div> + + <div class="tab-bar flex" role="tablist" aria-label="主导航"> + <button type="button" role="tab" id="tab-record" + @click="switchToRecord()" + :class="{ 'active': view === 'record' }" + :aria-selected="view === 'record'" + aria-controls="panel-record" + class="tab-button"> + 记录 + </button> + <button type="button" role="tab" id="tab-history" + @click="switchToHistory()" + :class="{ 'active': view === 'history' }" + :aria-selected="view === 'history'" + aria-controls="panel-history" + class="tab-button"> + 历史 + </button> + <button type="button" role="tab" id="tab-growth" + @click="switchToGrowth()" + :class="{ 'active': view === 'growth' }" + :aria-selected="view === 'growth'" + aria-controls="panel-growth" + class="tab-button"> + 生长 + </button> + <button type="button" role="tab" id="tab-report" + @click="switchToReport()" + :class="{ 'active': view === 'report' }" + :aria-selected="view === 'report'" + aria-controls="panel-report" + class="tab-button"> + 报告 + </button> + </div> + </header> + + <div class="content-pad"> + + <!-- ================= 记录 ================= --> + <div x-show="view === 'record'" + x-transition:enter="transition ease-out duration-150" + x-transition:enter-start="opacity-0" + role="tabpanel" id="panel-record" aria-labelledby="tab-record"> + + <!-- 编辑模式横幅 --> + <div x-show="isEditing" x-transition class="edit-banner"> + <div class="flex items-center justify-between gap-3"> + <div class="text-sm font-semibold" style="color: var(--accent-ink)"> + 正在编辑记录 + </div> + <button type="button" @click="cancelEdit()" class="edit-cancel-btn">取消</button> + </div> + </div> + + <!-- 今日摘要(非编辑时显示) --> + <div x-show="!isEditing" class="today-summary mb-3"> + <div class="today-summary-head"> + <span class="today-summary-title">今日</span> + <span class="today-summary-feed" x-show="todaySummary.lastMilkAt"> + 距上次喝奶 <strong x-text="getLastFeedLabel()"></strong> + </span> + <span class="today-summary-feed muted" x-show="!todaySummary.lastMilkAt && !todaySummary.loading"> + 今天还没记喝奶 + </span> + </div> + <div class="today-summary-grid"> + <div class="today-stat milk"> + <div class="today-stat-val"><span x-text="todaySummary.milk"></span><small>ml</small></div> + <div class="today-stat-lbl">奶量</div> + </div> + <div class="today-stat food"> + <div class="today-stat-val"><span x-text="todaySummary.food"></span><small>顿</small></div> + <div class="today-stat-lbl">辅食</div> + </div> + <div class="today-stat poo"> + <div class="today-stat-val"><span x-text="todaySummary.poo"></span><small>次</small></div> + <div class="today-stat-lbl">便便</div> + </div> + <div class="today-stat" :class="todaySummary.vitD ? 'vitd' : 'vitd empty'"> + <div class="today-stat-val"> + <span x-show="todaySummary.vitD">✓</span> + <span x-show="!todaySummary.vitD">—</span> + </div> + <div class="today-stat-lbl">维D</div> + </div> + </div> + </div> + + <!-- 维D提醒 --> + <div x-show="vitamindReminder.show && !isEditing" x-transition class="vitamind-reminder"> + <div class="flex items-start gap-3"> + <div class="text-2xl" aria-hidden="true">💊</div> + <div class="flex-1"> + <div class="font-bold text-sm" style="color: var(--accent-ink)">温馨提示</div> + <div class="text-sm mt-1" style="color: var(--ink-secondary)"> + 昨天好像还没记维生素 D,今天记得给宝宝补一下。 + </div> + <button type="button" + @click="setType('meds'); vitamindReminder.show = false" + class="mt-2 text-xs font-bold link-today"> + 现在去打卡 → + </button> + </div> + <button type="button" @click="vitamindReminder.show = false" + class="text-2xl leading-none font-light" + style="color: var(--muted); min-width: 36px; min-height: 36px;" + aria-label="关闭提醒">×</button> + </div> + </div> + + <div class="modern-card p-4 mb-3"> + <div class="label-title">记录时间</div> + <div class="datetime-split"> + <div class="datetime-split-field"> + <label class="sr-only" for="record-date">日期</label> + <input id="record-date" type="date" + x-model="form.date" + class="input-field text-base font-semibold"> + </div> + <div class="datetime-split-field"> + <label class="sr-only" for="record-time">时间</label> + <input id="record-time" type="time" + x-model="form.time" + class="input-field text-base font-semibold"> + </div> + </div> + </div> + + <div class="type-grid mb-3" role="group" aria-label="记录类型"> + <button type="button" @click="setType('milk')" + :class="form.type === 'milk' ? 'milk active' : 'milk'" + :aria-pressed="form.type === 'milk'" + class="type-button"> + <span aria-hidden="true">🍼</span> + <span>喝奶</span> + </button> + <button type="button" @click="setType('food')" + :class="form.type === 'food' ? 'food active' : 'food'" + :aria-pressed="form.type === 'food'" + class="type-button"> + <span aria-hidden="true">🥣</span> + <span>辅食</span> + </button> + <button type="button" @click="setType('poo')" + :class="form.type === 'poo' ? 'poo active' : 'poo'" + :aria-pressed="form.type === 'poo'" + class="type-button"> + <span aria-hidden="true">💩</span> + <span>便便</span> + </button> + <button type="button" @click="setType('meds')" + :class="form.type === 'meds' ? 'meds active' : 'meds'" + :aria-pressed="form.type === 'meds'" + class="type-button"> + <span aria-hidden="true">💊</span> + <span>维D</span> + </button> + </div> + + <div class="modern-card p-5"> + <!-- 喝奶 --> + <div x-show="form.type === 'milk'" class="space-y-4"> + <div> + <div class="label-title">奶类</div> + <div class="chip-row"> + <template x-for="opt in ['配方奶', '母乳亲喂', '母乳瓶喂']" :key="opt"> + <button type="button" @click="form.subType = opt" + class="chip" + :class="form.subType === opt ? 'active' : ''" + x-text="opt"></button> + </template> + </div> + </div> + <div> + <label class="label-title" for="milk-amount">摄入量 (ml)</label> + <div class="amount-presets mb-2"> + <template x-for="ml in milkPresets" :key="ml"> + <button type="button" + class="amount-chip milk" + :class="{ 'active': String(form.amount) === String(ml) }" + @click="setMilkAmount(ml)" + x-text="ml"></button> + </template> + </div> + <input id="milk-amount" type="number" inputmode="numeric" min="1" max="500" step="1" + x-model="form.amount" placeholder="180" + class="input-field text-xl font-bold" + style="color: var(--milk-ink)"> + <div x-show="lastMilkAmount && !isEditing" class="text-xs mt-1.5" style="color: var(--muted)"> + 默认已填上次奶量 <button type="button" class="link-today" + @click="setMilkAmount(lastMilkAmount)" + x-text="lastMilkAmount + 'ml'"></button> + </div> + </div> + <div> + <label class="label-title" for="milk-notes">备注</label> + <input id="milk-notes" type="text" x-model="form.notes" + class="input-field" placeholder="选填" maxlength="200"> + </div> + </div> + + <!-- 辅食 --> + <div x-show="form.type === 'food'" class="space-y-4"> + <div> + <label class="label-title" for="food-detail">食材详情</label> + <textarea id="food-detail" x-model="form.subType" rows="4" maxlength="500" + placeholder="例如:猪肝菠菜粥(半碗)、苹果泥(几口)" + class="input-field leading-relaxed"></textarea> + </div> + <label class="modern-checkbox"> + <input type="checkbox" x-model="form.isNewFood" class="mr-3"> + <span class="font-semibold flex-1" style="color: var(--food-ink)">含新食材(观察 3 天)</span> + </label> + <div> + <label class="label-title" for="food-reaction">反应</label> + <select id="food-reaction" x-model="form.tags" class="input-field"> + <option value="无异常">无异常 / 喜欢</option> + <option value="拒吃">拒吃 / 没胃口</option> + <option value="过敏/皮疹">过敏 / 皮疹</option> + <option value="呕吐">呕吐</option> + <option value="胀气">胀气 / 哭闹</option> + </select> + </div> + </div> + + <!-- 便便 --> + <div x-show="form.type === 'poo'" class="space-y-4"> + <div> + <label class="label-title" for="poo-type">性状</label> + <select id="poo-type" x-model="form.subType" class="input-field"> + <option value="正常糊状">正常糊状</option> + <option value="干硬球状">干硬(便秘)</option> + <option value="水样稀便">水样(腹泻)</option> + <option value="有奶瓣">有奶瓣</option> + <option value="墨绿色">墨绿色(铁未吸收)</option> + </select> + </div> + <div> + <label class="label-title" for="poo-notes">备注</label> + <input id="poo-notes" type="text" x-model="form.notes" + placeholder="如:带血丝、酸臭" class="input-field" maxlength="200"> + </div> + <div> + <div class="label-title">照片记录</div> + <div class="image-upload-area"> + <label class="upload-box" aria-label="拍照或从相册选择"> + <!-- 不设 capture:手机可在「拍照 / 相册」间选择,电脑直接选本地文件 --> + <input type="file" accept="image/*" + class="file-input" @change="handleFileSelect"> + <template x-if="!form.previewUrl"> + <span class="text-2xl" style="color: var(--muted)" aria-hidden="true">📷</span> + </template> + <template x-if="form.previewUrl"> + <img :src="form.previewUrl" alt="预览"> + </template> + </label> + <div class="text-sm flex-1"> + <div x-show="!form.previewUrl" style="color: var(--muted)"> + 拍照或从相册 / 本地选择 + </div> + <div x-show="form.previewUrl" class="font-semibold" style="color: var(--success)">已选择图片</div> + <button type="button" x-show="form.previewUrl" @click="clearFile" + class="mt-1 underline cursor-pointer text-sm" + style="color: var(--danger)"> + 删除 / 重选 + </button> + </div> + </div> + </div> + </div> + + <!-- 维D --> + <div x-show="form.type === 'meds'" class="space-y-4"> + <div class="meds-callout"> + <div class="icon" aria-hidden="true">☀️</div> + <div class="title">补充维生素 D</div> + <div class="hint">点下方按钮完成今日打卡</div> + </div> + <div> + <label class="label-title" for="meds-notes">备注</label> + <input id="meds-notes" type="text" x-model="form.notes" + placeholder="可选:AD 滴剂、益生菌" class="input-field" maxlength="200"> + </div> + </div> + + <button type="button" @click="submit" :disabled="loading" class="submit-button" + :class="{ + 'milk': form.type === 'milk', + 'food': form.type === 'food', + 'poo': form.type === 'poo', + 'meds': form.type === 'meds' + }"> + <span x-show="!loading && !isEditing">保存记录</span> + <span x-show="!loading && isEditing">保存修改</span> + <span x-show="loading && !uploading">保存中…</span> + <span x-show="uploading">正在上传图片…</span> + <span x-show="loading" class="spinner" aria-hidden="true"></span> + </button> + </div> + </div> + + <!-- ================= 历史 ================= --> + <div x-show="view === 'history'" + x-transition:enter="transition ease-out duration-150" + x-transition:enter-start="opacity-0" + role="tabpanel" id="panel-history" aria-labelledby="tab-history"> + + <div class="date-nav-card mb-3"> + <div class="flex items-center justify-between"> + <button type="button" @click="changeDate(-1)" class="date-nav-btn" aria-label="前一天"><</button> + <div class="flex flex-col items-center relative"> + <input type="date" x-model="historyDate" @change="fetchHistory()" + class="date-selector font-bold bg-transparent text-center outline-none appearance-none p-0 border-0 focus:ring-0 w-36 text-base relative z-10" + style="color: var(--ink)" + aria-label="选择日期"> + <div class="text-xs -mt-0.5 font-medium flex items-center gap-1" style="color: var(--muted)"> + <span x-text="getDayLabel(historyDate)"></span> + <button type="button" x-show="!isToday()" @click="resetToToday()" class="link-today ml-1">回今天</button> + </div> + </div> + <button type="button" @click="changeDate(1)" class="date-nav-btn" aria-label="后一天">></button> + </div> + <div class="flex items-center justify-between mt-3"> + <span class="age-pill" x-text="getAgeDisplay()"></span> + <button type="button" @click="exportData()" :disabled="exporting" class="export-btn"> + <span x-text="exporting ? '导出中…' : '导出数据'"></span> + </button> + </div> + </div> + + <!-- 四格统计:奶 / 辅食 / 便便 / 维D --> + <div class="stats-grid stats-grid-4 mb-3"> + <div class="stats-card milk"> + <div class="stats-label">总奶量</div> + <div class="stats-value"> + <span x-text="stats.milk"></span><span class="text-sm font-normal">ml</span> + </div> + </div> + <div class="stats-card food"> + <div class="stats-label">辅食</div> + <div class="stats-value"> + <span x-text="stats.food"></span><span class="text-sm font-normal">顿</span> + </div> + </div> + <div class="stats-card poo"> + <div class="stats-label">便便</div> + <div class="stats-value"> + <span x-text="stats.poo"></span><span class="text-sm font-normal">次</span> + </div> + </div> + <div class="stats-card" :class="stats.vitD ? 'vitd' : 'vitd empty'"> + <div class="stats-label">维D</div> + <div class="stats-value"> + <span x-show="stats.vitD">✓</span> + <span x-show="!stats.vitD" class="text-sm">—</span> + </div> + </div> + </div> + + <div x-show="loading" class="empty-state py-10"> + <div class="spinner spinner-dark mx-auto mb-3"></div> + <div class="empty-state-text">加载中…</div> + </div> + + <div class="space-y-2.5" x-show="!loading"> + <template x-if="logs.length === 0"> + <div class="empty-state"> + <div class="empty-state-icon" aria-hidden="true">📝</div> + <div class="empty-state-text">这一天还没有记录</div> + <div class="empty-state-hint">记下喝奶、辅食或维D,成长轨迹会更清晰</div> + <button type="button" @click="switchToRecord()" class="empty-action"> + 去记录一条 → + </button> + </div> + </template> + + <template x-for="log in logs" :key="log.id"> + <div @click="toggleExpand(log.id)" + @keydown.enter="toggleExpand(log.id)" + @keydown.space.prevent="toggleExpand(log.id)" + tabindex="0" + role="button" + :aria-expanded="expandedId === log.id" + class="log-item" + :class="{ + 'milk': log.type === 'milk', + 'food': log.type === 'food', + 'poo': log.type === 'poo', + 'meds': log.type === 'meds', + 'expanded': expandedId === log.id + }"> + <div class="flex-1 pr-2 overflow-hidden"> + <div class="flex items-center gap-2 mb-1 flex-wrap"> + <div class="font-bold text-sm transition-all" + style="color: var(--ink)" + :class="expandedId === log.id ? 'whitespace-pre-wrap break-words' : 'truncate'" + x-text="log.sub_type || formatType(log.type)"> + </div> + <span x-show="log.is_new_food" class="badge new">新</span> + <span x-show="log.tags && log.tags !== '无异常'" + x-text="log.tags" class="badge reaction"></span> + </div> + <div class="text-sm break-words leading-tight" style="color: var(--muted)"> + <span x-show="log.type==='milk'" class="font-bold mr-1" + style="color: var(--milk-ink)" + x-text="(log.amount || 0) + 'ml'"></span> + <span x-show="log.notes" x-text="log.notes"></span> + <div x-show="log.image_url" + @click.stop="openImage(log.image_url)" + class="photo-link"> + <span>📷 查看照片</span> + </div> + </div> + <div x-show="log.type === 'food' && expandedId !== log.id && log.sub_type && log.sub_type.length > 15" + class="text-xs mt-1" style="color: var(--muted)">… 点击查看详情</div> + + <!-- 展开后的操作 --> + <div x-show="expandedId === log.id" x-transition class="log-actions mt-2"> + <button type="button" class="log-action-btn edit" + @click.stop="startEdit(log)">编辑</button> + <button type="button" class="log-action-btn danger" + @click.stop="deleteRecord(log.id)">删除</button> + </div> + </div> + <div class="text-right flex flex-col items-end min-w-[52px]"> + <div class="text-sm font-bold font-mono" style="color: var(--ink-secondary)" + x-text="formatTimeCN(log.recorded_at)"></div> + <div class="text-base mt-1" x-text="getIcon(log.type)" aria-hidden="true"></div> + <template x-if="log.id === getLatestMilkId()"> + <div class="time-since"> + 距今 <span x-text="getTimeDiff(log.recorded_at)"></span> + </div> + </template> + </div> + <!-- 右上角快捷编辑 --> + <button type="button" @click.stop="startEdit(log)" + class="log-edit" + aria-label="编辑记录">✎</button> + </div> + </template> + </div> + </div> + + <!-- ================= 生长 ================= --> + <div x-show="view === 'growth'" + x-transition:enter="transition ease-out duration-150" + x-transition:enter-start="opacity-0" + role="tabpanel" id="panel-growth" aria-labelledby="tab-growth"> + + <div class="assessment-box" x-show="growth.aiVisible" x-transition> + <h3>智能发育评估</h3> + <div class="assessment-body whitespace-pre-wrap" + :class="{ 'assessment-loading': growth.aiText.includes('正在分析') }" + x-text="growth.aiText"></div> + </div> + + <div x-show="growth.loading" class="empty-state"> + <div class="spinner spinner-dark mx-auto mb-4"></div> + <div class="empty-state-text">正在获取最新数据…</div> + </div> + + <div x-show="growth.error" class="empty-state"> + <div class="empty-state-icon" aria-hidden="true">⚠️</div> + <div class="font-semibold mb-1" style="color: var(--danger)" x-text="growth.errorMessage"></div> + <div class="empty-state-hint">检查网络或生长数据 API 配置后重试</div> + <button type="button" @click="retryGrowth()" class="empty-action">重试</button> + </div> + + <div id="charts" x-show="!growth.loading && !growth.error"> + <div class="chart-container"> + <div id="bmiChart" class="chart-box"></div> + </div> + <div class="chart-container"> + <div id="weightChart" class="chart-box"></div> + </div> + <div class="chart-container"> + <div id="heightChart" class="chart-box"></div> + </div> + <div class="chart-container"> + <div id="headChart" class="chart-box"></div> + </div> + </div> + </div> + + <!-- ================= 报告 ================= --> + <div x-show="view === 'report'" + x-transition:enter="transition ease-out duration-150" + x-transition:enter-start="opacity-0" + role="tabpanel" id="panel-report" aria-labelledby="tab-report"> + + <!-- 周报/月报切换 --> + <div class="report-range-bar" role="group" aria-label="报告周期"> + <button type="button" @click="report.range = 7; loadReportData()" + :class="{ 'active': report.range === 7 }" + class="report-range-btn">周报 (7天)</button> + <button type="button" @click="report.range = 30; loadReportData()" + :class="{ 'active': report.range === 30 }" + class="report-range-btn">月报 (30天)</button> + </div> + + <!-- Loading --> + <div x-show="report.loading" class="empty-state"> + <div class="spinner spinner-dark mx-auto mb-3"></div> + <div class="empty-state-text">正在生成报告…</div> + </div> + + <!-- Empty --> + <div x-show="!report.loading && report.summary.activeDays === 0"> + <div class="empty-state"> + <div class="empty-state-icon" aria-hidden="true">📊</div> + <div class="empty-state-text"> + <span x-text="report.range === 7 ? '本周暂无记录' : '本月暂无记录'"></span> + </div> + <div class="empty-state-hint">多记几天后,这里会汇总奶量与辅食趋势</div> + <button type="button" @click="switchToRecord()" class="empty-action"> + 去记录一条 → + </button> + </div> + </div> + + <!-- Report content --> + <div x-show="!report.loading && report.summary.activeDays > 0"> + + <!-- Summary cards --> + <div class="report-summary-grid"> + <div class="report-summary-card milk"> + <div class="rsc-label">日均奶量</div> + <div class="rsc-value" x-text="report.summary.avgDailyMilk + 'ml'"></div> + </div> + <div class="report-summary-card milk"> + <div class="rsc-label">总奶量</div> + <div class="rsc-value" x-text="report.summary.totalMilk + 'ml'"></div> + </div> + <div class="report-summary-card food"> + <div class="rsc-label">辅食种类</div> + <div class="rsc-value" x-text="report.summary.foodVariety + '种'"></div> + </div> + <div class="report-summary-card poo"> + <div class="rsc-label">便便次数</div> + <div class="rsc-value" x-text="report.summary.poopCount + '次'"></div> + </div> + <div class="report-summary-card" style="grid-column: span 2;"> + <div class="rsc-label">记录天数</div> + <div class="rsc-value" x-text="report.summary.activeDays + '天'"></div> + </div> + </div> + + <!-- Bar chart: daily milk --> + <div class="chart-container"> + <div id="reportMilkBar" class="chart-box" style="height: 260px;"></div> + </div> + + <!-- Donut chart: milk type --> + <div class="chart-container"> + <div id="reportMilkDonut" class="chart-box" style="height: 260px;"></div> + </div> + + <!-- Food log --> + <div class="modern-card p-4 mb-3" x-show="report.foodLog.length > 0"> + <div class="report-section-title">🥣 尝试过的食物</div> + <div class="report-food-list"> + <template x-for="food in report.foodLog" :key="food"> + <span class="report-food-tag" x-text="food"></span> + </template> + </div> + </div> + + <!-- Poo pattern --> + <div class="modern-card p-4 mb-3"> + <div class="report-section-title">💩 便便情况</div> + <div class="report-poo-row"> + <span>正常</span> + <span class="report-poo-count normal" x-text="report.pooSummary.normal + '次'"></span> + <span style="margin-left: 8px;">异常</span> + <span class="report-poo-count abnormal" x-text="report.pooSummary.abnormal + '次'"></span> + </div> + <div x-show="report.pooSummary.total === 0" class="text-sm mt-2" style="color: var(--muted)"> + 本周期暂无便便记录 + </div> + </div> + </div> + </div> + + </div> + </div> + + <!-- 图片查看器 --> + <div x-show="showModal" x-transition.opacity + class="fixed inset-0 z-50 lightbox flex items-center justify-center" + @click.self="closeModal()" + @keydown.escape.window="showModal && closeModal()" + role="dialog" aria-modal="true" aria-label="图片预览"> + <div class="relative max-w-full max-h-full"> + <img :src="modalImage" alt="记录照片" + class="max-w-full max-h-[80vh] rounded-2xl shadow-2xl object-contain"> + <button type="button" @click="closeModal()" + class="lightbox-close" + aria-label="关闭"> + × + </button> + </div> + </div> + + <!-- Toast --> + <div x-data class="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 flex flex-col gap-2 pointer-events-none" + style="max-width: 90vw; top: max(1rem, env(safe-area-inset-top, 0px));" aria-live="polite"> + <template x-for="msg in $store.toast.messages" :key="msg.id"> + <div x-show="msg.visible" + x-transition:enter="transition ease-out duration-200" + x-transition:enter-start="opacity-0 -translate-y-3" + x-transition:leave="transition ease-in duration-150" + x-transition:leave-start="opacity-100" + x-transition:leave-end="opacity-0" + @click="$store.toast.dismiss(msg.id)" + class="pointer-events-auto px-4 py-3 text-sm cursor-pointer text-white toast-kid" + :class="{ + 'toast-success': msg.type === 'success', + 'toast-error': msg.type === 'error', + 'toast-warning': msg.type === 'warning', + 'toast-info': msg.type === 'info' + }" + role="status"> + <span x-text="msg.message"></span> + </div> + </template> + </div> + + <script src="./src/js/config.js"></script> + <script src="./src/js/utils.js"></script> + <script src="./src/js/db.js"></script> + <script src="./src/js/charts.js"></script> + <script src="./src/js/toast.js"></script> + <script src="./src/js/app.js"></script> +</body> +</html> diff --git a/manifest.webmanifest b/manifest.webmanifest @@ -0,0 +1,33 @@ +{ + "name": "宝宝日记", + "short_name": "宝宝日记", + "description": "记录宝宝喝奶、辅食、便便与生长的温暖日记", + "start_url": "./index.html", + "scope": "./", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#FAF7F2", + "theme_color": "#D4896A", + "lang": "zh-CN", + "icons": [ + { + "src": "./icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "./icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + }, + { + "src": "./icons/apple-touch-icon.svg", + "sizes": "180x180", + "type": "image/svg+xml", + "purpose": "any" + } + ], + "categories": ["health", "lifestyle"] +} diff --git a/src/js/app.js b/src/js/app.js @@ -0,0 +1,1172 @@ +// ==================== Baby Tracker Main App ==================== +document.addEventListener('alpine:init', () => { + const LAST_MILK_KEY = 'baby_last_milk'; + const MILK_PRESETS = [120, 150, 180, 210, 240]; + + function loadLastMilkPref() { + try { + const raw = localStorage.getItem(LAST_MILK_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } + } + + function saveLastMilkPref(amount, subType) { + try { + localStorage.setItem(LAST_MILK_KEY, JSON.stringify({ amount, subType })); + } catch { + /* ignore quota */ + } + } + + /** UTC ISO → datetime-local string in Asia/Shanghai (YYYY-MM-DDTHH:mm) */ + function isoToCNLocal(iso) { + if (!iso) return ''; + const d = new Date(new Date(iso).getTime() + 8 * 60 * 60 * 1000); + return d.toISOString().slice(0, 16); + } + + Alpine.data('app', () => ({ + + // --- State --- + ready: false, + view: 'record', + loading: false, + uploading: false, + exporting: false, + showModal: false, + modalImage: '', + expandedId: null, + now: Date.now(), + _clockTimer: null, + _previewUrl: null, + _darkMql: null, + _darkMqlListener: null, + + // Theme + theme: 'light', + + // Edit mode + editingId: null, + existingImageUrl: null, + removeImage: false, + + milkPresets: MILK_PRESETS, + lastMilkAmount: null, + lastMilkSubType: null, + + form: { + date: '', + time: '', + type: 'milk', + subType: '配方奶', + amount: '', + isNewFood: false, + tags: '无异常', + notes: '', + imageFile: null, + previewUrl: null + }, + + historyDate: '', + logs: [], + stats: { milk: 0, food: 0, poo: 0, vitD: false }, + + _historyCache: {}, // dateStr → { logs, stats } in-memory cache + _fetchHistoryLock: null, // pending fetch promise (prevents duplicate requests) + + todaySummary: { + loading: true, + milk: 0, + food: 0, + poo: 0, + vitD: false, + lastMilkAt: null + }, + + vitamindReminder: { show: false, checked: false }, + + growth: { + initialized: false, + loading: false, + error: false, + errorMessage: '', + aiVisible: false, + aiText: '正在分析最新数据...' + }, + + // Report tab state + report: { + range: 7, + loading: false, + summary: { avgDailyMilk: 0, totalMilk: 0, foodVariety: 0, poopCount: 0, activeDays: 0 }, + foodLog: [], + pooSummary: { normal: 0, abnormal: 0, total: 0 }, + _data: null, + _rendered: false + }, + + _cfg: { + IMGBED_API: '', + GROW_API_URL: '', + AI_API_URL: '', + BIRTH_DATE: '2025/03/09' + }, + + get isEditing() { + return !!this.editingId; + }, + + get formDatetime() { + if (!this.form.date || !this.form.time) return ''; + return `${this.form.date}T${this.form.time}`; + }, + + /** Set form date/time from a datetime-local style string (YYYY-MM-DDTHH:mm) */ + setFormDatetime(datetimeLocal) { + if (!datetimeLocal || typeof datetimeLocal !== 'string') { + this.form.date = ''; + this.form.time = ''; + return; + } + const [date = '', time = ''] = datetimeLocal.split('T'); + this.form.date = date; + this.form.time = (time || '').slice(0, 5); + }, + + // --- Lifecycle --- + async init() { + try { + if (window.envReady) await window.envReady; + await BabyDB.init(); + } catch (e) { + console.warn('Init warning:', e); + } + + this._cfg = { + IMGBED_API: window.env?.IMGBED_API || '', + GROW_API_URL: window.env?.GROW_API_URL || '', + AI_API_URL: window.env?.AI_API_URL || '', + BIRTH_DATE: window.env?.BIRTH_DATE || '2025/03/09' + }; + + // Restore last milk prefs + const pref = loadLastMilkPref(); + if (pref?.amount) this.lastMilkAmount = pref.amount; + if (pref?.subType) this.lastMilkSubType = pref.subType; + + this.resetForm(false); + this.historyDate = BabyUtils.getTodayCN(); + this.ready = true; + + this._clockTimer = setInterval(() => { this.now = Date.now(); }, 30000); + window.addEventListener('beforeunload', () => this.destroy()); + + // Init theme + const stored = localStorage.getItem('baby_theme'); + if (stored === 'dark' || stored === 'light') { + this.theme = stored; + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + this.theme = 'dark'; + } + this.applyTheme(); + + // Listen for OS-level theme changes (only when user hasn't stored a preference) + this._darkMql = window.matchMedia('(prefers-color-scheme: dark)'); + this._darkMqlListener = (e) => { + if (!localStorage.getItem('baby_theme')) { + this.theme = e.matches ? 'dark' : 'light'; + this.applyTheme(); + } + }; + this._darkMql.addEventListener('change', this._darkMqlListener); + + // Parallel bootstrap fetches + this.checkYesterdayVitD(); + this.refreshTodaySummary(); + this.hydrateLatestMilk(); + }, + + destroy() { + if (this._clockTimer) { + clearInterval(this._clockTimer); + this._clockTimer = null; + } + this._revokePreview(); + if (this._darkMql && this._darkMqlListener) { + this._darkMql.removeEventListener('change', this._darkMqlListener); + } + if (window.BabyCharts) BabyCharts.disposeAll(); + }, + + // --- Theme --- + toggleTheme() { + this.theme = this.theme === 'dark' ? 'light' : 'dark'; + this.applyTheme(); + localStorage.setItem('baby_theme', this.theme); + }, + + applyTheme() { + document.documentElement.dataset.theme = this.theme; + const meta = document.getElementById('theme-color-meta'); + if (meta) { + meta.content = this.theme === 'dark' ? '#1A1816' : '#D4896A'; + } + // iOS status bar content style (black-translucent needs dark icons on light bg) + let statusMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]'); + if (statusMeta) { + statusMeta.content = this.theme === 'dark' ? 'black-translucent' : 'default'; + } + // Re-render charts on current view to pick up new colors + if (this.view === 'report' && this.report._rendered) { + this.$nextTick(() => this.renderReportCharts()); + } + if (this.view === 'growth' && this.growth.initialized) { + // Full reload picks up CSS var colors; resize alone keeps old palette + this.growth.initialized = false; + this.$nextTick(() => this.loadGrowthData()); + } + }, + + // --- View Switching --- + async switchToHistory() { + this.view = 'history'; + await this.fetchHistory(); + }, + + async switchToGrowth() { + this.view = 'growth'; + // Start ECharts load early (no need to await — renderChart will handle it) + BabyCharts.ensureECharts(); + if (!this.growth.initialized && !this.growth.loading) { + await this.loadGrowthData(); + } else if (this.growth.initialized) { + this.$nextTick(() => BabyCharts.resizeAll()); + } + }, + + switchToRecord() { + this.view = 'record'; + // Keep edit state if mid-edit; otherwise refresh summary quietly + if (!this.isEditing) this.refreshTodaySummary(); + }, + + async switchToReport() { + this.view = 'report'; + BabyCharts.ensureECharts(); + if (!this.report._rendered && !this.report.loading) { + await this.loadReportData(); + } else if (this.report._rendered) { + this.$nextTick(() => this.renderReportCharts()); + } + }, + + // --- Today summary / last milk --- + async refreshTodaySummary() { + try { + const today = BabyUtils.getTodayCN(); + const start = `${today}T00:00:00+08:00`; + const end = `${today}T23:59:59.999+08:00`; + const { data, error } = await BabyDB.fetchRecords(start, end); + if (error) throw new Error(error.message); + + const milks = data.filter(i => i.type === 'milk'); + this.todaySummary = { + loading: false, + milk: milks.reduce((a, b) => a + (b.amount || 0), 0), + food: data.filter(i => i.type === 'food').length, + poo: data.filter(i => i.type === 'poo').length, + vitD: data.some(i => BabyUtils.isVitDRecord(i)), + // records are desc by time → first milk is latest + lastMilkAt: milks[0]?.recorded_at || null + }; + } catch (err) { + console.warn('Today summary failed:', err); + this.todaySummary.loading = false; + } + }, + + async hydrateLatestMilk() { + try { + const { data } = await BabyDB.fetchLatestMilk(); + if (!data) return; + + if (!this.todaySummary.lastMilkAt) { + this.todaySummary.lastMilkAt = data.recorded_at; + } + if (data.amount && !this.lastMilkAmount) { + this.lastMilkAmount = data.amount; + } + if (data.sub_type && !this.lastMilkSubType) { + this.lastMilkSubType = data.sub_type; + } + // If form is still empty milk defaults, apply once + if (!this.isEditing && this.form.type === 'milk' && !this.form.amount && data.amount) { + this.form.amount = String(data.amount); + if (data.sub_type) this.form.subType = data.sub_type; + } + } catch (err) { + console.warn('Latest milk hydrate failed:', err); + } + }, + + // --- Form --- + toggleExpand(id) { + this.expandedId = this.expandedId === id ? null : id; + }, + + /** Shared type defaults — used by both setType() and resetForm() */ + _applyTypeDefaults(t) { + if (t === 'milk') { + this.form.subType = this.lastMilkSubType || '配方奶'; + this.form.amount = this.lastMilkAmount ? String(this.lastMilkAmount) : ''; + } else if (t === 'food') { + this.form.subType = ''; + this.form.amount = ''; + } else if (t === 'poo') { + this.form.subType = '正常糊状'; + this.form.amount = ''; + } else if (t === 'meds') { + this.form.subType = '维D'; + this.form.amount = ''; + } + }, + + setType(t) { + if (this.form.type === t) return; + + this.form.type = t; + this.form.notes = this.isEditing ? this.form.notes : ''; + this.form.isNewFood = false; + this.form.tags = '无异常'; + + // Clear image when leaving poo + if (t !== 'poo') { + this._revokePreview(); + this.form.imageFile = null; + this.form.previewUrl = null; + this.existingImageUrl = null; + this.removeImage = true; + const input = document.querySelector('.file-input'); + if (input) input.value = ''; + } + + this._applyTypeDefaults(t); + }, + + setMilkAmount(ml) { + this.form.amount = String(ml); + }, + + handleFileSelect(event) { + const file = event.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + return BabyUtils.toast('请选择图片文件', 'warning'); + } + if (file.size > 5 * 1024 * 1024) { + return BabyUtils.toast('图片太大了,请小于 5MB', 'warning'); + } + + this._revokePreview(); + this.form.imageFile = file; + this.form.previewUrl = URL.createObjectURL(file); + this._previewUrl = this.form.previewUrl; + this.removeImage = false; + }, + + clearFile() { + this._revokePreview(); + this.form.imageFile = null; + this.form.previewUrl = null; + this.existingImageUrl = null; + this.removeImage = true; + const input = document.querySelector('.file-input'); + if (input) input.value = ''; + }, + + _revokePreview() { + if (this._previewUrl) { + BabyUtils.revokeObjectURL(this._previewUrl); + this._previewUrl = null; + } + }, + + async uploadImage(file) { + const api = this._cfg.IMGBED_API; + if (!api || api.includes('YOUR_')) { + throw new Error('图片上传服务未配置'); + } + + const formData = new FormData(); + formData.append('file', file); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch(api, { + method: 'POST', + body: formData, + signal: controller.signal + }); + if (!response.ok) throw new Error(`上传失败 (${response.status})`); + + const json = await response.json(); + + if (Array.isArray(json) && json[0]?.src) { + return new URL(json[0].src, api).href; + } + if (json.url) return json.url; + if (json.data?.url) return json.data.url; + if (json.src) return new URL(json.src, api).href; + + throw new Error('无法解析图片返回格式'); + } finally { + clearTimeout(timeoutId); + } + }, + + validateForm() { + if (!this.form.date || !this.form.time) { + BabyUtils.toast('请选择记录时间', 'warning'); + return false; + } + if (this.form.type === 'milk') { + const amount = parseInt(this.form.amount, 10); + if (!amount || amount <= 0) { + BabyUtils.toast('请填写有效奶量', 'warning'); + return false; + } + if (amount > 500) { + BabyUtils.toast('奶量看起来不太对,请确认', 'warning'); + return false; + } + } + if (this.form.type === 'food' && !String(this.form.subType || '').trim()) { + BabyUtils.toast('请填写食材', 'warning'); + return false; + } + return true; + }, + + buildPayload(imageUrl) { + return { + recorded_at: BabyUtils.toISOFromCNLocal(this.formDatetime), + type: this.form.type, + sub_type: this.form.subType || null, + amount: this.form.amount ? parseInt(this.form.amount, 10) : null, + is_new_food: this.form.type === 'food' ? !!this.form.isNewFood : false, + tags: this.form.type === 'food' ? this.form.tags : null, + notes: (this.form.notes || '').trim() || null, + image_url: imageUrl + }; + }, + + async submit() { + if (this.loading) return; + if (!this.validateForm()) return; + + this.loading = true; + + try { + // Resolve image + let imageUrl = this.removeImage ? null : this.existingImageUrl; + + if (this.form.type === 'poo' && this.form.imageFile) { + this.uploading = true; + try { + imageUrl = await this.uploadImage(this.form.imageFile); + } catch (e) { + BabyUtils.toast('图片上传失败: ' + e.message, 'error'); + return; + } finally { + this.uploading = false; + } + } else if (this.form.type !== 'poo') { + imageUrl = null; + } + + const payload = this.buildPayload(imageUrl); + const editing = this.isEditing; + + if (editing) { + const { error } = await BabyDB.updateRecord(this.editingId, payload); + if (error) throw new Error(error.message); + BabyUtils.toast('已更新', 'success'); + } else { + const { error } = await BabyDB.insertRecord(payload); + if (error) throw new Error(error.message); + BabyUtils.toast('保存成功', 'success'); + } + + // Remember milk prefs + if (this.form.type === 'milk' && payload.amount) { + this.lastMilkAmount = payload.amount; + this.lastMilkSubType = payload.sub_type || '配方奶'; + saveLastMilkPref(this.lastMilkAmount, this.lastMilkSubType); + } + + const savedDate = this.form.date; + // Invalidate cache for the affected date so next view shows fresh data + delete this._historyCache[savedDate]; + + if (this.form.type === 'meds') { + this.vitamindReminder.show = false; + } + + this.resetForm(true); + this.refreshTodaySummary(); + + this.historyDate = savedDate; + await this.switchToHistory(); + } catch (err) { + console.error('Submit error:', err); + BabyUtils.toast((this.isEditing ? '更新' : '保存') + '失败: ' + (err.message || '未知错误'), 'error'); + } finally { + this.loading = false; + this.uploading = false; + } + }, + + /** Reset form; keepType=true preserves current type for quick re-entry */ + resetForm(keepType) { + const type = keepType ? this.form.type : 'milk'; + this.editingId = null; + this.existingImageUrl = null; + this.removeImage = false; + this._revokePreview(); + this.form.imageFile = null; + this.form.previewUrl = null; + this._previewUrl = null; + const input = document.querySelector('.file-input'); + if (input) input.value = ''; + + this.form.type = type; + this.form.notes = ''; + this.form.isNewFood = false; + this.form.tags = '无异常'; + this.resetTimeCN(); + + this._applyTypeDefaults(type); + }, + + // --- Edit --- + startEdit(log) { + if (!log?.id) return; + + this.editingId = log.id; + this.expandedId = null; + this.removeImage = false; + this._revokePreview(); + this.form.imageFile = null; + this._previewUrl = null; + + this.form.type = log.type; + this.setFormDatetime(isoToCNLocal(log.recorded_at)); + this.form.subType = log.sub_type || ( + log.type === 'milk' ? '配方奶' : + log.type === 'poo' ? '正常糊状' : + log.type === 'meds' ? '维D' : '' + ); + this.form.amount = log.amount != null ? String(log.amount) : ''; + this.form.isNewFood = !!log.is_new_food; + this.form.tags = log.tags || '无异常'; + this.form.notes = log.notes || ''; + + if (log.type === 'poo' && log.image_url) { + this.existingImageUrl = log.image_url; + this.form.previewUrl = log.image_url; + } else { + this.existingImageUrl = null; + this.form.previewUrl = null; + } + + this.view = 'record'; + // Scroll form into view on next tick + this.$nextTick(() => { + const el = document.querySelector('.edit-banner') || document.querySelector('.modern-card'); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }, + + cancelEdit() { + this.resetForm(false); + BabyUtils.toast('已取消编辑', 'info'); + }, + + // --- History --- + computeStats(data) { + return { + milk: data.filter(i => i.type === 'milk').reduce((a, b) => a + (b.amount || 0), 0), + food: data.filter(i => i.type === 'food').length, + poo: data.filter(i => i.type === 'poo').length, + vitD: data.some(i => BabyUtils.isVitDRecord(i)) + }; + }, + + async fetchHistory() { + if (!this.historyDate) return; + + // Guard against future dates — show friendly message instead of empty + const today = BabyUtils.getTodayCN(); + if (this.historyDate > today) { + this.logs = []; + this.stats = { milk: 0, food: 0, poo: 0, vitD: false }; + return; + } + + // Return cached result if we already have this date + if (this._historyCache[this.historyDate]) { + const cached = this._historyCache[this.historyDate]; + this.logs = cached.logs; + this.stats = cached.stats; + if (this.historyDate === today) { + this.todaySummary = { ...this.todaySummary, ...cached.todaySummary }; + } + return; + } + + // Deduplicate concurrent fetches for the same date + if (this._fetchHistoryLock) { + await this._fetchHistoryLock; + return; + } + + this.loading = true; + this.expandedId = null; + + let resolveLock; + this._fetchHistoryLock = new Promise(r => { resolveLock = r; }); + + try { + const start = `${this.historyDate}T00:00:00+08:00`; + const end = `${this.historyDate}T23:59:59.999+08:00`; + const { data, error } = await BabyDB.fetchRecords(start, end); + + if (error) throw new Error(error.message); + + this.logs = data; + this.stats = this.computeStats(data); + + const cacheEntry = { logs: data, stats: { ...this.stats }, todaySummary: null }; + + // Keep today summary in sync when viewing today + if (this.historyDate === BabyUtils.getTodayCN()) { + const milks = data.filter(i => i.type === 'milk'); + this.todaySummary = { + loading: false, + milk: this.stats.milk, + food: this.stats.food, + poo: this.stats.poo, + vitD: this.stats.vitD, + lastMilkAt: milks[0]?.recorded_at || this.todaySummary.lastMilkAt + }; + cacheEntry.todaySummary = { ...this.todaySummary }; + } + + // Cache the result + this._historyCache[this.historyDate] = cacheEntry; + } catch (err) { + console.error('Fetch history error:', err); + BabyUtils.toast('加载失败: ' + err.message, 'error'); + this.logs = []; + this.stats = { milk: 0, food: 0, poo: 0, vitD: false }; + } finally { + this.loading = false; + this._fetchHistoryLock = null; + if (resolveLock) resolveLock(); + } + }, + + async deleteRecord(id) { + if (!confirm('确认删除这条记录?')) return; + + try { + const { error } = await BabyDB.deleteRecord(id); + if (error) throw new Error(error.message); + BabyUtils.toast('已删除', 'success'); + + // Invalidate cache for current history date + delete this._historyCache[this.historyDate]; + + // If deleting the record currently being edited + if (this.editingId === id) this.resetForm(false); + + await this.fetchHistory(); + this.refreshTodaySummary(); + this.checkYesterdayVitD(); + } catch (err) { + BabyUtils.toast('删除失败: ' + err.message, 'error'); + } + }, + + async exportData() { + if (this.exporting) return; + this.exporting = true; + + try { + const today = BabyUtils.getTodayCN(); + const startDate = BabyUtils.addDays(today, -29); + const start = `${startDate}T00:00:00+08:00`; + const end = `${today}T23:59:59.999+08:00`; + + const { data: foodData, error } = await BabyDB.fetchRecordsByType( + start, end, ['milk', 'food'] + ); + if (error) throw new Error(error.message); + + let growthRecord = null; + try { + const url = this._cfg.GROW_API_URL; + if (url && !url.includes('YOUR_')) { + const res = await fetch(url); + if (res.ok) { + const json = await res.json(); + if (Array.isArray(json?.data) && json.data.length) { + growthRecord = [...json.data].sort( + (a, b) => new Date(b.checkDate) - new Date(a.checkDate) + )[0]; + } + } + } + } catch (e) { + console.warn('获取生长数据失败:', e); + } + + const birthDateStr = this._cfg.BIRTH_DATE || '未知'; + const birthDate = BabyUtils.parseFlexibleDate(birthDateStr); + const currentAge = BabyUtils.getMonthDiff(birthDate, new Date()).toFixed(1); + + const milkRecords = (foodData || []).filter(r => r.type === 'milk'); + const foodRecords = (foodData || []).filter(r => r.type === 'food'); + const totalMilk = milkRecords.reduce((a, b) => a + (b.amount || 0), 0); + + const activeDays = new Set( + (foodData || []).map(r => { + const d = new Date(new Date(r.recorded_at).getTime() + 8 * 3600 * 1000); + return d.toISOString().slice(0, 10); + }) + ); + const dayCount = Math.max(activeDays.size, 1); + const avgDailyMilk = Math.round(totalMilk / dayCount); + + let text = ''; + text += `【宝宝基本信息】\n`; + text += `出生日期: ${birthDateStr}\n`; + text += `当前月龄: 约${currentAge}个月\n`; + + text += `\n【最近一次生长记录】\n`; + if (growthRecord) { + text += `检查日期: ${growthRecord.checkDate}\n`; + text += `体重: ${growthRecord.weight}kg\n`; + text += `身高: ${growthRecord.height}cm\n`; + text += `头围: ${growthRecord.headCircumference}cm\n`; + } else { + text += `暂无生长数据\n`; + } + + text += `\n【最近30天饮食统计】\n`; + text += `有记录天数: ${activeDays.size}天\n`; + text += `喝奶记录: ${milkRecords.length}次\n`; + text += `总奶量: ${totalMilk}ml\n`; + text += `日均奶量: ${avgDailyMilk}ml(按有记录天数)\n`; + text += `辅食记录: ${foodRecords.length}次\n`; + + text += `\n【详细饮食记录(按日期分组)】\n`; + + const groupedByDate = {}; + (foodData || []).forEach(record => { + const d = new Date(new Date(record.recorded_at).getTime() + 8 * 3600 * 1000); + const date = d.toISOString().slice(0, 10); + if (!groupedByDate[date]) groupedByDate[date] = []; + groupedByDate[date].push(record); + }); + + Object.keys(groupedByDate) + .sort((a, b) => b.localeCompare(a)) + .forEach(date => { + const records = groupedByDate[date]; + const dayMilk = records + .filter(r => r.type === 'milk') + .reduce((a, b) => a + (b.amount || 0), 0); + const dayFoods = records + .filter(r => r.type === 'food') + .map(r => r.sub_type) + .filter(Boolean); + + text += `\n${date} (${BabyUtils.getDayLabel(date)}):\n`; + if (dayMilk > 0) text += ` 喝奶: ${dayMilk}ml\n`; + if (dayFoods.length > 0) text += ` 辅食: ${dayFoods.join(';')}\n`; + }); + + text += `\n【备注】\n`; + text += `以上数据可用于分析宝宝的饮食结构和生长发育情况,请根据这些信息给出科学的饮食建议。`; + + const copied = await BabyUtils.copyToClipboard(text); + if (copied) { + BabyUtils.toast('数据已复制,可发送给 AI 获取饮食建议', 'success'); + } else { + BabyUtils.toast('复制失败,请检查浏览器权限', 'error'); + } + } catch (err) { + console.error('导出失败:', err); + BabyUtils.toast('导出失败: ' + err.message, 'error'); + } finally { + this.exporting = false; + } + }, + + openImage(url) { + if (!url) return; + this.modalImage = url; + this.showModal = true; + }, + + closeModal() { + this.showModal = false; + this.modalImage = ''; + }, + + // --- VitD Reminder --- + async checkYesterdayVitD() { + try { + const todayStr = BabyUtils.getTodayCN(); + const yesterdayStr = BabyUtils.getYesterdayCN(); + const { today, yesterday } = await BabyDB.checkVitDStatus(yesterdayStr, todayStr); + this.vitamindReminder.show = !today && !yesterday; + this.vitamindReminder.checked = true; + } catch (err) { + console.warn('VitD check failed:', err); + this.vitamindReminder.checked = true; + } + }, + + // --- Growth Charts --- + async loadGrowthData() { + this.growth.loading = true; + this.growth.error = false; + this.growth.errorMessage = ''; + this.growth.aiVisible = false; + + try { + const url = this._cfg.GROW_API_URL; + if (!url || url.includes('YOUR_')) { + throw new Error('生长数据 API 未配置'); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + let res; + try { + res = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + + if (!res.ok) throw new Error(`获取生长数据失败 (${res.status})`); + + const json = await res.json(); + if (!json || !Array.isArray(json.data)) { + throw new Error('生长数据格式不正确'); + } + + const records = json.data; + const birthDate = BabyUtils.parseFlexibleDate(this._cfg.BIRTH_DATE); + + const sorted = [...records] + .filter(r => r.checkDate) + .sort((a, b) => new Date(a.checkDate) - new Date(b.checkDate)); + const latest = sorted[sorted.length - 1]; + if (latest) this.fetchAIAssessment(latest); + + const userData = { weight: [], height: [], head: [], bmi: [] }; + + records.forEach(item => { + if (!item.checkDate) return; + const checkDate = new Date(item.checkDate); + const age = BabyUtils.getMonthDiff(birthDate, checkDate); + if (age < -0.5 || age > 25) return; + + if (item.weight > 0) userData.weight.push([age, item.weight]); + if (item.height > 0) userData.height.push([age, item.height]); + if (item.headCircumference > 0) userData.head.push([age, item.headCircumference]); + + const bmi = BabyUtils.calcBMI(item.weight, item.height); + if (bmi) userData.bmi.push([age, bmi]); + }); + + const sortFn = (a, b) => a[0] - b[0]; + Object.values(userData).forEach(arr => arr.sort(sortFn)); + + this.growth.loading = false; + this.growth.initialized = true; + + this.$nextTick(() => { + BabyCharts.renderChart('bmiChart', 'BMI', userData.bmi, 'bmi', [12, 22]); + BabyCharts.renderChart('weightChart', '体重 (kg)', userData.weight, 'weight', [0, 16]); + BabyCharts.renderChart('heightChart', '身长 (cm)', userData.height, 'height', [45, 100]); + BabyCharts.renderChart('headChart', '头围 (cm)', userData.head, 'head', [30, 52]); + }); + } catch (err) { + console.error(err); + this.growth.loading = false; + this.growth.error = true; + this.growth.errorMessage = err.name === 'AbortError' + ? '请求超时,请稍后重试' + : (err.message || '加载失败'); + } + }, + + async retryGrowth() { + this.growth.initialized = false; + await this.loadGrowthData(); + }, + + async fetchAIAssessment(latestRecord) { + const apiUrl = this._cfg.AI_API_URL; + if (!apiUrl || apiUrl === '' || apiUrl.includes('YOUR_')) { + this.growth.aiVisible = false; + return; + } + + this.growth.aiVisible = true; + this.growth.aiText = '正在分析最新数据...'; + + try { + const { checkDate, weight, height, headCircumference } = latestRecord; + const birth = BabyUtils.parseFlexibleDate(this._cfg.BIRTH_DATE); + const birthDateStr = [ + birth.getFullYear(), + String(birth.getMonth() + 1).padStart(2, '0'), + String(birth.getDate()).padStart(2, '0') + ].join('.'); + + const queryString = + `男婴出生日期 ${birthDateStr} 检查日期:${checkDate} ` + + `体重:${weight}kg 身高:${height}cm 头围:${headCircumference}cm ` + + `根据中国男婴《WS/T 423—2022》标准,给出当前水平,` + + `标明每个数值是什么范围之间(P50~P75)。150字左右`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + let res; + try { + res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: queryString }), + signal: controller.signal + }); + } finally { + clearTimeout(timeoutId); + } + + if (!res.ok) throw new Error(`服务器返回错误 ${res.status}`); + + const text = await res.text(); + if (!text || !text.trim()) throw new Error('AI 服务未返回数据'); + + this.growth.aiText = text.trim(); + } catch (err) { + console.error('AI API Error:', err); + + let errorMsg; + if (err.name === 'AbortError') { + errorMsg = 'AI 服务响应超时,请稍后重试'; + } else if (/502|503/.test(err.message)) { + errorMsg = 'AI 服务暂时不可用'; + } else if (/Failed to fetch|NetworkError/i.test(err.message)) { + errorMsg = '网络连接失败,请检查网络或 API 地址'; + } else { + errorMsg = err.message || '未知错误'; + } + + this.growth.aiText = + `智能评估暂时不可用\n\n${errorMsg}\n\n` + + `可稍后切换到生长页重试,或检查 AI_API_URL 配置`; + } + }, + + // --- Report --- + async loadReportData() { + this.report.loading = true; + this.report._rendered = false; + + try { + const end = BabyUtils.getTodayCN(); + const start = BabyUtils.addDays(end, -(this.report.range - 1)); + const { data, error } = await BabyDB.fetchRecords( + `${start}T00:00:00+08:00`, + `${end}T23:59:59.999+08:00` + ); + if (error) throw new Error(error.message); + + this.report._data = data || []; + this.computeReportSummary(); + this.report.loading = false; + + this.$nextTick(() => this.renderReportCharts()); + } catch (err) { + console.error('Report load failed:', err); + BabyUtils.toast('加载报告失败: ' + err.message, 'error'); + this.report.loading = false; + this.report.summary.activeDays = 0; + } + }, + + computeReportSummary() { + const data = this.report._data || []; + const milkRecords = data.filter(r => r.type === 'milk'); + const foodRecords = data.filter(r => r.type === 'food'); + const pooRecords = data.filter(r => r.type === 'poo'); + + // Active days + const activeDays = new Set( + data.map(r => { + const d = new Date(new Date(r.recorded_at).getTime() + 8 * 3600 * 1000); + return d.toISOString().slice(0, 10); + }) + ); + + // Milk stats + const totalMilk = milkRecords.reduce((a, b) => a + (b.amount || 0), 0); + const dayCount = Math.max(activeDays.size, 1); + const avgDailyMilk = Math.round(totalMilk / dayCount); + + // Food variety: parse sub_type strings split by common delimiters + const foodSet = new Set(); + foodRecords.forEach(r => { + const text = r.sub_type || ''; + // Remove parenthesized portions like (半碗), then split + const cleaned = text.replace(/[((][^))]*[))]/g, '、'); + cleaned.split(/[、,,;;]+/).forEach(item => { + const trimmed = item.trim(); + if (trimmed && trimmed.length < 30) foodSet.add(trimmed); + }); + }); + const foodLog = Array.from(foodSet).sort(); + + // Poo summary + let normalPoo = 0, abnormalPoo = 0; + pooRecords.forEach(r => { + if (r.sub_type === '正常糊状') { + normalPoo++; + } else { + abnormalPoo++; + } + }); + + this.report.summary = { + avgDailyMilk, + totalMilk, + foodVariety: foodSet.size, + poopCount: pooRecords.length, + activeDays: activeDays.size + }; + this.report.foodLog = foodLog; + this.report.pooSummary = { + normal: normalPoo, + abnormal: abnormalPoo, + total: pooRecords.length + }; + }, + + renderReportCharts() { + const data = this.report._data || []; + if (!data.length) return; + this.report._rendered = true; + + // --- Daily Milk Bar Chart --- + const end = BabyUtils.getTodayCN(); + const start = BabyUtils.addDays(end, -(this.report.range - 1)); + const dailyMilk = {}; + for (let i = 0; i < this.report.range; i++) { + const d = BabyUtils.addDays(start, i); + dailyMilk[d] = 0; + } + data.filter(r => r.type === 'milk').forEach(r => { + const d = new Date(new Date(r.recorded_at).getTime() + 8 * 3600 * 1000) + .toISOString().slice(0, 10); + if (dailyMilk.hasOwnProperty(d)) { + dailyMilk[d] += (r.amount || 0); + } + }); + + const dates = Object.keys(dailyMilk).sort(); + const values = dates.map(d => dailyMilk[d]); + const labels = dates.map(d => d.slice(5)); // "MM-DD" + + BabyCharts.renderBarChart('reportMilkBar', '每日奶量 (ml)', labels, values); + + // --- Milk Type Donut Chart --- + const typeMap = {}; + data.filter(r => r.type === 'milk').forEach(r => { + const st = r.sub_type || '未知'; + typeMap[st] = (typeMap[st] || 0) + (r.amount || 0); + }); + const donutData = Object.entries(typeMap).map(([name, value]) => ({ name, value })); + + BabyCharts.renderDonutChart('reportMilkDonut', '奶量分布', donutData); + }, + + // --- Date / Time Helpers --- + resetTimeCN() { + this.setFormDatetime(BabyUtils.getNowCNStr().slice(0, 16)); + }, + + getDayLabel(d) { return BabyUtils.getDayLabel(d); }, + formatTimeCN(iso) { return BabyUtils.formatTimeCN(iso); }, + formatType(t) { return BabyUtils.formatType(t); }, + getIcon(t) { return BabyUtils.getIcon(t); }, + + changeDate(days) { + this.historyDate = BabyUtils.addDays(this.historyDate, days); + this.fetchHistory(); + }, + + resetToToday() { + this.historyDate = BabyUtils.getTodayCN(); + this.fetchHistory(); + }, + + getLatestMilkId() { + if (this.historyDate !== BabyUtils.getTodayCN()) return null; + const milkLog = this.logs.find(l => l.type === 'milk'); + return milkLog ? milkLog.id : null; + }, + + getTimeDiff(recordTime) { + return BabyUtils.getTimeDiff(recordTime, this.now); + }, + + /** Display string for today-summary last feed */ + getLastFeedLabel() { + const t = this.todaySummary.lastMilkAt; + if (!t) return '暂无'; + return this.getTimeDiff(t); + }, + + getAgeDisplay() { + return BabyUtils.getAgeDisplay(this._cfg.BIRTH_DATE, this.historyDate); + }, + + isToday() { + return this.historyDate === BabyUtils.getTodayCN(); + } + })); +}); + +// Register shell-only service worker (static assets; never APIs) +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./sw.js').catch((err) => { + console.warn('SW register skipped:', err && err.message); + }); + }); +} diff --git a/src/js/charts.js b/src/js/charts.js @@ -0,0 +1,294 @@ +// ==================== Baby Tracker Charts ==================== +window.BabyCharts = (() => { + 'use strict'; + + const _charts = new Map(); // domId → { chart, handler } + let _sharedResizeHandler = null; + let _resizeTimer = null; + let _echartsReady = null; + + /** Lazy-load ECharts from CDN — only when growth tab is first opened */ + function ensureECharts() { + if (_echartsReady) return _echartsReady; + if (typeof echarts !== 'undefined') { + _echartsReady = Promise.resolve(true); + return _echartsReady; + } + + _echartsReady = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js'; + script.onload = () => resolve(true); + script.onerror = () => reject(new Error('ECharts 加载失败,请检查网络')); + document.head.appendChild(script); + }); + return _echartsReady; + } + + function ensureSharedResize() { + if (_sharedResizeHandler) return; + _sharedResizeHandler = () => { + clearTimeout(_resizeTimer); + _resizeTimer = setTimeout(() => { + for (const { chart } of _charts.values()) { + try { chart.resize(); } catch { /* disposed */ } + } + }, 150); + }; + window.addEventListener('resize', _sharedResizeHandler); + } + + function removeSharedResizeIfEmpty() { + if (_charts.size === 0 && _sharedResizeHandler) { + window.removeEventListener('resize', _sharedResizeHandler); + _sharedResizeHandler = null; + clearTimeout(_resizeTimer); + _resizeTimer = null; + } + } + + async function renderChart(domId, title, userPoints, standardKey, yRange) { + const el = document.getElementById(domId); + if (!el) return; + + await ensureECharts(); + if (typeof echarts === 'undefined') return; + + disposeByDomId(domId); + + const chart = echarts.init(el, null, { renderer: 'canvas' }); + + // Read colors from CSS custom properties so dark mode picks up automatically + const style = getComputedStyle(document.documentElement); + const babyColor = style.getPropertyValue('--accent').trim() || '#D4896A'; + const inkMuted = style.getPropertyValue('--muted').trim() || '#8A8580'; + const gridLine = style.getPropertyValue('--line-soft').trim() || '#EFEAE3'; + const inkColor = style.getPropertyValue('--ink').trim() || '#2C2A28'; + const surfaceColor = style.getPropertyValue('--surface').trim() || '#FFFFFF'; + + const option = { + backgroundColor: 'transparent', + title: { + text: title, + left: '8px', + top: '8px', + textStyle: { fontSize: 13, fontWeight: 650, color: inkColor } + }, + grid: { top: 46, right: 16, bottom: 28, left: 36, containLabel: false }, + tooltip: { + trigger: 'axis', + confine: true, + backgroundColor: inkColor, + borderWidth: 0, + textStyle: { color: surfaceColor, fontSize: 12 }, + formatter(params) { + const userItem = params.find(p => p.seriesName === '宝宝数据'); + if (!userItem || !userItem.value) return ''; + const [age, val] = userItem.value; + return `月龄 ${Number(age).toFixed(1)} 月<br/><b>${val}</b>`; + } + }, + xAxis: { + type: 'value', + min: 0, + max: 24, + interval: 3, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { formatter: '{value}月', color: inkMuted, fontSize: 11 } + }, + yAxis: { + type: 'value', + min: yRange + ? (value) => Math.floor(Math.min(value.min, yRange[0]) * 0.95) + : undefined, + scale: true, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { type: 'dashed', color: gridLine } }, + axisLabel: { color: inkMuted, fontSize: 11 } + }, + series: [ + ...BabyUtils.generateStandardSeries(standardKey), + { + name: '宝宝数据', + type: 'line', + data: userPoints, + smooth: false, + symbol: 'circle', + symbolSize: 7, + itemStyle: { color: babyColor, borderColor: surfaceColor, borderWidth: 2 }, + lineStyle: { color: babyColor, width: 2.5 }, + z: 10, + markPoint: userPoints.length + ? { + data: [{ + coord: userPoints[userPoints.length - 1], + symbolSize: 1, + label: { + show: true, + position: 'top', + formatter: () => String(userPoints[userPoints.length - 1][1]), + color: babyColor, + fontWeight: 700, + fontSize: 12 + } + }] + } + : undefined + } + ] + }; + + chart.setOption(option); + ensureSharedResize(); + _charts.set(domId, { chart, handler: null }); + } + + /** Helper: read CSS variable with fallback */ + function _cssVar(name, fallback) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + } + + /** Render a bar chart (used by report tab) */ + async function renderBarChart(domId, title, labels, values) { + const el = document.getElementById(domId); + if (!el) return; + await ensureECharts(); + if (typeof echarts === 'undefined') return; + disposeByDomId(domId); + + const accentColor = _cssVar('--accent', '#D4896A'); + const inkColor = _cssVar('--ink', '#2C2A28'); + const mutedColor = _cssVar('--muted', '#8A8580'); + const surfaceColor = _cssVar('--surface', '#FFFFFF'); + const lineColor = _cssVar('--line-soft', '#F0EBE3'); + + const chart = echarts.init(el, null, { renderer: 'canvas' }); + + chart.setOption({ + backgroundColor: 'transparent', + title: { + text: title, left: '8px', top: '8px', + textStyle: { fontSize: 13, fontWeight: 650, color: inkColor } + }, + grid: { top: 44, right: 16, bottom: 32, left: 44, containLabel: false }, + tooltip: { + trigger: 'axis', confine: true, + backgroundColor: inkColor, borderWidth: 0, + textStyle: { color: surfaceColor, fontSize: 12 } + }, + xAxis: { + type: 'category', data: labels, + axisLine: { show: false }, axisTick: { show: false }, + axisLabel: { color: mutedColor, fontSize: 10, rotate: labels.length > 10 ? 45 : 0 } + }, + yAxis: { + type: 'value', + axisLine: { show: false }, axisTick: { show: false }, + splitLine: { lineStyle: { type: 'dashed', color: lineColor } }, + axisLabel: { color: mutedColor, fontSize: 11 } + }, + series: [{ + type: 'bar', data: values, + itemStyle: { color: accentColor, borderRadius: [8, 8, 0, 0] }, + barMaxWidth: 40 + }] + }); + + ensureSharedResize(); + _charts.set(domId, { chart, handler: null }); + } + + /** Render a donut/pie chart (used by report tab) */ + async function renderDonutChart(domId, title, data) { + const el = document.getElementById(domId); + if (!el) return; + await ensureECharts(); + if (typeof echarts === 'undefined') return; + disposeByDomId(domId); + + const inkColor = _cssVar('--ink', '#2C2A28'); + const surfaceColor = _cssVar('--surface', '#FFFFFF'); + const mutedColor = _cssVar('--muted', '#8A8580'); + const palette = [ + _cssVar('--milk', '#6B9AC4'), + _cssVar('--accent', '#D4896A'), + _cssVar('--food', '#D4A574'), + _cssVar('--meds', '#9B8EC4'), + _cssVar('--poo', '#7D9B76') + ]; + + const chart = echarts.init(el, null, { renderer: 'canvas' }); + + chart.setOption({ + backgroundColor: 'transparent', + title: { + text: title, left: '8px', top: '8px', + textStyle: { fontSize: 13, fontWeight: 650, color: inkColor } + }, + tooltip: { + trigger: 'item', confine: true, + backgroundColor: inkColor, borderWidth: 0, + textStyle: { color: surfaceColor, fontSize: 12 }, + formatter: (p) => `${p.name}: ${p.value}ml (${p.percent}%)` + }, + legend: { + bottom: 8, + textStyle: { color: mutedColor, fontSize: 11 }, + itemWidth: 10, itemHeight: 10, itemGap: 16 + }, + series: [{ + type: 'pie', + radius: ['55%', '78%'], + center: ['50%', '46%'], + avoidLabelOverlap: false, + itemStyle: { borderRadius: 6, borderColor: surfaceColor, borderWidth: 2 }, + label: { + show: true, position: 'outside', + formatter: '{b}\n{d}%', color: mutedColor, fontSize: 11 + }, + emphasis: { label: { fontSize: 14, fontWeight: 'bold' } }, + data: data, + color: palette + }] + }); + + ensureSharedResize(); + _charts.set(domId, { chart, handler: null }); + } + + function disposeByDomId(domId) { + const entry = _charts.get(domId); + if (!entry) return; + try { entry.chart.dispose(); } catch { /* already disposed */ } + _charts.delete(domId); + removeSharedResizeIfEmpty(); + } + + function disposeAll() { + for (const { chart } of _charts.values()) { + try { chart.dispose(); } catch { /* already disposed */ } + } + _charts.clear(); + removeSharedResizeIfEmpty(); + } + + /** Re-render all charts (e.g. after tab becomes visible again) */ + function resizeAll() { + for (const { chart } of _charts.values()) { + try { chart.resize(); } catch { /* disposed */ } + } + } + + return { + ensureECharts, + renderChart, + renderBarChart, + renderDonutChart, + disposeByDomId, + disposeAll, + resizeAll + }; +})(); diff --git a/src/js/config.js b/src/js/config.js @@ -0,0 +1,77 @@ +/** + * Environment Configuration for Baby Tracker App + * + * Loads from remote API with local fallback defaults. + * Other modules should await window.envReady before reading window.env. + */ + +const CONFIG_API_URL = 'https://config.ooyy.de5.net/api/kv/babyday'; +const CONFIG_API_AUTH = 'c2RFV3dlMzIzMmZGVjplZGl0b3I='; +const CONFIG_TIMEOUT_MS = 5000; + +// Default fallback values (used when remote config is unavailable) +window.env = { + SUPABASE_URL: 'https://ovehbuxmhknxhuhetigc.supabase.co', + SUPABASE_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im92ZWhidXhtaGtueGh1aGV0aWdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjM1OTU1NjMsImV4cCI6MjA3OTE3MTU2M30.XuThuzSrDGhgFK5d82lqVy_ClyDy0xkYUxKe1ego2gs', + IMGBED_API: 'https://cf-imgbed-31z.pages.dev/upload?authCode=9YW0fVuUFAoF8KhGLrnTPCk', + GROW_API_URL: 'https://jsondata.888653.xyz/grow', + // Set to '' or null to disable AI assessment + AI_API_URL: 'https://aiask.888653.xyz', + // Baby birth date (YYYY/MM/DD) + BIRTH_DATE: '2025/03/09' +}; + +const CONFIG_KEYS = [ + 'SUPABASE_URL', + 'SUPABASE_KEY', + 'IMGBED_API', + 'GROW_API_URL', + 'AI_API_URL', + 'BIRTH_DATE' +]; + +async function loadRemoteConfig() { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CONFIG_TIMEOUT_MS); + + try { + const response = await fetch(CONFIG_API_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${CONFIG_API_AUTH}`, + 'Content-Type': 'application/json' + }, + signal: controller.signal + }); + + if (!response.ok) { + console.warn('Remote config unavailable, using defaults:', response.status); + return false; + } + + const config = await response.json(); + let applied = 0; + + for (const key of CONFIG_KEYS) { + if (config[key] !== undefined && config[key] !== null && config[key] !== '') { + window.env[key] = config[key]; + applied++; + } + } + + console.log(`Remote config loaded (${applied} keys applied)`); + return true; + } catch (error) { + if (error.name === 'AbortError') { + console.warn('Remote config timed out, using defaults'); + } else { + console.warn('Remote config failed, using defaults:', error.message); + } + return false; + } finally { + clearTimeout(timeoutId); + } +} + +// Modules should await this before depending on final env values +window.envReady = loadRemoteConfig(); diff --git a/src/js/db.js b/src/js/db.js @@ -0,0 +1,174 @@ +// ==================== Baby Tracker Database ==================== +window.BabyDB = (() => { + 'use strict'; + + let _db = null; + let _initPromise = null; + + /** + * Initialize Supabase client after env is ready. + * Safe to call multiple times — returns the same promise. + */ + function init() { + if (_initPromise) return _initPromise; + + _initPromise = (async () => { + // Wait for remote config (or fallback defaults) + if (window.envReady) { + try { await window.envReady; } catch { /* use defaults */ } + } + + const url = window.env?.SUPABASE_URL || ''; + const key = window.env?.SUPABASE_KEY || ''; + + if (!url || !key || url.includes('YOUR_') || key.includes('YOUR_')) { + console.warn('BabyDB: Supabase credentials not configured'); + return false; + } + + if (typeof supabase === 'undefined') { + console.warn('BabyDB: supabase-js SDK not loaded'); + return false; + } + + _db = supabase.createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false } + }); + return true; + })(); + + return _initPromise; + } + + function db() { + if (!_db) { + throw new Error('BabyDB 未初始化,请检查 Supabase 配置'); + } + return _db; + } + + async function ensureReady() { + const ok = await init(); + if (!ok) throw new Error('数据库未就绪,请检查网络或配置'); + } + + async function fetchRecords(start, end) { + await ensureReady(); + const { data, error } = await db() + .from('baby_records') + .select('*') + .gte('recorded_at', start) + .lte('recorded_at', end) + .order('recorded_at', { ascending: false }); + return { data: data || [], error }; + } + + async function fetchRecordsByType(start, end, types) { + await ensureReady(); + const { data, error } = await db() + .from('baby_records') + .select('*') + .gte('recorded_at', start) + .lte('recorded_at', end) + .in('type', types) + .order('recorded_at', { ascending: false }); + return { data: data || [], error }; + } + + async function insertRecord(payload) { + await ensureReady(); + const { data, error } = await db() + .from('baby_records') + .insert([payload]) + .select() + .single(); + return { data, error }; + } + + async function updateRecord(id, payload) { + await ensureReady(); + const { data, error } = await db() + .from('baby_records') + .update(payload) + .eq('id', id) + .select() + .single(); + return { data, error }; + } + + async function deleteRecord(id) { + await ensureReady(); + const { error } = await db() + .from('baby_records') + .delete() + .eq('id', id); + return { error }; + } + + /** Latest milk record (any day) — for default amount / last-feed timer */ + async function fetchLatestMilk() { + await ensureReady(); + const { data, error } = await db() + .from('baby_records') + .select('id, recorded_at, amount, sub_type') + .eq('type', 'milk') + .order('recorded_at', { ascending: false }) + .limit(1) + .maybeSingle(); + return { data: data || null, error }; + } + + /** + * Check VitD status for yesterday and today. + * Uses Asia/Shanghai date strings for comparison. + */ + async function checkVitDStatus(yesterdayStr, todayStr) { + try { + await ensureReady(); + const start = `${yesterdayStr}T00:00:00+08:00`; + const end = `${todayStr}T23:59:59+08:00`; + + const { data, error } = await db() + .from('baby_records') + .select('recorded_at, type, sub_type') + .eq('type', 'meds') + .gte('recorded_at', start) + .lte('recorded_at', end); + + if (error || !data) return { today: false, yesterday: false }; + + const isVitD = (row) => + (row.sub_type || '').includes('维D') || + (row.sub_type || '').includes('Vitamin') || + (row.sub_type || '').includes('VD'); + + // Convert recorded_at to CN date for comparison + const toCNDate = (iso) => { + const d = new Date(new Date(iso).getTime() + 8 * 60 * 60 * 1000); + return d.toISOString().slice(0, 10); + }; + + const today = data.some(row => isVitD(row) && toCNDate(row.recorded_at) === todayStr); + const yesterday = data.some(row => isVitD(row) && toCNDate(row.recorded_at) === yesterdayStr); + + return { today, yesterday }; + } catch (err) { + console.error('检查维D记录失败:', err); + return { today: false, yesterday: false }; + } + } + + // Kick off init early (non-blocking) + init(); + + return { + init, + fetchRecords, + fetchRecordsByType, + insertRecord, + updateRecord, + deleteRecord, + fetchLatestMilk, + checkVitDStatus + }; +})(); diff --git a/src/js/toast.js b/src/js/toast.js @@ -0,0 +1,37 @@ +// ==================== Baby Tracker Toast Notifications ==================== +document.addEventListener('alpine:init', () => { + Alpine.store('toast', { + messages: [], + _seq: 0, + + show(message, type = 'info', duration = 0) { + if (!message) return; + const defaults = { success: 2800, error: 4500, warning: 3500, info: 2800 }; + const id = `${Date.now()}-${++this._seq}`; + this.messages.push({ id, message, type, visible: true }); + + // Cap queue length to avoid stacking forever + if (this.messages.length > 5) { + const oldest = this.messages[0]; + this.dismiss(oldest.id); + } + + const ms = duration > 0 ? duration : (defaults[type] || 2800); + setTimeout(() => this.dismiss(id), ms); + }, + + dismiss(id) { + const msg = this.messages.find(m => m.id === id); + if (!msg || !msg.visible) return; + msg.visible = false; + setTimeout(() => { + this.messages = this.messages.filter(m => m.id !== id); + }, 300); + }, + + success(msg) { this.show(msg, 'success'); }, + error(msg) { this.show(msg, 'error'); }, + warning(msg) { this.show(msg, 'warning'); }, + info(msg) { this.show(msg, 'info'); } + }); +}); diff --git a/src/js/utils.js b/src/js/utils.js @@ -0,0 +1,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 + }; +})(); diff --git a/src/styles/main.css b/src/styles/main.css @@ -0,0 +1,1418 @@ +/* ============================================ + Baby Tracker — Warm Minimal (Parent Care) + Palette: + bg #FAF7F2 + surface #FFFFFF + ink #2C2A28 + muted #8A8580 + accent #D4896A terracotta + milk #6B9AC4 + food #D4A574 + poo #7D9B76 + meds #9B8EC4 + danger #C45C5C + success #5B9A6F + warning #C4923A + ============================================ */ + +:root { + /* Surfaces */ + --bg: #FAF7F2; + --bg-soft: #F3EDE4; + --surface: #FFFFFF; + --ink: #2C2A28; + --ink-secondary: #5C574F; + --muted: #8A8580; + --line: #E8E2D9; + --line-soft: #F0EBE3; + + /* Brand */ + --accent: #D4896A; + --accent-soft: #F6E4DB; + --accent-ink: #8B4F35; + --accent-border: #E8C9B8; + --on-accent: #FFFFFF; + + /* Type colors */ + --milk: #6B9AC4; + --milk-soft: #E8F1F8; + --milk-ink: #3D6488; + --milk-border: #C5D9EB; + --food: #D4A574; + --food-soft: #F7EDE0; + --food-ink: #8A6235; + --food-border: #E8D4B8; + --poo: #7D9B76; + --poo-soft: #E8F0E6; + --poo-ink: #4A6545; + --poo-border: #C5D8C0; + --meds: #9B8EC4; + --meds-soft: #EEEAF6; + --meds-ink: #5E5288; + --meds-border: #D5CEE8; + + /* Status */ + --danger: #C45C5C; + --danger-soft: #F8E8E8; + --danger-border: #E8C0C0; + --success: #5B9A6F; + --success-soft: #E5F2EA; + --success-border: #BFD9C8; + --success-ink: #2F5A3E; + --warning: #C4923A; + --warning-soft: #F8F0E0; + --warning-border: #E8D4A8; + --placeholder: #B5AFA6; + --icon-subtle: #C4BDB4; + + /* Overlays / focus */ + --overlay: rgba(44, 42, 40, 0.72); + --focus-ring: color-mix(in srgb, var(--accent) 22%, transparent); + --tint-shadow: color-mix(in srgb, var(--accent) 28%, transparent); + + /* Depth */ + --shadow-sm: 0 1px 2px rgba(44, 42, 40, 0.04), 0 1px 3px rgba(44, 42, 40, 0.06); + --shadow-md: 0 4px 16px rgba(44, 42, 40, 0.08); + --shadow-lg: 0 12px 32px rgba(44, 42, 40, 0.12); + + /* Radius */ + --radius-sm: 12px; + --radius-md: 16px; + --radius-lg: 20px; + --radius-full: 999px; + + /* Spacing rhythm */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --touch-min: 44px; + + /* Motion */ + --ease: 0.2s ease; + --ease-out: 0.22s cubic-bezier(0.22, 1, 0.36, 1); + + /* Safe areas */ + --sat: env(safe-area-inset-top, 0px); + --sab: env(safe-area-inset-bottom, 0px); + --sal: env(safe-area-inset-left, 0px); + --sar: env(safe-area-inset-right, 0px); +} + +/* ============================================ + Dark Mode — [data-theme="dark"] + ============================================ */ +[data-theme="dark"] { + --bg: #1A1816; + --bg-soft: #24211E; + --surface: #2C2926; + --ink: #EBE7E0; + --ink-secondary: #B0AAA2; + --muted: #8A857F; + --line: #3D3832; + --line-soft: #332E2A; + --accent: #E0A086; + --accent-soft: #3A2620; + --accent-ink: #E8C9B8; + --accent-border: #5A3A28; + --on-accent: #1A1816; + --milk: #8AB8D8; + --milk-soft: #1E2933; + --milk-ink: #B8D4E8; + --milk-border: #2D3F50; + --food: #E0C4A0; + --food-soft: #2D241E; + --food-ink: #E0C8A0; + --food-border: #4A3828; + --poo: #9ABD94; + --poo-soft: #1E2820; + --poo-ink: #B8D0B4; + --poo-border: #2D3F2A; + --meds: #B8AED8; + --meds-soft: #242130; + --meds-ink: #C8C0E0; + --meds-border: #3D3860; + --danger: #D88080; + --danger-soft: #362020; + --danger-border: #583030; + --success: #6DAD82; + --success-soft: #1E2822; + --success-border: #2A3F30; + --success-ink: #A8D0B8; + --warning: #D8A850; + --warning-soft: #2E2620; + --warning-border: #4A3A28; + --placeholder: #706B64; + --icon-subtle: #5A5550; + --overlay: rgba(0, 0, 0, 0.8); + --focus-ring: color-mix(in srgb, var(--accent) 28%, transparent); + --tint-shadow: color-mix(in srgb, var(--accent) 32%, transparent); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.18); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.22); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.32); +} + +/* ============================================ + Type tint tokens (shared by buttons / stats / logs) + ============================================ */ +.milk, .type-button.milk, .today-stat.milk, .stats-card.milk, +.submit-button.milk, .log-item.milk, .amount-chip, +.report-summary-card.milk { + --tint: var(--milk); + --tint-soft: var(--milk-soft); + --tint-ink: var(--milk-ink); + --tint-border: var(--milk-border); +} +.food, .type-button.food, .today-stat.food, .stats-card.food, +.submit-button.food, .log-item.food, .report-summary-card.food { + --tint: var(--food); + --tint-soft: var(--food-soft); + --tint-ink: var(--food-ink); + --tint-border: var(--food-border); +} +.poo, .type-button.poo, .today-stat.poo, .stats-card.poo, +.submit-button.poo, .log-item.poo, .report-summary-card.poo { + --tint: var(--poo); + --tint-soft: var(--poo-soft); + --tint-ink: var(--poo-ink); + --tint-border: var(--poo-border); +} +.meds, .type-button.meds, .today-stat.vitd, .stats-card.vitd, +.submit-button.meds, .log-item.meds, .report-summary-card.meds { + --tint: var(--meds); + --tint-soft: var(--meds-soft); + --tint-ink: var(--meds-ink); + --tint-border: var(--meds-border); +} + +/* --- Base --- */ +[x-cloak] { display: none !important; } +*, *::before, *::after { box-sizing: border-box; } + +html { + -webkit-tap-highlight-color: transparent; + -webkit-text-size-adjust: 100%; +} + +/* Keyboard accessibility */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} +:focus:not(:focus-visible) { + outline: none; +} +.tab-button:focus-visible, +.type-button:focus-visible, +.chip:focus-visible, +.amount-chip:focus-visible, +.submit-button:focus-visible, +.log-item:focus-visible, +.date-nav-btn:focus-visible, +.log-action-btn:focus-visible, +.export-btn:focus-visible, +.theme-toggle:focus-visible, +.report-range-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: inherit; +} +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +html, body { + overflow-x: hidden; + max-width: 100%; +} +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + background: var(--bg); + color: var(--ink); + margin: 0; + min-height: 100vh; + min-height: 100dvh; + padding-bottom: max(var(--space-4), var(--sab)); + overscroll-behavior-y: contain; +} + +/* --- Boot splash --- */ +.boot-splash { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--bg); + color: var(--muted); + padding: var(--sat) var(--sar) var(--sab) var(--sal); +} +.boot-splash .boot-mark { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + margin-bottom: 14px; + box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent-soft) 70%, transparent); + animation: boot-pulse 1.6s var(--ease-out) infinite; +} +.boot-splash .boot-brand { + font-size: 16px; + font-weight: 750; + color: var(--ink); + letter-spacing: 0.02em; + margin-bottom: 6px; +} +.boot-splash .boot-text { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--muted); +} +@keyframes boot-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.04); opacity: 0.92; } +} + +/* --- Main Container --- */ +.main-container { + max-width: 430px; + width: 100%; + margin: 0 auto; + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + overflow-x: hidden; + padding-left: var(--sal); + padding-right: var(--sar); +} + +.file-input { display: none; } + +.lightbox { + background: var(--overlay); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + padding: max(16px, var(--sat)) max(16px, var(--sar)) max(16px, var(--sab)) max(16px, var(--sal)); +} +.lightbox-close { + position: absolute; + top: -12px; + right: -12px; + width: var(--touch-min); + height: var(--touch-min); + background: var(--surface); + color: var(--ink-secondary); + border: 1px solid var(--line); + border-radius: 50%; + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + line-height: 1; + cursor: pointer; + transition: background var(--ease), color var(--ease); +} +.lightbox-close:hover { + background: var(--bg-soft); + color: var(--ink); +} + +.date-selector::-webkit-calendar-picker-indicator { + background: transparent; + bottom: 0; color: transparent; cursor: pointer; + height: auto; left: 0; position: absolute; right: 0; top: 0; width: auto; +} + +/* ============================================ + Header + ============================================ */ +.modern-header { + background: color-mix(in srgb, var(--surface) 92%, transparent); + backdrop-filter: blur(12px) saturate(1.2); + -webkit-backdrop-filter: blur(12px) saturate(1.2); + padding: calc(0.85rem + var(--sat)) 1rem 1rem; + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 40; +} + +.header-title { + font-weight: 700; + font-size: 1.05rem; + color: var(--ink); + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0; +} + +.header-title .subtitle { + font-size: 0.72rem; + font-weight: 500; + color: var(--muted); + margin-top: 2px; +} + +/* --- Tab Bar --- */ +.tab-bar { + background: var(--bg-soft); + border-radius: var(--radius-md); + padding: 4px; + margin-top: 0.85rem; + border: 1px solid var(--line-soft); + gap: 2px; +} + +.tab-button { + flex: 1; + min-height: 36px; + padding: 9px 4px; + border-radius: 12px; + font-weight: 600; + font-size: 12.5px; + color: var(--muted); + background: transparent; + border: none; + cursor: pointer; + transition: background var(--ease), color var(--ease), box-shadow var(--ease); + -webkit-user-select: none; + user-select: none; +} + +.tab-button:hover { + color: var(--ink-secondary); +} + +.tab-button.active { + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-sm); + font-weight: 700; +} + +.tab-button:active { + transform: scale(0.98); +} + +/* ============================================ + Cards + ============================================ */ +.modern-card { + background: var(--surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + border: 1px solid var(--line); +} + +/* ============================================ + Type Selection Buttons + ============================================ */ +.type-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} +@media (max-width: 360px) { + .type-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } +} + +.type-button { + min-height: 56px; + padding: 12px 6px; + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + font-weight: 650; + font-size: 12.5px; + background: var(--surface); + color: var(--muted); + border: 1.5px solid var(--line); + cursor: pointer; + transition: background var(--ease), border-color var(--ease), color var(--ease), box-shadow var(--ease), transform var(--ease); + -webkit-user-select: none; + user-select: none; +} + +.type-button span:first-child { + font-size: 22px; + line-height: 1; +} + +.type-button:active { transform: scale(0.97); } + +.type-button.active { + background: var(--tint-soft); + border-color: var(--tint); + color: var(--tint-ink); + box-shadow: 0 0 0 1px var(--tint); +} + +/* Sub-option chips (milk type etc.) */ +.chip-row { + display: flex; + gap: 8px; +} +.chip { + flex: 1; + min-height: 42px; + padding: 10px 8px; + border-radius: 12px; + border: 1.5px solid var(--line); + background: var(--bg); + color: var(--ink-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background var(--ease), border-color var(--ease), color var(--ease), transform var(--ease); +} +.chip.active { + background: var(--milk-soft); + border-color: var(--milk); + color: var(--milk-ink); + box-shadow: 0 0 0 1px var(--milk); +} +.chip:active { transform: scale(0.97); } + +/* ============================================ + Form Elements + ============================================ */ +.label-title { + display: block; + font-size: 12px; + font-weight: 650; + color: var(--muted); + margin-bottom: 8px; + margin-left: 2px; + letter-spacing: 0.02em; +} + +.input-field { + width: 100%; + padding: 13px 14px; + background: var(--bg); + border: 1.5px solid var(--line); + border-radius: 14px; + outline: none; + transition: border-color var(--ease), box-shadow var(--ease), background var(--ease); + font-size: 16px; + color: var(--ink); +} +.datetime-split { + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 8px; +} +.datetime-split-field { + min-width: 0; +} +.datetime-split .input-field { + min-width: 0; + width: 100%; + max-width: 100%; +} +input[type="date"], +input[type="time"] { + min-width: 0; + max-width: 100%; + -webkit-appearance: none; + appearance: none; +} +input[type="date"]::-webkit-calendar-picker-indicator, +input[type="time"]::-webkit-calendar-picker-indicator { + opacity: 0.7; + cursor: pointer; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@media (max-width: 360px) { + .datetime-split { + grid-template-columns: 1fr; + } +} + +.input-field::placeholder { color: var(--placeholder); } + +.input-field:focus { + background: var(--surface); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.modern-checkbox { + display: flex; + align-items: center; + padding: 13px 14px; + background: var(--warning-soft); + border-radius: 14px; + border: 1.5px solid var(--warning-border); +} + +.modern-checkbox input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--warning); + border-radius: 6px; +} + +/* Meds callout */ +.meds-callout { + padding: 22px 16px; + background: var(--meds-soft); + border-radius: var(--radius-md); + text-align: center; + border: 1.5px solid var(--meds-border); +} +.meds-callout .icon { font-size: 36px; margin-bottom: 8px; } +.meds-callout .title { font-weight: 700; color: var(--meds-ink); font-size: 16px; } +.meds-callout .hint { font-size: 13px; color: var(--muted); margin-top: 6px; } + +/* ============================================ + Submit Button + ============================================ */ +.submit-button { + width: 100%; + min-height: 50px; + padding: 15px; + border-radius: 14px; + border: none; + color: var(--on-accent); + font-weight: 700; + font-size: 16px; + cursor: pointer; + transition: opacity var(--ease), transform var(--ease), filter var(--ease), box-shadow var(--ease); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 18px; + background: var(--accent); + box-shadow: 0 2px 10px var(--tint-shadow); +} + +.submit-button:hover:not(:disabled) { filter: brightness(0.97); } +.submit-button:active:not(:disabled) { transform: scale(0.985); } +.submit-button:disabled { + cursor: not-allowed; + opacity: 0.65; + box-shadow: none; +} + +.submit-button.milk, +.submit-button.food, +.submit-button.poo, +.submit-button.meds { + background: var(--tint); + box-shadow: 0 2px 10px color-mix(in srgb, var(--tint) 30%, transparent); +} + +/* ============================================ + Date Navigation + ============================================ */ +.date-nav-card { + background: var(--surface); + border-radius: var(--radius-lg); + padding: 12px 12px 11px; + box-shadow: var(--shadow-sm); + border: 1px solid var(--line); +} + +.date-nav-btn { + width: var(--touch-min); + height: var(--touch-min); + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--bg); + border: 1px solid var(--line); + color: var(--ink-secondary); + cursor: pointer; + font-size: 16px; + font-weight: 700; + transition: background var(--ease), transform var(--ease); + flex-shrink: 0; +} +.date-nav-btn:hover { background: var(--bg-soft); } +.date-nav-btn:active { transform: scale(0.96); } + +.age-pill { + font-size: 12px; + font-weight: 700; + background: var(--accent-soft); + color: var(--accent-ink); + padding: 6px 12px; + border-radius: var(--radius-full); + line-height: 1.2; +} + +.export-btn { + font-size: 12px; + font-weight: 650; + background: var(--surface); + color: var(--ink-secondary); + border: 1px solid var(--line); + padding: 6px 12px; + min-height: 32px; + border-radius: var(--radius-full); + cursor: pointer; + transition: background var(--ease), border-color var(--ease); +} +.export-btn:hover { background: var(--bg); border-color: var(--muted); } +.export-btn:disabled { opacity: 0.55; cursor: not-allowed; } + +.link-today { + color: var(--accent); + font-weight: 650; + text-decoration: underline; + text-underline-offset: 2px; + background: none; + border: none; + cursor: pointer; + font-size: inherit; + padding: 0; +} + +/* ============================================ + Today Summary (record page) + ============================================ */ +.today-summary { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 14px; + box-shadow: var(--shadow-sm); +} + +.today-summary-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} + +.today-summary-title { + font-size: 13px; + font-weight: 750; + color: var(--ink); + letter-spacing: 0.01em; +} + +.today-summary-feed { + font-size: 12px; + color: var(--ink-secondary); +} +.today-summary-feed strong { + color: var(--accent-ink); + font-weight: 700; +} +.today-summary-feed.muted { color: var(--muted); } + +.today-summary-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +/* Shared metric cell (today + history) */ +.today-stat, +.stats-card { + text-align: center; + padding: 10px 4px; + border-radius: 12px; + background: var(--bg); + border: 1px solid var(--line-soft); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 58px; +} + +.today-stat.milk, .today-stat.food, .today-stat.poo, .today-stat.vitd, +.stats-card.milk, .stats-card.food, .stats-card.poo, .stats-card.vitd { + background: var(--tint-soft); + border-color: var(--tint-border); +} + +.today-stat.vitd.empty, +.stats-card.vitd.empty { + background: var(--bg); + border-color: var(--line); + --tint-ink: var(--placeholder); +} + +.today-stat-val, +.stats-value { + font-size: 17px; + font-weight: 750; + color: var(--ink); + line-height: 1.15; + letter-spacing: -0.01em; +} +.today-stat-val small { + font-size: 11px; + font-weight: 550; + margin-left: 1px; + color: var(--muted); +} +.today-stat.milk .today-stat-val, +.today-stat.food .today-stat-val, +.today-stat.poo .today-stat-val, +.today-stat.vitd .today-stat-val, +.stats-card.milk .stats-value, +.stats-card.food .stats-value, +.stats-card.poo .stats-value, +.stats-card.vitd .stats-value { + color: var(--tint-ink); +} +.today-stat.vitd.empty .today-stat-val, +.stats-card.vitd.empty .stats-value { + color: var(--placeholder); +} + +.today-stat-lbl, +.stats-label { + font-size: 10px; + font-weight: 650; + color: var(--muted); + margin-top: 3px; + letter-spacing: 0.03em; +} +.today-stat.milk .today-stat-lbl, +.today-stat.food .today-stat-lbl, +.today-stat.poo .today-stat-lbl, +.today-stat.vitd .today-stat-lbl, +.stats-card.milk .stats-label, +.stats-card.food .stats-label, +.stats-card.poo .stats-label, +.stats-card.vitd .stats-label { + color: color-mix(in srgb, var(--tint-ink) 78%, var(--muted)); +} +.today-stat.vitd.empty .today-stat-lbl, +.stats-card.vitd.empty .stats-label { + color: var(--muted); +} + +/* Edit banner */ +.edit-banner { + background: var(--accent-soft); + border: 1px solid var(--accent-border); + border-radius: var(--radius-md); + padding: 10px 14px; + margin-bottom: 12px; +} +.edit-cancel-btn { + font-size: 13px; + font-weight: 650; + color: var(--accent-ink); + background: var(--surface); + border: 1px solid var(--accent-border); + border-radius: var(--radius-full); + padding: 6px 14px; + min-height: 32px; + cursor: pointer; + transition: background var(--ease); +} +.edit-cancel-btn:hover { background: var(--bg); } + +/* Milk amount presets */ +.amount-presets { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.amount-chip { + flex: 1; + min-width: 52px; + min-height: 40px; + padding: 9px 6px; + border-radius: 10px; + border: 1.5px solid var(--line); + background: var(--bg); + color: var(--ink-secondary); + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background var(--ease), border-color var(--ease), color var(--ease), transform var(--ease); +} +.amount-chip.active { + background: var(--tint-soft, var(--milk-soft)); + border-color: var(--tint, var(--milk)); + color: var(--tint-ink, var(--milk-ink)); + box-shadow: 0 0 0 1px var(--tint, var(--milk)); +} +.amount-chip:active { transform: scale(0.97); } + +/* ============================================ + Stats Cards (history) + ============================================ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.stats-grid-4 { + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} +.stats-grid-4 .stats-value { font-size: 15px; } +.stats-card { + padding: 12px 6px; + border-radius: var(--radius-md); + min-height: 64px; +} + +/* ============================================ + Log Items + ============================================ */ +.log-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 14px 16px; + padding-right: 40px; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + border-left: 3px solid var(--line); + cursor: pointer; + transition: background var(--ease), box-shadow var(--ease), border-color var(--ease); + background: var(--surface); + box-shadow: var(--shadow-sm); + position: relative; +} + +.log-item:hover { + box-shadow: var(--shadow-md); +} + +.log-item.milk, +.log-item.food, +.log-item.poo, +.log-item.meds { + border-left-color: var(--tint); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--tint-soft) 55%, var(--surface)) 0%, + var(--surface) 28% + ); +} + +.log-item.expanded { + background: var(--bg); + border-color: var(--line); +} + +.badge { + padding: 3px 8px; + border-radius: 8px; + font-size: 11px; + font-weight: 650; +} + +.badge.new { + background: var(--danger-soft); + color: var(--danger); + border: 1px solid var(--danger-border); +} + +.badge.reaction { + background: var(--food-soft); + color: var(--food-ink); + border: 1px solid var(--food-border); +} + +.log-edit { + position: absolute; + top: 6px; + right: 6px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: none; + background: transparent; + color: var(--icon-subtle); + cursor: pointer; + font-size: 14px; + line-height: 1; + transition: color var(--ease), background var(--ease); +} +.log-edit:hover { + color: var(--accent-ink); + background: var(--accent-soft); +} + +.log-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.log-action-btn { + font-size: 12px; + font-weight: 650; + padding: 7px 14px; + min-height: 32px; + border-radius: var(--radius-full); + border: 1px solid var(--line); + background: var(--bg); + color: var(--ink-secondary); + cursor: pointer; + transition: background var(--ease), transform var(--ease); +} +.log-action-btn:active { transform: scale(0.97); } +.log-action-btn.edit { + border-color: var(--accent-border); + background: var(--accent-soft); + color: var(--accent-ink); +} +.log-action-btn.danger { + border-color: var(--danger-border); + background: var(--danger-soft); + color: var(--danger); +} + +.time-since { + margin-top: 4px; + font-size: 11px; + font-weight: 700; + color: var(--accent-ink); + background: var(--accent-soft); + padding: 2px 7px; + border-radius: 8px; + white-space: nowrap; +} + +.photo-link { + margin-top: 4px; + color: var(--poo-ink); + font-weight: 650; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--poo-soft); + padding: 4px 8px; + border-radius: 8px; + font-size: 13px; +} + +/* ============================================ + Image Upload + ============================================ */ +.image-upload-area { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; +} + +.upload-box { + width: 76px; + height: 76px; + border-radius: 14px; + background: var(--bg); + border: 1.5px dashed var(--muted); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + cursor: pointer; + transition: background var(--ease), border-color var(--ease); + overflow: hidden; + flex-shrink: 0; +} + +.upload-box:hover { + background: var(--accent-soft); + border-color: var(--accent); +} + +.upload-box img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ============================================ + VitD Reminder + ============================================ */ +.vitamind-reminder { + background: var(--warning-soft); + border: 1px solid var(--warning-border); + border-left: 3px solid var(--warning); + border-radius: var(--radius-md); + padding: 14px 16px; + margin-bottom: 14px; +} + +/* ============================================ + Growth / Chart Page + ============================================ */ +.assessment-box { + background: var(--success-soft); + border: 1px solid var(--success-border); + color: var(--success-ink); + padding: 18px; + border-radius: var(--radius-lg); + margin-bottom: 14px; + font-size: 14px; + line-height: 1.65; +} + +.assessment-box h3 { + margin: 0 0 10px 0; + font-size: 15px; + color: var(--success-ink); + display: flex; + align-items: center; + font-weight: 750; +} + +.assessment-box h3::before { + content: '✦'; + margin-right: 8px; + font-size: 14px; + color: var(--success); +} + +.assessment-loading { + color: var(--muted); + font-style: italic; +} + +.assessment-body { word-break: break-word; } + +.chart-container { + background: var(--surface); + border-radius: var(--radius-lg); + padding: 14px 10px 12px; + margin-bottom: 12px; + box-shadow: var(--shadow-sm); + border: 1px solid var(--line); +} + +.chart-box { + width: 100%; + height: 280px; +} + +/* ============================================ + Avatar Ring + ============================================ */ +.avatar-ring { + position: relative; + display: inline-flex; + border-radius: 50%; + box-shadow: 0 0 0 2px var(--surface), 0 0 0 3.5px var(--accent); + transition: transform var(--ease); +} +.avatar-ring:active { + transform: scale(0.96); +} +.avatar-ring img { + display: block; + border-radius: 50%; +} + +/* ============================================ + Spinner + ============================================ */ +.spinner { + width: 20px; + height: 20px; + border: 2.5px solid color-mix(in srgb, var(--on-accent) 35%, transparent); + border-top-color: var(--on-accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +.spinner-dark { + border-color: color-mix(in srgb, var(--accent) 22%, transparent); + border-top-color: var(--accent); +} + +@keyframes spin { to { transform: rotate(360deg); } } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} + +/* ============================================ + Empty State + ============================================ */ +.empty-state { + text-align: center; + padding: 48px 20px; + color: var(--muted); + background: var(--surface); + border: 1px dashed var(--line); + border-radius: var(--radius-lg); +} + +.empty-state-icon { + width: 64px; + height: 64px; + margin: 0 auto 14px; + border-radius: 50%; + background: var(--bg-soft); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + opacity: 1; +} + +.empty-state-text { + font-size: 14px; + font-weight: 600; + color: var(--ink-secondary); +} + +.empty-state-hint { + font-size: 12px; + font-weight: 500; + color: var(--muted); + margin-top: 6px; +} + +.empty-action { + margin-top: 16px; + font-size: 13px; + font-weight: 650; + color: var(--accent-ink); + background: var(--accent-soft); + border: 1px solid var(--accent-border); + border-radius: var(--radius-full); + cursor: pointer; + padding: 8px 16px; + min-height: 36px; + text-decoration: none; + transition: background var(--ease), transform var(--ease); +} +.empty-action:hover { + background: color-mix(in srgb, var(--accent-soft) 70%, var(--accent-border)); +} +.empty-action:active { transform: scale(0.97); } + +/* ============================================ + Toast + ============================================ */ +.toast-kid { + font-size: 14px; + border-radius: 14px; + box-shadow: var(--shadow-md); + font-weight: 600; + padding: 12px 16px !important; + backdrop-filter: blur(8px); +} + +.toast-success { background: var(--success) !important; } +.toast-error { background: var(--danger) !important; } +.toast-warning { background: var(--warning) !important; } +.toast-info { background: var(--milk) !important; } + +/* Utility helpers used from HTML */ +.text-accent { color: var(--accent); } +.text-ink { color: var(--ink); } +.text-muted { color: var(--muted); } +.text-milk { color: var(--milk-ink); } +.bg-surface { background: var(--surface); } + +/* ============================================ + Theme Toggle + ============================================ */ +.theme-toggle { + width: var(--touch-min); + height: var(--touch-min); + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--bg-soft); + border: 1px solid var(--line); + cursor: pointer; + font-size: 18px; + line-height: 1; + transition: background var(--ease), border-color var(--ease), transform var(--ease); + flex-shrink: 0; +} +.theme-toggle:hover { + background: var(--accent-soft); + border-color: var(--accent-border); +} +.theme-toggle:active { transform: scale(0.95); } + +/* ============================================ + Report Tab + ============================================ */ +.report-range-bar { + display: flex; + gap: 4px; + margin-bottom: 14px; + padding: 4px; + background: var(--bg-soft); + border: 1px solid var(--line-soft); + border-radius: var(--radius-md); +} +.report-range-btn { + flex: 1; + min-height: 36px; + padding: 9px 8px; + border-radius: 12px; + border: none; + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 650; + cursor: pointer; + transition: background var(--ease), color var(--ease), box-shadow var(--ease); +} +.report-range-btn.active { + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-sm); + font-weight: 700; +} +.report-range-btn:active { transform: scale(0.98); } + +.report-summary-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 14px; +} +.report-summary-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 14px 12px; + text-align: center; + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; +} +.report-summary-card.milk, +.report-summary-card.food, +.report-summary-card.poo, +.report-summary-card.meds { + background: linear-gradient( + 160deg, + var(--tint-soft) 0%, + var(--surface) 70% + ); + border-color: var(--tint-border); +} +.report-summary-card .rsc-value { + font-size: 22px; + font-weight: 750; + color: var(--ink); + line-height: 1.2; + letter-spacing: -0.02em; +} +.report-summary-card.milk .rsc-value, +.report-summary-card.food .rsc-value, +.report-summary-card.poo .rsc-value, +.report-summary-card.meds .rsc-value { + color: var(--tint-ink); +} +.report-summary-card .rsc-label { + font-size: 11px; + font-weight: 650; + color: var(--muted); + margin-top: 4px; + letter-spacing: 0.02em; +} + +.report-section-title { + font-size: 13px; + font-weight: 700; + color: var(--ink); + margin-bottom: 10px; +} + +.report-food-list { display: flex; flex-wrap: wrap; gap: 6px; } +.report-food-tag { + padding: 6px 11px; + border-radius: 10px; + background: var(--food-soft); + color: var(--food-ink); + border: 1px solid var(--food-border); + font-size: 12px; + font-weight: 600; +} + +.report-poo-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 4px; + flex-wrap: wrap; + font-size: 13px; + color: var(--ink-secondary); +} +.report-poo-count { + font-size: 14px; + font-weight: 700; + padding: 3px 10px; + border-radius: var(--radius-full); +} +.report-poo-count.normal { + color: var(--poo-ink); + background: var(--poo-soft); +} +.report-poo-count.abnormal { + color: var(--danger); + background: var(--danger-soft); +} + +/* Content bottom breathing room */ +.content-pad { + padding-left: 1rem; + padding-right: 1rem; + padding-top: 1rem; + padding-bottom: max(1.5rem, var(--sab)); +} diff --git a/sw.js b/sw.js @@ -0,0 +1,87 @@ +/* Baby Tracker — shell-only service worker + Precaches static UI assets. Never caches API / Supabase / config. */ +const CACHE = 'baby-shell-v1'; +const PRECACHE = [ + './', + './index.html', + './manifest.webmanifest', + './src/styles/main.css', + './src/js/config.js', + './src/js/utils.js', + './src/js/db.js', + './src/js/charts.js', + './src/js/toast.js', + './src/js/app.js', + './icons/icon.svg', + './icons/apple-touch-icon.svg' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE) + .then((cache) => cache.addAll(PRECACHE)) + .then(() => self.skipWaiting()) + .catch(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +function isShellRequest(url) { + // Only same-origin static assets + if (url.origin !== self.location.origin) return false; + const path = url.pathname; + if (path.endsWith('.html') || path.endsWith('/') || path.endsWith('.css') || + path.endsWith('.js') || path.endsWith('.svg') || path.endsWith('.webmanifest') || + path.endsWith('.ico') || path.endsWith('.png')) { + // Never cache remote config path if hosted same-origin under /api + if (path.includes('/api/')) return false; + return true; + } + return false; +} + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + + let url; + try { url = new URL(req.url); } catch { return; } + + // Pass through cross-origin (CDN, Supabase, APIs) + if (url.origin !== self.location.origin) return; + if (!isShellRequest(url)) return; + + // Network-first for HTML so deploys show up; cache-first for static assets + const isHTML = url.pathname.endsWith('.html') || url.pathname.endsWith('/'); + + if (isHTML) { + event.respondWith( + fetch(req).then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {}); + return res; + }).catch(() => caches.match(req).then((r) => r || caches.match('./index.html'))) + ); + return; + } + + event.respondWith( + caches.match(req).then((cached) => { + const network = fetch(req).then((res) => { + if (res && res.ok) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {}); + } + return res; + }).catch(() => cached); + return cached || network; + }) + ); +}); |