feat(projects/khora): show event details

Open timed, all-day, and agenda events in a read-only details dialog with their time, calendar, location, description, URL, organizer, and attendees.

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 16:09:09 -03:00
parent 379f559ded
commit 3a07038b84
6 changed files with 156 additions and 18 deletions
+2 -2
View File
@@ -3,8 +3,8 @@
Planned improvements, ordered roughly from least to most implementation complexity.
The order reflects engineering dependencies rather than product priority.
1. **Event details** — open an event from any view and show its complete time,
calendar, location, description, URL, organizer, and attendees.
1. **Event details — done.** Open an event from any view and show its complete
time, calendar, location, description, URL, organizer, and attendees.
2. **Keyboard navigation** — previous/next period, Today, view switching, refresh,
and zoom shortcuts.
3. **Session persistence** — remember the selected view, zoom, sidebar width,
+5
View File
@@ -62,6 +62,11 @@ class KhalRepository:
all_day=event.allday,
location=event.location or "",
color=event.color,
uid=str(event.uid or ""),
description=event.description or "",
url=event.url or "",
organizer=event.organizer or "",
attendees=event.attendees or "",
)
@staticmethod
+30
View File
@@ -71,6 +71,11 @@ class Event:
all_day: bool = False
location: str = ""
color: str | None = None
uid: str = ""
description: str = ""
url: str = ""
organizer: str = ""
attendees: str = ""
@property
def time_label(self) -> str:
@@ -80,3 +85,28 @@ class Event:
assert isinstance(self.start, dt.datetime)
assert isinstance(self.end, dt.datetime)
return f"{self.start:%H:%M}{self.end:%H:%M}"
@property
def when_label(self) -> str:
if self.all_day:
assert isinstance(self.start, dt.date)
assert isinstance(self.end, dt.date)
if self.end <= self.start + dt.timedelta(days=1):
return f"{self.start:%A, %B} {self.start.day}, {self.start.year} · All day"
last_day = self.end - dt.timedelta(days=1)
return (
f"{self.start:%B} {self.start.day} "
f"{last_day:%B} {last_day.day}, {last_day.year} · All day"
)
assert isinstance(self.start, dt.datetime)
assert isinstance(self.end, dt.datetime)
if self.start.date() == self.end.date():
return (
f"{self.start:%A, %B} {self.start.day}, {self.start.year} · "
f"{self.time_label}"
)
return (
f"{self.start:%B} {self.start.day}, {self.start:%H:%M} "
f"{self.end:%B} {self.end.day}, {self.end:%H:%M}, {self.end.year}"
)
+68 -16
View File
@@ -307,10 +307,18 @@ class KhoraWindow(Adw.ApplicationWindow):
label=event.summary,
xalign=0,
ellipsize=Pango.EllipsizeMode.END,
tooltip_text=event.summary,
css_classes=["all-day-event", self._event_color_class(event.color)],
)
box.append(event_label)
event_button = Gtk.Button(
child=event_label,
tooltip_text=event.summary,
css_classes=[
"flat",
"all-day-event",
self._event_color_class(event.color),
],
)
event_button.connect("clicked", lambda _button, item=event: self._show_event(item))
box.append(event_button)
headers.append(box)
row.append(headers)
return row
@@ -343,24 +351,32 @@ class KhoraWindow(Adw.ApplicationWindow):
label = Gtk.Label(
xalign=0,
yalign=0,
valign=Gtk.Align.START,
halign=Gtk.Align.FILL,
wrap=event_height >= 40,
lines=2 if event_height >= 40 else 1,
ellipsize=Pango.EllipsizeMode.END,
margin_top=start * self._slot_height,
height_request=event_height,
tooltip_text=f"{event.time_label} · {event.summary}",
css_classes=["timed-event", self._event_color_class(event.color)],
)
summary = GLib.markup_escape_text(event.summary)
if event_height >= 40:
label.set_markup(f"<b>{summary}</b>\n<small>{event.time_label}</small>")
elif event_height >= 22:
label.set_markup(f"<b>{summary}</b>")
overlay.add_overlay(label)
overlay.set_measure_overlay(label, False)
overlay.set_clip_overlay(label, True)
event_button = Gtk.Button(
child=label,
valign=Gtk.Align.START,
halign=Gtk.Align.FILL,
margin_top=start * self._slot_height,
height_request=event_height,
tooltip_text=f"{event.time_label} · {event.summary}",
css_classes=[
"flat",
"timed-event",
self._event_color_class(event.color),
],
)
event_button.connect("clicked", lambda _button, item=event: self._show_event(item))
overlay.add_overlay(event_button)
overlay.set_measure_overlay(event_button, False)
overlay.set_clip_overlay(event_button, True)
if day == dt.date.today():
indicator = self._current_time_indicator()
@@ -426,15 +442,51 @@ class KhoraWindow(Adw.ApplicationWindow):
self._event_color_providers[class_name] = provider
return class_name
@staticmethod
def _event_row(event: Event) -> Adw.ActionRow:
def _event_row(self, event: Event) -> Adw.ActionRow:
details = f"{event.time_label} · {event.calendar}"
if event.location:
details += f" · {event.location}"
row = Adw.ActionRow(title=event.summary, subtitle=details)
row.add_prefix(KhoraWindow._color_dot(event.color))
row = Adw.ActionRow(title=event.summary, subtitle=details, activatable=True)
row.add_prefix(self._color_dot(event.color))
row.connect("activated", lambda _row: self._show_event(event))
return row
def _show_event(self, event: Event) -> None:
details = Adw.PreferencesGroup()
calendar = Adw.ActionRow(title="Calendar", subtitle=event.calendar)
calendar.add_prefix(self._color_dot(event.color))
details.add(calendar)
for title, value in (
("Location", event.location),
("Organizer", event.organizer),
("Attendees", event.attendees),
("Description", event.description),
):
if value:
details.add(
Adw.ActionRow(
title=title,
subtitle=value,
subtitle_lines=0,
subtitle_selectable=True,
)
)
if event.url:
website = Adw.ActionRow(title="Website")
website.add_suffix(Gtk.LinkButton(uri=event.url, label="Open link"))
details.add(website)
dialog = Adw.AlertDialog(
heading=event.summary,
body=event.when_label,
extra_child=details,
content_width=520,
)
dialog.add_response("close", "Close")
dialog.present(self)
@staticmethod
def _color_dot(color: str | None) -> Gtk.Label:
dot = Gtk.Label(valign=Gtk.Align.CENTER)
+28
View File
@@ -1,3 +1,6 @@
import datetime as dt
from types import SimpleNamespace
from khora.khal_adapter import KhalRepository
@@ -22,3 +25,28 @@ def test_calendar_accounts_come_from_their_parent_directory() -> None:
calendars = repository.calendars
assert tuple(calendar.account for calendar in calendars) == ("personal", "university")
def test_event_details_cross_the_khal_boundary() -> None:
event = KhalRepository._to_event(
SimpleNamespace(
summary="Standup",
calendar="Work",
start_local=dt.datetime(2026, 8, 15, 9, 0),
end_local=dt.datetime(2026, 8, 15, 9, 30),
allday=False,
location="Meeting room",
color="#3584e4",
uid="event-id",
description="Discuss the calendar mines",
url="https://example.com/meeting",
organizer="organizer@example.com",
attendees="one@example.com,two@example.com",
)
)
assert event.uid == "event-id"
assert event.description == "Discuss the calendar mines"
assert event.url == "https://example.com/meeting"
assert event.organizer == "organizer@example.com"
assert event.attendees == "one@example.com,two@example.com"
+23
View File
@@ -69,6 +69,29 @@ def test_event_slot_range_clamps_events_to_the_day() -> None:
assert event_slot_range(event, dt.date(2026, 8, 15)) == (0, 48)
def test_event_when_label_includes_its_date() -> None:
event = Event(
summary="Write a calendar",
calendar="Personal",
start=dt.datetime(2026, 8, 15, 13, 30),
end=dt.datetime(2026, 8, 15, 15, 0),
)
assert event.when_label == "Saturday, August 15, 2026 · 13:3015:00"
def test_multiday_all_day_event_when_label_uses_inclusive_last_day() -> None:
event = Event(
summary="Conference",
calendar="Work",
start=dt.date(2026, 8, 15),
end=dt.date(2026, 8, 18),
all_day=True,
)
assert event.when_label == "August 15 August 17, 2026 · All day"
def test_all_day_event_label() -> None:
event = Event(
summary="Escape the calendar mines",