feat(projects/khora): add week agenda view

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 15:11:48 -03:00
parent 3dfb7307b2
commit 88b2725f81
5 changed files with 93 additions and 23 deletions
+4 -4
View File
@@ -12,12 +12,12 @@ Khora is an early, read-only prototype. It currently provides:
- a native GTK 4/libadwaita interface;
- calendar discovery from the existing khal configuration;
- per-calendar visibility controls;
- a day agenda with recurring and all-day events; and
- day and week agendas with recurring and all-day events; and
- explicit refreshes of khal's local index.
Editing, week and month views, event details, and filesystem monitoring come
next. The khal dependency is isolated in `khora.khal_adapter` so its internal
API can change without leaking through the application.
Editing, a month view, event details, and filesystem monitoring come next. The
khal dependency is isolated in `khora.khal_adapter` so its internal API can
change without leaking through the application.
## Development
+20 -6
View File
@@ -29,13 +29,27 @@ class KhalRepository:
)
def events_on(self, day: dt.date, visible: set[str] | None = None) -> tuple[Event, ...]:
return self.events_for_days((day,), visible)[day]
def events_for_days(
self,
days: tuple[dt.date, ...],
visible: set[str] | None = None,
) -> dict[dt.date, tuple[Event, ...]]:
self._collection.update_db()
events = (
self._to_event(event)
for event in self._collection.get_events_on(day)
if visible is None or event.calendar in visible
)
return tuple(sorted(events, key=self._sort_key))
return {
day: tuple(
sorted(
(
self._to_event(event)
for event in self._collection.get_events_on(day)
if visible is None or event.calendar in visible
),
key=self._sort_key,
)
)
for day in days
}
@staticmethod
def _to_event(event) -> Event:
+5
View File
@@ -4,6 +4,11 @@ import datetime as dt
from dataclasses import dataclass
def week_dates(day: dt.date) -> tuple[dt.date, ...]:
monday = day - dt.timedelta(days=day.weekday())
return tuple(monday + dt.timedelta(days=offset) for offset in range(7))
@dataclass(frozen=True)
class Calendar:
name: str
+51 -12
View File
@@ -10,7 +10,7 @@ from gi.repository import Adw, Gio, GLib, Gtk
from .colors import display_color
from .khal_adapter import KhalRepository
from .model import Event
from .model import Event, week_dates
class KhoraWindow(Adw.ApplicationWindow):
@@ -24,6 +24,7 @@ class KhoraWindow(Adw.ApplicationWindow):
self.set_default_size(960, 680)
self._repository = repository
self._visible_calendars: set[str] = set()
self._view_mode = "day"
self._toolbar = Adw.ToolbarView()
self.set_content(self._toolbar)
@@ -35,8 +36,7 @@ class KhoraWindow(Adw.ApplicationWindow):
assert repository is not None
self._visible_calendars = {calendar.name for calendar in repository.calendars}
self._event_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self._event_list.add_css_class("boxed-list")
self._agenda_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
self._empty = Adw.StatusPage(
icon_name="x-office-calendar-symbolic",
title="No events",
@@ -57,6 +57,15 @@ class KhoraWindow(Adw.ApplicationWindow):
today = Gtk.Button(label="Today", action_name="win.today")
header.pack_start(today)
view_switcher = Gtk.Box(css_classes=["linked"])
day = Gtk.ToggleButton(label="Day", active=True)
week = Gtk.ToggleButton(label="Week", group=day)
day.connect("toggled", self._on_view_toggled, "day")
week.connect("toggled", self._on_view_toggled, "week")
view_switcher.append(day)
view_switcher.append(week)
header.pack_start(view_switcher)
header.pack_end(Gtk.Button(icon_name="view-refresh-symbolic", action_name="win.refresh"))
self._install_action("today", self._on_today)
@@ -94,7 +103,7 @@ class KhoraWindow(Adw.ApplicationWindow):
def _build_agenda(self) -> Gtk.Widget:
overlay = Gtk.Overlay()
scroller = Gtk.ScrolledWindow(
child=self._event_list,
child=self._agenda_content,
hscrollbar_policy=Gtk.PolicyType.NEVER,
margin_top=24,
margin_bottom=24,
@@ -117,21 +126,46 @@ class KhoraWindow(Adw.ApplicationWindow):
return dt.date(selected.get_year(), selected.get_month(), selected.get_day_of_month())
def _refresh(self) -> None:
if self._repository is None or not hasattr(self, "_event_list"):
if self._repository is None or not hasattr(self, "_agenda_content"):
return
days = (
week_dates(self._selected_day())
if self._view_mode == "week"
else (self._selected_day(),)
)
try:
events = self._repository.events_on(self._selected_day(), self._visible_calendars)
events_by_day = self._repository.events_for_days(days, self._visible_calendars)
except Exception as error: # khal exposes several backend-specific errors
self._show_toast(str(error))
return
while row := self._event_list.get_row_at_index(0):
self._event_list.remove(row)
for event in events:
self._event_list.append(self._event_row(event))
self._empty.set_visible(not events)
self._event_list.set_visible(bool(events))
while child := self._agenda_content.get_first_child():
self._agenda_content.remove(child)
has_events = any(events_by_day.values())
if self._view_mode == "week":
for day in days:
self._agenda_content.append(self._day_group(day, events_by_day[day]))
elif has_events:
event_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
event_list.add_css_class("boxed-list")
for event in events_by_day[days[0]]:
event_list.append(self._event_row(event))
self._agenda_content.append(event_list)
self._empty.set_visible(not has_events and self._view_mode == "day")
self._agenda_content.set_visible(has_events or self._view_mode == "week")
@staticmethod
def _day_group(day: dt.date, events: tuple[Event, ...]) -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(title=f"{day:%A, %B} {day.day}")
if events:
for event in events:
group.add(KhoraWindow._event_row(event))
else:
group.set_description("No events")
return group
@staticmethod
def _event_row(event: Event) -> Adw.ActionRow:
@@ -159,6 +193,11 @@ class KhoraWindow(Adw.ApplicationWindow):
self._calendar.select_day(GLib.DateTime.new_now_local())
self._refresh()
def _on_view_toggled(self, button: Gtk.ToggleButton, mode: str) -> None:
if button.get_active():
self._view_mode = mode
self._refresh()
def _install_action(self, name: str, callback) -> None:
action = Gio.SimpleAction.new(name, None)
action.connect("activate", callback)
+13 -1
View File
@@ -1,6 +1,6 @@
import datetime as dt
from khora.model import Event
from khora.model import Event, week_dates
def test_timed_event_label_uses_24_hour_time() -> None:
@@ -14,6 +14,18 @@ def test_timed_event_label_uses_24_hour_time() -> None:
assert event.time_label == "13:3015:00"
def test_week_dates_runs_from_monday_through_sunday() -> None:
assert week_dates(dt.date(2026, 8, 15)) == (
dt.date(2026, 8, 10),
dt.date(2026, 8, 11),
dt.date(2026, 8, 12),
dt.date(2026, 8, 13),
dt.date(2026, 8, 14),
dt.date(2026, 8, 15),
dt.date(2026, 8, 16),
)
def test_all_day_event_label() -> None:
event = Event(
summary="Escape the calendar mines",