From 6d5e58273fb6e86b39cc935ccafbb38480e17f9b Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Fri, 13 Mar 2026 14:34:49 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20monthly=5Fstats()=20=E2=80=94=20m=C3=A9?= =?UTF-8?q?diane=20journali=C3=A8re=20et=20hebdomadaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/business/time_calc.py | 24 +++++++++++++++ tests/test_time_calc.py | 62 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/business/time_calc.py b/app/business/time_calc.py index 10a5834..55f858f 100644 --- a/app/business/time_calc.py +++ b/app/business/time_calc.py @@ -25,6 +25,30 @@ def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int: return actual_minutes - reference_minutes +import statistics as _stats + + +def monthly_stats(entries: list) -> dict: + """ + Calcule médiane journalière et médiane hebdomadaire (semaines ISO) + pour un groupe d'entrées. Les absences (total_minutes=0) sont incluses. + """ + if not entries: + return {"median_daily_min": 0, "median_weekly_min": 0} + + daily = [e.total_minutes() for e in entries] + median_daily = int(_stats.median(daily)) + + weekly: dict[tuple, int] = {} + for e in entries: + key = e.date.isocalendar()[:2] # (year, isoweek) + weekly[key] = weekly.get(key, 0) + e.total_minutes() + + median_weekly = int(_stats.median(weekly.values())) + + return {"median_daily_min": median_daily, "median_weekly_min": median_weekly} + + def count_day_types(entries: list) -> dict[str, int]: """Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros.""" counts: dict[str, int] = {} diff --git a/tests/test_time_calc.py b/tests/test_time_calc.py index 872df05..446d233 100644 --- a/tests/test_time_calc.py +++ b/tests/test_time_calc.py @@ -43,8 +43,9 @@ def test_week_balance_negative(): from app.business.time_calc import count_day_types -from app.models import WorkEntry -from datetime import date +from app.models import WorkEntry, TimeSlot +from app.business.time_calc import monthly_stats +from datetime import date, time as dtime def test_count_day_types_basic(): @@ -62,6 +63,63 @@ def test_count_day_types_empty(): assert count_day_types([]) == {} +def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry: + """Helper : crée un WorkEntry avec des TimeSlots.""" + entry = WorkEntry(date=d, day_type="WORK") + entry.time_slots = [ + TimeSlot(start_time=dtime(*[int(x) for x in s.split(":")]), + end_time=dtime(*[int(x) for x in e.split(":")])) + for s, e in slots + ] + return entry + + +def _absence(d: date) -> WorkEntry: + """Helper : crée un WorkEntry sans time_slots (0 min).""" + entry = WorkEntry(date=d, day_type="CONGE") + entry.time_slots = [] + return entry + + +def test_monthly_stats_empty(): + result = monthly_stats([]) + assert result == {"median_daily_min": 0, "median_weekly_min": 0} + + +def test_monthly_stats_median_daily_odd(): + # 420, 465, 510 → médiane = 465 + entries = [ + _entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min + _entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min + _entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min + ] + result = monthly_stats(entries) + assert result["median_daily_min"] == 465 + + +def test_monthly_stats_includes_absences(): + # 0, 465 → médiane de 2 valeurs = (0+465)/2 = 232 (int) + entries = [ + _absence(date(2025, 1, 6)), + _entry(date(2025, 1, 7), ("9:00", "16:45")), + ] + result = monthly_stats(entries) + assert result["median_daily_min"] == 232 + + +def test_monthly_stats_median_weekly(): + # Semaine 1 : 465+465 = 930 min + # Semaine 2 : 420 min + # médiane([930, 420]) = (420+930)/2 = 675 + entries = [ + _entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2 + _entry(date(2025, 1, 7), ("9:00", "16:45")), # sem 2 + _entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3 + ] + result = monthly_stats(entries) + assert result["median_weekly_min"] == 675 + + def test_count_day_types_single_type(): entries = [ WorkEntry(date=date(2025, 2, 1), day_type="RTT"),