281 lines
9.5 KiB
TypeScript
281 lines
9.5 KiB
TypeScript
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 };
|
||
}
|