Docker Build and Push / build-image (push) Successful in 1m40s
- date.js: addMonths 对 "YYYY-MM" 月粒度保持月粒度, 不再生成 "YYYY-MM-01"。 旧逻辑使日历切上月时请求 month=2026-10-01, 服务端判为非法后静默回退本月, 表现为上个月一片空白。 - MergedView: 「回到今天」同时把月份切回当前月; 日标题「已完成 x/y」改为 按 items 的 dayDone 现算, 避免把同周期后续日期的打卡计入历史日期, 并让打卡/撤销的乐观更新立即反映到标题。 - server /calendar: 兼容旧的 month=YYYY-MM-DD 请求并截回月份, 不再静默回退。 - server /habits: 回看历史日期时过滤当天尚未创建的打卡项, 与 /calendar 口径一致; summary.done 改用 dayDone (当天份额) 而非 completed (周期完成度)。 验证: Vite dev + 生产数据 (admin) 2026-10 显示 10 个环 / 完成率 32%, 10-22 的标题由错误的 3/14 修正为 1/14; 「回到今天」回到 2026-11 (14%)。
357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
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))
|
|
// 回看历史日期时, 当天还不存在的打卡项不应出现 (与 /calendar 口径一致)
|
|
.filter((h) => toDateStr(h.createdAt) <= date)
|
|
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))
|
|
// summary 是"当天进度": 按当日份额 (countOnDate >= expectedPerDay) 统计,
|
|
// 不能用 completed (周期完成度), 否则历史日期会把同周期后续日期的打卡算进来。
|
|
const doneCount = items.filter((i) => i.dayDone).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()
|
|
// 兼容旧版客户端: addMonths 曾把 "YYYY-MM" 算成 "YYYY-MM-01", 请求月份带了日号,
|
|
// 旧逻辑判为非法后静默回退到本月, 导致切到上月时看不到任何记录。这里截回月份。
|
|
const rawMonth = String(req.query.month || '')
|
|
const month = MONTH_RE.test(rawMonth)
|
|
? rawMonth
|
|
: DATE_RE.test(rawMonth)
|
|
? rawMonth.slice(0, 7)
|
|
: 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
|