diff --git a/app/services/metrics.py b/app/services/metrics.py index 2f7c11a..25293a1 100644 --- a/app/services/metrics.py +++ b/app/services/metrics.py @@ -71,6 +71,75 @@ def _overlap_minutes( return minutes_between(start, end) if end > start else 0.0 +def _clip_interval( + interval_start: datetime, + interval_end: datetime, + boundary_start: datetime, + boundary_end: datetime, +) -> tuple[datetime, datetime] | None: + start = max(interval_start, boundary_start) + end = min(interval_end, boundary_end) + if end <= start: + return None + return start, end + + +def _merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]: + ordered = sorted(intervals, key=lambda item: item[0]) + merged: list[tuple[datetime, datetime]] = [] + for start, end in ordered: + if not merged or start > merged[-1][1]: + merged.append((start, end)) + elif end > merged[-1][1]: + merged[-1] = (merged[-1][0], end) + return merged + + +def _subtract_intervals( + base_start: datetime, + base_end: datetime, + occupied: list[tuple[datetime, datetime]], +) -> list[tuple[datetime, datetime]]: + gaps: list[tuple[datetime, datetime]] = [] + cursor = base_start + for start, end in _merge_intervals(occupied): + if start > cursor: + gaps.append((cursor, start)) + if end > cursor: + cursor = end + if cursor < base_end: + gaps.append((cursor, base_end)) + return gaps + + +def _calendar_dates_between(start_at: datetime, end_at: datetime): + current = start_at.date() + last = end_at.date() + while current <= last: + yield current + current += timedelta(days=1) + + +def _blank_overtime_intervals( + current_date: date, + schedule: WorkScheduleConfig, +) -> list[tuple[datetime, datetime]]: + day_start = datetime.combine(current_date, time.min) + day_end = day_start + timedelta(days=1) + occupied: list[tuple[datetime, datetime]] = [] + raw_intervals = [ + _schedule_interval(current_date, schedule.day_start, schedule.day_end), + _schedule_interval(current_date, schedule.overtime_start, schedule.overtime_end), + _schedule_interval(current_date, schedule.night_start, schedule.night_end), + _schedule_interval(current_date - timedelta(days=1), schedule.night_start, schedule.night_end), + ] + for interval_start, interval_end in raw_intervals: + clipped = _clip_interval(interval_start, interval_end, day_start, day_end) + if clipped is not None: + occupied.append(clipped) + return _subtract_intervals(day_start, day_end, occupied) + + def _covered_meal_overlap_minutes( start_at: datetime, end_at: datetime, @@ -143,6 +212,21 @@ def _raw_shift_minutes( ) ) minutes[bucket] += max(0.0, raw_minutes - meal_minutes) + + for blank_start, blank_end in _blank_overtime_intervals(current_date, active_schedule): + raw_minutes = _overlap_minutes(start_at, end_at, blank_start, blank_end) + blank_start_at = max(start_at, blank_start) + blank_end_at = min(end_at, blank_end) + meal_minutes = sum( + _overlap_minutes(blank_start_at, blank_end_at, meal_start, meal_end) + for meal_start, meal_end in meal_intervals + if ( + start_at <= meal_start + and end_at >= meal_end + and end_at > meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES) + ) + ) + minutes["overtime"] += max(0.0, raw_minutes - meal_minutes) return minutes diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 46271d8..b1ab1e7 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -280,3 +280,33 @@ def test_admin_forced_release_still_caps_work_time(): assert metrics["duration_minutes"] == 60 assert metrics["effective_minutes"] == 60 assert item.allocated_minutes == 60 + + +def test_morning_blank_interval_counts_as_overtime(): + start_at = datetime(2026, 7, 25, 6, 0, 0) + end_at = datetime(2026, 7, 25, 8, 0, 0) + item = Item(good_qty=10) + + metrics = calculate_report_metrics(start_at, end_at, [item]) + + assert metrics["duration_minutes"] == 120 + assert metrics["effective_minutes"] == 120 + assert metrics["shift_day_minutes"] == 0 + assert metrics["shift_overtime_minutes"] == 120 + assert metrics["shift_night_minutes"] == 0 + assert item.allocated_minutes == 120 + + +def test_dinner_blank_interval_counts_as_overtime(): + start_at = datetime(2026, 7, 25, 17, 20, 0) + end_at = datetime(2026, 7, 25, 18, 0, 0) + item = Item(good_qty=10) + + metrics = calculate_report_metrics(start_at, end_at, [item]) + + assert metrics["duration_minutes"] == 40 + assert metrics["effective_minutes"] == 40 + assert metrics["shift_day_minutes"] == 0 + assert metrics["shift_overtime_minutes"] == 40 + assert metrics["shift_night_minutes"] == 0 + assert item.allocated_minutes == 40