diff --git a/handlers/import_duty_schedule.py b/handlers/import_duty_schedule.py index 8737fd5..a0e52b3 100644 --- a/handlers/import_duty_schedule.py +++ b/handlers/import_duty_schedule.py @@ -60,6 +60,33 @@ def _duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str: return dt.strftime("%Y-%m-%dT%H:%M:%SZ") +def _day_start_iso(d: date) -> str: + """ISO 8601 start of calendar day UTC: YYYY-MM-DDT00:00:00Z.""" + return d.isoformat() + "T00:00:00Z" + + +def _day_end_iso(d: date) -> str: + """ISO 8601 end of calendar day UTC: YYYY-MM-DDT23:59:59Z.""" + return d.isoformat() + "T23:59:59Z" + + +def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]: + """Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> [].""" + if not dates: + return [] + sorted_dates = sorted(set(dates)) + ranges: list[tuple[date, date]] = [] + start_d = end_d = sorted_dates[0] + for d in sorted_dates[1:]: + if (d - end_d).days == 1: + end_d = d + else: + ranges.append((start_d, end_d)) + start_d = end_d = d + ranges.append((start_d, end_d)) + return ranges + + async def import_duty_schedule_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.effective_user: return @@ -118,18 +145,22 @@ def _run_import( insert_duty(session, user.id, start_at, end_at, event_type="duty") num_duty += 1 for d in entry.unavailable_dates: - start_at = _duty_to_iso(d, hour_utc, minute_utc) - d_next = d + timedelta(days=1) - end_at = _duty_to_iso(d_next, hour_utc, minute_utc) insert_duty( - session, user.id, start_at, end_at, event_type="unavailable" + session, + user.id, + _day_start_iso(d), + _day_end_iso(d), + event_type="unavailable", ) num_unavailable += 1 - for d in entry.vacation_dates: - start_at = _duty_to_iso(d, hour_utc, minute_utc) - d_next = d + timedelta(days=1) - end_at = _duty_to_iso(d_next, hour_utc, minute_utc) - insert_duty(session, user.id, start_at, end_at, event_type="vacation") + for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates): + insert_duty( + session, + user.id, + _day_start_iso(start_d), + _day_end_iso(end_d), + event_type="vacation", + ) num_vacation += 1 return (len(result.entries), num_duty, num_unavailable, num_vacation) finally: diff --git a/tests/test_import_duty_schedule_integration.py b/tests/test_import_duty_schedule_integration.py index 1c68683..3f105b8 100644 --- a/tests/test_import_duty_schedule_integration.py +++ b/tests/test_import_duty_schedule_integration.py @@ -140,16 +140,16 @@ def test_import_full_flow_parse_then_import(db_url): def test_import_event_types_unavailable_vacation(db_url): - """Import creates records with event_type duty, unavailable, vacation.""" + """Import creates duty, unavailable (full day), vacation (periods). Unavailable: same-day 00:00–23:59. Three consecutive vacation days → one record.""" result = DutyScheduleResult( start_date=date(2026, 2, 16), - end_date=date(2026, 2, 18), + end_date=date(2026, 2, 20), entries=[ DutyScheduleEntry( full_name="Mixed User", duty_dates=[date(2026, 2, 16)], unavailable_dates=[date(2026, 2, 17)], - vacation_dates=[date(2026, 2, 18)], + vacation_dates=[date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)], ), ], ) @@ -159,9 +159,48 @@ def test_import_event_types_unavailable_vacation(db_url): session = get_session(db_url) try: - duties = get_duties(session, "2026-02-16", "2026-02-19") + duties = get_duties(session, "2026-02-16", "2026-02-21") finally: session.close() assert len(duties) == 3 types = {d[0].event_type for d in duties} assert types == {"duty", "unavailable", "vacation"} + + by_type = {d[0].event_type: d[0] for d in duties} + unav = by_type["unavailable"] + assert unav.start_at == "2026-02-17T00:00:00Z" + assert unav.end_at == "2026-02-17T23:59:59Z" + vac = by_type["vacation"] + assert vac.start_at == "2026-02-18T00:00:00Z" + assert vac.end_at == "2026-02-20T23:59:59Z" + + +def test_import_vacation_with_gap_two_periods(db_url): + """Vacation dates with a gap (17, 18, 20 Feb) → two records: 17–18 and 20.""" + result = DutyScheduleResult( + start_date=date(2026, 2, 16), + end_date=date(2026, 2, 21), + entries=[ + DutyScheduleEntry( + full_name="Vacation User", + duty_dates=[], + unavailable_dates=[], + vacation_dates=[date(2026, 2, 17), date(2026, 2, 18), date(2026, 2, 20)], + ), + ], + ) + num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0) + assert num_users == 1 + assert num_duty == 0 and num_unav == 0 and num_vac == 2 + + session = get_session(db_url) + try: + duties = get_duties(session, "2026-02-16", "2026-02-21") + finally: + session.close() + vacation_records = [d[0] for d in duties if d[0].event_type == "vacation"] + assert len(vacation_records) == 2 + starts = sorted(r.start_at for r in vacation_records) + ends = sorted(r.end_at for r in vacation_records) + assert starts == ["2026-02-17T00:00:00Z", "2026-02-20T00:00:00Z"] + assert ends == ["2026-02-18T23:59:59Z", "2026-02-20T23:59:59Z"] diff --git a/webapp/app.js b/webapp/app.js index b993578..544536b 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -365,17 +365,32 @@ }); const dates = Object.keys(grouped).sort(); let html = ""; + /** Format UTC date from ISO string as DD.MM for display. */ + function formatDateKey(isoDateStr) { + const d = new Date(isoDateStr); + const day = String(d.getUTCDate()).padStart(2, "0"); + const month = String(d.getUTCMonth() + 1).padStart(2, "0"); + return day + "." + month; + } dates.forEach(function (date) { const list = grouped[date]; html += "