Files
ftb-project-management/apps/web/lib/work-hours.ts
2026-06-26 13:06:22 +08:00

275 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 && isWeekend(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:0012:00 + 13:0018:00共 8h/天),周末跳过
* 精度0.5h
*
* 验证用例(注释式,供日后接 vitest 迁移):
* - 单日 11:00→14:00 = 1.5h(午休 12:0013:00 不算)
* - 单日 09:00→18:00 = 8h
* - 跨天 1月1日 09:00 → 1月2日 14:00 = 8 + 1 = 9h
* (注:旧需求是 9:0018:00 无午休=14h现按 8h/天口径,结果 9h
* - 跨周末 周五 17:00 → 周一 11:00 = 1 + 2 = 3h
* - 全在午休 12:30→13:00 = 0h
* - 全在周末 周六全天 = 0h
* - 负区间 / 非法 ISO = 0
*/
export function calcWorkHours(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 Math.round(total * 2) / 2;
}
/**
* 把小时数格式化为 "XhY天"Y 保留 1 位小数(整数则不带小数)
*/
export function formatWorkHours(hours: number): string {
if (!isFinite(hours) || hours <= 0) return '0h0天';
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。
*/
export function calcActualElapsedHours(startISO?: string | null, endISO?: string | null): number {
if (!startISO || !endISO) return 0;
const start = new Date(startISO).getTime();
const end = new Date(endISO).getTime();
if (isNaN(start) || isNaN(end) || end <= start) return 0;
const hours = (end - start) / MS_PER_HOUR;
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 '0h0天';
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 && isWeekend(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 };
}