#!/usr/bin/env python3
"""tanin-calendar — TANINUX calendar (GTK4 / libadwaita / PyGObject).

A GNOME/Apple-Calendar-style MONTH GRID over your calendars. It reads events
straight from Evolution Data Server (EDS) — the same backend GNOME Online
Accounts feeds — so any calendar you've added in GNOME Online Accounts (Google,
Nextcloud, local …) shows up here automatically.

View:
  • Header bar: title = current month + year ("June 2026"), prev/next month
    nav, and a "Today" button that jumps back to the current month.
  • A month grid: a weekday header row (Mon…Sun, week starts Monday) and a
    6×7 grid of day cells. Each cell shows its day number, dims days outside the
    current month, highlights today, and lists the day's events as small
    coloured chips (up to ~3, then a muted "+N more"). Clicking an event chip
    opens a detail dialog; clicking a day cell opens a popover listing all that
    day's events.

Theming inherits from tanin-libadwaita (Fuji monochrome). A stylesheet ships
alongside for the grid, cells and event chips; the rest is stock libadwaita.

If EDS isn't available (typelibs not installed), the window shows an
Adw.StatusPage instead of crashing — install `evolution-data-server`.

Env overrides (handy for previewing from the repo):
  TANIN_CAL_CSS   path to the stylesheet   (default /usr/share/tanin/calendar/tanin-calendar.css)
"""
import gi
import os
import sys
import threading

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Gdk, Gio, Pango  # noqa: E402

# --------------------------------------------------------------------------- #
#  EDS import guard — keep the app alive even when the typelibs are missing.
#  (On a fresh box without evolution-data-server, `gi.require_version` raises;
#   we degrade to a StatusPage rather than failing to start.)
# --------------------------------------------------------------------------- #
EDS_OK = False
EDS_ERROR = ""
try:
    gi.require_version("EDataServer", "1.2")
    gi.require_version("ECal", "2.0")
    gi.require_version("ICalGLib", "4.0")
    from gi.repository import EDataServer, ECal, ICalGLib  # noqa: E402
    EDS_OK = True
except (ValueError, ImportError) as e:
    EDS_ERROR = str(e)

CSS_PATH = os.environ.get(
    "TANIN_CAL_CSS", "/usr/share/tanin/calendar/tanin-calendar.css"
)

# The calendar content is clamped to this width so wide/maximised windows show
# centred, readable cells instead of letting the grid stretch edge-to-edge.
GRID_MAX_WIDTH = 960

# --------------------------------------------------------------------------- #
#  Time-grid geometry (Day / Week views)
# --------------------------------------------------------------------------- #
# Vertical scale of the hour axis. One constant shared by the axis, the day
# columns and the event-block placement so everything lines up to the pixel.
PX_PER_HOUR = 48
GRID_HEIGHT = PX_PER_HOUR * 24            # full 00:00–24:00 span
HOUR_AXIS_WIDTH = 56                      # width of the left "08:00" gutter
EVENT_MIN_HEIGHT = 22                     # never draw a sliver block
ALLDAY_ROW_HEIGHT = 30                    # the thin all-day strip up top
SNAP_MINUTES = 15                         # drag start/end snap granularity
DRAG_THRESHOLD = 6                        # px below which a drag counts as a click
SCROLL_TO_HOUR = 7                        # on open, reveal ~07:00 first

# English month/weekday names for headings (independent of system locale, so
# the UI stays English even on a non-English-locale box).
MONTHS_EN = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
]
WEEKDAYS_EN = [
    "Monday", "Tuesday", "Wednesday", "Thursday",
    "Friday", "Saturday", "Sunday",
]


# --------------------------------------------------------------------------- #
#  EDS access
# --------------------------------------------------------------------------- #
class Event:
    """A flattened calendar event, decoupled from the EDS objects."""

    __slots__ = (
        "summary", "location", "description",
        "start", "end", "all_day",
        "cal_name", "cal_color",
        "source", "uid", "rid",
    )

    def __init__(self):
        self.summary = ""
        self.location = ""
        self.description = ""
        self.start = None       # GLib.DateTime (local) or None
        self.end = None         # GLib.DateTime (local) or None
        self.all_day = False
        self.cal_name = ""
        self.cal_color = ""     # "#rrggbb" or ""
        # Enough to locate this event back in EDS for delete/edit: the EDS
        # source (calendar) it came from, plus the iCal UID and (for recurring
        # instances) the recurrence-id. rid is None for a plain single event.
        self.source = None      # EDataServer.Source or None
        self.uid = ""           # iCal UID
        self.rid = None         # recurrence-id string or None

    @property
    def day_key(self):
        """A sortable (and groupable) yyyy-mm-dd key for the start day."""
        if self.start is None:
            return "0000-00-00"
        return self.start.format("%Y-%m-%d")


def _ical_time_to_datetime(itime):
    """ICalGLib.Time → GLib.DateTime in local time, or None.

    All-day events carry a DATE (no time/zone); we anchor those at local
    midnight so they group under the right day.
    """
    if itime is None:
        return None
    try:
        if itime.is_date():
            return GLib.DateTime.new_local(
                itime.get_year(), itime.get_month(), itime.get_day(), 0, 0, 0
            )
        # DATE-TIME → seconds since the epoch, then to a local wall clock.
        ts = itime.as_timet()
        if ts:
            return GLib.DateTime.new_from_unix_local(ts)
        return GLib.DateTime.new_local(
            itime.get_year(), itime.get_month(), itime.get_day(),
            itime.get_hour(), itime.get_minute(), 0,
        )
    except Exception:  # noqa: BLE001 — never let one odd event break the load
        return None


def _text_value(component_text):
    """ECal.ComponentText (or None) → plain string."""
    if component_text is None:
        return ""
    try:
        return component_text.get_value() or ""
    except Exception:  # noqa: BLE001
        return ""


def _component_uid(comp):
    """The iCal UID of an ECal.Component, or "" — needed to locate it in EDS."""
    try:
        return comp.get_uid() or ""
    except Exception:  # noqa: BLE001
        return ""


def _component_rid(comp):
    """The recurrence-id (as an ISO string) for a single instance of a
    recurring event, or None for a plain (non-recurring) event. Passed to
    remove_object_sync so we delete the right instance."""
    try:
        rid = comp.get_recurid_as_string()
        return rid or None
    except Exception:  # noqa: BLE001
        return None


def _source_color(source):
    """Calendar colour from the EDS source's calendar extension, or ""."""
    try:
        ext = source.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR)
        if ext is not None:
            color = ext.get_color()
            if color:
                return color
    except Exception:  # noqa: BLE001
        pass
    return ""


def load_events(start_dt, end_dt):
    """Read every event in [start_dt, end_dt) across all calendar sources.

    Returns (events, errors): a list of Event, and a list of human-readable
    warning strings for calendars that failed to open (offline accounts etc.).
    """
    events = []
    errors = []

    registry = EDataServer.SourceRegistry.new_sync(None)
    sources = registry.list_sources(
        EDataServer.SOURCE_EXTENSION_CALENDAR
    )

    # An S-expression query bounded to the visible window keeps EDS from
    # returning the whole history. occur-in-time-range wants ISO-8601 UTC.
    start_iso = ECal.isodate_from_time_t(start_dt.to_unix())
    end_iso = ECal.isodate_from_time_t(end_dt.to_unix())
    sexp = (
        '(occur-in-time-range? (make-time "%s") (make-time "%s"))'
        % (start_iso, end_iso)
    )

    for source in sources:
        if not source.get_enabled():
            continue
        cal_name = source.get_display_name() or "Calendar"
        cal_color = _source_color(source)
        try:
            client = ECal.Client.connect_sync(
                source, ECal.ClientSourceType.EVENTS, 30, None
            )
        except GLib.Error as e:
            errors.append("%s: %s" % (cal_name, e.message))
            continue
        if client is None:
            errors.append("%s: not reachable" % cal_name)
            continue

        try:
            ok, comps = client.get_object_list_as_comps_sync(sexp, None)
        except GLib.Error as e:
            errors.append("%s: %s" % (cal_name, e.message))
            continue
        if not ok or not comps:
            continue

        for comp in comps:
            ev = _component_to_event(comp, cal_name, cal_color, source)
            if ev is not None:
                events.append(ev)

    events.sort(key=lambda e: (e.start.to_unix() if e.start else 0))
    return events, errors


def _component_to_event(comp, cal_name, cal_color, source=None):
    """ECal.Component → Event (or None if it has no usable start)."""
    try:
        ev = Event()
        ev.cal_name = cal_name
        ev.cal_color = cal_color
        ev.source = source
        ev.uid = _component_uid(comp)
        ev.rid = _component_rid(comp)
        ev.summary = _text_value(comp.get_summary()) or "(No title)"
        ev.location = comp.get_location() or ""

        descs = comp.get_descriptions()
        if descs:
            ev.description = _text_value(descs[0])

        dtstart = comp.get_dtstart()
        if dtstart is not None:
            itime = dtstart.get_value()
            if itime is not None:
                ev.all_day = bool(itime.is_date())
                ev.start = _ical_time_to_datetime(itime)

        dtend = comp.get_dtend()
        if dtend is not None:
            iend = dtend.get_value()
            if iend is not None:
                ev.end = _ical_time_to_datetime(iend)

        if ev.start is None:
            return None
        return ev
    except Exception:  # noqa: BLE001
        return None


