From 3f4c7bf66c65ba5f35d5c7dc04b97a06336e7742 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Tue, 17 Feb 2026 23:06:23 +0300 Subject: [PATCH] Implement date range handling for vacation and unavailable events - Added helper functions to generate ISO 8601 formatted start and end times for calendar days. - Introduced logic to merge consecutive vacation dates into a single record for improved data representation. - Updated the duty schedule import process to utilize the new date handling functions for unavailable and vacation events. - Enhanced integration tests to validate the correct handling of vacation periods and unavailable dates. - Modified the web application to display formatted date ranges for vacation and unavailable events. --- handlers/import_duty_schedule.py | 49 +++++++++++++++---- .../test_import_duty_schedule_integration.py | 47 ++++++++++++++++-- webapp/app.js | 21 ++++++-- 3 files changed, 101 insertions(+), 16 deletions(-) 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 += "

" + date + "

"; list.forEach(function (d) { const startDate = new Date(d.start_at); const endDate = new Date(d.end_at); - const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0"); - const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0"); const typeLabel = EVENT_TYPE_LABELS[d.event_type] || d.event_type; const itemClass = "duty-item duty-item--" + (d.event_type || "duty"); - html += "
" + escapeHtml(typeLabel) + " " + escapeHtml(d.full_name) + "
" + start + " – " + end + "
"; + let timeOrRange = ""; + if (d.event_type === "vacation" || d.event_type === "unavailable") { + const startStr = formatDateKey(d.start_at); + const endStr = formatDateKey(d.end_at); + timeOrRange = startStr === endStr ? startStr : startStr + " – " + endStr; + } else { + const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0"); + const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0"); + timeOrRange = start + " – " + end; + } + html += "
" + escapeHtml(typeLabel) + " " + escapeHtml(d.full_name) + "
" + timeOrRange + "
"; }); }); dutyListEl.innerHTML = html;