import { isChinaWorkday } from './china-workday-calendar'; export const WORK_HOURS = { morningStart: 9, morningEnd: 12, afternoonStart: 13, afternoonEnd: 18, hoursPerDay: 8, skipWeekends: true, } as const; const MS_PER_HOUR = 60 * 60 * 1000; function isWeekend(d: Date): boolean { const wd = d.getDay(); return wd === 0 || wd === 6; } function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)); } function dayBound(d: Date, hourFloat: number): Date { const h = Math.floor(hourFloat); const m = Math.round((hourFloat - h) * 60); const r = new Date(d); r.setHours(h, m, 0, 0); return r; } function dayOverlapHours(start: Date, end: Date, day: Date): number { if (WORK_HOURS.skipWeekends && !isChinaWorkday(day)) return 0; const morningStart = dayBound(day, WORK_HOURS.morningStart); const morningEnd = dayBound(day, WORK_HOURS.morningEnd); const afternoonStart = dayBound(day, WORK_HOURS.afternoonStart); const afternoonEnd = dayBound(day, WORK_HOURS.afternoonEnd); const morning = clamp(end.getTime(), morningStart.getTime(), morningEnd.getTime()) - clamp(start.getTime(), morningStart.getTime(), morningEnd.getTime()); const afternoon = clamp(end.getTime(), afternoonStart.getTime(), afternoonEnd.getTime()) - clamp(start.getTime(), afternoonStart.getTime(), afternoonEnd.getTime()); return Math.max(0, morning + afternoon) / MS_PER_HOUR; } /** * 计算 ISO 时间区间内属于工作时段的小时数 * * 工作时段:9:00–12:00 + 13:00–18:00(共 8h/天),周末跳过 * 精度:0.5h * * 验证用例(注释式,供日后接 vitest 迁移): * - 单日 11:00→14:00 = 1.5h(午休 12:00–13:00 不算) * - 单日 09:00→18:00 = 8h * - 跨天 1月1日 09:00 → 1月2日 14:00 = 8 + 1 = 9h * (注:旧需求是 9:00–18:00 无午休=14h,现按 8h/天口径,结果 9h) * - 跨周末 周五 17:00 → 周一 11:00 = 1 + 2 = 3h * - 全在午休 12:30→13:00 = 0h * - 全在周末 周六全天 = 0h * - 负区间 / 非法 ISO = 0 */ function calcWorkHoursRaw(startISO: string, endISO: string): number { if (!startISO || !endISO) return 0; const start = new Date(startISO); const end = new Date(endISO); if (isNaN(start.getTime()) || isNaN(end.getTime())) return 0; if (end.getTime() <= start.getTime()) return 0; let total = 0; const cursor = new Date(start); cursor.setHours(0, 0, 0, 0); const endDay = new Date(end); endDay.setHours(0, 0, 0, 0); while (cursor.getTime() <= endDay.getTime()) { total += dayOverlapHours(start, end, cursor); cursor.setDate(cursor.getDate() + 1); } return total; } export function calcWorkHours(startISO: string, endISO: string): number { const total = calcWorkHoursRaw(startISO, endISO); return Math.round(total * 2) / 2; } /** * 把小时数格式化为 "Xh(Y天)",Y 保留 1 位小数(整数则不带小数) */ export function formatWorkHours(hours: number): string { if (!isFinite(hours) || hours <= 0) return '0h(0天)'; const days = hours / WORK_HOURS.hoursPerDay; const dayStr = Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, ''); return `${hours}h(${dayStr}天)`; } /** * 简短版:"Xh",不带天数(用于横向空间紧张的列表行) */ export function formatWorkHoursShort(hours: number): string { if (!isFinite(hours) || hours <= 0) return '0h'; return `${hours}h`; } /** * 计算真实经过时长,而不是工作时段内时长。 * * 用于“实际耗时”口径:按开始/结束时间戳直接相减,精度 0.5h; * 只要有正向耗时,最低按 0.5h 计,避免 30 分钟内工作被显示为 0。 */ // 实际耗时只统计中国工作日历内的工作时段;加班通过 overtime 记录单独计入。 export function calcActualElapsedHours(startISO?: string | null, endISO?: string | null): number { if (!startISO || !endISO) return 0; const hours = calcWorkHoursRaw(startISO, endISO); if (hours <= 0) return 0; return Math.max(0.5, Math.round(hours * 2) / 2); } function formatNaturalDays(hours: number): string { if (!isFinite(hours) || hours <= 0) return '0天'; const days = hours / 24; if (days < 0.1) return `${Number(days.toFixed(2))}天`; return `${Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, '')}天`; } /** * 实际耗时显示,天数按自然日 24h 换算。 */ export function formatActualDuration(hours: number): string { if (!isFinite(hours) || hours <= 0) return '0h(0天)'; return `${hours}h(${formatNaturalDays(hours)})`; } /** * 仅天数版:"Y天" */ export function formatWorkDays(hours: number): string { if (!isFinite(hours) || hours <= 0) return '0天'; const days = hours / WORK_HOURS.hoursPerDay; const dayStr = Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, ''); return `${dayStr}天`; } /** * 给定开始 ISO + 工时小时数,返回结束 ISO(备用:旧 estimateHours 反推 expectedEndAt) * 算法:从开始时间起,按工作时段累加,到达目标小时数即返回。 */ export function addWorkHours(startISO: string, hours: number): string { const start = new Date(startISO); if (isNaN(start.getTime()) || hours <= 0) return startISO; let remaining = hours; const cursor = new Date(start); while (remaining > 0) { const day = new Date(cursor); day.setHours(0, 0, 0, 0); if (WORK_HOURS.skipWeekends && !isChinaWorkday(day)) { cursor.setDate(cursor.getDate() + 1); cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0); continue; } const morningStart = dayBound(day, WORK_HOURS.morningStart); const morningEnd = dayBound(day, WORK_HOURS.morningEnd); const afternoonStart = dayBound(day, WORK_HOURS.afternoonStart); const afternoonEnd = dayBound(day, WORK_HOURS.afternoonEnd); const segments: Array<[Date, Date]> = [ [morningStart, morningEnd], [afternoonStart, afternoonEnd], ]; let advanced = false; for (const [segStart, segEnd] of segments) { if (cursor.getTime() >= segEnd.getTime()) continue; const segBegin = cursor.getTime() < segStart.getTime() ? segStart : cursor; const availableH = (segEnd.getTime() - segBegin.getTime()) / MS_PER_HOUR; if (availableH <= 0) continue; if (remaining <= availableH) { const finalMs = segBegin.getTime() + remaining * MS_PER_HOUR; return new Date(finalMs).toISOString(); } remaining -= availableH; cursor.setTime(segEnd.getTime()); advanced = true; } if (!advanced) { cursor.setDate(cursor.getDate() + 1); cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0); } else if (cursor.getTime() >= dayBound(day, WORK_HOURS.afternoonEnd).getTime()) { cursor.setDate(cursor.getDate() + 1); cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0); } } return cursor.toISOString(); } /** * 把 datetime-local 输入值(如 "2026-06-16T09:00")规范化为本地 ISO(含秒和毫秒) */ export function localToISO(local: string): string { if (!local) return ''; const d = new Date(local); return isNaN(d.getTime()) ? '' : d.toISOString(); } /** * 把 ISO 时间转成 datetime-local 输入值 */ export function isoToLocal(iso: string): string { if (!iso) return ''; const d = new Date(iso); if (isNaN(d.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** * 友好显示:MM-DD HH:mm */ export function formatShortTime(iso: string): string { if (!iso) return ''; const d = new Date(iso); if (isNaN(d.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } export interface TimeInterval { start: string; end: string; } /** * 合并多个时间区间到工作时段内的总小时数(不重复计数) * * 算法:按 start 排序后扫描,重叠区间合并 end,对每个合并后的区间调 calcWorkHours 累加 * 用例:A(9-11) + B(10-12) → 合并为 9-12 = 3h */ export function mergeWorkHours(intervals: TimeInterval[]): number { const valid = intervals .filter((i) => i.start && i.end) .map((i) => ({ start: new Date(i.start).getTime(), end: new Date(i.end).getTime() })) .filter((i) => !isNaN(i.start) && !isNaN(i.end) && i.end > i.start) .sort((a, b) => a.start - b.start); if (valid.length === 0) return 0; const merged: Array<{ start: number; end: number }> = []; let cur = { ...valid[0] }; for (let i = 1; i < valid.length; i++) { const next = valid[i]; if (next.start <= cur.end) { cur.end = Math.max(cur.end, next.end); } else { merged.push(cur); cur = { ...next }; } } merged.push(cur); let total = 0; for (const m of merged) { total += calcWorkHours(new Date(m.start).toISOString(), new Date(m.end).toISOString()); } return Math.round(total * 2) / 2; } /** * 双口径汇总: * - calendarHours:合并所有重叠区间后的日历耗时(看版本实际走了几天) * - manhours:单条独立累加的人力投入(看总人力成本) */ export function calcTwoMetrics(intervals: TimeInterval[]): { calendarHours: number; manhours: number; } { const calendarHours = mergeWorkHours(intervals); let manhours = 0; for (const i of intervals) { if (!i.start || !i.end) continue; manhours += calcWorkHours(i.start, i.end); } return { calendarHours, manhours: Math.round(manhours * 2) / 2 }; }