Expand /oncall date parser to accept more formats

Users entering everyday forms like 7/3/26 hit a generic parse
failure because the parser only accepted four-digit years and a
bare m/d. Add two-digit-year and month-name (with optional
ordinal/year) formats, treating explicit-year inputs as fixed and
keeping the year-less roll-forward for bare m/d. Update the help
text and per-command parse hints to match.

Refs: #133
This commit is contained in:
amoussa1229 2026-06-26 18:24:40 +00:00
parent b304121cff
commit d95e4ec3b5
3 changed files with 87 additions and 10 deletions

View file

@ -439,7 +439,7 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]:
"`/oncall rate <ext> <amount>` — Set a person's rate\n"
"`/oncall roster` — Show all registered employees\n"
"`/oncall help` — Show this help message\n\n"
"_Dates can be: today, tomorrow, monday, friday, 4/5, 2026-04-05_"
"_Dates can be: today, tomorrow, monday, friday, 4/5, 7/3/26, jul 3, 2026-04-05_"
)
if is_admin:
text += (

View file

@ -57,8 +57,27 @@ DAY_NAMES = [
]
# Formats carrying an explicit year — parsed as-is, never rolled forward.
_DATED_FORMATS = (
"%Y-%m-%d",
"%m/%d/%Y",
"%m/%d/%y",
"%m-%d-%y",
"%b %d %Y",
"%B %d %Y",
"%b %d %y",
"%B %d %y",
)
# Year-less formats — stamped with the current year, then rolled to next year
# if already more than a day in the past.
_UNDATED_FORMATS = ("%m/%d", "%m-%d", "%b %d", "%B %d")
_ORDINAL_RE = re.compile(r"(\d+)(st|nd|rd|th)\b")
def parse_date(text: str) -> datetime | None:
"""Parse flexible date input: today, tomorrow, day names, m/d, YYYY-MM-DD."""
"""Parse flexible date input: today, tomorrow, day names, m/d, m/d/yy,
YYYY-MM-DD, and month-name forms (e.g. ``jul 3``, ``july 3rd 2026``)."""
now = datetime.now(EASTERN)
text = text.strip().lower()
@ -75,13 +94,19 @@ def parse_date(text: str) -> datetime | None:
delta = 7
return now + timedelta(days=delta)
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m/%d", "%m-%d"):
candidate = _ORDINAL_RE.sub(r"\1", text)
for fmt in _DATED_FORMATS:
try:
parsed = datetime.strptime(text, fmt)
if "%Y" not in fmt:
parsed = parsed.replace(year=now.year)
if parsed.replace(tzinfo=EASTERN) < now - timedelta(days=1):
parsed = parsed.replace(year=now.year + 1)
return datetime.strptime(candidate, fmt).replace(tzinfo=EASTERN)
except ValueError:
continue
for fmt in _UNDATED_FORMATS:
try:
parsed = datetime.strptime(candidate, fmt).replace(year=now.year)
if parsed.replace(tzinfo=EASTERN) < now - timedelta(days=1):
parsed = parsed.replace(year=now.year + 1)
return parsed.replace(tzinfo=EASTERN)
except ValueError:
continue
@ -673,7 +698,7 @@ def _handle_pick(
date = parse_date(date_text)
if not date:
respond(
text=f"Couldn't parse date: `{date_text}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
text=f"Couldn't parse date: `{date_text}`. Try: today, tomorrow, friday, 4/5, 7/3/26, jul 3, 2026-04-05"
)
return
@ -907,7 +932,7 @@ def _handle_drop(
date = parse_date(parts[1])
if not date:
respond(
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 7/3/26, jul 3, 2026-04-05"
)
return

View file

@ -54,6 +54,58 @@ def test_case_insensitive(slackbot_app):
assert slackbot_app.parse_date(" FRIDAY ").strftime("%Y-%m-%d") == "2026-06-05"
@freeze_time(FROZEN)
def test_two_digit_year_slash(slackbot_app):
assert slackbot_app.parse_date("7/3/26").strftime("%Y-%m-%d") == "2026-07-03"
@freeze_time(FROZEN)
def test_two_digit_year_padded(slackbot_app):
assert slackbot_app.parse_date("07/03/26").strftime("%Y-%m-%d") == "2026-07-03"
@freeze_time(FROZEN)
def test_two_digit_year_dash(slackbot_app):
assert slackbot_app.parse_date("7-3-26").strftime("%Y-%m-%d") == "2026-07-03"
@freeze_time(FROZEN)
def test_two_digit_year_past_not_rolled(slackbot_app):
# Explicit two-digit year in the past stays put — no roll-forward.
assert slackbot_app.parse_date("1/1/26").strftime("%Y-%m-%d") == "2026-01-01"
@freeze_time(FROZEN)
def test_month_name_short(slackbot_app):
assert slackbot_app.parse_date("jul 3").strftime("%Y-%m-%d") == "2026-07-03"
@freeze_time(FROZEN)
def test_month_name_full(slackbot_app):
assert slackbot_app.parse_date("july 3").strftime("%Y-%m-%d") == "2026-07-03"
@freeze_time(FROZEN)
def test_month_name_with_year(slackbot_app):
assert slackbot_app.parse_date("july 3 2026").strftime("%Y-%m-%d") == "2026-07-03"
@freeze_time(FROZEN)
def test_month_name_ordinal(slackbot_app):
assert slackbot_app.parse_date("july 3rd").strftime("%Y-%m-%d") == "2026-07-03"
@freeze_time(FROZEN)
def test_month_name_past_wraps_to_next_year(slackbot_app):
# Year-less month name in the past rolls forward, like m/d.
assert slackbot_app.parse_date("jan 1").year == 2027
@freeze_time(FROZEN)
def test_eastern_tz_stamped(slackbot_app):
assert slackbot_app.parse_date("7/3/26").tzinfo is not None
@freeze_time(FROZEN)
def test_invalid_returns_none(slackbot_app):
assert slackbot_app.parse_date("notaday") is None