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) -> DatetimeRange:
 54        now = timezone.localtime()
 55        start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
 56        start_of_week = start_of_today - datetime.timedelta(
 57            days=start_of_today.weekday()
 58        )
 59        start_of_month = start_of_today.replace(day=1)
 60        start_of_quarter = start_of_today.replace(
 61            month=((start_of_today.month - 1) // 3) * 3 + 1, day=1
 62        )
 63        start_of_year = start_of_today.replace(month=1, day=1)
 64
 65        def end_of_day(dt: datetime.datetime) -> datetime.datetime:
 66            return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
 67
 68        def end_of_month(dt: datetime.datetime) -> datetime.datetime:
 69            last_day = monthrange(dt.year, dt.month)[1]
 70            return end_of_day(dt.replace(day=last_day))
 71
 72        def end_of_quarter(dt: datetime.datetime) -> datetime.datetime:
 73            end_month = ((dt.month - 1) // 3 + 1) * 3
 74            return end_of_month(dt.replace(month=end_month))
 75
 76        def end_of_year(dt: datetime.datetime) -> datetime.datetime:
 77            return end_of_month(dt.replace(month=12))
 78
 79        if value == cls.TODAY:
 80            return DatetimeRange(start_of_today, end_of_day(now))
 81        if value == cls.THIS_WEEK:
 82            return DatetimeRange(
 83                start_of_week, end_of_day(start_of_week + datetime.timedelta(days=6))
 84            )
 85        if value == cls.THIS_WEEK_TO_DATE:
 86            return DatetimeRange(start_of_week, now)
 87        if value == cls.THIS_MONTH:
 88            return DatetimeRange(start_of_month, end_of_month(start_of_month))
 89        if value == cls.THIS_MONTH_TO_DATE:
 90            return DatetimeRange(start_of_month, now)
 91        if value == cls.THIS_QUARTER:
 92            return DatetimeRange(start_of_quarter, end_of_quarter(start_of_quarter))
 93        if value == cls.THIS_QUARTER_TO_DATE:
 94            return DatetimeRange(start_of_quarter, now)
 95        if value == cls.THIS_YEAR:
 96            return DatetimeRange(start_of_year, end_of_year(start_of_year))
 97        if value == cls.THIS_YEAR_TO_DATE:
 98            return DatetimeRange(start_of_year, now)
 99        if value == cls.LAST_WEEK:
100            last_week_start = start_of_week - datetime.timedelta(days=7)
101            return DatetimeRange(
102                last_week_start,
103                end_of_day(last_week_start + datetime.timedelta(days=6)),
104            )
105        if value == cls.LAST_WEEK_TO_DATE:
106            return DatetimeRange(start_of_week - datetime.timedelta(days=7), now)
107        if value == cls.LAST_MONTH:
108            last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
109            return DatetimeRange(last_month, end_of_month(last_month))
110        if value == cls.LAST_MONTH_TO_DATE:
111            last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
112            return DatetimeRange(last_month, now)
113        if value == cls.LAST_QUARTER:
114            last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
115                day=1
116            )
117            return DatetimeRange(last_quarter, end_of_quarter(last_quarter))
118        if value == cls.LAST_QUARTER_TO_DATE:
119            last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
120                day=1
121            )
122            return DatetimeRange(last_quarter, now)
123        if value == cls.LAST_YEAR:
124            last_year = start_of_year.replace(year=start_of_year.year - 1)
125            return DatetimeRange(last_year, end_of_year(last_year))
126        if value == cls.LAST_YEAR_TO_DATE:
127            last_year = start_of_year.replace(year=start_of_year.year - 1)
128            return DatetimeRange(last_year, now)
129        if value == cls.SINCE_30_DAYS_AGO:
130            return DatetimeRange(now - datetime.timedelta(days=30), now)
131        if value == cls.SINCE_60_DAYS_AGO:
132            return DatetimeRange(now - datetime.timedelta(days=60), now)
133        if value == cls.SINCE_90_DAYS_AGO:
134            return DatetimeRange(now - datetime.timedelta(days=90), now)
135        if value == cls.SINCE_365_DAYS_AGO:
136            return DatetimeRange(now - datetime.timedelta(days=365), now)
137        if value == cls.NEXT_WEEK:
138            next_week_start = start_of_week + datetime.timedelta(days=7)
139            return DatetimeRange(
140                next_week_start,
141                end_of_day(next_week_start + datetime.timedelta(days=6)),
142            )
143        if value == cls.NEXT_4_WEEKS:
144            return DatetimeRange(now, end_of_day(now + datetime.timedelta(days=28)))
145        if value == cls.NEXT_MONTH:
146            next_month = (start_of_month + datetime.timedelta(days=31)).replace(day=1)
147            return DatetimeRange(next_month, end_of_month(next_month))
148        if value == cls.NEXT_QUARTER:
149            next_quarter = (start_of_quarter + datetime.timedelta(days=90)).replace(
150                day=1
151            )
152            return DatetimeRange(next_quarter, end_of_quarter(next_quarter))
153        if value == cls.NEXT_YEAR:
154            next_year = start_of_year.replace(year=start_of_year.year + 1)
155            return DatetimeRange(next_year, end_of_year(next_year))
156        raise ValueError(f"Invalid range: {value}")
157
158
159class DatetimeRange:
160    def __init__(
161        self,
162        start: datetime.datetime | datetime.date | str,
163        end: datetime.datetime | datetime.date | str,
164    ):
165        self.start = start
166        self.end = end
167
168        if isinstance(self.start, str) and self.start:
169            self.start = datetime.datetime.fromisoformat(self.start)
170
171        if isinstance(self.end, str) and self.end:
172            self.end = datetime.datetime.fromisoformat(self.end)
173
174        if isinstance(self.start, datetime.date) and not isinstance(
175            self.start, datetime.datetime
176        ):
177            self.start = timezone.localtime().replace(
178                year=self.start.year, month=self.start.month, day=self.start.day
179            )
180
181        if isinstance(self.end, datetime.date) and not isinstance(
182            self.start, datetime.datetime
183        ):
184            self.end = timezone.localtime().replace(
185                year=self.end.year, month=self.end.month, day=self.end.day
186            )
187
188    def as_tuple(self) -> tuple[datetime.datetime, datetime.datetime]:
189        return (self.start, self.end)
190
191    def total_days(self) -> int:
192        return (self.end - self.start).days
193
194    def iter_days(self) -> Iterator[datetime.date]:
195        """Yields each day in the range."""
196        return iter(
197            self.start.date() + datetime.timedelta(days=i)
198            for i in range(0, self.total_days())
199        )
200
201    def iter_weeks(self) -> Iterator[datetime.datetime]:
202        """Yields the start of each week in the range."""
203        current = self.start - datetime.timedelta(days=self.start.weekday())
204        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
205        while current <= self.end:
206            next_week = current + datetime.timedelta(weeks=1)
207            yield current
208            current = next_week
209
210    def iter_months(self) -> Iterator[datetime.datetime]:
211        """Yields the start of each month in the range."""
212        current = self.start.replace(day=1)
213        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
214        while current <= self.end:
215            if current.month == 12:
216                next_month = current.replace(year=current.year + 1, month=1)
217            else:
218                next_month = current.replace(month=current.month + 1)
219            yield current
220            current = next_month
221
222    def iter_quarters(self) -> Iterator[datetime.datetime]:
223        """Yields the start of each quarter in the range."""
224        current = self.start.replace(month=((self.start.month - 1) // 3) * 3 + 1, day=1)
225        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
226        while current <= self.end:
227            next_quarter_month = ((current.month - 1) // 3 + 1) * 3 + 1
228            if next_quarter_month > 12:
229                next_quarter_month -= 12
230                next_year = current.year + 1
231            else:
232                next_year = current.year
233            next_quarter = datetime.datetime(
234                next_year, next_quarter_month, 1, tzinfo=current.tzinfo
235            )
236            yield current
237            current = next_quarter
238
239    def iter_years(self) -> Iterator[datetime.datetime]:
240        """Yields the start of each year in the range."""
241        current = self.start.replace(month=1, day=1)
242        current = current.replace(hour=0, minute=0, second=0, microsecond=0)
243        while current <= self.end:
244            next_year = current.replace(year=current.year + 1)
245            yield current
246            current = next_year
247
248    def __repr__(self) -> str:
249        return f"DatetimeRange({self.start}, {self.end})"
250
251    def __str__(self) -> str:
252        return f"{self.start} to {self.end}"
253
254    def __eq__(self, other: object) -> bool:
255        if not isinstance(other, DatetimeRange):
256            return False
257        return self.start == other.start and self.end == other.end
258
259    def __hash__(self) -> int:
260        return hash((self.start, self.end))
261
262    def __contains__(self, item: datetime.datetime) -> bool:
263        return self.start <= item <= self.end