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:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user