aboutsummaryrefslogtreecommitdiffstats
authorhui <hao263@outlook.com>2026-10-09 06:35:47 +0800
committerhui <hao263@outlook.com>2026-10-09 06:35:47 +0800
commite66ffea0e82569509c46ef3eaaf5595d3fd9cc55 (patch)
treeece9457704df251b29f5ae0b701bd1ee7f48ebfc
downloadbaby-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="关闭提醒">&times;</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="前一天">&lt;</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="后一天">&gt;</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="关闭">
+ &times;
+ </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;
+ })
+ );
+});