Extracted the grouping algorithm (previously duplicated between mobile.views. CalendarView and management.views.EventListView's own "List" mode) into a shared events.services.calendar.agenda_groups, and wired it into both -- plus mobile.coach_views.CoachScheduleView, the coach app's own upcoming- events screen, which had no grouping at all before (one flat card). All three now read identically and can't drift apart. Also: give each management event-list row's kind badge a fixed width, so titles start at the same horizontal position regardless of whether the row says "Game" or "Tournament". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""Date-range math and grid layout for the Events page's Week/Month/Season calendar
|
|
views (management/views.py's EventListView, template event_list.html). Kept separate
|
|
from the view itself since none of this touches the request/queryset-scoping layer --
|
|
it only turns "an anchor date + a list of already-visible events" into the shapes each
|
|
template needs.
|
|
|
|
Three grids, one per granularity:
|
|
- week_grid: a 7-day x hour-gutter layout with absolutely-positioned blocks (top/height
|
|
as a percentage of the visible hour span), including simple side-by-side column
|
|
layout for events that overlap in time on the same day.
|
|
- month_grid: a standard 6-row calendar grid, each day carrying its own event list
|
|
(title + kind, no time-of-day positioning -- there's no room for it at that scale).
|
|
- season_grid: one month_grid per month spanning the season, but day cells carry only
|
|
a count (a full title list is illegible at that scale) -- see D5-alike "Season" view.
|
|
"""
|
|
|
|
import calendar as _calendar
|
|
import datetime
|
|
import itertools
|
|
|
|
from django.utils import timezone
|
|
|
|
#: The week grid's visible hours -- the full day, always, so nothing is ever
|
|
#: scrolled out of view regardless of what time events happen to fall at (this
|
|
#: used to default to a narrower 08:00-22:00 window, only widening for events
|
|
#: outside it -- a very early or very late one still left the grid clipped at
|
|
#: the other end, e.g. midnight..22:00 rather than the full day).
|
|
DEFAULT_DAY_START_HOUR = 0
|
|
DEFAULT_DAY_END_HOUR = 24
|
|
|
|
#: Floor on a block's rendered height, in percent of the visible hour span -- a very
|
|
#: short event (a 15-minute weigh-in) would otherwise render as a sliver too thin to
|
|
#: click or read.
|
|
MIN_BLOCK_HEIGHT_PCT = 4.0
|
|
|
|
|
|
def week_bounds(anchor: datetime.date) -> tuple[datetime.date, datetime.date]:
|
|
"""Monday..Sunday of the week containing ``anchor``."""
|
|
start = anchor - datetime.timedelta(days=anchor.weekday())
|
|
return start, start + datetime.timedelta(days=6)
|
|
|
|
|
|
def month_bounds(anchor: datetime.date) -> tuple[datetime.date, datetime.date]:
|
|
"""First..last day of the month containing ``anchor``."""
|
|
last_day = _calendar.monthrange(anchor.year, anchor.month)[1]
|
|
return anchor.replace(day=1), anchor.replace(day=last_day)
|
|
|
|
|
|
def add_months(anchor: datetime.date, months: int) -> datetime.date:
|
|
"""``anchor`` shifted by whole months, clamped to day 1 -- only ever used to
|
|
step between month-starts (month_bounds/season month list), so the
|
|
day-of-month is never meaningful to preserve."""
|
|
month_index = anchor.month - 1 + months
|
|
year = anchor.year + month_index // 12
|
|
month = month_index % 12 + 1
|
|
return datetime.date(year, month, 1)
|
|
|
|
|
|
def agenda_groups(items, *, start_of=lambda item: item.start, show_past=False, today=None):
|
|
"""Group already start-sorted ``items`` the way every agenda-style
|
|
schedule in this app presents itself: "This week"/"Next week", then
|
|
everything further out under its own month divider -- shared by
|
|
mobile.views.CalendarView (the member app's own Calendar), management.
|
|
views.EventListView (the desktop's "List" view), and mobile.coach_views.
|
|
CoachScheduleView (the coach app's "Schedule"), so the three stay
|
|
behaviourally identical rather than three hand-rolled copies of the same
|
|
grouping drifting apart over time.
|
|
|
|
``start_of`` extracts an item's start datetime -- the default assumes a
|
|
bare ``Event``-like object; pass a lambda for anything else (e.g.
|
|
CalendarView's own rows, each a dict wrapping one).
|
|
|
|
``show_past`` is for a caller already querying in descending order (most
|
|
recent first) -- "This week"/"Next week" only make sense for what's
|
|
ahead, so past mode skips that split and groups straight into months
|
|
instead, newest month first, oldest last.
|
|
|
|
Returns ``(this_week, next_week, later_months)`` -- ``later_months`` is
|
|
``[{"month_start": date, "items": [...]}, ...]``; ``this_week``/
|
|
``next_week`` are always ``[]`` in show_past mode, with everything
|
|
carried in ``later_months`` instead.
|
|
"""
|
|
if not items:
|
|
return [], [], []
|
|
|
|
def _month_start(item):
|
|
return timezone.localtime(start_of(item)).date().replace(day=1)
|
|
|
|
if show_past:
|
|
months = [{"month_start": month_start, "items": list(month_items)} for month_start, month_items in itertools.groupby(items, key=_month_start)]
|
|
return [], [], months
|
|
|
|
today = today or timezone.localdate()
|
|
_this_week_start, this_week_end = week_bounds(today)
|
|
next_week_end = this_week_end + datetime.timedelta(days=7)
|
|
|
|
this_week, next_week, later = [], [], []
|
|
for item in items:
|
|
item_date = timezone.localtime(start_of(item)).date()
|
|
if item_date <= this_week_end:
|
|
this_week.append(item)
|
|
elif item_date <= next_week_end:
|
|
next_week.append(item)
|
|
else:
|
|
later.append(item)
|
|
|
|
later_months = [{"month_start": month_start, "items": list(month_items)} for month_start, month_items in itertools.groupby(later, key=_month_start)]
|
|
return this_week, next_week, later_months
|
|
|
|
|
|
def _local_span(event) -> tuple[datetime.datetime, datetime.datetime]:
|
|
"""An event's start/end in local time, end defaulting to +1h when unset
|
|
(mirrors the "assumed duration" read-time fallback events.models.Event
|
|
documents for non-GAME kinds -- this is a display concern, so it doesn't
|
|
touch the stored field)."""
|
|
start = timezone.localtime(event.start)
|
|
end = timezone.localtime(event.end) if event.end else start + datetime.timedelta(hours=1)
|
|
if end <= start:
|
|
end = start + datetime.timedelta(hours=1)
|
|
return start, end
|
|
|
|
|
|
def _assign_columns(blocks: list[dict]) -> None:
|
|
"""Side-by-side layout for same-day events that overlap in time -- sets
|
|
``left_pct``/``width_pct`` on each block dict in place. Greedy interval
|
|
scheduling: sort by start, hand each event the lowest-numbered column
|
|
whose previous occupant has already ended, and once a run of mutually
|
|
overlapping events (a "cluster") is fully placed, every block in it shares
|
|
that cluster's column count as its width divisor -- otherwise an event
|
|
that only overlaps one neighbour would render at 1/3 width just because
|
|
the *neighbour* also overlaps something else further along."""
|
|
blocks.sort(key=lambda b: (b["start"], b["end"]))
|
|
columns: list[datetime.datetime] = [] # end time currently occupying each column
|
|
cluster: list[dict] = []
|
|
cluster_end = None
|
|
|
|
def flush(cluster_blocks):
|
|
if not cluster_blocks:
|
|
return
|
|
width = max(b["column"] for b in cluster_blocks) + 1
|
|
for b in cluster_blocks:
|
|
b["width_pct"] = round(100 / width, 2)
|
|
b["left_pct"] = round(b["column"] * 100 / width, 2)
|
|
|
|
for block in blocks:
|
|
if cluster_end is not None and block["start"] >= cluster_end:
|
|
flush(cluster)
|
|
cluster, columns, cluster_end = [], [], None
|
|
|
|
placed = False
|
|
for index, occupied_until in enumerate(columns):
|
|
if block["start"] >= occupied_until:
|
|
columns[index] = block["end"]
|
|
block["column"] = index
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
columns.append(block["end"])
|
|
block["column"] = len(columns) - 1
|
|
|
|
cluster.append(block)
|
|
cluster_end = max(cluster_end, block["end"]) if cluster_end else block["end"]
|
|
|
|
flush(cluster)
|
|
|
|
|
|
def week_grid(events, week_start: datetime.date) -> dict:
|
|
"""``events`` (already club/visibility/season-scoped) laid out across the
|
|
Monday..Sunday week starting ``week_start``. Returns the hour gutter
|
|
bounds plus, per day, a list of blocks each carrying top/height/left/width
|
|
percentages for absolute positioning against a single shared-height grid."""
|
|
week_end = week_start + datetime.timedelta(days=6)
|
|
day_start_hour, day_end_hour = DEFAULT_DAY_START_HOUR, DEFAULT_DAY_END_HOUR
|
|
by_day: dict[datetime.date, list] = {week_start + datetime.timedelta(days=i): [] for i in range(7)}
|
|
|
|
spans = []
|
|
for event in events:
|
|
start, end = _local_span(event)
|
|
if not (week_start <= start.date() <= week_end):
|
|
continue
|
|
spans.append((event, start, end))
|
|
day_start_hour = min(day_start_hour, start.hour)
|
|
end_hour_frac = end.hour + end.minute / 60 + (1 if end.second or end.microsecond else 0)
|
|
day_end_hour = max(day_end_hour, int(end_hour_frac) + (1 if end_hour_frac % 1 else 0))
|
|
|
|
span_hours = max(day_end_hour - day_start_hour, 1)
|
|
for event, start, end in spans:
|
|
start_frac = max(0.0, (start.hour + start.minute / 60) - day_start_hour)
|
|
end_frac = min(float(span_hours), (end.hour + end.minute / 60) - day_start_hour)
|
|
by_day[start.date()].append(
|
|
{
|
|
"event": event,
|
|
"start": start,
|
|
"end": end,
|
|
"top_pct": round(100 * start_frac / span_hours, 2),
|
|
"height_pct": max(round(100 * (end_frac - start_frac) / span_hours, 2), MIN_BLOCK_HEIGHT_PCT),
|
|
}
|
|
)
|
|
|
|
days = []
|
|
for i in range(7):
|
|
day = week_start + datetime.timedelta(days=i)
|
|
blocks = by_day[day]
|
|
_assign_columns(blocks)
|
|
days.append({"date": day, "is_today": day == timezone.localdate(), "blocks": blocks})
|
|
|
|
return {
|
|
"week_start": week_start,
|
|
"week_end": week_end,
|
|
"days": days,
|
|
"hours": list(range(day_start_hour, day_end_hour + 1)),
|
|
"day_start_hour": day_start_hour,
|
|
"day_end_hour": day_end_hour,
|
|
# The grid always spans the full day (see DEFAULT_DAY_START_HOUR/END_HOUR's
|
|
# own comment -- never clipped), so a plain "top of the grid" scroll position
|
|
# would default to an empty 00:00 view most weeks. The template scrolls its
|
|
# bounded viewport to just before this hour instead -- None when the week has
|
|
# no events at all, since there's nothing to reveal either way.
|
|
"first_event_hour": min((start.hour for _event, start, _end in spans), default=None),
|
|
}
|
|
|
|
|
|
def month_grid(events, anchor: datetime.date) -> dict:
|
|
"""A standard calendar grid (always full weeks, so always a multiple of 7
|
|
cells) for the month containing ``anchor``, each day carrying the events
|
|
that start on it. Cells outside the month stay in the grid (so the week
|
|
rows line up) but are flagged ``in_month=False`` for the template to dim."""
|
|
month_start, month_end = month_bounds(anchor)
|
|
grid_start = month_start - datetime.timedelta(days=month_start.weekday())
|
|
weeks_needed = -(-((month_end - grid_start).days + 1) // 7) # ceil div
|
|
grid_end = grid_start + datetime.timedelta(days=weeks_needed * 7 - 1)
|
|
|
|
by_day: dict[datetime.date, list] = {}
|
|
for event in events:
|
|
start, _end = _local_span(event)
|
|
day = start.date()
|
|
if grid_start <= day <= grid_end:
|
|
by_day.setdefault(day, []).append(event)
|
|
|
|
today = timezone.localdate()
|
|
weeks = []
|
|
day = grid_start
|
|
while day <= grid_end:
|
|
week = []
|
|
for _ in range(7):
|
|
week.append(
|
|
{
|
|
"date": day,
|
|
"in_month": day.month == anchor.month,
|
|
"is_today": day == today,
|
|
"events": by_day.get(day, []),
|
|
}
|
|
)
|
|
day += datetime.timedelta(days=1)
|
|
weeks.append(week)
|
|
|
|
return {"month_start": month_start, "month_end": month_end, "weeks": weeks}
|
|
|
|
|
|
def season_grid(events, season) -> list[dict]:
|
|
"""One compact month_grid per month spanning ``season``, day cells
|
|
trimmed to just a count (see module docstring) -- events are split up
|
|
front by month so each month's grid only scans its own slice, not the
|
|
whole season's list."""
|
|
events_by_month: dict[tuple[int, int], list] = {}
|
|
for event in events:
|
|
start, _end = _local_span(event)
|
|
events_by_month.setdefault((start.year, start.month), []).append(event)
|
|
|
|
months = []
|
|
cursor = season.start_date.replace(day=1)
|
|
end_month = season.end_date.replace(day=1)
|
|
while cursor <= end_month:
|
|
grid = month_grid(events_by_month.get((cursor.year, cursor.month), []), cursor)
|
|
for week in grid["weeks"]:
|
|
for cell in week:
|
|
cell["count"] = len(cell["events"])
|
|
del cell["events"]
|
|
months.append({"label": cursor, "weeks": grid["weeks"]})
|
|
cursor = add_months(cursor, 1)
|
|
|
|
return months
|