From 7fb9bdef13cf0c05a67cba69b5511b481594edeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 15:53:54 +0000 Subject: [PATCH 1/3] fix(time): gracefully parse free-form departure times Empty minutes on the split HH/MM fields produced a NaN validation error loop (e.g. typing 08 / 0800 with blank MM). Accept compact forms like 0800 and 0800 AM on both the frontend and backend normalizer, default missing parts, and cover with unit tests. Co-authored-by: jssturm --- app/engine/planner.py | 83 ++++++++++----- static/app.html | 4 +- static/js/app.js | 235 +++++++++++++++++++++++++++++++---------- static/js/i18n.js | 4 +- test_normalize_time.py | 37 +++++++ 5 files changed, 279 insertions(+), 84 deletions(-) create mode 100644 test_normalize_time.py diff --git a/app/engine/planner.py b/app/engine/planner.py index a13f5b9..2efe400 100644 --- a/app/engine/planner.py +++ b/app/engine/planner.py @@ -1354,40 +1354,75 @@ def _fmt_time(hour: int, minute: int) -> str: # --------------------------------------------------------------------------- +def _to_24h(hour: int, meridiem: str | None) -> int | None: + """Convert hour + optional AM/PM to 24-hour clock, or None if invalid.""" + if meridiem: + if hour < 1 or hour > 12: + return None + if meridiem == "AM" and hour == 12: + return 0 + if meridiem == "PM" and hour != 12: + return hour + 12 + return hour + # No meridiem: treat as 24-hour when hour >= 13, else leave as-is (AM-ish). + if hour < 0 or hour > 23: + return None + return hour + + def _normalize_departure_time(raw: str) -> str: """Normalize a user-entered departure time string to 'HH:MM AM/PM' format. - Accepts '7:00 AM', '07:00', '7am', '0700', '7:00AM', etc. + Accepts '7:00 AM', '07:00', '7am', '0700', '0800 AM', '7:00AM', bare + hour integers, and similar free-form variants. Returns the normalized time string or the raw input if unparseable. """ - stripped = raw.strip().upper() - # Try "H:MM AM/PM" or "HH:MM AM/PM" - m = re.match(r'(\d{1,2}):(\d{2})\s*(AM|PM)?', stripped) + stripped = re.sub(r"[.\u00b7]", ":", raw.strip().upper()) + stripped = re.sub(r"\s+", " ", stripped).strip() + + def _finish(hour: int, minute: int, meridiem: str | None) -> str | None: + if minute < 0 or minute > 59: + return None + hour_24 = _to_24h(hour, meridiem) + if hour_24 is None: + return None + return _fmt_time(hour_24, minute) + + # Try "H:MM AM/PM" or "HH:MM AM/PM" (optional colon variants already normalized) + m = re.match(r"^(\d{1,2}):(\d{2})\s*(AM|PM)?$", stripped) if m: - hour = int(m.group(1)) - minute = int(m.group(2)) - meridiem = m.group(3) or ('AM' if hour < 12 else 'PM') - # Convert to 24-hour for _fmt_time: - # - 12:xx AM → hour 0 (midnight) - # - 12:xx PM → hour 12 (noon, unchanged) - # - AM otherwise → hour unchanged - # - PM otherwise → hour + 12 - if meridiem == 'AM' and hour == 12: - hour = 0 - elif meridiem == 'PM' and hour != 12: - hour += 12 - return _fmt_time(hour, minute) - # Try "7am", "7 AM", "07:00AM" etc without minutes - m = re.match(r'(\d{1,2})\s*(AM|PM)', stripped) + result = _finish(int(m.group(1)), int(m.group(2)), m.group(3)) + if result: + return result + + # Compact HHmm / Hmm with optional AM/PM: "0700", "800", "0800 AM", "0800AM" + m = re.match(r"^(\d{3,4})\s*(AM|PM)?$", stripped) if m: - hour = int(m.group(1)) + digits = m.group(1) meridiem = m.group(2) - return _fmt_time(hour if meridiem == 'AM' or hour == 12 else hour + 12, 0) + if len(digits) == 3: + hour, minute = int(digits[0]), int(digits[1:]) + else: + hour, minute = int(digits[:2]), int(digits[2:]) + # With AM/PM, hour must be 1–12; without, allow 00–23 military style. + result = _finish(hour, minute, meridiem) + if result: + return result + + # Try "7am", "7 AM", "08AM" etc without minutes + m = re.match(r"^(\d{1,2})\s*(AM|PM)$", stripped) + if m: + result = _finish(int(m.group(1)), 0, m.group(2)) + if result: + return result + # Try bare hour like "7", "14" - m = re.match(r'^(\d{1,2})$', stripped) + m = re.match(r"^(\d{1,2})$", stripped) if m: - hour = int(m.group(1)) - return _fmt_time(hour, 0) + result = _finish(int(m.group(1)), 0, None) + if result: + return result + # Return as-is; _parse_time will handle fallback return raw diff --git a/static/app.html b/static/app.html index 47d658e..e7b4918 100644 --- a/static/app.html +++ b/static/app.html @@ -124,7 +124,7 @@

Plan Your Trip

+ placeholder="07" maxlength="4" autocomplete="off" /> : @@ -133,7 +133,7 @@

Plan Your Trip

-
When you plan to leave. Enter the time and select AM or PM.
+
When you plan to leave. Enter a time like 8:00, 0800, or 8am, and select AM or PM.
diff --git a/static/js/app.js b/static/js/app.js index ded9d22..056054b 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -435,16 +435,25 @@ const departurePeriod = getSelectedPeriod(); var departure = ""; if (hh || mm) { - // Validate time before constructing the departure string - var timeErr = validateTimeInput(hh, mm); - if (timeErr) { - showToast(timeErr, "error"); + // Extract a valid time from whatever the user typed (08 + blank, + // compact 0800, 8am, etc.) instead of hard-failing into a toast loop. + var parsedDep = parseFlexibleDeparture(hh, mm, departurePeriod); + if (!parsedDep) { + showToast(t("error.validTime"), "error"); $btnGenerate.disabled = false; $btnGenerate.innerHTML = "🚀 Generate Itinerary"; return; } - departure = (hh || "00") + ":" + (mm || "00"); - if (departurePeriod) departure = departure + " " + departurePeriod; + // Reflect the normalized values back into the fields + if ($tripDepartureHh) $tripDepartureHh.value = parsedDep.hh; + if ($tripDepartureMm) $tripDepartureMm.value = parsedDep.mm; + if ($tripDepartureAmpm) { + var buttons = $tripDepartureAmpm.querySelectorAll(".ampm-btn"); + buttons.forEach(function (b) { + b.classList.toggle("active", b.dataset.period === parsedDep.period); + }); + } + departure = parsedDep.hh + ":" + parsedDep.mm + " " + parsedDep.period; } const restaurants = $tripRestaurants.value.trim(); if (start) payload.starting_location = start; @@ -1079,7 +1088,7 @@
- +