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")
|
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:
|
async def import_duty_schedule_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
if not update.message or not update.effective_user:
|
if not update.message or not update.effective_user:
|
||||||
return
|
return
|
||||||
@@ -118,18 +145,22 @@ def _run_import(
|
|||||||
insert_duty(session, user.id, start_at, end_at, event_type="duty")
|
insert_duty(session, user.id, start_at, end_at, event_type="duty")
|
||||||
num_duty += 1
|
num_duty += 1
|
||||||
for d in entry.unavailable_dates:
|
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(
|
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
|
num_unavailable += 1
|
||||||
for d in entry.vacation_dates:
|
for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):
|
||||||
start_at = _duty_to_iso(d, hour_utc, minute_utc)
|
insert_duty(
|
||||||
d_next = d + timedelta(days=1)
|
session,
|
||||||
end_at = _duty_to_iso(d_next, hour_utc, minute_utc)
|
user.id,
|
||||||
insert_duty(session, user.id, start_at, end_at, event_type="vacation")
|
_day_start_iso(start_d),
|
||||||
|
_day_end_iso(end_d),
|
||||||
|
event_type="vacation",
|
||||||
|
)
|
||||||
num_vacation += 1
|
num_vacation += 1
|
||||||
return (len(result.entries), num_duty, num_unavailable, num_vacation)
|
return (len(result.entries), num_duty, num_unavailable, num_vacation)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -140,16 +140,16 @@ def test_import_full_flow_parse_then_import(db_url):
|
|||||||
|
|
||||||
|
|
||||||
def test_import_event_types_unavailable_vacation(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(
|
result = DutyScheduleResult(
|
||||||
start_date=date(2026, 2, 16),
|
start_date=date(2026, 2, 16),
|
||||||
end_date=date(2026, 2, 18),
|
end_date=date(2026, 2, 20),
|
||||||
entries=[
|
entries=[
|
||||||
DutyScheduleEntry(
|
DutyScheduleEntry(
|
||||||
full_name="Mixed User",
|
full_name="Mixed User",
|
||||||
duty_dates=[date(2026, 2, 16)],
|
duty_dates=[date(2026, 2, 16)],
|
||||||
unavailable_dates=[date(2026, 2, 17)],
|
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)
|
session = get_session(db_url)
|
||||||
try:
|
try:
|
||||||
duties = get_duties(session, "2026-02-16", "2026-02-19")
|
duties = get_duties(session, "2026-02-16", "2026-02-21")
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
assert len(duties) == 3
|
assert len(duties) == 3
|
||||||
types = {d[0].event_type for d in duties}
|
types = {d[0].event_type for d in duties}
|
||||||
assert types == {"duty", "unavailable", "vacation"}
|
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();
|
const dates = Object.keys(grouped).sort();
|
||||||
let html = "";
|
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) {
|
dates.forEach(function (date) {
|
||||||
const list = grouped[date];
|
const list = grouped[date];
|
||||||
html += "<h2>" + date + "</h2>";
|
html += "<h2>" + date + "</h2>";
|
||||||
list.forEach(function (d) {
|
list.forEach(function (d) {
|
||||||
const startDate = new Date(d.start_at);
|
const startDate = new Date(d.start_at);
|
||||||
const endDate = new Date(d.end_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 typeLabel = EVENT_TYPE_LABELS[d.event_type] || d.event_type;
|
||||||
const itemClass = "duty-item duty-item--" + (d.event_type || "duty");
|
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;
|
dutyListEl.innerHTML = html;
|
||||||
|
|||||||
Reference in New Issue
Block a user