feat(V0.2): 打卡 / 日历 / 清单模块
- 数据: Habit/CheckIn/CheckList/CheckListItem/CheckListTemplate + 迁移 + 重新生成 client
- 后端: server/checkin/period.js 周期计算(日/周/月/年, 周一起始, Asia/Shanghai)
/api/checkin/* 打卡项CRUD/打卡/撤销/补打/某日详情/月历
/api/checklists/* 清单与检查项CRUD/勾选/全选/复制/模板(系统预置出差·回老家)
- 前端: 底部新增「打卡」Tab -> /checkin, 页内三视图
环形进度 RingProgress + 完成特效(粒子/光晕/触感, 可关闭)
打卡视图: 待办列表/乐观更新/撤销/新建编辑
日历视图: 月历网格 + 今天·昨天·明天 + 日期标记/完成率
清单视图: 场景清单/勾选进度条/模板生成/另存模板/归档
- 文档: docs/V0.2/需求文档.md 补充实现约定(附录B)与接口清单
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// 打卡周期计算: 全部以 Asia/Shanghai 的"本地日历日"为准
|
||||
// 日期统一用 "YYYY-MM-DD" 字符串 (字典序 = 时间序, 可直接用于范围查询)
|
||||
|
||||
export const TZ = 'Asia/Shanghai'
|
||||
|
||||
const FMT = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
/** 任意时间 -> 本地日历日 "YYYY-MM-DD" */
|
||||
export function toDateStr(d = new Date()) {
|
||||
return FMT.format(d instanceof Date ? d : new Date(d))
|
||||
}
|
||||
|
||||
/** "YYYY-MM-DD" -> UTC 零点的 Date (仅用于日期运算, 不做时区展示) */
|
||||
function toUTC(s) {
|
||||
const [y, m, d] = String(s).split('-').map(Number)
|
||||
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
|
||||
}
|
||||
|
||||
function fromUTC(dt) {
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function addDays(s, n) {
|
||||
const d = toUTC(s)
|
||||
d.setUTCDate(d.getUTCDate() + n)
|
||||
return fromUTC(d)
|
||||
}
|
||||
|
||||
export function addMonths(s, n) {
|
||||
const [y, m] = String(s).split('-').map(Number)
|
||||
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
|
||||
const day = Number(String(s).slice(8, 10)) || 1
|
||||
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
|
||||
first.setUTCDate(Math.min(day, lastDay))
|
||||
return fromUTC(first)
|
||||
}
|
||||
|
||||
/** 周一为一周第一天 */
|
||||
export function startOfWeek(s) {
|
||||
const dow = (toUTC(s).getUTCDay() + 6) % 7 // 0=周一
|
||||
return addDays(s, -dow)
|
||||
}
|
||||
|
||||
export function endOfWeek(s) {
|
||||
return addDays(startOfWeek(s), 6)
|
||||
}
|
||||
|
||||
export function startOfMonth(s) {
|
||||
return String(s).slice(0, 8) + '01'
|
||||
}
|
||||
|
||||
export function endOfMonth(s) {
|
||||
const [y, m] = String(s).split('-').map(Number)
|
||||
return fromUTC(new Date(Date.UTC(y, m || 1, 0)))
|
||||
}
|
||||
|
||||
export function startOfYear(s) {
|
||||
return String(s).slice(0, 4) + '-01-01'
|
||||
}
|
||||
|
||||
export function endOfYear(s) {
|
||||
return String(s).slice(0, 4) + '-12-31'
|
||||
}
|
||||
|
||||
export const PERIODS = ['day', 'week', 'month', 'year']
|
||||
|
||||
export const PERIOD_LABEL = { day: '每天', week: '每周', month: '每月', year: '每年' }
|
||||
|
||||
/** 周期窗口 [start, end] (闭区间, 本地日历日) */
|
||||
export function periodRange(period, s) {
|
||||
switch (period) {
|
||||
case 'week': return { start: startOfWeek(s), end: endOfWeek(s) }
|
||||
case 'month': return { start: startOfMonth(s), end: endOfMonth(s) }
|
||||
case 'year': return { start: startOfYear(s), end: endOfYear(s) }
|
||||
default: return { start: s, end: s }
|
||||
}
|
||||
}
|
||||
|
||||
/** 周期窗口内的天数 */
|
||||
export function periodDays(period, s) {
|
||||
const { start, end } = periodRange(period, s)
|
||||
return Math.round((toUTC(end) - toUTC(start)) / 86400000) + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 日均期望次数: 用于日历上"某一天是否完成了当天应做的份额"
|
||||
* - 每天 n 次 -> n
|
||||
* - 每周 n 次 -> ceil(n / 7)
|
||||
* - 每月 n 次 -> ceil(n / 当月天数)
|
||||
* - 每年 n 次 -> ceil(n / 365)
|
||||
* 环形进度仍以"周期完成度"(周期内累计次数 / n) 为准, 两者不冲突
|
||||
*/
|
||||
export function expectedPerDay(period, target, s) {
|
||||
const n = Math.max(1, Number(target) || 1)
|
||||
if (period === 'week') return Math.max(1, Math.ceil(n / 7))
|
||||
if (period === 'month') return Math.max(1, Math.ceil(n / periodDays('month', s)))
|
||||
if (period === 'year') return Math.max(1, Math.ceil(n / 365))
|
||||
return n
|
||||
}
|
||||
|
||||
/** 该周期在日期 d 之前是否已完整结束 (用于 streak 判断: 未结束的周期不算断签) */
|
||||
export function periodIsClosed(period, d, today) {
|
||||
const { end } = periodRange(period, d)
|
||||
return end < today
|
||||
}
|
||||
|
||||
/** 遍历 [start, end] 闭区间的每一天 */
|
||||
export function eachDay(start, end, fn) {
|
||||
let cur = start
|
||||
let guard = 0
|
||||
while (cur <= end && guard++ < 4000) {
|
||||
fn(cur)
|
||||
cur = addDays(cur, 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user