# --------------------------------------------------------------------------- #
#  UI
# --------------------------------------------------------------------------- #
class CalendarWindow(Adw.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app)
        self.set_title("Calendar")
        self.set_default_size(960, 760)

        # Single focused-date state (the 1st-of-month + day of focus) drives
        # all three views. The month grid spans the whole calendar month padded
        # to full weeks (Mon-start); Day/Week derive their range from focus_day.
        now = GLib.DateTime.new_now_local()
        self.cur_year = now.get_year()
        self.cur_month = now.get_month()
        self.cur_day = now.get_day_of_month()

        # Which view is active: "day" / "week" / "month". Default = month.
        self.view_mode = "month"
        self._switching = False  # guard so programmatic stack changes don't loop

        # The day the user has SELECTED (distinct from "today"). Drives the
        # always-visible bottom agenda. Defaults to today. Kept as a (y, m, d)
        # tuple so it survives month navigation cleanly.
        self.selected_day = (self.cur_year, self.cur_month, self.cur_day)

        # Cache of the most recent load so the bottom agenda can re-render on
        # selection changes without hitting EDS again.
        self._last_buckets = {}

        toolbar = Adw.ToolbarView()
        toolbar.add_top_bar(self._build_header())
        toolbar.add_top_bar(self._build_strip())

        # The active view (and any status pages) live in this content box so we
        # can swap them out per reload without rebuilding the chrome.
        self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.content.set_vexpand(True)
        self.content.set_hexpand(True)

        # The always-visible bottom agenda for the SELECTED day. It sits below
        # the grid/week views (and re-renders on selection without reloading).
        self.bottom_agenda = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.bottom_agenda.add_css_class("selected-agenda")
        self.bottom_agenda.set_hexpand(True)

        body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        body.set_vexpand(True)
        body.set_hexpand(True)
        body.append(self.content)
        body.append(self.bottom_agenda)
        toolbar.set_content(body)

        # A ToastOverlay wraps everything so we can surface save/error toasts.
        self._toast_overlay = Adw.ToastOverlay()
        self._toast_overlay.set_child(toolbar)

        self.set_content(self._toast_overlay)

        # Sync the stack to the default view. Defer the first load to an idle
        # callback so the window maps IMMEDIATELY even if EDS is slow, and never let
        # a load error keep the window from showing (it prints instead of breaking).
        self.view_stack.set_visible_child_name("month")
        GLib.idle_add(self._initial_load)

    def _initial_load(self):
        try:
            self._reload()
        except Exception:  # noqa: BLE001
            import traceback
            traceback.print_exc()
        return False

    # ---- focused date helpers --------------------------------------------- #
    def _focus_date(self):
        """The focused day as a GLib.DateTime (local midnight)."""
        return GLib.DateTime.new_local(
            self.cur_year, self.cur_month, self.cur_day, 0, 0, 0
        )

    def _set_focus(self, dt):
        self.cur_year = dt.get_year()
        self.cur_month = dt.get_month()
        self.cur_day = dt.get_day_of_month()

    # ---- selection helpers ------------------------------------------------ #
    def _selected_date(self):
        """The selected day as a GLib.DateTime (local midnight)."""
        y, m, d = self.selected_day
        return GLib.DateTime.new_local(y, m, d, 0, 0, 0)

    def _selected_key(self):
        y, m, d = self.selected_day
        return "%04d-%02d-%02d" % (y, m, d)

    def _select_day(self, day):
        """Select a day cell/column. Re-renders the grid highlight (cheap, no
        EDS reload) and refreshes the bottom agenda for the new day."""
        new = (day.get_year(), day.get_month(), day.get_day_of_month())
        if new == self.selected_day:
            return
        self.selected_day = new
        self._reload()

    # ---- header ----------------------------------------------------------- #
    def _build_header(self):
        header = Adw.HeaderBar()

        # Centre: a Day | Week | Month view switcher bound to a ViewStack.
        # The stack pages carry no content of their own — they exist only to
        # drive the switcher; the real view renders into self.content below.
        self.view_stack = Adw.ViewStack()
        for name, title in (("day", "Day"), ("week", "Week"),
                            ("month", "Month")):
            self.view_stack.add_titled(Gtk.Box(), name, title)

        switcher = Adw.ViewSwitcher()
        switcher.set_stack(self.view_stack)
        switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE)
        header.set_title_widget(switcher)

        self.view_stack.connect(
            "notify::visible-child-name", self._on_view_changed)

        # left: "+" New event. A labelled icon button so the action reads
        # clearly without crowding the centred switcher.
        new_btn = Gtk.Button()
        new_btn.set_icon_name("list-add-symbolic")
        new_btn.set_tooltip_text("New event")
        new_btn.add_css_class("flat")
        new_btn.connect("clicked", self._on_new_event)
        header.pack_start(new_btn)

        # right: Today (jump to the current date in the active view).
        today_btn = Gtk.Button(label="Today")
        today_btn.add_css_class("flat")
        today_btn.connect("clicked", self._on_today)
        header.pack_end(today_btn)

        return header

    # ---- strip (month-left / year-right + nav) ---------------------------- #
    def _build_strip(self):
        """The masthead strip below the header: prev/next nav, the big month
        name on the LEFT and the big year on the RIGHT (year lighter/muted).

        Wrapped in an Adw.Clamp so the strip lines up with the clamped grid on
        wide windows instead of flinging the year off to the far edge.
        """
        clamp = Adw.Clamp()
        clamp.set_maximum_size(GRID_MAX_WIDTH)
        clamp.set_tightening_threshold(GRID_MAX_WIDTH)
        clamp.add_css_class("cal-strip")

        strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        strip.add_css_class("cal-strip-inner")
        clamp.set_child(strip)

        prev_btn = Gtk.Button()
        prev_btn.set_icon_name("go-previous-symbolic")
        prev_btn.add_css_class("flat")
        prev_btn.add_css_class("circular")
        prev_btn.set_tooltip_text("Previous")
        prev_btn.connect("clicked", self._on_prev)

        next_btn = Gtk.Button()
        next_btn.set_icon_name("go-next-symbolic")
        next_btn.add_css_class("flat")
        next_btn.add_css_class("circular")
        next_btn.set_tooltip_text("Next")
        next_btn.connect("clicked", self._on_next)

        nav = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2)
        nav.add_css_class("cal-nav")
        nav.append(prev_btn)
        nav.append(next_btn)
        nav.set_valign(Gtk.Align.CENTER)
        strip.append(nav)

        # Month name + optional day subtitle, grouped so they share a baseline
        # and never jump when the subtitle toggles between views.
        title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        title_box.set_valign(Gtk.Align.CENTER)

        self.month_label = Gtk.Label(label="")
        self.month_label.add_css_class("month-title")
        self.month_label.set_halign(Gtk.Align.START)
        self.month_label.set_xalign(0)
        title_box.append(self.month_label)

        # Day view also shows the weekday/day number next to the month.
        self.day_sub_label = Gtk.Label(label="")
        self.day_sub_label.add_css_class("day-subtitle")
        self.day_sub_label.set_halign(Gtk.Align.START)
        self.day_sub_label.set_xalign(0)
        self.day_sub_label.set_valign(Gtk.Align.CENTER)
        title_box.append(self.day_sub_label)
        strip.append(title_box)

        # Spacer pushes the year to the far right.
        spacer = Gtk.Box()
        spacer.set_hexpand(True)
        strip.append(spacer)

        # Year, large + muted, on the RIGHT.
        self.year_label = Gtk.Label(label="")
        self.year_label.add_css_class("year-title")
        self.year_label.set_halign(Gtk.Align.END)
        self.year_label.set_xalign(1)
        self.year_label.set_valign(Gtk.Align.CENTER)
        strip.append(self.year_label)

        return clamp

    def _update_strip(self):
        """Refresh the masthead for the current focus + view."""
        focus = self._focus_date()
        self.month_label.set_label(MONTHS_EN[self.cur_month - 1])
        self.year_label.set_label(str(self.cur_year))

        if self.view_mode == "day":
            wd = WEEKDAYS_EN[focus.get_day_of_week() - 1]
            self.day_sub_label.set_label(
                "· %s %d" % (wd, self.cur_day))
            self.day_sub_label.set_visible(True)
        else:
            self.day_sub_label.set_label("")
            self.day_sub_label.set_visible(False)

    # ---- view switching --------------------------------------------------- #
    def _on_view_changed(self, _stack, _param):
        if self._switching:
            return
        name = self.view_stack.get_visible_child_name()
        if name and name != self.view_mode:
            self.view_mode = name
            self._reload()

    # ---- nav -------------------------------------------------------------- #
    def _on_today(self, _btn):
        now = GLib.DateTime.new_now_local()
        self.cur_year = now.get_year()
        self.cur_month = now.get_month()
        self.cur_day = now.get_day_of_month()
        self.selected_day = (self.cur_year, self.cur_month, self.cur_day)
        self._reload()

    def _on_prev(self, _btn):
        self._step(-1)

    def _on_next(self, _btn):
        self._step(1)

    def _step(self, direction):
        """Move by one view-unit: 1 day (Day), 1 week (Week), 1 month
        (Month). A single focused-date state drives all three views."""
        if self.view_mode == "day":
            self._set_focus(self._focus_date().add_days(direction))
        elif self.view_mode == "week":
            self._set_focus(self._focus_date().add_days(7 * direction))
        else:  # month
            self.cur_month += direction
            if self.cur_month < 1:
                self.cur_month = 12
                self.cur_year -= 1
            elif self.cur_month > 12:
                self.cur_month = 1
                self.cur_year += 1
            # clamp the day so the 1st-driven grid math stays valid
            self.cur_day = min(
                self.cur_day,
                _days_in_month(self.cur_year, self.cur_month))
        self._reload()

    # ---- content ---------------------------------------------------------- #
    def _set_content(self, widget):
        """Swap the single child of the content box."""
        child = self.content.get_first_child()
        while child is not None:
            nxt = child.get_next_sibling()
            self.content.remove(child)
            child = nxt
        self.content.append(widget)

    def _grid_bounds(self):
        """Six-week window the grid actually displays.

        Returns (grid_start, grid_end) where:
          • grid_start is the Monday on/just before the 1st of the month,
          • grid_end is exclusive (grid_start + 42 days).
        Fetching over this window means every visible cell is populated.
        """
        first = GLib.DateTime.new_local(self.cur_year, self.cur_month, 1, 0, 0, 0)
        # get_day_of_week: 1=Mon … 7=Sun → days to step back to reach Monday.
        offset = first.get_day_of_week() - 1
        grid_start = first.add_days(-offset)
        grid_end = grid_start.add_days(42)  # 6 weeks × 7 days, exclusive
        return grid_start, grid_end

    def _week_bounds(self):
        """Monday→Sunday week containing the focused date.

        Returns (week_start, week_end) where week_start is that Monday (local
        midnight) and week_end is exclusive (week_start + 7 days).
        """
        focus = self._focus_date()
        offset = focus.get_day_of_week() - 1  # 1=Mon … 7=Sun
        week_start = focus.add_days(-offset)
        return week_start, week_start.add_days(7)

    def _day_bounds(self):
        """[focused day, next day) — the single day's range."""
        start = self._focus_date()
        return start, start.add_days(1)

    def _reload(self):
        # Keep the switcher in sync with view_mode (e.g. after Today / restore).
        self._switching = True
        if self.view_stack.get_visible_child_name() != self.view_mode:
            self.view_stack.set_visible_child_name(self.view_mode)
        self._switching = False

        self._update_strip()

        if not EDS_OK:
            self._set_content(self._eds_missing_page())
            self._clear_bottom_agenda()
            return

        if self.view_mode == "day":
            start, end = self._day_bounds()
        elif self.view_mode == "week":
            start, end = self._week_bounds()
        else:
            start, end = self._grid_bounds()

        # Load EDS events in a WORKER THREAD. The ECal/SourceRegistry *_sync calls
        # spin the GLib main context internally, which DEADLOCKS when invoked from
        # the running GTK main loop (the window then never paints). Do the blocking
        # read off-thread and apply the result back on the main thread. A token
        # drops stale loads if the user navigates/switches before this finishes.
        mode = self.view_mode
        self._load_token = getattr(self, "_load_token", 0) + 1
        token = self._load_token

        def worker():
            try:
                result, err = load_events(start, end), None
            except Exception as e:  # noqa: BLE001
                result, err = None, str(e)
            GLib.idle_add(self._on_loaded, token, mode, start, result, err)

        threading.Thread(target=worker, daemon=True).start()

    def _on_loaded(self, token, mode, start, result, err):
        if token != getattr(self, "_load_token", 0) or mode != self.view_mode:
            return False  # a newer load superseded this one
        if err is not None:
            self._set_content(self._error_page(err))
            return False
        events, errors = result
        buckets = {}
        for ev in events:
            buckets.setdefault(ev.day_key, []).append(ev)
        self._last_buckets = buckets
        if mode == "day":
            self._set_content(self._build_day(start, buckets, errors))
        elif mode == "week":
            self._set_content(self._build_week(start, buckets, errors))
        else:
            self._set_content(self._build_grid(start, buckets, errors))
        self._update_bottom_agenda()
        return False

    # ---- month grid ------------------------------------------------------- #
    def _build_grid(self, grid_start, buckets, errors):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        outer.set_vexpand(True)
        outer.set_hexpand(True)

        if errors:
            outer.append(self._error_banner(errors))

        grid = Gtk.Grid()
        grid.add_css_class("month-grid")
        grid.set_vexpand(True)
        grid.set_hexpand(True)
        grid.set_row_homogeneous(True)
        grid.set_column_homogeneous(True)

        # Weekday header row (Mon…Sun).
        short = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
        for col, name in enumerate(short):
            lbl = Gtk.Label(label=name)
            lbl.add_css_class("weekday-header")
            lbl.set_hexpand(True)
            grid.attach(lbl, col, 0, 1, 1)

        today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d")

        # 6 week-rows × 7 day-columns.
        for week in range(6):
            for dow in range(7):
                day = grid_start.add_days(week * 7 + dow)
                cell = self._build_cell(day, buckets, today_key)
                grid.attach(cell, dow, week + 1, 1, 1)

        # Clamp the grid so it stays centred and readable on wide/maximised
        # windows instead of stretching the cells edge-to-edge ("pulled out of
        # shape"). The clamp caps the width; the grid itself fills that.
        clamp = Adw.Clamp()
        clamp.set_maximum_size(GRID_MAX_WIDTH)
        clamp.set_tightening_threshold(GRID_MAX_WIDTH)
        clamp.add_css_class("grid-clamp")
        clamp.set_child(grid)
        clamp.set_vexpand(True)
        clamp.set_hexpand(True)

        scroller = Gtk.ScrolledWindow()
        scroller.set_vexpand(True)
        scroller.set_hexpand(True)
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_child(clamp)
        outer.append(scroller)
        return outer

    def _build_cell(self, day, buckets, today_key):
        """One day cell: day number + up to 3 event chips + '+N more'."""
        key = day.format("%Y-%m-%d")
        day_events = buckets.get(key, [])
        in_month = (day.get_month() == self.cur_month
                    and day.get_year() == self.cur_year)

        cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        cell.add_css_class("day-cell")
        if not in_month:
            cell.add_css_class("other-month")
        if key == today_key:
            cell.add_css_class("today")
        if key == self._selected_key():
            cell.add_css_class("selected")

        # Day number, top-left.
        num = Gtk.Label(label=str(day.get_day_of_month()))
        num.add_css_class("day-number")
        num.set_halign(Gtk.Align.START)
        num.set_xalign(0)
        cell.append(num)

        # Event chips (up to 3), then a muted "+N more".
        MAX_CHIPS = 3
        for ev in day_events[:MAX_CHIPS]:
            cell.append(self._event_chip(ev))
        extra = len(day_events) - MAX_CHIPS
        if extra > 0:
            more = Gtk.Label(label="+%d more" % extra)
            more.add_css_class("more-label")
            more.set_halign(Gtk.Align.START)
            more.set_xalign(0)
            cell.append(more)

        # Click anywhere in the cell → SELECT that day. The bottom agenda then
        # shows its events (this replaces the old click-a-day popover).
        click = Gtk.GestureClick()
        click.set_button(1)
        click.connect("released", lambda *_a, d=day: self._select_day(d))
        cell.add_controller(click)

        # Right-click on a month day cell → "New event" defaulting to this day.
        self._attach_day_menu(cell, day)
        return cell

    def _event_chip(self, ev):
        """A small coloured chip: colour bar + time + truncated title.

        Activatable; clicking opens the event-detail dialog.
        """
        btn = Gtk.Button()
        btn.add_css_class("event-chip")
        btn.add_css_class("flat")
        btn.set_hexpand(True)
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)

        dot = self._color_dot(ev.cal_color, size=8)
        dot.add_css_class("chip-dot")
        row.append(dot)

        text = ev.summary
        if not ev.all_day and ev.start is not None:
            text = "%s %s" % (ev.start.format("%H:%M"), ev.summary)
        lbl = Gtk.Label(label=text)
        lbl.add_css_class("chip-label")
        lbl.set_halign(Gtk.Align.START)
        lbl.set_xalign(0)
        lbl.set_ellipsize(Pango.EllipsizeMode.END)
        lbl.set_hexpand(True)
        row.append(lbl)

        btn.set_child(row)
        btn.set_tooltip_text(ev.summary)
        return btn

    # ---- day view (time grid, one column) --------------------------------- #
    def _build_day(self, day, buckets, errors):
        """The shared time grid restricted to a single full-width day column."""
        return self._build_time_grid([day], buckets, errors, single=True)

    # ---- week view (time grid, seven columns) ----------------------------- #
    def _build_week(self, week_start, buckets, errors):
        """The shared time grid across Mon→Sun (seven equal day columns)."""
        days = [week_start.add_days(i) for i in range(7)]
        return self._build_time_grid(days, buckets, errors, single=False)

    # ---- shared time grid ------------------------------------------------- #
    def _build_time_grid(self, days, buckets, errors, single):
        """A GNOME/Apple-style time grid.

        Layout (top → bottom):
          • a pinned header row (outside the scroller): an empty axis-width
            spacer, then one header per day (weekday name + date, today lit);
          • a thin all-day row (axis spacer + one all-day cell per day);
          • a vertically scrolling area: a fixed hour axis on the LEFT (00–23)
            and one ``.day-column`` per day, all sharing PX_PER_HOUR. Timed
            events are absolutely placed in a per-column Gtk.Fixed at
            y = start-hour-fraction × PX_PER_HOUR (height = duration × scale,
            min EVENT_MIN_HEIGHT). A now-line is drawn on today's column.

        Each day column carries a Gtk.GestureDrag for drag-to-create.
        """
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        outer.set_vexpand(True)
        outer.set_hexpand(True)
        if errors:
            outer.append(self._error_banner(errors))

        today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d")
        now = GLib.DateTime.new_now_local()

        short = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

        # --- pinned header row (weekday + date), outside the scroller ------- #
        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        header.add_css_class("timegrid-header")
        corner = Gtk.Box()
        corner.add_css_class("timegrid-corner")
        corner.set_size_request(HOUR_AXIS_WIDTH, -1)
        header.append(corner)
        for day in days:
            key = day.format("%Y-%m-%d")
            wd = WEEKDAYS_EN[day.get_day_of_week() - 1]
            col_head = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
            col_head.add_css_class("timegrid-col-header")
            col_head.set_hexpand(True)
            if key == today_key:
                col_head.add_css_class("today")
            if key == self._selected_key():
                col_head.add_css_class("selected")
            if single:
                name = Gtk.Label(
                    label="%s, %d %s %d" % (
                        wd, day.get_day_of_month(),
                        MONTHS_EN[day.get_month() - 1], day.get_year()))
                name.add_css_class("timegrid-col-name")
                col_head.append(name)
            else:
                nm = Gtk.Label(label=short[day.get_day_of_week() - 1])
                nm.add_css_class("timegrid-col-name")
                col_head.append(nm)
                num = Gtk.Label(label=str(day.get_day_of_month()))
                num.add_css_class("timegrid-col-date")
                col_head.append(num)
            hclick = Gtk.GestureClick()
            hclick.set_button(1)
            hclick.connect("released", lambda *_a, d=day: self._select_day(d))
            col_head.add_controller(hclick)
            header.append(col_head)

        # --- all-day row (axis spacer + one cell per day) ------------------ #
        allday = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        allday.add_css_class("allday-row")
        ad_axis = Gtk.Label(label="all-day")
        ad_axis.add_css_class("allday-axis")
        ad_axis.set_size_request(HOUR_AXIS_WIDTH, ALLDAY_ROW_HEIGHT)
        allday.append(ad_axis)
        for day in days:
            key = day.format("%Y-%m-%d")
            cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
            cell.add_css_class("allday-cell")
            cell.set_hexpand(True)
            if key == today_key:
                cell.add_css_class("today")
            ad_events = [e for e in buckets.get(key, []) if e.all_day]
            for ev in ad_events:
                cell.append(self._allday_chip(ev))
            allday.append(cell)

        # --- the scrolling time grid --------------------------------------- #
        grid_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        grid_row.add_css_class("timegrid-body")

        # Hour axis: a fixed-width column of "HH:00" labels, one per hour.
        axis = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        axis.add_css_class("time-axis")
        axis.set_size_request(HOUR_AXIS_WIDTH, GRID_HEIGHT)
        for hour in range(24):
            lbl = Gtk.Label(label="%02d:00" % hour)
            lbl.add_css_class("hour-label")
            lbl.set_halign(Gtk.Align.END)
            lbl.set_valign(Gtk.Align.START)
            lbl.set_xalign(1)
            lbl.set_size_request(-1, PX_PER_HOUR)
            axis.append(lbl)
        grid_row.append(axis)

        for day in days:
            grid_row.append(self._build_day_column(
                day, buckets, today_key, now))

        scroller = Gtk.ScrolledWindow()
        scroller.set_vexpand(True)
        scroller.set_hexpand(True)
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_child(grid_row)

        outer.append(header)
        outer.append(allday)
        outer.append(scroller)

        # On open, scroll so ~07:00–08:00 is visible. Deferred to idle so the
        # adjustment has its real upper bound by then.
        def _scroll_to_morning():
            adj = scroller.get_vadjustment()
            if adj is not None:
                target = min(SCROLL_TO_HOUR * PX_PER_HOUR,
                             max(0, adj.get_upper() - adj.get_page_size()))
                adj.set_value(target)
            return False
        GLib.idle_add(_scroll_to_morning)
        return outer

    def _build_day_column(self, day, buckets, today_key, now):
        """One day's column: hairline hour lines, absolutely-placed event
        blocks, an optional now-line, and a drag-to-create gesture."""
        key = day.format("%Y-%m-%d")

        column = Gtk.Fixed()
        column.add_css_class("day-column")
        column.set_hexpand(True)
        column.set_size_request(-1, GRID_HEIGHT)
        if key == today_key:
            column.add_css_class("today")

        # Hour + half-hour hairlines as thin Fixed children spanning the width.
        # We don't know the column's pixel width yet, so the lines are added as
        # full-width boxes via a size allocation hook.
        lines = []
        for hour in range(24):
            full = Gtk.Box()
            full.add_css_class("hour-line")
            full.set_size_request(10, 1)
            column.put(full, 0, hour * PX_PER_HOUR)
            lines.append(full)
            if hour < 24:
                half = Gtk.Box()
                half.add_css_class("half-line")
                half.set_size_request(10, 1)
                column.put(half, 0, int((hour + 0.5) * PX_PER_HOUR))
                lines.append(half)

        # A selection rectangle reused for drag-to-create (hidden until a drag).
        # It is a child of THIS column's Gtk.Fixed, so it can only ever live
        # inside this one day column. Its width tracks the column's real pixel
        # width (see _on_width below); x is pinned to 0 so it never spills into
        # a neighbouring column — it only ever grows vertically (time).
        sel_box = Gtk.Box()
        sel_box.add_css_class("drag-selection")
        sel_box.set_visible(False)
        sel_box.set_halign(Gtk.Align.START)
        column.put(sel_box, 0, 0)
        # The column's current pixel width, kept up to date by the
        # notify::width handler and reused to size the selection box. Starts at
        # 1 so a drag before the first allocation still draws something sane.
        col_w = {"w": 1}

        # Timed event blocks, with best-effort side-by-side overlap splitting.
        timed = [e for e in buckets.get(key, [])
                 if not e.all_day and e.start is not None]
        timed.sort(key=lambda e: e.start.to_unix())
        placements = _layout_overlaps(timed, day)
        blocks = []
        for ev, col_i, col_n in placements:
            y, h = self._event_geometry(ev, day)
            block = self._event_block(ev)
            column.put(block, 0, y)
            blocks.append((block, y, h, col_i, col_n))

        # Now-line on today's column.
        now_line = None
        if key == today_key:
            now_line = Gtk.Box()
            now_line.add_css_class("now-line")
            now_line.set_size_request(10, 2)
            frac = (now.get_hour() * 60 + now.get_minute()) / 60.0
            column.put(now_line, 0, int(frac * PX_PER_HOUR) - 1)

        # Resize hook: stretch hairlines / now-line / blocks to the real width.
        def _on_width(_col, w, _h, _b=None):
            # Remember THIS column's real pixel width so the drag-selection box
            # can be sized to exactly one column (never the whole grid).
            col_w["w"] = max(1, w)
            for ln in lines:
                ln.set_size_request(w, 1)
            if now_line is not None:
                now_line.set_size_request(w, 2)
            for block, _y, _bh, col_i, col_n in blocks:
                bw = max(1, int(w / col_n) - 2)
                column.move(block, col_i * int(w / col_n) + 1, _y)
                block.set_size_request(bw, _bh)
            # Keep an in-progress selection box pinned to one column's width.
            if sel_box.get_visible():
                _bh2 = sel_box.get_height()
                sel_box.set_size_request(col_w["w"], max(1, _bh2))
        # Gtk.Fixed has no allocate signal; track size via the widget's
        # "notify" on width through a size-allocate via a tick callback.
        column.connect("notify::width", lambda c, _p:
                       _on_width(c, c.get_width(), c.get_height()))

        # --- drag-to-create ------------------------------------------------ #
        drag = Gtk.GestureDrag()
        drag.set_button(1)
        state = {"start_y": 0.0}

        def _drag_begin(_g, _sx, sy):
            state["start_y"] = sy
            sel_box.set_visible(True)
            # Width = this single column's tracked pixel width; x pinned to 0 so
            # the box stays wholly inside the column the drag started on.
            w = col_w["w"] or max(1, column.get_width())
            sel_box.set_size_request(max(1, w), 1)
            column.move(sel_box, 0, int(sy))

        def _drag_update(_g, ox, oy):
            sy = state["start_y"]
            cy = sy + oy
            top = max(0, min(sy, cy))
            bot = min(GRID_HEIGHT, max(sy, cy))
            w = col_w["w"] or max(1, column.get_width())
            sel_box.set_size_request(max(1, w), max(1, int(bot - top)))
            column.move(sel_box, 0, int(top))

        def _drag_end(_g, ox, oy):
            sel_box.set_visible(False)
            sy = state["start_y"]
            cy = sy + oy
            self._drag_to_new_event(day, sy, cy, abs(oy))

        drag.connect("drag-begin", _drag_begin)
        drag.connect("drag-update", _drag_update)
        drag.connect("drag-end", _drag_end)
        column.add_controller(drag)

        # Right-click on the empty time grid → "New event here" at the clicked
        # time. Right-clicks that land on an event block are consumed by the
        # block's own button-3 menu, so they don't reach the column.
        self._attach_newhere_menu(column, day, get_minutes=_y_to_minutes)
        return column

    def _event_geometry(self, ev, day):
        """(y_px, height_px) for a timed event clamped to this day's 0–24h."""
        day_start = GLib.DateTime.new_local(
            day.get_year(), day.get_month(), day.get_day_of_month(), 0, 0, 0)
        day_end = day_start.add_days(1)
        s = ev.start
        e = ev.end if ev.end is not None else ev.start.add_hours(1)
        # Clamp to the visible day.
        s_unix = max(s.to_unix(), day_start.to_unix())
        e_unix = min(e.to_unix(), day_end.to_unix())
        start_min = (s_unix - day_start.to_unix()) / 60.0
        end_min = (e_unix - day_start.to_unix()) / 60.0
        y = int(start_min / 60.0 * PX_PER_HOUR)
        h = max(EVENT_MIN_HEIGHT, int((end_min - start_min) / 60.0 * PX_PER_HOUR))
        return y, h

    def _event_block(self, ev):
        """A positioned, clickable event block: time + title, calendar tint."""
        btn = Gtk.Button()
        btn.add_css_class("event-block")
        btn.add_css_class("flat")
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))
        btn.set_tooltip_text(ev.summary)

        # Right-click → context menu (Delete / Details). A separate button-3
        # GestureClick: it does not conflict with the left-button click above.
        self._attach_event_menu(btn, ev)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        time_lbl = Gtk.Label(label=self._time_label(ev))
        time_lbl.add_css_class("event-block-time")
        time_lbl.set_halign(Gtk.Align.START)
        time_lbl.set_xalign(0)
        time_lbl.set_ellipsize(Pango.EllipsizeMode.END)
        box.append(time_lbl)
        title = Gtk.Label(label=ev.summary)
        title.add_css_class("event-block-title")
        title.set_halign(Gtk.Align.START)
        title.set_xalign(0)
        title.set_ellipsize(Pango.EllipsizeMode.END)
        box.append(title)
        btn.set_child(box)

        # Tint the block with the calendar colour (left accent + faint fill).
        if ev.cal_color:
            cls = _color_class(ev.cal_color)
            css = (".event-block.cb-%s { border-left: 3px solid %s; "
                   "background-color: %s; }"
                   % (cls, ev.cal_color, _tint(ev.cal_color)))
            provider = Gtk.CssProvider()
            try:
                provider.load_from_data(css.encode("utf-8"))
                btn.get_style_context().add_provider(
                    provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
                btn.add_css_class("cb-%s" % cls)
            except Exception:  # noqa: BLE001
                pass
        return btn

    def _allday_chip(self, ev):
        """A compact all-day pill for the all-day row."""
        btn = Gtk.Button()
        btn.add_css_class("allday-chip")
        btn.add_css_class("flat")
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))
        btn.set_tooltip_text(ev.summary)
        lbl = Gtk.Label(label=ev.summary)
        lbl.add_css_class("allday-chip-label")
        lbl.set_halign(Gtk.Align.START)
        lbl.set_xalign(0)
        lbl.set_ellipsize(Pango.EllipsizeMode.END)
        btn.set_child(lbl)
        if ev.cal_color:
            cls = _color_class(ev.cal_color)
            css = (".allday-chip.cb-%s { border-left: 3px solid %s; }"
                   % (cls, ev.cal_color))
            provider = Gtk.CssProvider()
            try:
                provider.load_from_data(css.encode("utf-8"))
                btn.get_style_context().add_provider(
                    provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
                btn.add_css_class("cb-%s" % cls)
            except Exception:  # noqa: BLE001
                pass
        return btn

    def _drag_to_new_event(self, day, y0, y1, drag_dist):
        """Map two Y positions on a day column → start/end times and open the
        New-event dialog pre-filled. A near-zero drag (a click) defaults to a
        one-hour event at the clicked hour."""
        if drag_dist < DRAG_THRESHOLD:
            # Treat as a click: 1-hour event at the clicked hour (snapped).
            start_min = _snap(_y_to_minutes(y0), SNAP_MINUTES)
            end_min = start_min + 60
        else:
            a = _snap(_y_to_minutes(min(y0, y1)), SNAP_MINUTES)
            b = _snap(_y_to_minutes(max(y0, y1)), SNAP_MINUTES)
            if b <= a:
                b = a + SNAP_MINUTES
            start_min, end_min = a, b
        # Clamp within the day [0, 24h]. Keep at least one snap step.
        start_min = max(0, min(start_min, 24 * 60 - SNAP_MINUTES))
        end_min = max(start_min + SNAP_MINUTES, min(end_min, 24 * 60))

        base = GLib.DateTime.new_local(
            day.get_year(), day.get_month(), day.get_day_of_month(), 0, 0, 0)
        start_dt = base.add_seconds(start_min * 60)
        end_dt = base.add_seconds(end_min * 60)
        self._on_new_event(None, defaults={
            "date": base, "start_dt": start_dt, "end_dt": end_dt,
            "all_day": False,
        })

    # ---- right-click context menus ---------------------------------------- #
    def _attach_event_menu(self, widget, ev):
        """Attach a button-3 (right-click) context menu to an event widget.

        The menu offers Delete (removes from EDS off-thread) and Details (the
        existing detail dialog). The left-button gestures on the same widget are
        untouched, so normal click-to-open still works.
        """
        gesture = Gtk.GestureClick()
        gesture.set_button(3)

        popover = Gtk.Popover()
        popover.set_has_arrow(True)
        popover.set_parent(widget)
        popover.set_position(Gtk.PositionType.BOTTOM)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.add_css_class("context-menu")

        del_btn = Gtk.Button(label="Delete")
        del_btn.add_css_class("flat")
        del_btn.add_css_class("context-menu-item")
        del_btn.add_css_class("destructive")
        del_btn.set_halign(Gtk.Align.FILL)
        del_btn.get_child().set_halign(Gtk.Align.START)

        def _on_delete(_b, e=ev):
            popover.popdown()
            self._delete_event(e)
        del_btn.connect("clicked", _on_delete)
        box.append(del_btn)

        det_btn = Gtk.Button(label="Details")
        det_btn.add_css_class("flat")
        det_btn.add_css_class("context-menu-item")
        det_btn.set_halign(Gtk.Align.FILL)
        det_btn.get_child().set_halign(Gtk.Align.START)

        def _on_details(_b, e=ev):
            popover.popdown()
            self._show_detail(e)
        det_btn.connect("clicked", _on_details)
        box.append(det_btn)

        popover.set_child(box)

        def _on_pressed(_g, _n, x, y):
            # Anchor the popover at the pointer within the clicked widget.
            popover.set_pointing_to(_point_rect(x, y))
            popover.popup()
        gesture.connect("pressed", _on_pressed)
        widget.add_controller(gesture)

    def _attach_newhere_menu(self, widget, day, get_minutes=None):
        """Attach a button-3 menu offering "New event here" to a time-grid day
        column. ``get_minutes`` maps the click Y (within the column) → minutes
        since midnight so the dialog opens pre-filled at the clicked time."""
        gesture = Gtk.GestureClick()
        gesture.set_button(3)

        popover = Gtk.Popover()
        popover.set_has_arrow(True)
        popover.set_parent(widget)
        popover.set_position(Gtk.PositionType.BOTTOM)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.add_css_class("context-menu")

        new_btn = Gtk.Button(label="New event here")
        new_btn.add_css_class("flat")
        new_btn.add_css_class("context-menu-item")
        new_btn.set_halign(Gtk.Align.FILL)
        new_btn.get_child().set_halign(Gtk.Align.START)
        box.append(new_btn)
        popover.set_child(box)

        click_state = {"y": 0.0}

        def _on_new(_b, d=day):
            popover.popdown()
            self._new_event_at(d, click_state["y"], get_minutes)
        new_btn.connect("clicked", _on_new)

        def _on_pressed(_g, _n, x, y):
            click_state["y"] = y
            popover.set_pointing_to(_point_rect(x, y))
            popover.popup()
        gesture.connect("pressed", _on_pressed)
        widget.add_controller(gesture)

    def _attach_day_menu(self, widget, day):
        """Attach a button-3 menu offering "New event" to a month-view day
        cell (defaults the date to that day)."""
        gesture = Gtk.GestureClick()
        gesture.set_button(3)

        popover = Gtk.Popover()
        popover.set_has_arrow(True)
        popover.set_parent(widget)
        popover.set_position(Gtk.PositionType.BOTTOM)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.add_css_class("context-menu")
        new_btn = Gtk.Button(label="New event")
        new_btn.add_css_class("flat")
        new_btn.add_css_class("context-menu-item")
        new_btn.set_halign(Gtk.Align.FILL)
        new_btn.get_child().set_halign(Gtk.Align.START)
        box.append(new_btn)
        popover.set_child(box)

        def _on_new(_b, d=day):
            popover.popdown()
            base = GLib.DateTime.new_local(
                d.get_year(), d.get_month(), d.get_day_of_month(), 0, 0, 0)
            self._on_new_event(None, defaults={"date": base, "all_day": False})
        new_btn.connect("clicked", _on_new)

        def _on_pressed(_g, _n, x, y):
            popover.set_pointing_to(_point_rect(x, y))
            popover.popup()
        gesture.connect("pressed", _on_pressed)
        widget.add_controller(gesture)

    def _new_event_at(self, day, y, get_minutes):
        """Open the create dialog pre-filled with ``day`` and the time the user
        right-clicked (a one-hour default). Reuses the drag/click→create path so
        snapping/clamping match drag-to-create exactly."""
        if get_minutes is not None:
            start_min = _snap(get_minutes(y), SNAP_MINUTES)
        else:
            start_min = _snap(_y_to_minutes(y), SNAP_MINUTES)
        start_min = max(0, min(start_min, 24 * 60 - SNAP_MINUTES))
        end_min = min(start_min + 60, 24 * 60)
        base = GLib.DateTime.new_local(
            day.get_year(), day.get_month(), day.get_day_of_month(), 0, 0, 0)
        start_dt = base.add_seconds(start_min * 60)
        end_dt = base.add_seconds(end_min * 60)
        self._on_new_event(None, defaults={
            "date": base, "start_dt": start_dt, "end_dt": end_dt,
            "all_day": False,
        })

    # ---- delete (write) --------------------------------------------------- #
    def _delete_event(self, ev):
        """Remove an event from EDS in a WORKER THREAD, then reload + toast.

        Like every other EDS access in this app, connect_sync/remove_object_sync
        spin the GLib main context and DEADLOCK if called on the GTK main loop,
        so the blocking work happens off-thread and the result is applied back
        via GLib.idle_add.
        """
        if not EDS_OK:
            self._toast("Evolution Data Server is not available.")
            return
        if ev.source is None or not ev.uid:
            self._toast("This event cannot be deleted (missing source).")
            return

        source = ev.source
        uid = ev.uid
        rid = ev.rid

        def worker():
            err = None
            try:
                client = ECal.Client.connect_sync(
                    source, ECal.ClientSourceType.EVENTS, 30, None)
                if client is None:
                    raise RuntimeError("calendar not reachable")
                ok = client.remove_object_sync(
                    uid, rid, ECal.ObjModType.THIS,
                    ECal.OperationFlags.NONE, None)
                if not ok:
                    raise RuntimeError("the calendar rejected the deletion")
            except Exception as e:  # noqa: BLE001
                err = str(e)
            GLib.idle_add(self._on_event_deleted, err)

        threading.Thread(target=worker, daemon=True).start()

    def _on_event_deleted(self, err):
        if err is not None:
            self._toast("Could not delete event: %s" % err)
            return False
        self._toast("Event deleted.")
        self._reload()
        return False

    def _agenda_row(self, ev):
        """A roomy agenda line: colour dot + time range + title + location."""
        btn = Gtk.Button()
        btn.add_css_class("agenda-row")
        btn.add_css_class("flat")
        btn.set_hexpand(True)
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)

        dot = self._color_dot(ev.cal_color, size=10)
        dot.set_valign(Gtk.Align.CENTER)
        row.append(dot)

        time_lbl = Gtk.Label(label=self._time_label(ev))
        time_lbl.add_css_class("agenda-time")
        time_lbl.set_halign(Gtk.Align.START)
        time_lbl.set_xalign(0)
        time_lbl.set_width_chars(11)
        time_lbl.set_valign(Gtk.Align.CENTER)
        row.append(time_lbl)

        text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
        text.set_valign(Gtk.Align.CENTER)
        title = Gtk.Label(label=ev.summary)
        title.add_css_class("agenda-title")
        title.set_halign(Gtk.Align.START)
        title.set_xalign(0)
        title.set_ellipsize(Pango.EllipsizeMode.END)
        text.append(title)
        if ev.location:
            loc = Gtk.Label(label=ev.location)
            loc.add_css_class("agenda-location")
            loc.set_halign(Gtk.Align.START)
            loc.set_xalign(0)
            loc.set_ellipsize(Pango.EllipsizeMode.END)
            text.append(loc)
        text.set_hexpand(True)
        row.append(text)

        btn.set_child(row)
        btn.set_tooltip_text(ev.summary)
        return btn

    # ---- bottom selected-day agenda --------------------------------------- #
    def _clear_bottom_agenda(self):
        child = self.bottom_agenda.get_first_child()
        while child is not None:
            nxt = child.get_next_sibling()
            self.bottom_agenda.remove(child)
            child = nxt

    def _update_bottom_agenda(self):
        """Render the always-visible bottom section listing the SELECTED day's
        events. Day view is itself an agenda for the focused day, so we hide the
        bottom section there to avoid duplicating it."""
        self._clear_bottom_agenda()
        # Day and Week are now full time grids that show events in place, so the
        # bottom selected-day agenda would only duplicate them — hide it there.
        # It stays for Month (where cells only fit a few chips).
        if self.view_mode in ("day", "week"):
            self.bottom_agenda.set_visible(False)
            return
        self.bottom_agenda.set_visible(True)

        day = self._selected_date()
        wd = WEEKDAYS_EN[day.get_day_of_week() - 1]
        heading_text = "%s, %d %s" % (
            wd, day.get_day_of_month(), MONTHS_EN[day.get_month() - 1])

        inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        inner.add_css_class("selected-agenda-inner")

        heading = Gtk.Label(label=heading_text)
        heading.add_css_class("selected-heading")
        heading.set_halign(Gtk.Align.START)
        heading.set_xalign(0)
        inner.append(heading)

        day_events = list(self._last_buckets.get(self._selected_key(), []))
        all_day = [e for e in day_events if e.all_day]
        timed = [e for e in day_events if not e.all_day]
        timed.sort(key=lambda e: (e.start.to_unix() if e.start else 0))

        if not day_events:
            empty = Gtk.Label(label="No events")
            empty.add_css_class("selected-empty")
            empty.set_halign(Gtk.Align.START)
            empty.set_xalign(0)
            inner.append(empty)
        else:
            listbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
            for ev in all_day + timed:
                listbox.append(self._agenda_row(ev))
            inner.append(listbox)

        # Clamp the bottom agenda to align with the (clamped) grid above.
        clamp = Adw.Clamp()
        clamp.set_maximum_size(GRID_MAX_WIDTH)
        clamp.set_tightening_threshold(GRID_MAX_WIDTH)
        clamp.set_child(inner)

        scroller = Gtk.ScrolledWindow()
        scroller.add_css_class("selected-agenda-scroller")
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_min_content_height(120)
        scroller.set_max_content_height(220)
        scroller.set_propagate_natural_height(True)
        scroller.set_child(clamp)
        self.bottom_agenda.append(scroller)

    # ---- day popover ------------------------------------------------------ #
    def _show_day(self, day, day_events):
        """Popover/dialog listing all of a day's events (overflow reachable)."""
        dialog = Adw.Dialog()
        wd = WEEKDAYS_EN[day.get_day_of_week() - 1]
        title = "%s, %d %s %d" % (wd, day.get_day_of_month(),
                                  MONTHS_EN[day.get_month() - 1],
                                  day.get_year())
        dialog.set_title(title)
        dialog.set_content_width(420)

        tv = Adw.ToolbarView()
        tv.add_top_bar(Adw.HeaderBar())

        page = Adw.PreferencesPage()
        group = Adw.PreferencesGroup()
        group.set_title(title)

        if not day_events:
            empty = Adw.ActionRow()
            empty.set_title("No events")
            empty.set_subtitle("There is nothing scheduled for this day.")
            group.add(empty)
        else:
            for ev in day_events:
                group.add(self._event_row(ev, dialog))
        page.add(group)

        tv.set_content(page)
        dialog.set_child(tv)
        dialog.present(self)

    def _event_row(self, ev, parent_dialog=None):
        row = Adw.ActionRow()
        row.set_title(GLib.markup_escape_text(ev.summary))
        row.set_title_lines(2)

        subtitle_bits = [self._time_label(ev)]
        if ev.location:
            subtitle_bits.append(ev.location)
        row.set_subtitle(GLib.markup_escape_text(" · ".join(subtitle_bits)))
        row.set_subtitle_lines(1)

        row.add_prefix(self._color_dot(ev.cal_color))

        row.set_activatable(True)

        def _open(_r, e=ev):
            if parent_dialog is not None:
                parent_dialog.close()
            self._show_detail(e)
        row.connect("activated", _open)
        chevron = Gtk.Image.new_from_icon_name("go-next-symbolic")
        chevron.add_css_class("dim-label")
        row.add_suffix(chevron)
        return row

    def _color_dot(self, color, size=10):
        dot = Gtk.Box()
        dot.add_css_class("event-dot")
        dot.set_size_request(size, size)
        dot.set_valign(Gtk.Align.CENTER)
        if color:
            css = (".event-dot.c-%s { background-color: %s; }"
                   % (_color_class(color), color))
            provider = Gtk.CssProvider()
            try:
                provider.load_from_data(css.encode("utf-8"))
                dot.get_style_context().add_provider(
                    provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
                dot.add_css_class("c-%s" % _color_class(color))
            except Exception:  # noqa: BLE001
                pass
        return dot

    def _time_label(self, ev):
        if ev.all_day or ev.start is None:
            return "All day"
        start = ev.start.format("%H:%M")
        if ev.end is not None:
            # only show the end time when it's on the same day
            if ev.end.format("%Y-%m-%d") == ev.start.format("%Y-%m-%d"):
                return "%s – %s" % (start, ev.end.format("%H:%M"))
        return start

    # ---- detail ----------------------------------------------------------- #
    def _show_detail(self, ev):
        dialog = Adw.Dialog()
        dialog.set_title(ev.summary or "Event")
        dialog.set_content_width(420)

        tv = Adw.ToolbarView()
        tv.add_top_bar(Adw.HeaderBar())

        page = Adw.PreferencesPage()

        head = Adw.PreferencesGroup()
        head.set_title(ev.summary or "(No title)")
        page.add(head)

        info = Adw.PreferencesGroup()
        info.add(self._detail_row("Time", self._detail_time(ev)))
        if ev.location:
            info.add(self._detail_row("Location", ev.location))
        info.add(self._detail_row("Calendar", ev.cal_name or "—"))
        page.add(info)

        if ev.description:
            desc_group = Adw.PreferencesGroup()
            desc_group.set_title("Description")
            desc_label = Gtk.Label(label=ev.description)
            desc_label.set_wrap(True)
            desc_label.set_xalign(0)
            desc_label.set_selectable(True)
            desc_label.set_margin_top(6)
            desc_label.set_margin_bottom(6)
            desc_label.set_margin_start(12)
            desc_label.set_margin_end(12)
            desc_group.add(desc_label)
            page.add(desc_group)

        tv.set_content(page)
        dialog.set_child(tv)
        dialog.present(self)

    def _detail_row(self, title, value):
        row = Adw.ActionRow()
        row.set_title(title)
        row.set_subtitle(value)
        row.set_subtitle_selectable(True)
        return row

    def _detail_time(self, ev):
        if ev.all_day or ev.start is None:
            day = ev.start.format("%d.%m.%Y") if ev.start else ""
            return "All day · %s" % day if day else "All day"
        out = ev.start.format("%a, %d.%m.%Y %H:%M")
        if ev.end is not None:
            if ev.end.format("%Y-%m-%d") == ev.start.format("%Y-%m-%d"):
                out += " – %s" % ev.end.format("%H:%M")
            else:
                out += " – %s" % ev.end.format("%d.%m.%Y %H:%M")
        return out

    # ---- event creation (write) ------------------------------------------- #
    def _on_new_event(self, _btn, defaults=None):
        """Open the New-event form.

        ``defaults`` is an optional dict of pre-fill values
        ``{date, start_dt, end_dt, all_day}`` (any/all may be omitted). A plain
        "+" click passes nothing → the form falls back to the selected day with
        a 09:00–10:00 default. A drag-to-create passes the dragged column date
        and the snapped start/end times.
        """
        if not EDS_OK:
            self._toast("Evolution Data Server is not available.")
            return
        # _writable_calendars() hits EDS (SourceRegistry.new_sync). A sync EDS call
        # on the main loop DEADLOCKS the whole app — do it off-thread, then present
        # the dialog back on the main thread.
        def worker():
            try:
                writable = _writable_calendars()
            except Exception:  # noqa: BLE001
                writable = []
            GLib.idle_add(self._after_writable, writable, defaults)

        threading.Thread(target=worker, daemon=True).start()

    def _after_writable(self, writable, defaults=None):
        if not writable:
            self._toast("No writable calendar found.")
            return False
        self._present_new_event_dialog(writable, defaults)
        return False

    def _present_new_event_dialog(self, writable, defaults=None):
        # Resolve the pre-fill defaults. ``date`` drives the calendar picker;
        # ``start_dt``/``end_dt`` (GLib.DateTime, local) drive the time spinners.
        defaults = defaults or {}
        def_date = defaults.get("date") or self._selected_date()
        def_start = defaults.get("start_dt")
        def_end = defaults.get("end_dt")
        def_all_day = bool(defaults.get("all_day", False))
        def_sh = def_start.get_hour() if def_start is not None else 9
        def_sm = def_start.get_minute() if def_start is not None else 0
        def_eh = def_end.get_hour() if def_end is not None else 10
        def_em = def_end.get_minute() if def_end is not None else 0
        dialog = Adw.Dialog()
        dialog.set_title("New Event")
        dialog.set_content_width(440)

        tv = Adw.ToolbarView()
        header = Adw.HeaderBar()
        header.set_show_end_title_buttons(False)
        header.set_show_start_title_buttons(False)

        cancel_btn = Gtk.Button(label="Cancel")
        cancel_btn.connect("clicked", lambda _b: dialog.close())
        header.pack_start(cancel_btn)

        save_btn = Gtk.Button(label="Save")
        save_btn.add_css_class("suggested-action")
        header.pack_end(save_btn)
        tv.add_top_bar(header)

        page = Adw.PreferencesPage()

        # --- main group: title + calendar -------------------------------- #
        grp = Adw.PreferencesGroup()

        title_row = Adw.EntryRow()
        title_row.set_title("Title")
        grp.add(title_row)

        cal_row = Adw.ComboRow()
        cal_row.set_title("Calendar")
        cal_model = Gtk.StringList()
        for _src, name in writable:
            cal_model.append(name)
        cal_row.set_model(cal_model)
        grp.add(cal_row)
        page.add(grp)

        # --- when group: all-day switch + date + times ------------------- #
        when = Adw.PreferencesGroup()
        when.set_title("When")

        allday_row = Adw.SwitchRow()
        allday_row.set_title("All day")
        allday_row.set_active(def_all_day)
        when.add(allday_row)

        sel = def_date
        date_row = Adw.ActionRow()
        date_row.set_title("Date")
        cal_picker = Gtk.Calendar()
        cal_picker.select_day(
            GLib.DateTime.new_local(
                sel.get_year(), sel.get_month(), sel.get_day_of_month(),
                12, 0, 0))
        date_btn = Gtk.MenuButton()
        date_btn.add_css_class("flat")
        date_btn.set_valign(Gtk.Align.CENTER)
        date_pop = Gtk.Popover()
        date_pop.set_child(cal_picker)
        date_btn.set_popover(date_pop)

        def _fmt_date_btn(*_a):
            d = cal_picker.get_date()
            date_btn.set_label(d.format("%a, %d %b %Y"))
        _fmt_date_btn()
        cal_picker.connect("day-selected", _fmt_date_btn)
        date_row.add_suffix(date_btn)
        date_row.set_activatable_widget(date_btn)
        when.add(date_row)

        start_row = Adw.SpinRow.new_with_range(0, 23, 1)
        start_row.set_title("Start hour")
        start_row.set_value(def_sh)
        when.add(start_row)

        start_min_row = Adw.SpinRow.new_with_range(0, 55, 5)
        start_min_row.set_title("Start minute")
        start_min_row.set_value(def_sm)
        when.add(start_min_row)

        end_row = Adw.SpinRow.new_with_range(0, 23, 1)
        end_row.set_title("End hour")
        end_row.set_value(def_eh)
        when.add(end_row)

        end_min_row = Adw.SpinRow.new_with_range(0, 55, 5)
        end_min_row.set_title("End minute")
        end_min_row.set_value(def_em)
        when.add(end_min_row)
        page.add(when)

        # All-day hides the time rows.
        def _toggle_allday(*_a):
            show = not allday_row.get_active()
            for r in (start_row, start_min_row, end_row, end_min_row):
                r.set_visible(show)
        allday_row.connect("notify::active", _toggle_allday)
        _toggle_allday()

        # --- optional details -------------------------------------------- #
        details = Adw.PreferencesGroup()
        details.set_title("Details")
        loc_row = Adw.EntryRow()
        loc_row.set_title("Location")
        details.add(loc_row)
        desc_row = Adw.EntryRow()
        desc_row.set_title("Description")
        details.add(desc_row)
        page.add(details)

        tv.set_content(page)
        dialog.set_child(tv)

        def _on_save(_b):
            title = title_row.get_text().strip()
            if not title:
                title_row.add_css_class("error")
                self._toast("Please enter a title.")
                return
            src, _name = writable[cal_row.get_selected()]
            all_day = allday_row.get_active()
            d = cal_picker.get_date()
            year, month, day = d.get_year(), d.get_month(), d.get_day_of_month()
            sh = int(start_row.get_value())
            sm = int(start_min_row.get_value())
            eh = int(end_row.get_value())
            em = int(end_min_row.get_value())

            spec = {
                "summary": title,
                "location": loc_row.get_text().strip(),
                "description": desc_row.get_text().strip(),
                "all_day": all_day,
                "year": year, "month": month, "day": day,
                "sh": sh, "sm": sm, "eh": eh, "em": em,
            }
            save_btn.set_sensitive(False)
            cancel_btn.set_sensitive(False)
            save_btn.set_label("Saving…")
            self._save_event_async(src, spec, dialog, save_btn, cancel_btn)

        save_btn.connect("clicked", _on_save)
        dialog.present(self)

    def _save_event_async(self, source, spec, dialog, save_btn, cancel_btn):
        """Build the VEVENT and write it via ECal in a WORKER THREAD.

        EVERY EDS call here (connect_sync, create_object_sync) is synchronous and
        spins the GLib main context, so it MUST run off the GTK main loop or the
        window deadlocks. On completion we hop back to the main thread via
        GLib.idle_add to close the dialog + reload (success) or report (error).
        """
        def worker():
            err = None
            try:
                client = ECal.Client.connect_sync(
                    source, ECal.ClientSourceType.EVENTS, 30, None)
                if client is None:
                    raise RuntimeError("calendar not reachable")
                comp = _build_vevent(spec)
                ok, _uid = client.create_object_sync(
                    comp, ECal.OperationFlags.NONE, None)
                if not ok:
                    raise RuntimeError("the calendar rejected the event")
            except Exception as e:  # noqa: BLE001
                err = str(e)
            GLib.idle_add(self._on_event_saved, err, dialog,
                          save_btn, cancel_btn)

        threading.Thread(target=worker, daemon=True).start()

    def _on_event_saved(self, err, dialog, save_btn, cancel_btn):
        if err is not None:
            save_btn.set_sensitive(True)
            cancel_btn.set_sensitive(True)
            save_btn.set_label("Save")
            self._toast("Could not save event: %s" % err)
            return False
        dialog.close()
        self._toast("Event created.")
        self._reload()
        return False

    def _toast(self, text):
        if hasattr(self, "_toast_overlay") and self._toast_overlay is not None:
            self._toast_overlay.add_toast(Adw.Toast.new(text))

    # ---- placeholder pages ------------------------------------------------ #
    def _eds_missing_page(self):
        page = Adw.StatusPage()
        page.set_icon_name("dialog-warning-symbolic")
        page.set_title("Evolution Data Server not installed")
        page.set_description(
            "Displaying calendars requires the "
            "evolution-data-server package.\n\n" + EDS_ERROR
        )
        return page

    def _error_page(self, message):
        page = Adw.StatusPage()
        page.set_icon_name("dialog-error-symbolic")
        page.set_title("Calendars could not be loaded")
        page.set_description(message)
        return page

    def _error_banner(self, errors):
        banner = Adw.Banner()
        banner.set_title("Some calendars are not reachable")
        banner.set_revealed(True)
        banner.set_tooltip_text("\n".join(errors))
        return banner


def _writable_calendars():
    """List (source, display_name) for every enabled, WRITABLE calendar source.

    The registry build is a synchronous EDS call, but it returns quickly and is
    only invoked from the New-event click (which opens a dialog before any write
    happens off-thread). Read-only calendars (Source.get_writable() is False) are
    skipped so users can only create where the backend permits.
    """
    out = []
    try:
        registry = EDataServer.SourceRegistry.new_sync(None)
    except Exception:  # noqa: BLE001
        return out
    for source in registry.list_sources(
            EDataServer.SOURCE_EXTENSION_CALENDAR):
        try:
            if not source.get_enabled():
                continue
            if not source.get_writable():
                continue
        except Exception:  # noqa: BLE001
            continue
        out.append((source, source.get_display_name() or "Calendar"))
    return out


def _ical_date(year, month, day):
    """A floating DATE (is_date=True) with the given Y/M/D set explicitly.

    Building from Y/M/D directly avoids the timezone truncation you get when
    converting a local-midnight unix timestamp to a UTC DATE (which can land on
    the previous calendar day).
    """
    t = ICalGLib.Time.new_null_date()
    t.set_date(year, month, day)
    t.set_is_date(True)
    return t


def _ical_datetime_utc(year, month, day, hour, minute):
    """A DATE-TIME in UTC for the given local wall clock."""
    local = GLib.DateTime.new_local(year, month, day, hour, minute, 0)
    utc = ICalGLib.Timezone.get_utc_timezone()
    return ICalGLib.Time.new_from_timet_with_zone(local.to_unix(), False, utc)


def _build_vevent(spec):
    """Assemble a VEVENT ICalGLib.Component from the form spec dict."""
    comp = ICalGLib.Component.new_vevent()
    comp.add_property(ICalGLib.Property.new_uid(GLib.uuid_string_random()))

    now_utc = ICalGLib.Time.new_from_timet_with_zone(
        GLib.DateTime.new_now_utc().to_unix(), False,
        ICalGLib.Timezone.get_utc_timezone())
    comp.add_property(ICalGLib.Property.new_dtstamp(now_utc))

    comp.add_property(ICalGLib.Property.new_summary(spec["summary"]))
    if spec.get("location"):
        comp.add_property(ICalGLib.Property.new_location(spec["location"]))
    if spec.get("description"):
        comp.add_property(
            ICalGLib.Property.new_description(spec["description"]))

    all_day = spec["all_day"]
    y, m, d = spec["year"], spec["month"], spec["day"]

    if all_day:
        comp.add_property(ICalGLib.Property.new_dtstart(_ical_date(y, m, d)))
        # All-day DTEND is the exclusive next day.
        nxt = GLib.DateTime.new_local(y, m, d, 0, 0, 0).add_days(1)
        comp.add_property(ICalGLib.Property.new_dtend(
            _ical_date(nxt.get_year(), nxt.get_month(),
                       nxt.get_day_of_month())))
    else:
        comp.add_property(ICalGLib.Property.new_dtstart(
            _ical_datetime_utc(y, m, d, spec["sh"], spec["sm"])))
        local_start = GLib.DateTime.new_local(y, m, d, spec["sh"], spec["sm"], 0)
        local_end = GLib.DateTime.new_local(y, m, d, spec["eh"], spec["em"], 0)
        # Guard against an end at/before the start (roll to +1h).
        if local_end.to_unix() <= local_start.to_unix():
            local_end = local_start.add_hours(1)
        comp.add_property(ICalGLib.Property.new_dtend(
            _ical_datetime_utc(
                local_end.get_year(), local_end.get_month(),
                local_end.get_day_of_month(),
                local_end.get_hour(), local_end.get_minute())))
    return comp


def _color_class(color):
    """A CSS-safe class suffix derived from a colour string."""
    return "".join(c for c in color if c.isalnum()) or "x"


def _point_rect(x, y):
    """A 1×1 Gdk.Rectangle at (x, y).

    NB: ``Gdk.Rectangle(x=…, y=…)`` SILENTLY IGNORES its kwargs (boxed-struct
    init is deprecated/no-op), so the fields must be assigned after the fact or
    every popover would anchor at (0, 0) instead of the pointer.
    """
    rect = Gdk.Rectangle()
    rect.x = int(x)
    rect.y = int(y)
    rect.width = 1
    rect.height = 1
    return rect


# --------------------------------------------------------------------------- #
#  Time-grid math helpers
# --------------------------------------------------------------------------- #
def _y_to_minutes(y):
    """A Y pixel offset within a day column → minutes since 00:00."""
    return (y / float(PX_PER_HOUR)) * 60.0


def _snap(minutes, step):
    """Round ``minutes`` to the nearest ``step`` (e.g. 15)."""
    return int(round(minutes / float(step)) * step)


def _tint(color):
    """A translucent fill derived from a calendar colour for event blocks.

    Returns a GTK ``alpha(#rrggbb, …)`` expression so the block reads as a
    faint wash of the calendar colour over the dark Fuji background.
    """
    return "alpha(%s, 0.22)" % color


def _layout_overlaps(timed, day):
    """Best-effort overlap layout for a day's timed events.

    Greedy sweep: events are grouped into clusters that transitively overlap;
    within a cluster each event gets the first column index that's free, and
    every event in the cluster is told how many columns the cluster spans so
    blocks split the width evenly. Returns a list of (event, col_index,
    col_count) in input order.
    """
    if not timed:
        return []

    def _bounds(ev):
        s = ev.start.to_unix()
        e = (ev.end if ev.end is not None else ev.start.add_hours(1)).to_unix()
        if e <= s:
            e = s + 3600
        return s, e

    out = []
    cluster = []          # list of (ev, s, e, col)
    cluster_max_end = None

    def _flush(cluster):
        n = 0
        for _ev, _s, _e, col in cluster:
            n = max(n, col + 1)
        for ev, _s, _e, col in cluster:
            out.append((ev, col, n))

    for ev in timed:
        s, e = _bounds(ev)
        if cluster and s >= cluster_max_end:
            _flush(cluster)
            cluster = []
            cluster_max_end = None
        # find the lowest free column among events still overlapping ``s``
        used = set()
        for _ev2, _s2, e2, col2 in cluster:
            if e2 > s:
                used.add(col2)
        col = 0
        while col in used:
            col += 1
        cluster.append((ev, s, e, col))
        cluster_max_end = e if cluster_max_end is None else max(cluster_max_end, e)
    if cluster:
        _flush(cluster)

    # restore input order
    order = {id(ev): i for i, ev in enumerate(timed)}
    out.sort(key=lambda t: order[id(t[0])])
    return out


def _days_in_month(year, month):
    """Number of days in the given month (1..12), Gregorian."""
    if month == 12:
        nxt = GLib.DateTime.new_local(year + 1, 1, 1, 0, 0, 0)
    else:
        nxt = GLib.DateTime.new_local(year, month + 1, 1, 0, 0, 0)
    return nxt.add_days(-1).get_day_of_month()


# --------------------------------------------------------------------------- #
#  Application
# --------------------------------------------------------------------------- #
class CalendarApp(Adw.Application):
    def __init__(self):
        super().__init__(application_id="ch.gabrielevarano.TaninCalendar",
                         flags=Gio.ApplicationFlags.FLAGS_NONE)

    def do_activate(self):
        self._load_css()
        win = self.props.active_window
        if not win:
            win = CalendarWindow(self)
        win.present()

    def _load_css(self):
        if not os.path.exists(CSS_PATH):
            return
        provider = Gtk.CssProvider()
        try:
            provider.load_from_path(CSS_PATH)
        except Exception:  # noqa: BLE001
            return
        display = Gdk.Display.get_default()
        if display is not None:
            Gtk.StyleContext.add_provider_for_display(
                display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)


if __name__ == "__main__":
    sys.exit(CalendarApp().run(sys.argv))
