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.
This commit is contained in:
2026-02-17 23:06:23 +03:00
parent 7a963eccd1
commit 3f4c7bf66c
3 changed files with 101 additions and 16 deletions

View File

@@ -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:

View File

@@ -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:0023: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: 1718 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"]

View File

@@ -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 += "<h2>" + date + "</h2>";
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 += "<div class=\"" + itemClass + "\"><span class=\"duty-item-type\">" + escapeHtml(typeLabel) + "</span> <span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
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 += "<div class=\"" + itemClass + "\"><span class=\"duty-item-type\">" + escapeHtml(typeLabel) + "</span> <span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + timeOrRange + "</div></div>";
});
});
dutyListEl.innerHTML = html;