import fs from 'node:fs/promises'; import path from 'node:path'; const TANG_OWNER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db'; function todayCst() { return new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10); } function normalizeChildId(childId) { const id = Number.parseInt(String(childId ?? ''), 10); if (!Number.isFinite(id) || id <= 0) { throw Object.assign(new Error('孩子不存在'), { statusCode: 404 }); } return id; } function nowTimeCst() { const d = new Date(Date.now() + 8 * 3600e3); return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; } function dowOf(ds) { return new Date(Date.UTC(+ds.slice(0, 4), +ds.slice(5, 7) - 1, +ds.slice(8, 10))).getUTCDay(); } function theirWeekdayFromJs(jsDow) { return jsDow === 0 ? 7 : jsDow; } function addDays(ds, n) { const d = new Date(`${ds}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); } function monthStart(month) { return `${month}-01`; } function daysInMonth(month) { const [y, m] = month.split('-').map(Number); return new Date(Date.UTC(y, m, 0)).getUTCDate(); } async function readJson(filePath, fallback) { try { const text = await fs.readFile(filePath, 'utf8'); return JSON.parse(text); } catch (error) { if (error?.code === 'ENOENT') return fallback; throw error; } } async function writeJson(filePath, value) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function categoryMap(categories) { const map = new Map(); for (const c of categories) map.set(c.id, c); return map; } function enrichTask(task, categories) { const cat = task.category_id ? categories.find((c) => c.id === task.category_id) : null; return { ...task, category_name: cat?.name ?? task.category_name ?? null, category_color: cat?.color ?? task.category_color ?? '#6b7280', }; } function dayStatus(tasks, date) { const today = todayCst(); if (!tasks.length) return 'n'; const done = tasks.filter((t) => t.status === 'done').length; if (done === tasks.length) return 'green'; if (date < today) return done > 0 ? 'yellow' : 'red'; return done > 0 ? 'yellow' : 'n'; } function computeStreak(taskDatesByDay, childId, endDate) { let streak = 0; let d = endDate; for (let i = 0; i < 400; i += 1) { const tasks = (taskDatesByDay.get(`${childId}:${d}`) ?? []).filter((t) => t.status !== 'cancelled'); if (!tasks.length) break; const done = tasks.filter((t) => t.status === 'done').length; if (done === tasks.length) streak += 1; else break; d = addDays(d, -1); } return streak; } function computeBestStreak(allTasks, childId) { const byDate = new Map(); for (const t of allTasks) { if (t.child_id !== childId) continue; const key = t.task_date; if (!byDate.has(key)) byDate.set(key, []); byDate.get(key).push(t); } const dates = [...byDate.keys()].sort(); let best = 0; let cur = 0; let prev = null; for (const d of dates) { const tasks = byDate.get(d); const full = tasks.length && tasks.every((t) => t.status === 'done'); if (full) { if (prev && addDays(prev, 1) === d) cur += 1; else cur = 1; best = Math.max(best, cur); } else { cur = 0; } prev = d; } return best; } export function createLearnAssistantService({ dataRoot }) { if (!dataRoot) throw new Error('dataRoot is required'); function storeDir(familyId) { return path.join(dataRoot, 'families', familyId); } function legacyStoreDir(familyId) { return path.join(dataRoot, familyId); } function paths(familyId) { const dir = storeDir(familyId); return { dir, settings: path.join(dir, 'settings.json'), children: path.join(dir, 'children.json'), categories: path.join(dir, 'categories.json'), templates: path.join(dir, 'templates.json'), tasks: path.join(dir, 'tasks.json'), meta: path.join(dir, 'meta.json'), }; } async function loadStore(familyId) { const p = paths(familyId); const [settings, children, categories, templates, tasks, meta] = await Promise.all([ readJson(p.settings, { password: '123456' }), readJson(p.children, []), readJson(p.categories, []), readJson(p.templates, []), readJson(p.tasks, []), readJson(p.meta, { nextChildId: 1, nextCategoryId: 1, nextTemplateId: 1, nextTaskId: 1 }), ]); return { settings, children, categories, templates, tasks, meta, paths: p }; } async function saveStore(store) { const { paths: p, settings, children, categories, templates, tasks, meta } = store; await Promise.all([ writeJson(p.settings, settings), writeJson(p.children, children), writeJson(p.categories, categories), writeJson(p.templates, templates), writeJson(p.tasks, tasks), writeJson(p.meta, meta), ]); } function childEnergy(tasks, childId) { return tasks .filter((t) => t.child_id === childId && t.status === 'done') .reduce((sum, t) => sum + Number(t.points ?? 0), 0); } async function ensureDailyTasks(store, childId, date) { const activeTemplates = store.templates.filter((t) => t.active !== false && t.child_id === childId); const jsDow = dowOf(date); const theirDow = theirWeekdayFromJs(jsDow); let changed = false; for (const tpl of activeTemplates) { const weekdays = String(tpl.weekdays ?? '').split(',').map((x) => +x).filter(Boolean); if (!weekdays.includes(theirDow)) continue; const start = String(tpl.start_date ?? '').slice(0, 10); const end = tpl.end_date ? String(tpl.end_date).slice(0, 10) : null; if (start && date < start) continue; if (end && date > end) continue; const exists = store.tasks.some( (t) => t.child_id === childId && t.task_date === date && t.template_id === tpl.id, ); if (exists) continue; const cat = store.categories.find((c) => c.id === tpl.category_id); store.tasks.push({ id: store.meta.nextTaskId++, child_id: childId, template_id: tpl.id, category_id: tpl.category_id ?? null, category_name: cat?.name ?? null, category_color: cat?.color ?? '#6b7280', title: tpl.title, description: tpl.description ?? '', task_type: tpl.task_type ?? 'check', target_value: tpl.target_value ?? '', unit: tpl.unit ?? '', points: tpl.points ?? 10, task_date: date, start_date: start, status: 'pending', value_submitted: null, remark: null, completed_time: null, }); changed = true; } return changed; } async function getToday(familyId, childId, date = todayCst()) { childId = normalizeChildId(childId); const store = await loadStore(familyId); const child = store.children.find((c) => c.id === childId); if (!child) throw Object.assign(new Error('孩子不存在'), { statusCode: 404 }); if (await ensureDailyTasks(store, childId, date)) await saveStore(store); const tasks = store.tasks .filter((t) => t.child_id === childId && t.task_date === date) .map((t) => enrichTask(t, store.categories)); const today = todayCst(); const pastPending = store.tasks .filter((t) => t.child_id === childId && t.status === 'pending' && t.task_date < today) .sort((a, b) => b.task_date.localeCompare(a.task_date)) .map((t) => enrichTask({ ...t, task_date: t.task_date }, store.categories)); const done = tasks.filter((t) => t.status === 'done').length; const byDay = new Map(); for (const t of store.tasks) { const key = `${t.child_id}:${t.task_date}`; if (!byDay.has(key)) byDay.set(key, []); byDay.get(key).push(t); } return { child: { id: child.id, name: child.name, emoji: child.emoji }, date, tasks, pastPending, total: tasks.length, done, streak: computeStreak(byDay, childId, date), best: computeBestStreak(store.tasks, childId), energy: childEnergy(store.tasks, childId), }; } async function getDay(familyId, childId, date) { childId = normalizeChildId(childId); const store = await loadStore(familyId); if (await ensureDailyTasks(store, childId, date)) await saveStore(store); const tasks = store.tasks .filter((t) => t.child_id === childId && t.task_date === date) .map((t) => enrichTask(t, store.categories)); return { tasks }; } async function checkin(familyId, taskId, { value = null, remark = null } = {}) { const store = await loadStore(familyId); const task = store.tasks.find((t) => t.id === taskId); if (!task) throw Object.assign(new Error('任务不存在'), { statusCode: 404 }); if (task.status === 'done') throw Object.assign(new Error('任务已完成'), { statusCode: 400 }); task.status = 'done'; task.value_submitted = value == null || value === '' ? null : String(value); task.remark = remark ? String(remark) : null; task.completed_time = nowTimeCst(); await saveStore(store); const todayTasks = store.tasks.filter( (t) => t.child_id === task.child_id && t.task_date === task.task_date, ); const done = todayTasks.filter((t) => t.status === 'done').length; return { done, total: todayTasks.length, allDone: done === todayTasks.length, points: task.points ?? 0, }; } async function getCalendar(familyId, childId, month) { childId = normalizeChildId(childId); const store = await loadStore(familyId); const m = month || todayCst().slice(0, 7); const dim = daysInMonth(m); for (let d = 1; d <= dim; d += 1) { const ds = `${m}-${String(d).padStart(2, '0')}`; if (await ensureDailyTasks(store, childId, ds)) await saveStore(store); } const days = []; for (let d = 1; d <= dim; d += 1) { const ds = `${m}-${String(d).padStart(2, '0')}`; const tasks = store.tasks.filter((t) => t.child_id === childId && t.task_date === ds); if (!tasks.length) continue; const done = tasks.filter((t) => t.status === 'done').length; days.push({ date: ds, total: tasks.length, done, status: dayStatus(tasks, ds), }); } return { month: m, days }; } async function getGrowth(familyId, childId) { childId = normalizeChildId(childId); const store = await loadStore(familyId); const childTasks = store.tasks.filter((t) => t.child_id === childId); const totalDone = childTasks.filter((t) => t.status === 'done').length; const byDay = new Map(); for (const t of childTasks) { if (!byDay.has(t.task_date)) byDay.set(t.task_date, []); byDay.get(t.task_date).push(t); } let fullDays = 0; for (const tasks of byDay.values()) { if (tasks.length && tasks.every((t) => t.status === 'done')) fullDays += 1; } const since = addDays(todayCst(), -30); const recent = childTasks.filter((t) => t.task_date >= since); const byCategory = new Map(); for (const t of recent) { const name = t.category_name || store.categories.find((c) => c.id === t.category_id)?.name || '其他'; if (!byCategory.has(name)) byCategory.set(name, { name, total: 0, done: 0 }); const row = byCategory.get(name); row.total += 1; if (t.status === 'done') row.done += 1; } const byDayStreak = new Map(); for (const t of store.tasks) { const key = `${t.child_id}:${t.task_date}`; if (!byDayStreak.has(key)) byDayStreak.set(key, []); byDayStreak.get(key).push(t); } return { taskPoints: childTasks.filter((t) => t.status === 'done').reduce((s, t) => s + Number(t.points ?? 0), 0), fullDays, energy: childEnergy(store.tasks, childId), streak: computeStreak(byDayStreak, childId, todayCst()), best: computeBestStreak(store.tasks, childId), totalDone, byCategory: [...byCategory.values()], }; } async function getHistory(familyId, childId) { childId = normalizeChildId(childId); const store = await loadStore(familyId); const childTasks = store.tasks.filter((t) => t.child_id === childId); const byDate = new Map(); for (const t of childTasks) { if (!byDate.has(t.task_date)) byDate.set(t.task_date, []); byDate.get(t.task_date).push(enrichTask(t, store.categories)); } const data = [...byDate.entries()] .sort((a, b) => b[0].localeCompare(a[0])) .map(([date, tasks]) => { const done = tasks.filter((t) => t.status === 'done').length; return { date, total: tasks.length, done, status: dayStatus(tasks, date), tasks: tasks.map((t) => ({ id: t.id, d: date, title: t.title, description: t.description, category_name: t.category_name, category_color: t.category_color, task_type: t.task_type, target_value: t.target_value, unit: t.unit, status: t.status, value_submitted: t.value_submitted, completed_time: t.completed_time, remark: t.remark, points: t.points, })), }; }); const tplStarts = store.templates .filter((t) => t.child_id === childId && t.active !== false) .map((t) => String(t.start_date ?? '').slice(0, 10)) .filter(Boolean) .sort(); return { data, cycleStart: tplStarts[0] ?? null }; } async function listChildren(familyId) { const store = await loadStore(familyId); return { data: store.children.map(({ id, name, emoji }) => ({ id, name, emoji })) }; } async function parentStats(familyId) { const store = await loadStore(familyId); const today = todayCst(); const weekStart = addDays(today, -6); const monthStartDate = addDays(today, -29); const children = store.children.map((child) => { const byDay = new Map(); for (const t of store.tasks.filter((x) => x.child_id === child.id)) { if (!byDay.has(t.task_date)) byDay.set(t.task_date, []); byDay.get(t.task_date).push(t); } return { ...child, streak: computeStreak( new Map([...byDay.entries()].map(([d, tasks]) => [`${child.id}:${d}`, tasks])), child.id, today, ), }; }); const allToday = store.tasks.filter((t) => t.task_date === today); const allWeek = store.tasks.filter((t) => t.task_date >= weekStart && t.task_date <= today); const trend = []; for (let i = 6; i >= 0; i -= 1) { const d = addDays(today, -i); const tasks = store.tasks.filter((t) => t.task_date === d); trend.push({ d, total: tasks.length, done: tasks.filter((t) => t.status === 'done').length, }); } const missedMap = new Map(); for (const t of store.tasks.filter((t) => t.task_date >= monthStartDate && t.task_date < today)) { if (t.status !== 'pending') continue; missedMap.set(t.title, (missedMap.get(t.title) ?? 0) + 1); } const missed = [...missedMap.entries()] .map(([title, count]) => ({ title, count })) .sort((a, b) => b.count - a.count) .slice(0, 8); const catStats = new Map(); for (const t of store.tasks.filter((t) => t.task_date >= monthStartDate)) { const name = t.category_name || store.categories.find((c) => c.id === t.category_id)?.name || '其他'; if (!catStats.has(name)) catStats.set(name, { name, total: 0, done: 0 }); const row = catStats.get(name); row.total += 1; if (t.status === 'done') row.done += 1; } const avgBuckets = new Map(); for (const t of store.tasks.filter((t) => t.status === 'done' && t.completed_time && t.task_date >= monthStartDate)) { const name = t.category_name || store.categories.find((c) => c.id === t.category_id)?.name || '其他'; const [hh, mm] = String(t.completed_time).split(':').map(Number); const mins = hh * 60 + mm; if (!avgBuckets.has(name)) avgBuckets.set(name, []); avgBuckets.get(name).push(mins); } const avgTime = [...avgBuckets.entries()].map(([name, mins]) => { const avg = Math.round(mins.reduce((a, b) => a + b, 0) / mins.length); return { name, time: `${String(Math.floor(avg / 60)).padStart(2, '0')}:${String(avg % 60).padStart(2, '0')}` }; }); return { children, today: { total: allToday.length, done: allToday.filter((t) => t.status === 'done').length, }, week: { total: allWeek.length, done: allWeek.filter((t) => t.status === 'done').length, }, trend, missed, categoryRate: [...catStats.values()], avgTime, }; } async function parentListTemplates(familyId) { const store = await loadStore(familyId); const cats = categoryMap(store.categories); return { data: store.templates .filter((t) => t.active !== false) .map((t) => ({ ...t, category_name: t.category_id ? cats.get(t.category_id)?.name ?? null : null, })), }; } async function parentSaveTemplate(familyId, body, id = null) { const store = await loadStore(familyId); const payload = { child_id: +body.child_id, category_id: body.category_id ? +body.category_id : null, title: String(body.title ?? '').trim(), description: String(body.description ?? ''), task_type: body.task_type ?? 'check', target_value: String(body.target_value ?? ''), unit: String(body.unit ?? ''), weekdays: Array.isArray(body.weekdays) ? body.weekdays.join(',') : String(body.weekdays ?? ''), start_date: String(body.start_date ?? '').slice(0, 10), end_date: body.end_date ? String(body.end_date).slice(0, 10) : null, points: +body.points || 10, active: true, }; if (!payload.title) throw Object.assign(new Error('任务名称必填'), { statusCode: 400 }); if (id) { const idx = store.templates.findIndex((t) => t.id === id); if (idx < 0) throw Object.assign(new Error('模板不存在'), { statusCode: 404 }); store.templates[idx] = { ...store.templates[idx], ...payload }; } else { store.templates.push({ id: store.meta.nextTemplateId++, ...payload }); } await saveStore(store); return { ok: true }; } async function parentDeleteTemplate(familyId, id) { const store = await loadStore(familyId); const tpl = store.templates.find((t) => t.id === id); if (!tpl) throw Object.assign(new Error('模板不存在'), { statusCode: 404 }); tpl.active = false; await saveStore(store); return { ok: true }; } async function parentListCategories(familyId) { const store = await loadStore(familyId); return { data: store.categories }; } async function parentAddCategory(familyId, body) { const store = await loadStore(familyId); const name = String(body.name ?? '').trim(); if (!name) throw Object.assign(new Error('分类名称必填'), { statusCode: 400 }); store.categories.push({ id: store.meta.nextCategoryId++, name, color: body.color ?? '#3b5bfd', }); await saveStore(store); return { ok: true }; } async function parentDeleteCategory(familyId, id) { const store = await loadStore(familyId); store.categories = store.categories.filter((c) => c.id !== id); for (const t of store.templates) { if (t.category_id === id) t.category_id = null; } await saveStore(store); return { ok: true }; } async function parentListChildren(familyId) { const store = await loadStore(familyId); return { data: store.children }; } async function parentAddChild(familyId, body) { const store = await loadStore(familyId); const name = String(body.name ?? '').trim(); if (!name) throw Object.assign(new Error('昵称必填'), { statusCode: 400 }); store.children.push({ id: store.meta.nextChildId++, name, emoji: body.emoji || '🙂', }); await saveStore(store); return { ok: true }; } async function parentDeleteChild(familyId, id) { const store = await loadStore(familyId); store.children = store.children.filter((c) => c.id !== id); store.tasks = store.tasks.filter((t) => t.child_id !== id); store.templates = store.templates.filter((t) => t.child_id !== id); await saveStore(store); return { ok: true }; } async function parentChangePassword(familyId, body) { const store = await loadStore(familyId); if (String(store.settings.password) !== String(body.old ?? '')) { throw Object.assign(new Error('当前密码不正确'), { statusCode: 401 }); } const next = String(body.new ?? ''); if (next.length < 3) throw Object.assign(new Error('新密码至少 3 位'), { statusCode: 400 }); store.settings.password = next; await saveStore(store); return { ok: true }; } async function seedFamilyDefaults(familyId) { const p = paths(familyId); try { await fs.access(p.children); return { seeded: false, reason: 'already_exists' }; } catch { // continue } const store = { settings: { password: '123456' }, children: [ { id: 2, name: '一心', emoji: '🙂' }, { id: 3, name: '豆豆', emoji: '🙂' }, ], categories: [ { id: 1, name: '数学', color: '#3b5bfd' }, { id: 2, name: '英语', color: '#16a34a' }, { id: 3, name: '语文', color: '#f59e0b' }, ], templates: [ { id: 1, child_id: 2, category_id: 1, title: '数学练习册', description: '要求媒每天做两面练习', task_type: 'count', target_value: '2', unit: '面', weekdays: '1,2,3,4,5,6,7', start_date: '2026-09-03', end_date: null, points: 15, active: true, }, { id: 2, child_id: 2, category_id: 2, title: '阅读30分钟', description: '阅读30分钟,深度阅读', task_type: 'check', target_value: '30', unit: '分钟', weekdays: '1,2,3,4,5,6,7', start_date: '2026-09-03', end_date: null, points: 10, active: true, }, { id: 3, child_id: 3, category_id: 1, title: '数学练习册', description: '要求媒每天做两面练习', task_type: 'count', target_value: '2', unit: '面', weekdays: '1,2,3,4,5,6,7', start_date: '2026-09-03', end_date: null, points: 15, active: true, }, { id: 4, child_id: 3, category_id: 2, title: '阅读30分钟', description: '阅读30分钟,深度阅读', task_type: 'check', target_value: '30', unit: '分钟', weekdays: '1,2,3,4,5,6,7', start_date: '2026-09-03', end_date: null, points: 10, active: true, }, ], tasks: [], meta: { nextChildId: 4, nextCategoryId: 4, nextTemplateId: 5, nextTaskId: 1 }, paths: p, }; await saveStore(store); return { seeded: true, familyId }; } async function assertLegacyPassword(familyId, password) { const store = await loadStore(familyId); if (String(store.settings.password ?? '') !== String(password ?? '')) { const err = new Error('密码错误'); err.statusCode = 401; throw err; } } return { loadStore, seedFamilyDefaults, listChildren, getToday, getDay, checkin, getCalendar, getGrowth, getHistory, parentStats, parentListTemplates, parentSaveTemplate, parentDeleteTemplate, parentListCategories, parentAddCategory, parentDeleteCategory, parentListChildren, parentAddChild, parentDeleteChild, parentChangePassword, assertLegacyPassword, }; } export const learnAssistantInternals = { TANG_OWNER_ID, todayCst, };