// MCP 工具: 打卡 (打卡项 / 打卡记录) import { z } from 'zod' import { prisma } from '../../db.js' import { toDateStr, addDays, periodRange, PERIODS } from '../../checkin/period.js' import { tool, reqStr } from '../helpers.js' const BACKFILL_DAYS = 7 const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ function normDate(v) { const s = String(v || '') return DATE_RE.test(s) ? s : toDateStr() } function groupCounts(records) { const map = new Map() for (const r of records) { if (!map.has(r.habitId)) map.set(r.habitId, new Map()) const byDate = map.get(r.habitId) byDate.set(r.date, (byDate.get(r.date) || 0) + 1) } return map } function sumRange(byDate, start, end) { let n = 0 let cursor = start while (cursor <= end) { n += byDate.get(cursor) || 0 cursor = addDays(cursor, 1) } return n } export function registerCheckinTools(server, ctx) { const userId = ctx.userId tool(server, 'list_habits', { title: '查询打卡项', description: '列出打卡项及其指定日期所在周期的完成进度; 默认只返回未归档项。', inputSchema: { date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'), includeArchived: z.boolean().optional().describe('是否包含已归档打卡项, 默认 false'), }, annotations: { readOnlyHint: true }, }, async ({ date, includeArchived = false }) => { const today = toDateStr() const day = normDate(date) const habits = await prisma.habit.findMany({ where: { userId, ...(includeArchived ? {} : { archived: false }) }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }) if (!habits.length) return { date: day, today, items: [] } const ranges = new Map(habits.map((h) => [h.id, periodRange(h.period, day)])) let min = null let max = null for (const r of ranges.values()) { if (!min || r.start < min) min = r.start if (!max || r.end > max) max = r.end } const records = await prisma.checkIn.findMany({ where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max }, }, select: { habitId: true, date: true }, }) const grouped = groupCounts(records) const items = habits.map((h) => { const range = ranges.get(h.id) const byDate = grouped.get(h.id) || new Map() const periodDone = sumRange(byDate, range.start, range.end) const target = Math.max(1, h.target) return { ...h, target, periodStart: range.start, periodEnd: range.end, periodDone, completed: periodDone >= target, countOnDate: byDate.get(day) || 0, } }) return { date: day, today, items } }) tool(server, 'create_habit', { title: '新建打卡项', description: '新建一个周期性打卡目标, 例如每天喝 8 杯水、每周运动 3 次。', inputSchema: { name: z.string().describe('打卡项名称, 最多 20 字'), period: z.enum(PERIODS).optional().describe('周期: day/week/month/year, 默认 day'), target: z.coerce.number().int().min(1).optional().describe('每周期目标次数, 默认 1'), color: z.string().optional().describe('颜色, 默认 #1677ff'), icon: z.string().optional().describe('图标 (emoji 或图标名)'), note: z.string().optional().describe('备注'), }, }, async ({ name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' }) => { const cleanName = reqStr(name, '名称', 20) if (!PERIODS.includes(period)) throw new Error('周期类型不合法') const t = Math.floor(Number(target)) if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数') const last = await prisma.habit.findFirst({ where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true }, }) const row = await prisma.habit.create({ data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || ''), sortOrder: (last?.sortOrder ?? -1) + 1, }, }) return { item: row } }) tool(server, 'update_habit', { title: '修改打卡项', description: '修改打卡项名称、周期、目标次数、颜色、图标、备注、归档状态或排序值。', inputSchema: { habitId: z.coerce.number().int().positive().describe('打卡项 ID'), name: z.string().optional().describe('新名称, 最多 20 字'), period: z.enum(PERIODS).optional().describe('新周期'), target: z.coerce.number().int().min(1).optional().describe('新目标次数'), color: z.string().optional().describe('新颜色'), icon: z.string().optional().describe('新图标'), note: z.string().optional().describe('新备注'), archived: z.boolean().optional().describe('是否归档'), sortOrder: z.coerce.number().int().optional().describe('排序值'), }, }, async ({ habitId, name, period, target, color, icon, note, archived, sortOrder }) => { const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } }) if (!exist) throw new Error('打卡项不存在') const data = {} if (name !== undefined) data.name = reqStr(name, '名称', 20) if (period !== undefined) { if (!PERIODS.includes(period)) throw new Error('周期类型不合法') data.period = period } if (target !== undefined) { const t = Math.floor(Number(target)) if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数') data.target = t } if (color !== undefined) data.color = String(color) if (icon !== undefined) data.icon = String(icon) if (note !== undefined) data.note = String(note) if (archived !== undefined) data.archived = !!archived if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0 const row = await prisma.habit.update({ where: { id: Number(habitId) }, data }) return { item: row } }) tool(server, 'delete_habit', { title: '删除打卡项', description: '默认归档并保留历史记录; hard=true 时永久删除打卡项及其全部打卡记录。', inputSchema: { habitId: z.coerce.number().int().positive().describe('打卡项 ID'), hard: z.boolean().optional().describe('true 永久删除, 默认 false 仅归档'), }, annotations: { destructiveHint: true }, }, async ({ habitId, hard = false }) => { const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } }) if (!exist) throw new Error('打卡项不存在') if (hard) { await prisma.checkIn.deleteMany({ where: { habitId: Number(habitId) } }) await prisma.habit.delete({ where: { id: Number(habitId) } }) return { ok: true, removed: true } } const row = await prisma.habit.update({ where: { id: Number(habitId) }, data: { archived: true }, }) return { ok: true, removed: false, item: row } }) tool(server, 'reorder_habits', { title: '打卡项排序', description: '按传入的打卡项 ID 数组重排, 数组顺序即新顺序。', inputSchema: { ids: z.array(z.coerce.number().int().positive()).describe('打卡项 ID 的新顺序'), }, }, async ({ ids }) => { const uniq = [...new Set(ids.map(Number))] if (!uniq.length) throw new Error('ids 不能为空') if (uniq.length !== ids.length) throw new Error('ids 不能重复') const rows = await prisma.habit.findMany({ where: { userId, id: { in: uniq } }, select: { id: true }, }) if (rows.length !== uniq.length) throw new Error('包含无效的打卡项') await prisma.$transaction( ids.map((hid, idx) => prisma.habit.update({ where: { id: Number(hid) }, data: { sortOrder: idx }, })), ) return { ok: true, orderedIds: ids.map(Number) } }) tool(server, 'check_habit', { title: '打卡', description: '为打卡项记录一次完成; 可为最近 7 天补打, 不能打未来日期。', inputSchema: { habitId: z.coerce.number().int().positive().describe('打卡项 ID'), date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'), note: z.string().optional().describe('打卡备注, 最多 200 字'), }, }, async ({ habitId, date, note }) => { const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } }) if (!habit) throw new Error('打卡项不存在') const today = toDateStr() const day = normDate(date) if (day > today) throw new Error('不能为未来日期打卡') if (day < addDays(today, -BACKFILL_DAYS)) throw new Error(`最多只能补打最近 ${BACKFILL_DAYS} 天`) const dayRecords = await prisma.checkIn.findMany({ where: { habitId: habit.id, date: day }, select: { seq: true }, }) const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1 const row = await prisma.checkIn.create({ data: { habitId: habit.id, userId, date: day, seq, note: String(note || '').slice(0, 200), source: day === today ? 'user' : 'backfill', }, }) return { ok: true, date: day, seq, item: row } }) tool(server, 'undo_habit_check', { title: '撤销打卡', description: '撤销指定日期所在周期内最近一次打卡记录。', inputSchema: { habitId: z.coerce.number().int().positive().describe('打卡项 ID'), date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'), }, annotations: { destructiveHint: true }, }, async ({ habitId, date }) => { const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } }) if (!habit) throw new Error('打卡项不存在') const day = normDate(date) const { start, end } = periodRange(habit.period, day) const last = await prisma.checkIn.findFirst({ where: { habitId: habit.id, date: { gte: start, lte: end } }, orderBy: [{ date: 'desc' }, { seq: 'desc' }], }) if (!last) throw new Error('本周期还没有打卡记录') await prisma.checkIn.delete({ where: { id: last.id } }) return { ok: true, date: last.date, seq: last.seq } }) }