Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3import datetime
  4from calendar import monthrange
  5from collections.abc import Iterator
  6from enum import Enum
  7
  8from plain.utils import timezone
  9
 10
 11class DatetimeRangeAliases(Enum):
 12    TODAY = "Today"
 13    THIS_WEEK = "This Week"
 14    THIS_WEEK_TO_DATE = "This Week-to-date"
 15    THIS_MONTH = "This Month"
 16    THIS_MONTH_TO_DATE = "This Month-to-date"
 17    THIS_QUARTER = "This Quarter"
 18    THIS_QUARTER_TO_DATE = "This Quarter-to-date"
 19    THIS_YEAR = "This Year"
 20    THIS_YEAR_TO_DATE = "This Year-to-date"
 21    LAST_WEEK = "Last Week"
 22    LAST_WEEK_TO_DATE = "Last Week-to-date"
 23    LAST_MONTH = "Last Month"
 24    LAST_MONTH_TO_DATE = "Last Month-to-date"
 25    LAST_QUARTER = "Last Quarter"
 26    LAST_QUARTER_TO_DATE = "Last Quarter-to-date"
 27    LAST_YEAR = "Last Year"
 28    LAST_YEAR_TO_DATE = "Last Year-to-date"
 29    SINCE_30_DAYS_AGO = "Since 30 Days Ago"
 30    SINCE_60_DAYS_AGO = "Since 60 Days Ago"
 31    SINCE_90_DAYS_AGO = "Since 90 Days Ago"
 32    SINCE_365_DAYS_AGO = "Since 365 Days Ago"
 33    NEXT_WEEK = "Next Week"
 34    NEXT_4_WEEKS = "Next 4 Weeks"
 35    NEXT_MONTH = "Next Month"
 36    NEXT_QUARTER = "Next Quarter"
 37    NEXT_YEAR = "Next Year"
 38
 39    # TODO doesn't include anything less than a day...
 40    # ex. SINCE_1_HOUR_AGO = "Since 1 Hour Ago"
 41
 42    def __str__(self) -> str:
 43        return self.value
 44
 45    @classmethod
 46    def from_value(cls, value: str) -> DatetimeRangeAliases:
 47        for member in cls:
 48            if member.value == value:
 49                return member
 50        raise ValueError(f"{value} is not a valid value for {cls.__name__}")
 51
 52    @classmethod
 53    def to_range(cls, value: str | DatetimeRangeAliases) -> DatetimeRange:
 54        # Convert enum to string value if needed
 55        if isinstance(value, DatetimeRangeAliases):
 56            value = value.value
 57
 58        now = timezone.localtime()
 59        start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
 60        start_of_week = start_of_today - datetime.timedelta(
 61            days=start_of_today.weekday()
 62        )
 63        start_of_month = start_of_today.replace(day=1)
 64        start_of_quarter = start_of_today.replace(
 65            month=((start_of_today.month - 1) // 3) * 3 + 1, day=1
 66        )
 67        start_of_year = start_of_today.replace(month=1, day=1)
 68
 69        def end_of_day(dt: datetime.datetime) -> datetime.datetime:
 70            return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
 71
 72        def end_of_month(dt: datetime.datetime) -> datetime.datetime:
 73            last_day = monthrange(dt.year, dt.month)[1]
 74            return end_of_day(dt.replace(day=last_day))
 75
 76        def end_of_quarter(dt: datetime.datetime) -> datetime.datetime:
 77            end_month = ((dt.month - 1) // 3 + 1) * 3
 78            return end_of_month(dt.replace(month=end_month))
 79
 80        def end_of_year(dt: datetime.datetime) -> datetime.datetime:
 81            return end_of_month(dt.replace(month=12))
 82
 83        if value == cls.TODAY.value:
 84            return DatetimeRange(start_of_today, end_of_day(now))
 85        if value == cls.THIS_WEEK.value:
 86            return DatetimeRange(
 87                start_of_week, end_of_day(start_of_week + datetime.timedelta(days=6))
 88            )
 89        if value == cls.THIS_WEEK_TO_DATE.value:
 90            return DatetimeRange(start_of_week, now)
 91        if value == cls.THIS_MONTH.value:
 92            return DatetimeRange(start_of_month, end_of_month(start_of_month))
 93        if value == cls.THIS_MONTH_TO_DATE.value:
 94            return DatetimeRange(start_of_month, now)
 95        if value == cls.THIS_QUARTER.value:
 96            return DatetimeRange(start_of_quarter, end_of_quarter(start_of_quarter))
 97        if value == cls.THIS_QUARTER_TO_DATE.value:
 98            return DatetimeRange(start_of_quarter, now)
 99        if value == cls.THIS_YEAR.value:
100            return DatetimeRange(start_of_year, end_of_year(start_of_year))
101        if value == cls.THIS_YEAR_TO_DATE.value:
102            return DatetimeRange(start_of_year, now)
103        if value == cls.LAST_WEEK.value:
104            last_week_start = start_of_week - datetime.timedelta(days=7)
105            return DatetimeRange(
106                last_week_start,
107                end_of_day(last_week_start + datetime.timedelta(days=6)),
108            )
109        if value == cls.LAST_WEEK_TO_DATE.value:
110            return DatetimeRange(start_of_week - datetime.timedelta(days=7), now)
111        if value == cls.LAST_MONTH.value:
112            last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
113            return DatetimeRange(last_month, end_of_month(last_month))
114        if value == cls.LAST_MONTH_TO_DATE.value:
115            last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
116            return DatetimeRange(last_month, now)
117        if value == cls.LAST_QUARTER.value:
118            last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
119                day=1
120            )
121            return DatetimeRange(last_quarter, end_of_quarter(last_quarter))
122        if value == cls.LAST_QUARTER_TO_DATE.value:
123            last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
124                day=1
125            )
126            return DatetimeRange(last_quarter, now)
127        if value == cls.LAST_YEAR.value:
128            last_year = start_of_year.replace(year=start_of_year.year - 1)
129            return DatetimeRange(last_year, end_of_year(last_year))
130        if value == cls.LAST_YEAR_TO_DATE.value:
131            last_year = start_of_year.replace(year=start_of_year.year - 1)
132            return DatetimeRange(last_year, now)
133        if value == cls.SINCE_30_DAYS_AGO.value:
134            return DatetimeRange(now - datetime.timedelta(days=30), now)
135        if value == cls.SINCE_60_DAYS_AGO.value:
136            return DatetimeRange(now - datetime.timedelta(days=60), now)
137        if value == cls.SINCE_90_DAYS_AGO.value:
138            return DatetimeRange(now - datetime.timedelta(days=90), now)
139        if value == cls.SINCE_365_DAYS_AGO.value:
140            return DatetimeRange(now - datetime.timedelta(days=365), now)
141        if value == cls.NEXT_WEEK.value:
142            next_week_start = start_of_week + datetime.timedelta(days=7)
143            return DatetimeRange(
144                next_week_start,
145                end_of_day(next_week_start + datetime.timedelta(days=6)),
146            )
147        if value == cls.NEXT_4_WEEKS.value:
148            return DatetimeRange(now, end_of_day(now + datetime.timedelta(days=28)))
149        if value == cls.NEXT_MONTH.value:
150            next_month = (start_of_month + datetime.timedelta(days=31)).replace(day=1)
151            return DatetimeRange(next_month, end_of_month(next_month))
152        if value == cls.NEXT_QUARTER.value:
153            next_quarter = (start_of_quarter + datetime.timedelta(days=90)).replace(
154                day=1
155            )
156            return DatetimeRange(next_quarter, end_of_quarter(next_quarter))
157        if value == cls.NEXT_YEAR.value:
158            next_year = start_of_year.replace(year=start_of_year.year + 1)
159            return DatetimeRange(next_year, end_of_year(next_year))
160        raise ValueError(f"Invalid range: {value}")
161
162
163class DatetimeRange:
164    start: datetime.datetime
165    end: datetime.datetime
166
167    def __init__(
168        self,
169        start: datetime.datetime | datetime.date | str,
170        end: datetime.datetime | datetime.date | str,
171    ):
172        # Convert all inputs to datetime.datetime
173        if isinstance(start, str) and start:
174            self.start = datetime.datetime.fromisoformat(start)
175        elif isinstance(start, datetime.date) and not isinstance(
176            start, datetime.datetime
177        ):
178            self.start = timezone.localtime().replace(
179                year=start.year, month=start.month, day=start.day
180            )
181        else:
182            self.start = start  # type: ignore[assignment]
183
184        if isinstance(end, str) and end:
185            self.end = datetime.datetime.fromisoformat(end)
186        elif isinstance(end, datetime.date) and not isinstance(end, datetime.datetime):
187            self.end = timezone.localtime().replace(
188                year=end.year, month=end.month, day=end.day
189            )
190        else:
191            self.end = end  # type: ignore[assignment]
192
193    def as_tuple(self) -> tuple[datetime.datetime, datetime.datetime]:
194        return (self.start, self.end)
195
196    def total_days(self) -> int:
197        return (self.end - self.start).days
198
199    def iter_days(self) -> Iterator[datetime.date]:
200        """Yields each day in the range."""
201        return iter(
202            self.start.date() + datetime.timedelta(days=i)
203            for i in range(0, self.total_days())
204        )
205
206    def iter_weeks(self) -> Iterator[datetime.datetime]:
207        """Yields the start of each week in the range."""
208        current = self.start - datetime.timedelta(days=self.start.weekday())
209        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
210        while current <= self.end:
211            next_week = current + datetime.timedelta(weeks=1)
212            yield current
213            current = next_week
214
215    def iter_months(self) -> Iterator[datetime.datetime]:
216        """Yields the start of each month in the range."""
217        current = self.start.replace(day=1)
218        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
219        while current <= self.end:
220            if current.month == 12:
221                next_month = current.replace(year=current.year + 1, month=1)
222            else:
223                next_month = current.replace(month=current.month + 1)
224            yield current
225            current = next_month
226
227    def iter_quarters(self) -> Iterator[datetime.datetime]:
228        """Yields the start of each quarter in the range."""
229        current = self.start.replace(month=((self.start.month - 1) // 3) * 3 + 1, day=1)
230        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
231        while current <= self.end:
232            next_quarter_month = ((current.month - 1) // 3 + 1) * 3 + 1
233            if next_quarter_month > 12:
234                next_quarter_month -= 12
235                next_year = current.year + 1
236            else:
237                next_year = current.year
238            next_quarter = datetime.datetime(
239                next_year, next_quarter_month, 1, tzinfo=current.tzinfo
240            )
241            yield current
242            current = next_quarter
243
244    def iter_years(self) -> Iterator[datetime.datetime]:
245        """Yields the start of each year in the range."""
246        current = self.start.replace(month=1, day=1)
247        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
248        while current <= self.end:
249            next_year = current.replace(year=current.year + 1)
250            yield current
251            current = next_year
252
253    def __repr__(self) -> str:
254        return f"DatetimeRange({self.start}, {self.end})"
255
256    def __str__(self) -> str:
257        return f"{self.start} to {self.end}"
258
259    def __eq__(self, other: object) -> bool:
260        if not isinstance(other, DatetimeRange):
261            return False
262        return self.start == other.start and self.end == other.end
263
264    def __hash__(self) -> int:
265        return hash((self.start, self.end))
266
267    def __contains__(self, item: datetime.datetime) -> bool:
268        return self.start <= item <= self.end