fix(projects/khora): adopt calendar-style header navigation

Arrange branding, today and date navigation around a dynamic period title, with refresh and view controls on the right.

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 15:43:55 -03:00
parent 10af41bcba
commit f4cc409dd3
3 changed files with 95 additions and 4 deletions
+26
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import calendar
import datetime as dt
from dataclasses import dataclass
@@ -9,6 +10,31 @@ def week_dates(day: dt.date) -> tuple[dt.date, ...]:
return tuple(monday + dt.timedelta(days=offset) for offset in range(7))
def period_label(day: dt.date, mode: str) -> str:
if mode == "month":
return f"{day:%B} {day.year}"
if mode != "week":
return f"{day:%A, %B} {day.day}, {day.year}"
start, *_, end = week_dates(day)
if start.year != end.year:
return f"{start:%B} {start.day}, {start.year} {end:%B} {end.day}, {end.year}"
if start.month != end.month:
return f"{start:%B} {start.day} {end:%B} {end.day}, {start.year}"
return f"{start:%B} {start.day}{end.day}, {start.year}"
def shifted_date(day: dt.date, mode: str, direction: int) -> dt.date:
if mode != "month":
step = 7 if mode == "week" else 1
return day + dt.timedelta(days=step * direction)
month_index = day.year * 12 + day.month - 1 + direction
year, zero_based_month = divmod(month_index, 12)
month = zero_based_month + 1
return dt.date(year, month, min(day.day, calendar.monthrange(year, month)[1]))
def event_slot_range(event: Event, day: dt.date) -> tuple[int, int]:
"""Return the half-hour rows occupied by a timed event on one day."""
assert isinstance(event.start, dt.datetime)
+47 -3
View File
@@ -10,7 +10,7 @@ from gi.repository import Adw, Gio, GLib, Gtk, Pango
from .colors import display_color
from .khal_adapter import KhalRepository
from .model import Event, event_slot_range, week_dates
from .model import Event, event_slot_range, period_label, shifted_date, week_dates
class KhoraWindow(Adw.ApplicationWindow):
@@ -53,16 +53,53 @@ class KhoraWindow(Adw.ApplicationWindow):
def _build_header(self) -> Adw.HeaderBar:
header = Adw.HeaderBar()
header.set_title_widget(Gtk.Box())
brand = Gtk.Box(spacing=8, margin_end=12)
brand.append(Gtk.Image(icon_name="x-office-calendar-symbolic", pixel_size=24))
brand.append(Gtk.Label(label="Khora", css_classes=["title"]))
header.pack_start(brand)
header.pack_start(Gtk.Button(label="Today", action_name="win.today"))
navigation = Gtk.Box()
previous = Gtk.Button(
icon_name="go-previous-symbolic",
action_name="win.previous",
css_classes=["flat"],
tooltip_text="Previous period",
)
following = Gtk.Button(
icon_name="go-next-symbolic",
action_name="win.next",
css_classes=["flat"],
tooltip_text="Next period",
)
navigation.append(previous)
navigation.append(following)
header.pack_start(navigation)
self._period_label = Gtk.Label(css_classes=["title"], margin_start=6)
header.pack_start(self._period_label)
menu = Gio.Menu()
for mode in ("day", "week", "month", "agenda"):
menu.append(mode.title(), f"win.view::{mode}")
self._view_button = Gtk.MenuButton(label="Week", menu_model=menu)
header.set_title_widget(self._view_button)
header.pack_end(Gtk.Button(icon_name="view-refresh-symbolic", action_name="win.refresh"))
controls = Gtk.Box(spacing=6)
controls.append(
Gtk.Button(
icon_name="view-refresh-symbolic",
action_name="win.refresh",
tooltip_text="Refresh calendars",
)
)
controls.append(self._view_button)
header.pack_end(controls)
self._install_action("today", self._on_today)
self._install_action("previous", lambda *_: self._navigate(-1))
self._install_action("next", lambda *_: self._navigate(1))
self._install_action("refresh", lambda *_: self._refresh())
view_action = Gio.SimpleAction.new("view", GLib.VariantType.new("s"))
view_action.connect("activate", self._on_view_selected)
@@ -122,6 +159,7 @@ class KhoraWindow(Adw.ApplicationWindow):
if self._repository is None or not hasattr(self, "_view_content"):
return
self._period_label.set_label(period_label(self._selected_day(), self._view_mode))
self._clear_view()
self._empty.set_visible(False)
if self._view_mode == "month":
@@ -292,6 +330,12 @@ class KhoraWindow(Adw.ApplicationWindow):
self._calendar.select_day(GLib.DateTime.new_now_local())
self._refresh()
def _navigate(self, direction: int) -> None:
target = shifted_date(self._selected_day(), self._view_mode, direction)
selected = GLib.DateTime.new_local(target.year, target.month, target.day, 0, 0, 0)
self._calendar.select_day(selected)
self._refresh()
def _on_view_selected(self, _action: Gio.SimpleAction, parameter: GLib.Variant) -> None:
self._view_mode = parameter.get_string()
self._view_button.set_label(self._view_mode.title())
+22 -1
View File
@@ -1,6 +1,6 @@
import datetime as dt
from khora.model import Event, event_slot_range, week_dates
from khora.model import Event, event_slot_range, period_label, shifted_date, week_dates
def test_timed_event_label_uses_24_hour_time() -> None:
@@ -26,6 +26,27 @@ def test_week_dates_runs_from_monday_through_sunday() -> None:
)
def test_period_labels_follow_the_active_view() -> None:
day = dt.date(2026, 8, 15)
assert period_label(day, "day") == "Saturday, August 15, 2026"
assert period_label(day, "week") == "August 1016, 2026"
assert period_label(day, "month") == "August 2026"
assert period_label(day, "agenda") == "Saturday, August 15, 2026"
def test_week_label_handles_month_boundaries() -> None:
assert period_label(dt.date(2026, 9, 1), "week") == "August 31 September 6, 2026"
def test_shifted_date_uses_the_active_view_interval() -> None:
day = dt.date(2026, 8, 15)
assert shifted_date(day, "day", 1) == dt.date(2026, 8, 16)
assert shifted_date(day, "week", -1) == dt.date(2026, 8, 8)
assert shifted_date(dt.date(2026, 1, 31), "month", 1) == dt.date(2026, 2, 28)
def test_event_slot_range_rounds_to_half_hours() -> None:
event = Event(
summary="Oddly timed meeting",