// 任务模块前端: 自然日边界 + 保留选项可见性 // 与后端 server/task-domain/date.js 保持同一口径, 改动需同步 export const TZ = 'Asia/Shanghai' export const RETENTION_LABEL = { never: '永不显示', today: '当天', '3d': '3 天', week: '本周', month: '本月', forever: '永久显示', } export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'forever'] export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' } export const STATUS_OPTIONS = ['todo', 'doing', 'done'] // 任务编号 (T-TK-编号): 服务端分配 `Project.code` + 项目内自增 `Task.number` // - 列表 / 看板: 只显示 `#序号` (短, 不占卡片宽度) // - 详情页: 显示完整编号 `项目编号-序号`, 点按复制 // - 离线新建、上行定号前的任务 number 为空 -> 两处都不出编号 (不做假编号) /** 列表 / 看板的短编号: '#12'; 未定号返回 null */ export function taskShortId(task) { const n = task?.number return n === null || n === undefined || n === '' ? null : `#${n}` } /** 完整任务编号: 'IB-12'; 未定号返回 null */ export function taskFullId(task, code) { const n = task?.number if (n === null || n === undefined || n === '') return null return code ? `${code}-${n}` : `#${n}` } const FMT = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', }) export function toDateStr(d = new Date()) { return FMT.format(d instanceof Date ? d : new Date(d)) } 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 startOfWeek(s) { const dow = (toUTC(s).getUTCDay() + 6) % 7 return addDays(s, -dow) } export function startOfMonth(s) { return String(s).slice(0, 8) + '01' } /** 保留选项 -> 窗口起点 ("YYYY-MM-DD") 或 特殊标记 */ export function retentionStart(option, now = new Date()) { const today = toDateStr(now) switch (option) { case 'never': return { mode: 'never' } case 'forever': return { mode: 'all' } case 'today': return { mode: 'since', date: today } case '3d': return { mode: 'since', date: addDays(today, -2) } case 'week': return { mode: 'since', date: startOfWeek(today) } case 'month': return { mode: 'since', date: startOfMonth(today) } default: return { mode: 'all' } } } /** 单条任务在某保留选项下是否可见 */ export function isVisibleByRetention(task, option, now = new Date()) { if (task.status !== 'done') return true const win = retentionStart(option, now) if (win.mode === 'never') return false if (win.mode === 'all') return true if (!task.doneAt) return false return toDateStr(task.doneAt) >= win.date } /** 按任务所属列的保留选项过滤任务 */ export function filterVisible(tasks, columns, now = new Date()) { const map = new Map((columns || []).map((c) => [c.id, c.retention || 'forever'])) return (tasks || []).filter((t) => isVisibleByRetention(t, map.get(t.columnId) || 'forever', now)) } /** 距离下一个自然日/周/月边界的毫秒数, 用于定时重算 (T-EX-18) */ export function msToNextBoundary(option, now = new Date()) { if (!['today', '3d', 'week', 'month'].includes(option)) return null const today = toDateStr(now) let next if (option === 'today' || option === '3d') next = addDays(today, 1) else if (option === 'week') next = addDays(startOfWeek(today), 7) else { const [y, m] = today.split('-').map(Number) const nm = new Date(Date.UTC(y, m, 1)) next = nm.toISOString().slice(0, 10) } // 本地零点转北京时间零点: 用 Date 构造 "next 00:00 +08:00" const target = new Date(next + 'T00:00:00+08:00').getTime() return Math.max(1000, target - now.getTime()) }