// 打卡周期计算: 全部以 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) } }