import { Router } from 'express' import { prisma } from '../db.js' import { authRequired } from '../middleware.js' import { toDateStr, addDays, periodRange, periodDays, expectedPerDay, periodIsClosed, eachDay, PERIODS, PERIOD_LABEL, } from '../checkin/period.js' const router = Router() router.use(authRequired) // 补打卡允许的回溯天数 (F-CK-10) const BACKFILL_DAYS = 7 const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ const MONTH_RE = /^\d{4}-\d{2}$/ function normDate(v) { const s = String(v || '') return DATE_RE.test(s) ? s : toDateStr() } // ---------- 统计辅助 ---------- // 记录 -> { habitId: { date: count } } function groupRecords(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 countInRange(byDate, start, end) { let n = 0 eachDay(start, end, (d) => { n += byDate.get(d) || 0 }) return n } // 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断 function computeStreak(habit, byDate, today) { let cursor = today let streak = 0 for (let i = 0; i < 400; i++) { const { start } = periodRange(habit.period, cursor) const done = countInRange(byDate, start, periodRange(habit.period, cursor).end) if (done >= habit.target) { streak++ } else if (periodIsClosed(habit.period, cursor, today)) { break } const prevEnd = addDays(start, -1) if (prevEnd < '1970-01-01') break cursor = prevEnd } return streak } async function loadActiveHabits(userId) { return prisma.habit.findMany({ where: { userId, archived: false }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }) } // 组装单个打卡项的展示态 function buildItem(habit, byDate, date, today) { const { start, end } = periodRange(habit.period, date) const done = countInRange(byDate, start, end) const target = Math.max(1, habit.target) const onDate = byDate.get(date) || 0 const expPerDay = expectedPerDay(habit.period, target, date) return { id: habit.id, name: habit.name, period: habit.period, sortOrder: habit.sortOrder, periodLabel: PERIOD_LABEL[habit.period] || '每天', target, color: habit.color, icon: habit.icon, note: habit.note, archived: habit.archived, periodStart: start, periodEnd: end, periodDays: periodDays(habit.period, date), done, remaining: Math.max(0, target - done), percent: Math.min(1, target > 0 ? done / target : 1), completed: done >= target, over: Math.max(0, done - target), countOnDate: onDate, expectedPerDay: expPerDay, dayDone: onDate >= expPerDay, streak: computeStreak(habit, byDate, today), } } // 拉取 [start, end] 内的打卡记录并分组 async function loadRecords(userId, habits, ranges) { let min = ranges[0]?.start let max = ranges[0]?.end for (const r of ranges) { if (r.start < min) min = r.start if (r.end > max) max = r.end } if (!min || !max) return new Map() 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 }, }) return groupRecords(records) } // ---------- 打卡项 ---------- // GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度 router.get('/habits', async (req, res, next) => { try { const userId = req.user.sub const today = toDateStr() const date = normDate(req.query.date) const habits = await loadActiveHabits(userId) const ranges = habits.map((h) => periodRange(h.period, date)) const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today)) const doneCount = items.filter((i) => i.completed).length res.json({ today, date, items, summary: { total: items.length, done: doneCount } }) } catch (e) { next(e) } }) // POST /api/checkin/habits 新建打卡项 router.post('/habits', async (req, res, next) => { try { const userId = req.user.sub const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {} const cleanName = String(name || '').trim() if (!cleanName) return res.status(400).json({ error: '名称不能为空' }) if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) const t = Math.floor(Number(target)) if (!Number.isFinite(t) || t < 1) return res.status(400).json({ 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 }, }) res.json({ item: row }) } catch (e) { next(e) } }) // PUT /api/checkin/habits/:id 编辑 router.put('/habits/:id', async (req, res, next) => { try { const userId = req.user.sub const id = Number(req.params.id) const exist = await prisma.habit.findFirst({ where: { id, userId } }) if (!exist) return res.status(404).json({ error: '打卡项不存在' }) const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {} const data = {} if (name !== undefined) { const n = String(name).trim() if (!n) return res.status(400).json({ error: '名称不能为空' }) if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) data.name = n } if (period !== undefined) { if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) data.period = period } if (target !== undefined) { const t = Math.floor(Number(target)) if (!Number.isFinite(t) || t < 1) return res.status(400).json({ 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 }, data }) res.json({ item: row }) } catch (e) { next(e) } }) // POST /api/checkin/habits/reorder { ids: [习惯id 的新顺序] } 按数组下标重写 sortOrder router.post('/habits/reorder', async (req, res, next) => { try { const userId = req.user.sub const ids = Array.isArray((req.body || {}).ids) ? req.body.ids.map(Number).filter(Number.isFinite) : [] if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' }) const habits = await prisma.habit.findMany({ where: { userId, id: { in: ids } } }) if (habits.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的打卡项' }) await prisma.$transaction( ids.map((id, i) => prisma.habit.update({ where: { id }, data: { sortOrder: i } })), ) res.json({ ok: true }) } catch (e) { next(e) } }) // DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除 router.delete('/habits/:id', async (req, res, next) => { try { const userId = req.user.sub const id = Number(req.params.id) const exist = await prisma.habit.findFirst({ where: { id, userId } }) if (!exist) return res.status(404).json({ error: '打卡项不存在' }) if (req.query.hard === '1') { await prisma.checkIn.deleteMany({ where: { habitId: id } }) await prisma.habit.delete({ where: { id } }) return res.json({ ok: true, removed: true }) } const row = await prisma.habit.update({ where: { id }, data: { archived: true } }) res.json({ ok: true, removed: false, item: row }) } catch (e) { next(e) } }) // ---------- 打卡 / 撤销 ---------- // POST /api/checkin/habits/:id/check { date?, note? } 打一次卡 router.post('/habits/:id/check', async (req, res, next) => { try { const userId = req.user.sub const id = Number(req.params.id) const habit = await prisma.habit.findFirst({ where: { id, userId } }) if (!habit) return res.status(404).json({ error: '打卡项不存在' }) const today = toDateStr() const date = normDate((req.body || {}).date) if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' }) if (date < addDays(today, -BACKFILL_DAYS)) { return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` }) } const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } }) const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1 const note = String((req.body || {}).note || '').slice(0, 200) await prisma.checkIn.create({ data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' }, }) const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)]) res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) } catch (e) { // 唯一约束冲突 = 并发重复提交, 视为已打卡 if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' }) next(e) } }) // DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡 router.delete('/habits/:id/check', async (req, res, next) => { try { const userId = req.user.sub const id = Number(req.params.id) const habit = await prisma.habit.findFirst({ where: { id, userId } }) if (!habit) return res.status(404).json({ error: '打卡项不存在' }) const today = toDateStr() const date = normDate(req.query.date) const { start, end } = periodRange(habit.period, date) const last = await prisma.checkIn.findFirst({ where: { habitId: id, date: { gte: start, lte: end } }, orderBy: [{ date: 'desc' }, { seq: 'desc' }], }) if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' }) await prisma.checkIn.delete({ where: { id: last.id } }) const grouped = await loadRecords(userId, [habit], [{ start, end }]) res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) } catch (e) { next(e) } }) // ---------- 某日详情 / 月历 ---------- // GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记) router.get('/day', async (req, res, next) => { try { const userId = req.user.sub const today = toDateStr() const date = normDate(req.query.date) const habits = await loadActiveHabits(userId) const ranges = habits.map((h) => periodRange(h.period, date)) const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) const items = habits.map((h) => { const base = buildItem(h, grouped.get(h.id) || new Map(), date, today) return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) } }) res.json({ today, date, isToday: date === today, isFuture: date > today, items }) } catch (e) { next(e) } }) // GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率 router.get('/calendar', async (req, res, next) => { try { const userId = req.user.sub const today = toDateStr() const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7) const start = month + '-01' const end = addDays(monthStartNext(month), -1) const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }) const active = habits.filter((h) => !h.archived) const records = await prisma.checkIn.findMany({ where: { userId, date: { gte: start, lte: end } }, select: { habitId: true, date: true }, }) const grouped = groupRecords(records) const days = [] let expTotal = 0 let doneTotal = 0 eachDay(start, end, (d) => { let expected = 0 let actual = 0 let over = false const detail = [] for (const h of active) { if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望 const exp = expectedPerDay(h.period, Math.max(1, h.target), d) const cnt = (grouped.get(h.id) || new Map()).get(d) || 0 expected += exp actual += Math.min(cnt, exp) if (cnt > exp) over = true detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp }) } const isFuture = d > today if (!isFuture) { expTotal += expected; doneTotal += actual } let status = 'empty' if (expected > 0 && actual >= expected) status = over ? 'over' : 'full' else if (actual > 0) status = 'partial' days.push({ date: d, expected, actual, status, isToday: d === today, isFuture, habits: detail, }) }) res.json({ today, month, days, rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0, habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })), }) } catch (e) { next(e) } }) function monthStartNext(month) { const [y, m] = month.split('-').map(Number) const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月) return d.toISOString().slice(0, 10) } export default router