Files
cowork-local/core/calendar_grid.py
T
2026-08-09 20:12:05 +07:00

84 lines
2.9 KiB
Python

"""Pure calendar-grid math for the Schedule Task tab's Calendar view (no Qt
dependency, so it's directly unit-testable) — month/week grids, period
navigation, and grouping tasks by their scheduled date.
"""
from __future__ import annotations
import calendar as _calendar_mod
from datetime import date, timedelta
from typing import Dict, List
from .tasks import parse_run_at
GRANULARITIES = ("week", "month", "year")
def month_grid(anchor: date) -> List[List[date]]:
"""Full Mon-Sun weeks covering ``anchor``'s month, including leading/
trailing days from the adjacent months so every week is exactly 7 long."""
cal = _calendar_mod.Calendar(firstweekday=0)
weeks: List[List[date]] = []
week: List[date] = []
for d in cal.itermonthdates(anchor.year, anchor.month):
week.append(d)
if len(week) == 7:
weeks.append(week)
week = []
if week:
weeks.append(week)
return weeks
def week_days(anchor: date) -> List[date]:
"""The 7 dates (Mon..Sun) of the week containing ``anchor``."""
start = anchor - timedelta(days=anchor.weekday())
return [start + timedelta(days=i) for i in range(7)]
def shift_period(anchor: date, granularity: str, direction: int) -> date:
"""New anchor after moving Prev(-1)/Next(+1) one ``granularity`` unit."""
if granularity == "week":
return anchor + timedelta(days=7 * direction)
if granularity == "year":
target_year = anchor.year + direction
try:
return anchor.replace(year=target_year)
except ValueError: # Feb 29 landing on a non-leap year
return anchor.replace(year=target_year, day=28)
# month
month_index = anchor.month - 1 + direction
year = anchor.year + month_index // 12
month = month_index % 12 + 1
day = min(anchor.day, _calendar_mod.monthrange(year, month)[1])
return date(year, month, day)
def group_tasks_by_date(tasks: List[dict]) -> Dict[str, List[dict]]:
"""``{"YYYY-MM-DD": [task, ...]}`` from each task's schedule.run_at —
tasks with no (or an unparseable) run_at are simply omitted, since they
have no date to place on a calendar."""
grouped: Dict[str, List[dict]] = {}
for t in tasks:
dt = parse_run_at((t.get("schedule") or {}).get("run_at"))
if dt is None:
continue
grouped.setdefault(dt.date().isoformat(), []).append(t)
return grouped
def month_task_counts(tasks_by_date: Dict[str, List[dict]], year: int) -> Dict[int, int]:
"""``{month(1-12): count}`` of tasks scheduled anywhere in ``year``."""
counts = {m: 0 for m in range(1, 13)}
for date_str, items in tasks_by_date.items():
parts = date_str.split("-")
if len(parts) != 3:
continue
y, m, _d = parts
try:
y_int, m_int = int(y), int(m)
except ValueError:
continue
if y_int == year and 1 <= m_int <= 12:
counts[m_int] += len(items)
return counts