Files
memind/learn-assistant-service.mjs
T
john 348e192800 feat(learn): add family learning assistant API routes and public pages
Wire /learn static delivery, parent/child portal routes, and Tang page build scripts for 103 deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 17:09:52 +08:00

726 lines
24 KiB
JavaScript

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,
};