/** * Pure time-calculation engine for the Reported → Calculated derivation (ADR-014). * * Given the current ReportedTimeBlocks of one Assignment + workday and the WorkRule * effective on that date, decomposes worked time into categorized spans: * REGULAR (within-scheduled) / OVERTIME (statutory-excess) / NIGHT (late-night) / HOLIDAY. * * All arithmetic is integer minutes relative to the workday start (minutes may exceed * 1440 for overnight shifts). NIGHT spans are an independent premium axis: they overlap * REGULAR/OVERTIME/HOLIDAY spans in time and are emitted as additional blocks, mirroring * how the late-night premium stacks on top of base pay categories under Japanese labor law. * * Every rule that fires stamps a stable calculation tag (issue #7): results are fully * reconstructable from tags + sourceReportedBlockIds. */ export type RoundingDirection = "UP" | "DOWN" | "NEAREST"; export interface RoundingConfig { unitMinutes: number; direction: RoundingDirection; } export interface CalculationRule { clockInRounding: RoundingConfig; clockOutRounding: RoundingConfig; breakRounding: RoundingConfig; breakDeductionMinutes: number; dailyOvertimeThresholdMinutes: number; /** Minutes-from-midnight (0-1439); start > end means the window wraps midnight. */ nightWindowStart: number; nightWindowEnd: number; } export interface ReportedSpanInput { id: string; blockType: "WORK" | "BREAK" | "STEP_OUT"; startAt: Date; endAt: Date; } export type SpanCategory = "REGULAR" | "OVERTIME" | "NIGHT" | "HOLIDAY"; export interface CalculatedSpan { category: SpanCategory; startAt: Date; endAt: Date; minutes: number; calculationTagKeys: string[]; sourceReportedBlockIds: string[]; } /** Stable calculation tag keys (semantic, never display names — ADR-015). */ export const CALCULATION_TAGS = { roundingClockIn: "ROUNDING_CLOCK_IN", roundingClockOut: "ROUNDING_CLOCK_OUT", roundingBreak: "ROUNDING_BREAK", breakDeduction: "BREAK_DEDUCTION", dailyOvertimeThreshold: "DAILY_OVERTIME_THRESHOLD", nightWindow: "NIGHT_WINDOW", holidayStatutory: "HOLIDAY_STATUTORY", holidayPrescribed: "HOLIDAY_PRESCRIBED", } as const; interface MinuteInterval { start: number; end: number; sourceIds: string[]; } export function roundMinutes(value: number, config: RoundingConfig): number { const unit = config.unitMinutes >= 1 ? config.unitMinutes : 1; switch (config.direction) { case "UP": return Math.ceil(value / unit) * unit; case "DOWN": return Math.floor(value / unit) * unit; case "NEAREST": return Math.round(value / unit) * unit; } } function toMinutes(at: Date, dayStart: Date): number { return (at.getTime() - dayStart.getTime()) / 60_000; } function subtractInterval(intervals: MinuteInterval[], cut: MinuteInterval): MinuteInterval[] { const result: MinuteInterval[] = []; for (const interval of intervals) { if (cut.end <= interval.start || cut.start >= interval.end) { result.push(interval); continue; } if (cut.start > interval.start) { result.push({ start: interval.start, end: cut.start, sourceIds: interval.sourceIds }); } if (cut.end < interval.end) { result.push({ start: cut.end, end: interval.end, sourceIds: interval.sourceIds }); } } return result; } /** Night-window intervals in workday-relative minutes, covering overnight spill (up to +2 days). */ function nightIntervals(rule: CalculationRule): Array<{ start: number; end: number }> { const windows: Array<{ start: number; end: number }> = []; for (let day = 0; day < 3; day++) { const base = day * 1440; if (rule.nightWindowStart > rule.nightWindowEnd) { // Wraps midnight, e.g. 22:00-05:00 → [22:00, 24:00) and [00:00, 05:00) windows.push({ start: base + rule.nightWindowStart, end: base + 1440 }); windows.push({ start: base, end: base + rule.nightWindowEnd }); } else if (rule.nightWindowStart < rule.nightWindowEnd) { windows.push({ start: base + rule.nightWindowStart, end: base + rule.nightWindowEnd }); } } return windows.sort((a, b) => a.start - b.start); } export interface DeriveDaySpansInput { /** Workday start (the calendar date the blocks belong to). */ workDate: Date; reportedBlocks: ReportedSpanInput[]; rule: CalculationRule; /** Set when workDate is a CompanyHoliday: the whole day's work is HOLIDAY-category. */ holidayKind: "STATUTORY" | "PRESCRIBED" | null; } /** * Decomposes one workday's reported blocks into categorized spans. * * Pipeline: rounding → break subtraction (+ break-deduction shortfall) → * holiday classification or daily-overtime split → night-window intersection. */ export function deriveDaySpans(input: DeriveDaySpansInput): CalculatedSpan[] { const { workDate, reportedBlocks, rule, holidayKind } = input; const dayTags: string[] = []; // --- Step 1: rounding --- const workIntervals: MinuteInterval[] = []; const nonWorkCuts: MinuteInterval[] = []; let reportedBreakMinutes = 0; const sorted = [...reportedBlocks].sort((a, b) => a.startAt.getTime() - b.startAt.getTime()); for (const block of sorted) { const rawStart = toMinutes(block.startAt, workDate); const rawEnd = toMinutes(block.endAt, workDate); if (block.blockType === "WORK") { const start = roundMinutes(rawStart, rule.clockInRounding); const end = roundMinutes(rawEnd, rule.clockOutRounding); if (start !== rawStart) dayTags.push(CALCULATION_TAGS.roundingClockIn); if (end !== rawEnd) dayTags.push(CALCULATION_TAGS.roundingClockOut); if (end > start) workIntervals.push({ start, end, sourceIds: [block.id] }); } else { const start = roundMinutes(rawStart, rule.breakRounding); const end = roundMinutes(rawEnd, rule.breakRounding); if ((start !== rawStart || end !== rawEnd) && block.blockType === "BREAK") { dayTags.push(CALCULATION_TAGS.roundingBreak); } if (end > start) { nonWorkCuts.push({ start, end, sourceIds: [block.id] }); // Only BREAK counts toward satisfying the break-deduction rule; STEP_OUT (step-out) // reduces worked time but is not a rest period. if (block.blockType === "BREAK") reportedBreakMinutes += end - start; } } } // --- Step 2: subtract breaks / step-outs from worked time --- let worked = workIntervals; for (const cut of nonWorkCuts) { worked = subtractInterval(worked, cut); } // Break-deduction shortfall: guarantee at least breakDeductionMinutes of non-worked rest // per day by trimming the tail of the last worked interval. const shortfall = Math.max(0, rule.breakDeductionMinutes - reportedBreakMinutes); if (shortfall > 0 && worked.length > 0) { let remaining = shortfall; for (let i = worked.length - 1; i >= 0 && remaining > 0; i--) { const interval = worked[i]; const trim = Math.min(remaining, interval.end - interval.start); interval.end -= trim; remaining -= trim; } worked = worked.filter((interval) => interval.end > interval.start); dayTags.push(CALCULATION_TAGS.breakDeduction); } worked.sort((a, b) => a.start - b.start); if (worked.length === 0) return []; const toDate = (minutes: number) => new Date(workDate.getTime() + minutes * 60_000); const spans: CalculatedSpan[] = []; const baseTags = [...new Set(dayTags)]; // --- Step 3: holiday classification / daily-overtime split --- if (holidayKind !== null) { const holidayTag = holidayKind === "STATUTORY" ? CALCULATION_TAGS.holidayStatutory : CALCULATION_TAGS.holidayPrescribed; for (const interval of worked) { spans.push({ category: "HOLIDAY", startAt: toDate(interval.start), endAt: toDate(interval.end), minutes: interval.end - interval.start, calculationTagKeys: [...baseTags, holidayTag], sourceReportedBlockIds: interval.sourceIds, }); } } else { let cumulative = 0; for (const interval of worked) { const length = interval.end - interval.start; const regularRoom = Math.max(0, rule.dailyOvertimeThresholdMinutes - cumulative); const regularLength = Math.min(length, regularRoom); if (regularLength > 0) { spans.push({ category: "REGULAR", startAt: toDate(interval.start), endAt: toDate(interval.start + regularLength), minutes: regularLength, calculationTagKeys: [...baseTags], sourceReportedBlockIds: interval.sourceIds, }); } if (length > regularLength) { spans.push({ category: "OVERTIME", startAt: toDate(interval.start + regularLength), endAt: toDate(interval.end), minutes: length - regularLength, calculationTagKeys: [...baseTags, CALCULATION_TAGS.dailyOvertimeThreshold], sourceReportedBlockIds: interval.sourceIds, }); } cumulative += length; } } // --- Step 4: night-window intersection (independent premium axis) --- for (const window of nightIntervals(rule)) { for (const interval of worked) { const start = Math.max(interval.start, window.start); const end = Math.min(interval.end, window.end); if (end > start) { spans.push({ category: "NIGHT", startAt: toDate(start), endAt: toDate(end), minutes: end - start, calculationTagKeys: [...baseTags, CALCULATION_TAGS.nightWindow], sourceReportedBlockIds: interval.sourceIds, }); } } } return spans; }