diff --git a/learn-assistant-family.mjs b/learn-assistant-family.mjs new file mode 100644 index 0000000..f454463 --- /dev/null +++ b/learn-assistant-family.mjs @@ -0,0 +1,271 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const TANG_OWNER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db'; +const LEGACY_TANG_SLUG = 'tang'; +const INVITE_TTL_MS = 7 * 24 * 3600 * 1000; + +async function readJson(filePath, fallback) { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')); + } 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 memberLabel(userId, displayName) { + return displayName?.trim() || `用户 ${String(userId).slice(0, 8)}`; +} + +export function createLearnAssistantFamilyRegistry({ dataRoot, seedFamilyDefaults } = {}) { + if (!dataRoot) throw new Error('dataRoot is required'); + + const registryPath = path.join(dataRoot, 'registry.json'); + const invitesDir = path.join(dataRoot, 'invites'); + const familiesDir = path.join(dataRoot, 'families'); + + async function loadRegistry() { + return readJson(registryPath, { + families: {}, + slugToFamilyId: {}, + userToFamilyId: {}, + }); + } + + async function saveRegistry(registry) { + await writeJson(registryPath, registry); + } + + function familyStorePath(familyId) { + return path.join(familiesDir, familyId); + } + + async function ensureFamilyStore(familyId) { + await fs.mkdir(familyStorePath(familyId), { recursive: true }); + } + + async function migrateLegacyOwnerStore(ownerId, familyId) { + const legacyDir = path.join(dataRoot, ownerId); + const targetDir = familyStorePath(familyId); + try { + await fs.access(legacyDir); + } catch { + return { migrated: false }; + } + try { + await fs.access(targetDir); + return { migrated: false, reason: 'target_exists' }; + } catch { + await fs.mkdir(path.dirname(targetDir), { recursive: true }); + await fs.rename(legacyDir, targetDir); + return { migrated: true, from: legacyDir, to: targetDir }; + } + } + + async function ensureLegacyTangFamily(registry) { + if (registry.slugToFamilyId[LEGACY_TANG_SLUG]) { + return registry.families[registry.slugToFamilyId[LEGACY_TANG_SLUG]]; + } + const familyId = crypto.randomUUID(); + const now = new Date().toISOString(); + const family = { + id: familyId, + slug: LEGACY_TANG_SLUG, + name: '唐家庭', + ownerUserId: TANG_OWNER_ID, + createdAt: now, + members: [{ userId: TANG_OWNER_ID, role: 'owner', joinedAt: now, displayName: '唐' }], + }; + registry.families[familyId] = family; + registry.slugToFamilyId[LEGACY_TANG_SLUG] = familyId; + registry.userToFamilyId[TANG_OWNER_ID] = familyId; + await ensureFamilyStore(familyId); + await migrateLegacyOwnerStore(TANG_OWNER_ID, familyId); + if (seedFamilyDefaults) { + try { + await fs.access(path.join(familyStorePath(familyId), 'children.json')); + } catch { + await seedFamilyDefaults(familyId); + } + } + await saveRegistry(registry); + return family; + } + + async function getFamilyBySlug(slug) { + const registry = await loadRegistry(); + await ensureLegacyTangFamily(registry); + const fresh = await loadRegistry(); + const familyId = fresh.slugToFamilyId[String(slug ?? '').trim()]; + if (!familyId) { + const err = new Error('家庭不存在'); + err.statusCode = 404; + throw err; + } + return fresh.families[familyId]; + } + + async function getFamilyById(familyId) { + const registry = await loadRegistry(); + const family = registry.families[familyId]; + if (!family) { + const err = new Error('家庭不存在'); + err.statusCode = 404; + throw err; + } + return family; + } + + async function getFamilyForUser(userId, { displayName } = {}) { + if (!userId) { + const err = new Error('请先登录 Memind'); + err.statusCode = 401; + throw err; + } + let registry = await loadRegistry(); + await ensureLegacyTangFamily(registry); + registry = await loadRegistry(); + + let familyId = registry.userToFamilyId[userId]; + if (familyId && registry.families[familyId]) { + return { family: registry.families[familyId], created: false }; + } + + familyId = crypto.randomUUID(); + const slug = `home_${familyId.replace(/-/g, '').slice(0, 8)}`; + const now = new Date().toISOString(); + const family = { + id: familyId, + slug, + name: `${memberLabel(userId, displayName)}的家庭`, + ownerUserId: userId, + createdAt: now, + members: [{ userId, role: 'owner', joinedAt: now, displayName: displayName ?? null }], + }; + registry.families[familyId] = family; + registry.slugToFamilyId[slug] = familyId; + registry.userToFamilyId[userId] = familyId; + await ensureFamilyStore(familyId); + await saveRegistry(registry); + return { family, created: true }; + } + + function assertFamilyMember(family, userId) { + const member = family.members.find((m) => m.userId === userId); + if (!member) { + const err = new Error('无权访问该家庭'); + err.statusCode = 403; + throw err; + } + return member; + } + + async function requireFamilyMember(userId, familyId) { + const family = await getFamilyById(familyId); + assertFamilyMember(family, userId); + return family; + } + + async function requireFamilyForUser(userId, { displayName } = {}) { + const { family } = await getFamilyForUser(userId, { displayName }); + assertFamilyMember(family, userId); + return family; + } + + async function createInvite(userId, { displayName } = {}) { + const family = await requireFamilyForUser(userId, { displayName }); + const member = assertFamilyMember(family, userId); + if (member.role !== 'owner') { + const err = new Error('仅主家长可邀请协家长'); + err.statusCode = 403; + throw err; + } + const token = crypto.randomBytes(16).toString('hex'); + const invite = { + token, + familyId: family.id, + invitedBy: userId, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + INVITE_TTL_MS).toISOString(), + }; + await writeJson(path.join(invitesDir, `${token}.json`), invite); + return { token, familyId: family.id, familySlug: family.slug, expiresAt: invite.expiresAt }; + } + + async function acceptInvite(userId, token, { displayName } = {}) { + if (!token) { + const err = new Error('邀请码无效'); + err.statusCode = 400; + throw err; + } + const invite = await readJson(path.join(invitesDir, `${token}.json`), null); + if (!invite) { + const err = new Error('邀请不存在或已失效'); + err.statusCode = 404; + throw err; + } + if (new Date(invite.expiresAt).getTime() < Date.now()) { + const err = new Error('邀请已过期'); + err.statusCode = 410; + throw err; + } + + const registry = await loadRegistry(); + const existingFamilyId = registry.userToFamilyId[userId]; + if (existingFamilyId && existingFamilyId !== invite.familyId) { + const err = new Error('你已加入其他家庭,需先退出后再接受新邀请'); + err.statusCode = 409; + throw err; + } + + const family = registry.families[invite.familyId]; + if (!family) { + const err = new Error('家庭不存在'); + err.statusCode = 404; + throw err; + } + if (!family.members.some((m) => m.userId === userId)) { + family.members.push({ + userId, + role: 'parent', + joinedAt: new Date().toISOString(), + displayName: displayName ?? null, + }); + registry.userToFamilyId[userId] = family.id; + registry.families[family.id] = family; + await saveRegistry(registry); + } + await fs.unlink(path.join(invitesDir, `${token}.json`)).catch(() => {}); + return { family, joined: true }; + } + + function familyUrls(family, publicBase = 'https://m.tkmind.cn') { + const childUrl = `${publicBase}/learn/child.html?f=${encodeURIComponent(family.slug)}`; + const parentUrl = `${publicBase}/learn/parent.html?f=${encodeURIComponent(family.slug)}`; + const joinPath = `${publicBase}/learn/join.html?token=`; + return { childUrl, parentUrl, joinPath }; + } + + return { + LEGACY_TANG_SLUG, + familyStorePath, + loadRegistry, + ensureLegacyTangFamily, + getFamilyBySlug, + getFamilyById, + getFamilyForUser, + requireFamilyForUser, + requireFamilyMember, + createInvite, + acceptInvite, + familyUrls, + resolveFamilyIdFromSlug: async (slug) => (await getFamilyBySlug(slug)).id, + }; +} diff --git a/learn-assistant-family.test.mjs b/learn-assistant-family.test.mjs new file mode 100644 index 0000000..b6b459d --- /dev/null +++ b/learn-assistant-family.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createLearnAssistantFamilyRegistry } from './learn-assistant-family.mjs'; +import { createLearnAssistantService } from './learn-assistant-service.mjs'; + +test('family registry creates owner family and accepts co-parent invite', async () => { + const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'learn-family-')); + const service = createLearnAssistantService({ dataRoot }); + const registry = createLearnAssistantFamilyRegistry({ + dataRoot, + seedFamilyDefaults: service.seedFamilyDefaults, + }); + + const dad = 'dad-user-id-001'; + const mom = 'mom-user-id-002'; + + const { family: dadFamily, created } = await registry.getFamilyForUser(dad, { displayName: '爸爸' }); + assert.equal(created, true); + assert.equal(dadFamily.ownerUserId, dad); + + const invite = await registry.createInvite(dad, { displayName: '爸爸' }); + assert.ok(invite.token); + + const joined = await registry.acceptInvite(mom, invite.token, { displayName: '妈妈' }); + assert.equal(joined.family.id, dadFamily.id); + assert.equal(joined.family.members.length, 2); + + const momFamily = await registry.requireFamilyForUser(mom); + assert.equal(momFamily.id, dadFamily.id); + + await service.parentAddChild(dadFamily.id, { name: '宝宝', emoji: '🙂' }); + const children = await service.listChildren(dadFamily.id); + assert.equal(children.data.length, 1); + + const momView = await service.listChildren((await registry.requireFamilyForUser(mom)).id); + assert.equal(momView.data[0].name, '宝宝'); +}); + +test('legacy tang slug resolves and seeds defaults', async () => { + const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'learn-tang-')); + const service = createLearnAssistantService({ dataRoot }); + const registry = createLearnAssistantFamilyRegistry({ + dataRoot, + seedFamilyDefaults: service.seedFamilyDefaults, + }); + + const family = await registry.getFamilyBySlug('tang'); + assert.equal(family.slug, 'tang'); + const children = await service.listChildren(family.id); + assert.equal(children.data.length, 2); +}); diff --git a/learn-assistant-routes.mjs b/learn-assistant-routes.mjs new file mode 100644 index 0000000..aac08a5 --- /dev/null +++ b/learn-assistant-routes.mjs @@ -0,0 +1,294 @@ +import path from 'node:path'; +import { createLearnAssistantFamilyRegistry } from './learn-assistant-family.mjs'; +import { createLearnAssistantService } from './learn-assistant-service.mjs'; + +function handleError(res, error) { + const status = error?.statusCode ?? 500; + const message = error instanceof Error ? error.message : '请求失败'; + if (status >= 500) console.error('[LearnAssistant]', message); + return res.status(status).json({ error: message }); +} + +async function runHandler(res, work) { + try { + const body = await work; + return res.json(body); + } catch (error) { + return handleError(res, error); + } +} + +function readLegacyPassword(req) { + return req.get('x-parent-pw') || req.get('x-admin-pw') || ''; +} + +function currentUser(req) { + return req.currentUser ?? null; +} + +export function createLearnAssistantRuntime(options = {}) { + const dataRoot = options.dataRoot ?? path.join(options.rootDir ?? process.cwd(), 'data', 'learn-assistant'); + const service = options.service ?? createLearnAssistantService({ dataRoot }); + const familyRegistry = options.familyRegistry + ?? createLearnAssistantFamilyRegistry({ dataRoot, seedFamilyDefaults: service.seedFamilyDefaults }); + return { dataRoot, familyRegistry, service }; +} + +export function attachLearnAssistantPublicRoutes(api, options = {}) { + const { familyRegistry, service } = createLearnAssistantRuntime(options); + const prefix = options.routePrefix ?? '/learn'; + + async function withSlug(req, res, handler) { + try { + const family = await familyRegistry.getFamilyBySlug(req.params.slug); + return runHandler(res, handler(family)); + } catch (error) { + return handleError(res, error); + } + } + + api.get(`${prefix}/public/:slug/children`, (req, res) => + withSlug(req, res, (family) => service.listChildren(family.id))); + + api.get(`${prefix}/public/:slug/today`, (req, res) => + withSlug(req, res, (family) => + service.getToday(family.id, req.query.child, req.query.date))); + + api.get(`${prefix}/public/:slug/day`, (req, res) => + withSlug(req, res, (family) => + service.getDay(family.id, req.query.child, req.query.date))); + + api.post(`${prefix}/public/:slug/checkin`, (req, res) => + withSlug(req, res, (family) => + service.checkin(family.id, req.body?.id, req.body))); + + api.get(`${prefix}/public/:slug/calendar`, (req, res) => + withSlug(req, res, (family) => + service.getCalendar(family.id, req.query.child, req.query.month))); + + api.get(`${prefix}/public/:slug/growth`, (req, res) => + withSlug(req, res, (family) => + service.getGrowth(family.id, req.query.child))); + + api.get(`${prefix}/public/:slug/history`, (req, res) => + withSlug(req, res, (family) => + service.getHistory(family.id, req.query.child))); + + // legacy slug routes (tang 等旧链接) + api.get(`${prefix}/:slug/children`, (req, res) => + withSlug(req, res, (family) => service.listChildren(family.id))); + + api.get(`${prefix}/:slug/today`, (req, res) => + withSlug(req, res, (family) => + service.getToday(family.id, req.query.child, req.query.date))); + + api.get(`${prefix}/:slug/day`, (req, res) => + withSlug(req, res, (family) => + service.getDay(family.id, req.query.child, req.query.date))); + + api.post(`${prefix}/:slug/checkin`, (req, res) => + withSlug(req, res, (family) => + service.checkin(family.id, req.body?.id, req.body))); + + api.get(`${prefix}/:slug/calendar`, (req, res) => + withSlug(req, res, (family) => + service.getCalendar(family.id, req.query.child, req.query.month))); + + api.get(`${prefix}/:slug/growth`, (req, res) => + withSlug(req, res, (family) => + service.getGrowth(family.id, req.query.child))); + + api.get(`${prefix}/:slug/history`, (req, res) => + withSlug(req, res, (family) => + service.getHistory(family.id, req.query.child))); + + // legacy password parent API (tang 旧家长端) + async function withLegacyParent(req, res, handler) { + try { + const family = await familyRegistry.getFamilyBySlug(req.params.slug); + await service.assertLegacyPassword(family.id, readLegacyPassword(req)); + return runHandler(res, handler(family)); + } catch (error) { + return handleError(res, error); + } + } + + api.get(`${prefix}/:slug/parent/stats`, (req, res) => + withLegacyParent(req, res, (family) => service.parentStats(family.id))); + + api.get(`${prefix}/:slug/parent/templates`, (req, res) => + withLegacyParent(req, res, (family) => service.parentListTemplates(family.id))); + + api.post(`${prefix}/:slug/parent/templates`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentSaveTemplate(family.id, req.body))); + + api.put(`${prefix}/:slug/parent/templates/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentSaveTemplate(family.id, req.body, +req.params.id))); + + api.delete(`${prefix}/:slug/parent/templates/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentDeleteTemplate(family.id, +req.params.id))); + + api.get(`${prefix}/:slug/parent/categories`, (req, res) => + withLegacyParent(req, res, (family) => service.parentListCategories(family.id))); + + api.post(`${prefix}/:slug/parent/categories`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentAddCategory(family.id, req.body))); + + api.delete(`${prefix}/:slug/parent/categories/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentDeleteCategory(family.id, +req.params.id))); + + api.get(`${prefix}/:slug/parent/children`, (req, res) => + withLegacyParent(req, res, (family) => service.parentListChildren(family.id))); + + api.post(`${prefix}/:slug/parent/children`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentAddChild(family.id, req.body))); + + api.delete(`${prefix}/:slug/parent/children/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentDeleteChild(family.id, +req.params.id))); + + api.post(`${prefix}/:slug/parent/password`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentChangePassword(family.id, req.body))); + + return { familyRegistry, service }; +} + +export function attachLearnAssistantParentRoutes(api, options = {}) { + const { familyRegistry, service } = createLearnAssistantRuntime(options); + const prefix = options.routePrefix ?? '/learn'; + + function requireUser(req, res) { + const user = currentUser(req); + if (!user?.id) { + res.status(401).json({ error: '请先登录 Memind' }); + return null; + } + return user; + } + + async function withFamily(req, res, handler) { + const user = requireUser(req, res); + if (!user) return null; + try { + const family = await familyRegistry.requireFamilyForUser(user.id, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + return runHandler(res, handler(family, user)); + } catch (error) { + return handleError(res, error); + } + } + + api.get(`${prefix}/me/family`, async (req, res) => { + const user = requireUser(req, res); + if (!user) return; + try { + const { family, created } = await familyRegistry.getFamilyForUser(user.id, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + const urls = familyRegistry.familyUrls(family); + return res.json({ + family: { + id: family.id, + slug: family.slug, + name: family.name, + members: family.members, + }, + role: family.members.find((m) => m.userId === user.id)?.role ?? 'parent', + urls, + created, + }); + } catch (error) { + return handleError(res, error); + } + }); + + api.post(`${prefix}/me/invite`, (req, res) => + withFamily(req, res, async (family, user) => { + const invite = await familyRegistry.createInvite(user.id, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + const urls = familyRegistry.familyUrls(family); + return { + ...invite, + joinUrl: `${urls.joinPath}${invite.token}`, + }; + })); + + api.post(`${prefix}/me/join`, async (req, res) => { + const user = requireUser(req, res); + if (!user) return; + try { + const result = await familyRegistry.acceptInvite(user.id, req.body?.token, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + return res.json({ + ok: true, + family: { + id: result.family.id, + slug: result.family.slug, + name: result.family.name, + members: result.family.members, + }, + }); + } catch (error) { + return handleError(res, error); + } + }); + + api.get(`${prefix}/me/parent/stats`, (req, res) => + withFamily(req, res, (family) => service.parentStats(family.id))); + + api.get(`${prefix}/me/parent/templates`, (req, res) => + withFamily(req, res, (family) => service.parentListTemplates(family.id))); + + api.post(`${prefix}/me/parent/templates`, (req, res) => + withFamily(req, res, (family) => + service.parentSaveTemplate(family.id, req.body))); + + api.put(`${prefix}/me/parent/templates/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentSaveTemplate(family.id, req.body, +req.params.id))); + + api.delete(`${prefix}/me/parent/templates/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentDeleteTemplate(family.id, +req.params.id))); + + api.get(`${prefix}/me/parent/categories`, (req, res) => + withFamily(req, res, (family) => service.parentListCategories(family.id))); + + api.post(`${prefix}/me/parent/categories`, (req, res) => + withFamily(req, res, (family) => + service.parentAddCategory(family.id, req.body))); + + api.delete(`${prefix}/me/parent/categories/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentDeleteCategory(family.id, +req.params.id))); + + api.get(`${prefix}/me/parent/children`, (req, res) => + withFamily(req, res, (family) => service.parentListChildren(family.id))); + + api.post(`${prefix}/me/parent/children`, (req, res) => + withFamily(req, res, (family) => + service.parentAddChild(family.id, req.body))); + + api.delete(`${prefix}/me/parent/children/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentDeleteChild(family.id, +req.params.id))); + + return { familyRegistry, service }; +} + +/** @deprecated use attachLearnAssistantPublicRoutes + attachLearnAssistantParentRoutes */ +export function attachLearnAssistantRoutes(api, options = {}) { + const runtime = attachLearnAssistantPublicRoutes(api, options); + attachLearnAssistantParentRoutes(api, options); + return runtime; +} diff --git a/learn-assistant-service.mjs b/learn-assistant-service.mjs new file mode 100644 index 0000000..c00bd9d --- /dev/null +++ b/learn-assistant-service.mjs @@ -0,0 +1,725 @@ +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, +}; diff --git a/public/learn/child.html b/public/learn/child.html new file mode 100644 index 0000000..ccc016d --- /dev/null +++ b/public/learn/child.html @@ -0,0 +1,103 @@ + +
+正在处理邀请…