"""Unified DateService — single source of truth for date handling across PM. Design invariants: - Internally: Gregorian datetime.date (UTC-safe, no timezone shift). - API input/output: ISO 8601 (YYYY-MM-DD) or Jalali (YYYY/MM/DD) when convenient. - UI: Jalali (Shamsi) display with English/Persian digit support. """ from __future__ import annotations import datetime as _dt from typing import Any from app.utils.jalali import ( days_in_month, gregorian_to_jalali, is_leap_jalali, jalali_to_gregorian, parse_flexible_date, to_jalali_str, ) class DateService: @staticmethod def parse(value: Any) -> _dt.date | None: """Parse ISO, Jalali, or datetime into a standard Gregorian date.""" if value is None: return None if isinstance(value, _dt.date) and not isinstance(value, _dt.datetime): return value if isinstance(value, _dt.datetime): return value.date() return parse_flexible_date(str(value)) @staticmethod def to_iso(value: _dt.date | None) -> str | None: """Format date as ISO 8601 string (YYYY-MM-DD).""" if value is None: return None return value.isoformat() @staticmethod def today() -> _dt.date: """Return today's Gregorian date.""" return _dt.date.today() @staticmethod def to_jalali(value: _dt.date | None, sep: str = "/") -> str | None: """Format date as Jalali string (YYYY/MM/DD).""" return to_jalali_str(value, sep=sep) @staticmethod def calculate_duration(start_date: _dt.date | None, end_date: _dt.date | None) -> int: """Duration in days = the GAP between the two dates (end date not counted). This is the convention the UI uses, so the number a user types is the number displayed back to them: 16 → 20 Mordad = 4 days start == end = 0 days (same day, no span) Returns 0 when either date is missing or the range is inverted. """ if not start_date or not end_date: return 0 diff = (end_date - start_date).days return max(0, diff) @staticmethod def compute_end_date(start_date: _dt.date | None, duration_days: int) -> _dt.date | None: """End date for a start date and a gap-based duration. Exclusive, the exact inverse of calculate_duration: compute_end_date(d, calculate_duration(d, e)) == e calculate_duration(d, compute_end_date(d, n)) == n A duration of 0 (or 1) means the task starts and ends the same day. """ if not start_date: return None if duration_days <= 0: return start_date return start_date + _dt.timedelta(days=duration_days) @staticmethod def today() -> _dt.date: """Return current Gregorian date.""" return _dt.date.today() @staticmethod def today_jalali(sep: str = "/") -> str: """Return today as Jalali string.""" return to_jalali_str(_dt.date.today(), sep=sep) or "" date_service = DateService()