ProjectCraft / core /date_engine.py
yania-n's picture
Claude Sonnet 4.6
Initial deployment of ProjectCraft
3c5146b
Raw History Blame Contribute Delete
3.14 kB
"""Three-mode date intelligence engine for ProjectCraft."""
import re
from datetime import date, timedelta
def detect_date_mode(start: str, end: str, start_ai: bool, end_ai: bool) -> str:
"""
Detect which date mode to use.
has_start = bool(start.strip()) and not start_ai
has_end = bool(end.strip()) and not end_ai
Mode A: both provided -> return "A"
Mode B: start only -> return "B"
Mode C: end only -> return "C"
Neither: raise ValueError("At least one date must be provided.")
"""
has_start = bool(start.strip()) and not start_ai
has_end = bool(end.strip()) and not end_ai
if has_start and has_end:
return "A"
elif has_start:
return "B"
elif has_end:
return "C"
else:
raise ValueError("At least one date must be provided.")
def validate_date_order(start: str, end: str) -> bool:
"""Return True if start <= end (both as YYYY-MM-DD strings). Return True if either is empty."""
if not start or not end:
return True
parsed_start = parse_iso_date(start)
parsed_end = parse_iso_date(end)
if parsed_start is None or parsed_end is None:
return True
return parsed_start <= parsed_end
def format_date_range(start: str, end: str) -> str:
"""Format two YYYY-MM-DD strings into a readable range like 'Jan 15, 2025 -> Mar 20, 2025'. Handle None/empty gracefully."""
def fmt(d: str) -> str:
if not d:
return ""
parsed = parse_iso_date(d)
if parsed is None:
return ""
# strftime('%d') zero-pads; lstrip('0') gives non-padded day cross-platform
day = parsed.strftime("%d").lstrip("0") or "0"
return parsed.strftime(f"%b {day}, %Y")
start_fmt = fmt(start) if start else ""
end_fmt = fmt(end) if end else ""
if start_fmt and end_fmt:
return f"{start_fmt} \u2192 {end_fmt}"
elif start_fmt:
return start_fmt
elif end_fmt:
return end_fmt
return ""
def parse_iso_date(date_str: str):
"""Parse a YYYY-MM-DD string to datetime.date. Return None if invalid."""
if not date_str or not isinstance(date_str, str):
return None
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date_str.strip()):
return None
try:
parts = date_str.strip().split("-")
return date(int(parts[0]), int(parts[1]), int(parts[2]))
except (ValueError, IndexError):
return None
def days_between(start: str, end: str) -> int:
"""Return number of days between two YYYY-MM-DD strings. Return 0 if either is invalid."""
parsed_start = parse_iso_date(start)
parsed_end = parse_iso_date(end)
if parsed_start is None or parsed_end is None:
return 0
return abs((parsed_end - parsed_start).days)
def offset_date(base_date: str, offset_days: int) -> str:
"""Add offset_days to a YYYY-MM-DD string. Return the result as YYYY-MM-DD."""
parsed = parse_iso_date(base_date)
if parsed is None:
raise ValueError(f"Invalid base date: {base_date!r}")
result = parsed + timedelta(days=offset_days)
return result.isoformat()