Files
Panama/docs/superpowers/plans/2026-08-17-calendar-agenda.md
T
Gabriel Brown e1faaf7a76 Drop the extension, and give the test suite a front door
Phase 6, the last of the fresh-install spec.

159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor
contracts. A shebang and the executable bit already select the interpreter. The
extension only ever added something that had to stay in sync, and the rename
proved the point twice over in the space of an hour.

The spec's stated risk was Vicinae's script discovery. One script was renamed and
reloaded on its own before the other 46 followed; it came back as
scripts:panama.capture and all 47 resolve. What the probe turned up instead is
that the extension was never only a filename: Vicinae's command IDs embed it, so
every ID changed. Nothing in this repository refers to them, so nothing breaks.
The only trace is Vicinae's metadata.json, whose visited map had two Panama
entries that are now orphaned -- two commands lost their usage ranking and will
earn it back. Worth knowing before anyone renames these again on a machine that
has a keybind pointing at one.

Rewriting the references by exact filename missed two things it structurally
could not see: a name built from a variable, settings-$page.sh, and a glob,
-name '*.sh'. Both were in the contract that counts the generated commands, which
promptly reported 47 expected and 0 found. The mechanical part of a rename is the
part that looks finished.

The three subcommands. panama doctor fronts a health check that already existed
and already ran at the end of every install but could not be reached from a
terminal. panama upgrade re-runs the installer from anywhere. panama test runs
the suite, which had no entry point at all -- 121 files that were the main safety
net in this repository and were invisible in it.

Writing that runner found three tests nothing was running.
calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test
are unittest suites without the executable bit, so no contract invoked them and
the first draft of the runner skipped them silently. All three pass, and have
passed unobserved for weeks. The runner collects *_test.py as well now, because a
runner with a blind spot is worse than no runner for the same reason a dependency
checker with one is: it reports PASS.

Six worktrees pruned. Each was re-checked rather than trusted to the spec's list,
and two needed it: panama-commands is not on feat/panama-commands but on
feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead
of its remote, not of main, with every commit patch-equivalent to landed work.
roadmap-completion stays; it has five commits that are genuinely unlanded. The
branches are left alone: pruning a worktree costs nothing, deleting a branch is a
decision.

121 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:55:55 -04:00

812 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Calendar Agenda Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the approved A · Daybook date menu using the workstation's existing Evolution Data Server calendars, with a quiet upcoming-event capsule and GNOME Calendar handoff.
**Architecture:** A Python/GObject bridge queries enabled EDS calendar sources through `EDataServer` and `ECal`, expands recurring instances, and streams normalized JSON snapshots. A Quickshell singleton owns that stream and exposes date/event selectors to a two-column Daybook, while GNOME Calendar remains responsible for accounts and event editing.
**Tech Stack:** Python 3.14, PyGObject, Evolution Data Server 3.60 (`EDataServer`, `ECal`, `ICalGLib`), Quickshell 0.3/QML, Gio D-Bus, Bash contract tests, jq, Hyprland 0.56
**Spec:** `docs/superpowers/specs/2026-08-17-calendar-agenda-design.md`
## Global Constraints
- Use only enabled EDS event sources; never read authentication extensions or credential stores.
- GNOME Calendar owns account configuration and event editing.
- Live event content must never be written to logs, settings, test fixtures, screenshots committed to the repository, or documentation.
- The bridge may emit event content only to its Quickshell consumer over stdout.
- Descriptions and attendees never enter the bridge output; descriptions may only be inspected to locate an allowed HTTPS meeting URL.
- Allowed meeting providers are Google Meet, Zoom, Microsoft Teams, Webex, and Jitsi.
- Calendar failures must not break notifications, media, weather, or the static month grid.
- The bar capsule appears from 15 minutes before through 5 minutes after a timed event starts; all-day events never produce it.
- No calendar toasts, sounds, pulse, shimmer, or continuously repainting animation.
- No test may create, modify, or delete a real calendar event.
- Wi-Fi QR sharing is removed from Panama's roadmap and package guidance.
---
## File map
### Calendar data boundary
- `config/dot/quickshell/scripts/calendar-agenda` — executable Python bridge; the only code that imports EDS libraries.
- `config/dot/quickshell/services/CalendarAgenda.qml` — singleton state consumed by shell UI; supervises the bridge and owns date-derived selectors.
- `tests/quickshell/calendar_agenda_bridge_test.py` — pure normalization and URL-policy tests using synthetic components only.
- `tests/quickshell/calendar-agenda-helper-contract` — executable/probe/live-query contract that redacts live content.
- `tests/quickshell/calendar-agenda-contract` — live Quickshell IPC/state contract using synthetic in-memory fixtures.
### Daybook presentation
- `config/dot/quickshell/modules/datemenu/AgendaEvent.qml` — one event row.
- `config/dot/quickshell/modules/datemenu/AgendaPane.qml` — Up Next, all-day section, timed agenda, empty/error states, and notification summary.
- `config/dot/quickshell/modules/datemenu/NotificationSummary.qml` — quiet bridge from Agenda to the existing grouped notification list.
- `config/dot/quickshell/modules/datemenu/DateMenuPanel.qml` — approved two-column layout and agenda/notification routing.
- `config/dot/quickshell/modules/datemenu/CalendarGrid.qml` — date selection, adjacent-month navigation, and event dots.
- `config/dot/quickshell/modules/datemenu/DateMenu.qml` — panel sizing, preparation, and notification read-state behavior.
- `config/dot/quickshell/modules/datemenu/qmldir` — explicit registrations for new components.
### Shell and bar integration
- `config/dot/quickshell/services/ShellState.qml``dateMenuPage` and explicit Agenda/Notifications open semantics.
- `config/dot/quickshell/modules/bar/Clock.qml` — clock opens Agenda rather than an undifferentiated notification overlay.
- `config/dot/quickshell/modules/bar/CalendarIndicator.qml` — quiet 15-minute capsule.
- `config/dot/quickshell/modules/bar/Bar.qml` — mounts the capsule in the right status area.
- `config/dot/quickshell/shell.qml` — calendar IPC fixture/diagnostics surface and notification-page routing.
### Documentation
- `config/dot/hypr/DESKTOP-PARITY.md` — calendar becomes Live and Wi-Fi QR is removed.
- `config/dot/hypr/README.md` — EDS/GNOME Calendar handoff and capsule behavior.
- `setup/packages/hyprland-packages` — remove `qrencode` if it is present only for the abandoned Wi-Fi QR feature.
---
### Task 1: EDS calendar bridge
**Files:**
- Create: `config/dot/quickshell/scripts/calendar-agenda`
- Create: `tests/quickshell/calendar_agenda_bridge_test.py`
- Create: `tests/quickshell/calendar-agenda-helper-contract`
**Interfaces:**
- Consumes: enabled sources returned by `EDataServer.SourceRegistry.list_enabled(EDataServer.SOURCE_EXTENSION_CALENDAR)`.
- Produces: `calendar-agenda probe`, `calendar-agenda query START END`, and `calendar-agenda watch START END`.
- Produces: one-line JSON snapshots with `ok`, `generatedAt`, `sources`, `events`, and `errors`.
- Produces event objects with exactly `id`, `sourceId`, `uid`, `summary`, `start`, `end`, `allDay`, `location`, and `joinUrl`.
- [x] **Step 1: Write failing pure bridge tests**
Create `tests/quickshell/calendar_agenda_bridge_test.py` with import-by-path and synthetic policy cases:
```python
import importlib.machinery
import importlib.util
import pathlib
import unittest
from unittest.mock import Mock
ROOT = pathlib.Path(__file__).resolve().parents[2]
HELPER = ROOT / "config/dot/quickshell/scripts/calendar-agenda"
loader = importlib.machinery.SourceFileLoader("calendar_agenda", str(HELPER))
spec = importlib.util.spec_from_loader(loader.name, loader)
calendar_agenda = importlib.util.module_from_spec(spec)
loader.exec_module(calendar_agenda)
class CalendarAgendaBridgeTest(unittest.TestCase):
def test_meeting_url_allowlist(self):
values = ["Planning: https://meet.google.com/abc-defg-hij"]
self.assertEqual(calendar_agenda.find_join_url(values), values[0].split()[-1])
self.assertEqual(calendar_agenda.find_join_url(["https://example.com/private"]), "")
self.assertEqual(calendar_agenda.find_join_url(["http://zoom.us/j/123"]), "")
def test_stable_instance_id_includes_recurrence(self):
self.assertEqual(
calendar_agenda.stable_event_id("source", "uid", "20260817T133000Z"),
"source:uid:20260817T133000Z",
)
def test_snapshot_sort_order(self):
events = [
{"id": "b", "start": 20, "end": 30, "allDay": False},
{"id": "a", "start": 10, "end": 20, "allDay": True},
]
self.assertEqual([event["id"] for event in calendar_agenda.sort_events(events)], ["a", "b"])
def test_normalizes_an_all_day_recurrence_instance(self):
component_id = Mock()
component_id.get_uid.return_value = "uid"
component_id.get_rid.return_value = "20260817"
summary = Mock()
summary.get_value.return_value = "Synthetic event"
component = Mock()
component.get_id.return_value = component_id
component.get_summary.return_value = summary
component.get_location.return_value = ""
component.get_url.return_value = None
component.get_descriptions.return_value = []
start = Mock()
start.as_timet.return_value = 1786924800
start.is_date.return_value = True
end = Mock()
end.as_timet.return_value = 1787011200
end.is_date.return_value = True
event = calendar_agenda.normalize_instance(
{"id": "source", "name": "Synthetic", "color": "#82aaff"},
component,
start,
end,
)
self.assertTrue(event["allDay"])
self.assertEqual(event["id"], "source:uid:20260817")
if __name__ == "__main__":
unittest.main()
```
- [x] **Step 2: Run the unit test and verify RED**
Run:
```bash
python3 tests/quickshell/calendar_agenda_bridge_test.py
```
Expected: FAIL because `config/dot/quickshell/scripts/calendar-agenda` does not exist.
- [x] **Step 3: Implement the bridge's pure contract and CLI boundary**
Create `config/dot/quickshell/scripts/calendar-agenda` with a Python shebang and no top-level execution on import. Begin with these pure helpers:
```python
MEETING_HOST_SUFFIXES = (
"meet.google.com",
"zoom.us",
"teams.microsoft.com",
"teams.live.com",
"webex.com",
"meet.jit.si",
)
def stable_event_id(source_id: str, uid: str, recurrence_id: str) -> str:
return f"{source_id}:{uid}:{recurrence_id}"
def sort_events(events: list[dict[str, object]]) -> list[dict[str, object]]:
return sorted(events, key=lambda event: (not bool(event["allDay"]), int(event["start"]), str(event["id"])))
def is_allowed_meeting_host(host: str) -> bool:
normalized = host.lower().rstrip(".")
return any(normalized == suffix or normalized.endswith("." + suffix) for suffix in MEETING_HOST_SUFFIXES)
```
`find_join_url` must parse URLs with `urllib.parse.urlsplit`, require `https`, lowercase the hostname, and match either an exact allowed host or a subdomain boundary (`host == suffix or host.endswith("." + suffix)`). Strip trailing sentence punctuation before parsing.
Then implement these exact public boundaries: `find_join_url(values: list[str]) -> str`, `normalize_instance(source_meta, component, instance_start, instance_end) -> dict[str, object]`, `probe() -> dict[str, object]`, `collect_snapshot(start: int, end: int) -> dict[str, object]`, `watch(start: int, end: int) -> int`, and `main(argv: list[str]) -> int`.
Import GI with exact version requirements:
```python
import gi
gi.require_version("ECal", "2.0")
gi.require_version("EDataServer", "1.2")
from gi.repository import ECal, EDataServer, Gio, GLib
```
For each enabled source:
```python
client = ECal.Client.connect_sync(source, ECal.ClientSourceType.EVENTS, 5, None)
client.generate_instances_sync(start, end, None, on_instance, None)
```
The callback signature is `(component, instance_start, instance_end, cancellable, callback_data)`. Convert callback times with `as_timet()`. Use `component.get_id().get_uid()` and `.get_rid()`, `component.get_summary().get_value()`, `component.get_location()`, `component.get_url()`, and `component.get_descriptions()`.
Use source metadata from `source.get_uid()`, `source.get_display_name()`, and the calendar extension's `get_color()`. Fall back to `#82aaff` only when color is absent or invalid. Catch failures per source and append a generic `{sourceId, code: "unavailable"}` error without raw exception text.
`watch` must emit the initial snapshot, subscribe to EDS source-registry changes and GNOME CalendarServer invalidation signals when available, debounce refreshes by 350 ms, and emit a fallback snapshot every 600 seconds. Failure to subscribe to GNOME CalendarServer must not stop periodic EDS reads.
Write each snapshot with `json.dumps(snapshot, separators=(",", ":")) + "\n"` and immediately flush stdout. Never print event data to stderr.
Make the helper executable:
```bash
chmod +x config/dot/quickshell/scripts/calendar-agenda
```
- [x] **Step 4: Run pure tests and verify GREEN**
Run:
```bash
python3 tests/quickshell/calendar_agenda_bridge_test.py
```
Expected: four tests PASS.
- [x] **Step 5: Add the redacted live helper contract**
Create `tests/quickshell/calendar-agenda-helper-contract` with:
```bash
#!/usr/bin/env bash
set -euo pipefail
fail() { printf 'calendar agenda helper contract: %s\n' "$1" >&2; exit 1; }
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$project_root/config/dot/quickshell/scripts/calendar-agenda"
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
probe="$($helper probe)"
jq -e '.eds == true and .sourceRegistry == true and .enabledSources > 0' <<<"$probe" >/dev/null \
|| fail 'EDS readiness probe failed'
range_start="$(date +%s)"
range_end="$((range_start + 1209600))"
snapshot="$($helper query "$range_start" "$range_end")"
jq -e '
.ok == true and
(.sources | type == "array" and length > 0) and
(.events | type == "array") and
([.events[] | keys_unsorted - ["id","sourceId","uid","summary","start","end","allDay","location","joinUrl"]] | all(length == 0))
' <<<"$snapshot" >/dev/null || fail 'live snapshot shape is invalid'
printf 'calendar agenda helper contract: PASS (%s sources, %s events; contents redacted)\n' \
"$(jq '.sources | length' <<<"$snapshot")" "$(jq '.events | length' <<<"$snapshot")"
```
Make it executable and run it. Expected: PASS with counts only; no source names, summaries, locations, or URLs in terminal output.
- [x] **Step 6: Commit the bridge**
```bash
git add config/dot/quickshell/scripts/calendar-agenda \
tests/quickshell/calendar_agenda_bridge_test.py \
tests/quickshell/calendar-agenda-helper-contract
git commit -m "Add the EDS calendar bridge"
```
---
### Task 2: Quickshell calendar service and deterministic contract
**Files:**
- Create: `config/dot/quickshell/services/CalendarAgenda.qml`
- Create: `tests/quickshell/calendar-agenda-contract`
- Modify: `config/dot/quickshell/shell.qml`
**Interfaces:**
- Consumes: `calendar-agenda watch START END` newline-delimited snapshots.
- Produces: singleton properties `phase`, `available`, `sources`, `events`, `selectedDate`, `selectedDateKey`, `selectedEvents`, `nextEvent`, `nextEventForSelectedDate`, `rangeStart`, `rangeEnd`, `capsuleVisible`, and `capsuleText`.
- Produces: functions `selectDate(date)`, `selectIsoDate(dateString)`, `setVisibleMonth(year, month)`, `eventsForDate(date)`, `markersForDate(date)`, `openEvent(event)`, `openCalendar(date)`, `join(event)`, `refresh()`, `applyFixture(name)`, and `clearFixture()`.
- Produces IPC target `calendar-agenda` with `fixture`, `reset`, `open`, `select`, `refresh`, and `status`.
- [x] **Step 1: Write the failing live service contract**
Create `tests/quickshell/calendar-agenda-contract` that first asserts the IPC target, loads fixture `daybook`, and validates only synthetic state:
```bash
#!/usr/bin/env bash
set -euo pipefail
fail() { printf 'calendar agenda contract: %s\n' "$1" >&2; exit 1; }
cleanup() {
qs ipc call calendar-agenda reset >/dev/null 2>&1 || true
qs ipc call calendar-agenda close >/dev/null 2>&1 || true
}
trap cleanup EXIT
qs ipc show | rg -q '^target calendar-agenda$' || fail 'calendar IPC target is missing'
qs ipc call calendar-agenda fixture daybook >/dev/null
status="$(qs ipc call calendar-agenda status)"
jq -e '.fixture == true and .sourceCount == 3 and .eventCount == 5 and .selectedDate == "2026-08-17"' \
<<<"$status" >/dev/null || fail 'daybook fixture state is malformed'
qs ipc call calendar-agenda select 2026-08-18 >/dev/null
jq -e '.selectedDate == "2026-08-18" and .selectedEventCount == 1' \
<<<"$(qs ipc call calendar-agenda status)" >/dev/null || fail 'date selection did not update events'
qs ipc call calendar-agenda reset >/dev/null
jq -e '.fixture == false' <<<"$(qs ipc call calendar-agenda status)" >/dev/null \
|| fail 'fixture reset did not restore live mode'
printf 'calendar agenda contract: PASS\n'
```
- [x] **Step 2: Run the contract and verify RED**
Run `tests/quickshell/calendar-agenda-contract`.
Expected: FAIL with `calendar IPC target is missing`.
- [x] **Step 3: Implement `CalendarAgenda.qml` stream supervision**
Create the singleton with `pragma Singleton`, `Quickshell`, `Quickshell.Io`, and `QtQuick` imports. Use a streaming parser, not `StdioCollector`:
```qml
Process {
id: watchProc
command: [root.helperPath, "watch", String(root.rangeStart), String(root.rangeEnd)]
running: !root.fixtureMode && root.rangeStart > 0
stdout: SplitParser {
onRead: data => root.consumeSnapshot(data)
}
onExited: (code, status) => {
if (!root.fixtureMode)
restartTimer.restart();
}
}
```
The restart timer uses 2 seconds and does not loop while fixture mode is active. `consumeSnapshot(data)` parses one line, rejects snapshots without `ok === true`, assigns arrays atomically, and sets `phase` to `ready` or `degraded` when `errors.length > 0`.
Implement date keys in local time with `Qt.formatDate(new Date(epoch * 1000), "yyyy-MM-dd")`. For multi-day/all-day markers, treat `end` as exclusive and include each local date touched before `end - 1`.
`setVisibleMonth(year, month)` computes a range from seven days before the first day through seven days after the final 42-cell grid day. Only restart the bridge when the requested range falls outside the loaded range.
`nextEvent` excludes all-day and ended events. `capsuleVisible` is true when `start - now` is at most 900 seconds and at least -300 seconds. `capsuleText` is `Now` after start, otherwise the ceiling minute count plus `m`.
`applyFixture("daybook")` must install three synthetic sources and five synthetic events spanning August 1718, 2026. Use clearly synthetic labels (`Fixture standup`, `Fixture planning`, `Fixture focus`, `Fixture dinner`, `Fixture tomorrow`) and no real account or event data.
- [x] **Step 4: Add calendar IPC without exposing live content**
Add to `shell.qml`:
```qml
IpcHandler {
target: "calendar-agenda"
function fixture(name: string): void { CalendarAgenda.applyFixture(name); }
function reset(): void { CalendarAgenda.clearFixture(); }
function refresh(): void { CalendarAgenda.refresh(); }
function open(): void { ShellState.openDateMenu("agenda"); }
function close(): void { ShellState.close(); }
function select(date: string): void { CalendarAgenda.selectIsoDate(date); }
function status(): string {
return JSON.stringify({
phase: CalendarAgenda.phase,
fixture: CalendarAgenda.fixtureMode,
sourceCount: CalendarAgenda.sources.length,
eventCount: CalendarAgenda.events.length,
selectedDate: CalendarAgenda.selectedDateKey,
selectedEventCount: CalendarAgenda.selectedEvents.length,
capsuleVisible: CalendarAgenda.capsuleVisible,
capsuleText: CalendarAgenda.capsuleText
});
}
}
```
Do not include titles, source names, locations, identifiers, or URLs in status output.
- [x] **Step 5: Run service contracts and verify GREEN**
Allow Quickshell to hot reload, then run:
```bash
tests/quickshell/calendar-agenda-contract
tests/quickshell/calendar-agenda-helper-contract
qs log -n -t 80 --no-color | rg -i 'calendar|error|warn'
```
Expected: both contracts PASS; no calendar QML error or bridge restart loop.
- [x] **Step 6: Commit the service**
```bash
git add config/dot/quickshell/services/CalendarAgenda.qml \
config/dot/quickshell/shell.qml \
tests/quickshell/calendar-agenda-contract
git commit -m "Add live calendar state to Quickshell"
```
---
### Task 3: Explicit date-menu routing and interactive month grid
**Files:**
- Modify: `config/dot/quickshell/services/ShellState.qml`
- Modify: `config/dot/quickshell/modules/bar/Clock.qml`
- Modify: `config/dot/quickshell/shell.qml`
- Modify: `config/dot/quickshell/modules/datemenu/CalendarGrid.qml`
- Modify: `tests/quickshell/calendar-agenda-contract`
**Interfaces:**
- Consumes: `CalendarAgenda.selectDate(date)` and `CalendarAgenda.markersForDate(date)`.
- Produces: `ShellState.dateMenuPage`, `ShellState.toggleDateMenu(page)`, and `ShellState.openDateMenu(page)`.
- Produces from CalendarGrid: signals `dateActivated(date)` and `visibleMonthChanged(year, month)`.
- [x] **Step 1: Extend the failing contract with page-routing assertions**
Append fixture assertions:
```bash
qs ipc call calendar-agenda open >/dev/null
jq -e '.open == true and .page == "agenda"' <<<"$(qs ipc call calendar-agenda status)" >/dev/null \
|| fail 'calendar open did not route to Agenda'
qs ipc call notifications open >/dev/null
jq -e '.open == true and .page == "notifications"' <<<"$(qs ipc call calendar-agenda status)" >/dev/null \
|| fail 'notification open did not route to Notifications'
```
Update the calendar status response to include only `open: ShellState.notificationsOpen` and `page: ShellState.dateMenuPage` in addition to the non-sensitive fields from Task 2.
- [x] **Step 2: Run the contract and verify RED**
Expected: FAIL because `dateMenuPage` and notification `open()` do not exist.
- [x] **Step 3: Implement explicit ShellState routing**
Add:
```qml
property string dateMenuPage: "agenda"
function openDateMenu(page: string): void {
root.dateMenuPage = page === "notifications" ? "notifications" : "agenda";
root.activeOverlay = "notifications";
}
function toggleDateMenu(page: string): void {
const target = page === "notifications" ? "notifications" : "agenda";
if (root.activeOverlay === "notifications" && root.dateMenuPage === target) {
root.activeOverlay = "";
return;
}
root.dateMenuPage = target;
root.activeOverlay = "notifications";
}
```
Change Clock activation to `ShellState.toggleDateMenu("agenda")`. Change notification IPC `toggle()` to `ShellState.toggleDateMenu("notifications")`, add `open()` using `openDateMenu("notifications")`, and leave `Super+B` routed through that IPC target.
- [x] **Step 4: Make CalendarGrid date-aware and event-aware**
Replace numeric-only cell objects with full local `Date` values and add:
```qml
signal dateActivated(date date)
signal visibleMonthChanged(int year, int month)
property date selectedDate: CalendarAgenda.selectedDate
function activateCell(cell): void {
if (!cell.current) {
root.viewYear = cell.date.getFullYear();
root.viewMonth = cell.date.getMonth();
}
root.dateActivated(cell.date);
}
```
Every cell receives `markers: CalendarAgenda.markersForDate(cell.date).slice(0, 3)`. Render dots in a centered row beneath the day number with each marker's source color. Today remains solid accent; selected non-today uses `Theme.alpha(Theme.accent, 0.18)` plus accent text. Add a hover surface only now that cells are actionable.
Month changes call `visibleMonthChanged(viewYear, viewMonth)`. Adjacent-month date activation changes month and selection in one click.
- [x] **Step 5: Verify routing and grid contracts**
Run:
```bash
tests/quickshell/calendar-agenda-contract
rg -q 'markersForDate' config/dot/quickshell/modules/datemenu/CalendarGrid.qml
rg -q 'dateActivated' config/dot/quickshell/modules/datemenu/CalendarGrid.qml
```
Expected: contract PASS and both interaction hooks present.
- [x] **Step 6: Commit routing and grid behavior**
```bash
git add config/dot/quickshell/services/ShellState.qml \
config/dot/quickshell/modules/bar/Clock.qml \
config/dot/quickshell/shell.qml \
config/dot/quickshell/modules/datemenu/CalendarGrid.qml \
tests/quickshell/calendar-agenda-contract
git commit -m "Route the interactive Panama date menu"
```
---
### Task 4: Build the approved A · Daybook panel
**Files:**
- Create: `config/dot/quickshell/modules/datemenu/AgendaEvent.qml`
- Create: `config/dot/quickshell/modules/datemenu/AgendaPane.qml`
- Create: `config/dot/quickshell/modules/datemenu/NotificationSummary.qml`
- Modify: `config/dot/quickshell/modules/datemenu/DateMenuPanel.qml`
- Modify: `config/dot/quickshell/modules/datemenu/DateMenu.qml`
- Modify: `config/dot/quickshell/modules/datemenu/MediaCard.qml`
- Modify: `config/dot/quickshell/modules/datemenu/qmldir`
- Modify: `tests/quickshell/calendar-agenda-contract`
**Interfaces:**
- Consumes: `CalendarAgenda.selectedEvents`, `nextEventForSelectedDate`, `phase`, and action functions.
- Consumes: `ShellState.dateMenuPage`, `Notifs.hasNotifications`, and existing `NotificationList`.
- Produces: a 760-pixel two-column date menu with left width 310 and independently bounded right content.
- [x] **Step 1: Add failing structural and live-surface assertions**
Extend the contract to assert the loaded layer and approved components:
```bash
hyprctl layers | rg -q 'namespace: qs-popover-datemenu' || fail 'Daybook layer did not map'
rg -q 'implicitWidth: 760' config/dot/quickshell/modules/datemenu/DateMenu.qml \
|| fail 'Daybook is not the approved width'
for component in AgendaPane AgendaEvent NotificationSummary; do
rg -q "${component} 1.0 ${component}.qml" config/dot/quickshell/modules/datemenu/qmldir \
|| fail "$component is not registered"
done
```
Run the contract and expect failure on width or missing components.
- [x] **Step 2: Implement focused event and summary components**
`AgendaEvent.qml` accepts `property var event`, `property bool past`, and signals `activated` and `joinRequested`. It renders a 48-pixel tabular time column, 3-pixel source-color rail, elided summary, source/location metadata, and optional Join pill. All-day rows replace time with `All day`.
`NotificationSummary.qml` is visible only when `Notifs.hasNotifications`; it displays history count and emits `activated`. It never marks notifications read by itself.
`AgendaPane.qml` renders exactly one of:
- loading copy (`Loading calendars…`);
- unavailable copy plus `Open Calendar`;
- empty-day copy plus `Open Calendar`;
- Up Next plus the selected day's ordered list.
The Up Next card is omitted for selected dates other than today and when no future timed event exists. Use `ScrollColumn` with a maximum content height derived from the panel's available height. Event clicks call `CalendarAgenda.openEvent(event)`; Join calls `CalendarAgenda.join(event)`.
- [x] **Step 3: Recompose DateMenuPanel as Daybook**
Keep the existing header styling and Prism container, but replace the single `Column` with this concrete structure:
```qml
Column {
id: frame
spacing: 0
Item {
id: header
width: parent.width
height: 42
}
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.1) }
Row {
width: parent.width
Item {
id: leftColumn
width: 310
height: rightColumn.height
}
Rectangle {
width: 1
height: rightColumn.height
color: Theme.alpha(Theme.fg, 0.1)
}
Item {
id: rightColumn
width: frame.width - 311
height: Math.max(438, activeContent.implicitHeight)
property Item activeContent: ShellState.dateMenuPage === "agenda" ? agendaPane : notificationList
AgendaPane { visible: ShellState.dateMenuPage === "agenda" }
NotificationList { visible: ShellState.dateMenuPage === "notifications" }
}
}
}
```
Populate `leftColumn` inline in `DateMenuPanel.qml` with a `Column` containing CalendarGrid, conditional MediaCard, and WeatherCard. Populate `header` with the existing date text and pill controls. Bind date activation to `CalendarAgenda.selectDate(date)` and month changes to `CalendarAgenda.setVisibleMonth(year, month)`.
When Agenda's notification summary activates, set `ShellState.dateMenuPage = "notifications"` and call `Notifs.markAllRead()`. Notification view header exposes `Back to agenda`, DND, and Clear All. Returning to agenda retains selected date and month.
Resize MediaCard typography/controls responsively for the 310-pixel column; do not duplicate its playback logic.
- [x] **Step 4: Update DateMenu lifecycle and bounds**
Set `DateMenu.implicitWidth: 760`. Bound height to the active screen's available geometry, with the panel content choosing a maximum right-column scroll height instead of overflowing the monitor.
On opening:
```qml
CalendarAgenda.refresh();
if (ShellState.dateMenuPage === "notifications")
Notifs.markAllRead();
panel.prepare(ShellState.dateMenuPage);
```
Add a connection that marks notifications read when an already-open panel switches into Notifications. Opening from the clock must not mark them read.
- [x] **Step 5: Verify fixture visuals on the real layer surface**
Run:
```bash
qs ipc call calendar-agenda fixture daybook
qs ipc call calendar-agenda open
tests/quickshell/calendar-agenda-contract
```
Capture a temporary full-screen screenshot with `grim`, inspect the Daybook at original detail, and verify:
- no clipped text at 1.5 scale;
- month and agenda are simultaneously visible;
- right content has no unexplained empty glass;
- three source colors remain restrained;
- notification summary is quieter than Up Next;
- no continuously repainting animation.
Close the panel and reset fixture mode after inspection. Do not add screenshots to git.
- [x] **Step 6: Commit the Daybook**
```bash
git add config/dot/quickshell/modules/datemenu \
tests/quickshell/calendar-agenda-contract
git commit -m "Build the Prism calendar Daybook"
```
---
### Task 5: Quiet upcoming-event capsule
**Files:**
- Create: `config/dot/quickshell/modules/bar/CalendarIndicator.qml`
- Modify: `config/dot/quickshell/modules/bar/Bar.qml`
- Modify: `config/dot/quickshell/services/CalendarAgenda.qml`
- Modify: `tests/quickshell/calendar-agenda-contract`
**Interfaces:**
- Consumes: `CalendarAgenda.capsuleVisible`, `capsuleText`, and `nextEvent`.
- Produces: a right-side Pill showing calendar glyph plus `12m` or `Now`.
- Produces: `applyFixture("upcoming")` and `applyFixture("outside-window")` for boundary tests.
- [x] **Step 1: Add failing capsule boundary assertions**
Append:
```bash
qs ipc call calendar-agenda fixture upcoming >/dev/null
jq -e '.capsuleVisible == true and .capsuleText == "12m"' \
<<<"$(qs ipc call calendar-agenda status)" >/dev/null || fail 'upcoming capsule window is wrong'
qs ipc call calendar-agenda fixture outside-window >/dev/null
jq -e '.capsuleVisible == false' <<<"$(qs ipc call calendar-agenda status)" >/dev/null \
|| fail 'capsule appears outside the quiet window'
```
Run and expect RED because those fixtures do not exist.
- [x] **Step 2: Implement deterministic timing fixtures and capsule state**
Add `fixtureNow` to CalendarAgenda. Production uses `Date.now() / 1000`; fixture modes use their explicit clock. `upcoming` creates one timed event exactly 12 minutes ahead. `outside-window` creates one 20 minutes ahead plus an all-day event. Verify all-day never wins `nextEvent`.
Use a one-minute `Timer` only while events are loaded to update relative time. Midnight refresh remains separate. There is no sub-minute repaint.
- [x] **Step 3: Build CalendarIndicator and mount it in Bar**
Use `Pill` with `visible: CalendarAgenda.capsuleVisible`, horizontal padding 8, a calendar Nerd Font glyph, and tabular relative text. `onActivated` opens Agenda. Import `QtQuick.Controls` and use `ToolTip.visible: root.hovered && root.visible` with the next event's title; the title must not otherwise appear in the bar.
Mount the indicator at the beginning of Bar's right row, before ActivityIndicator, so it does not shift the centered clock.
Use a single 120 ms opacity/width entrance tied to visibility. No looping animation.
- [x] **Step 4: Run capsule and full shell contracts**
```bash
tests/quickshell/calendar-agenda-contract
for test in tests/quickshell/*; do "$test"; done
```
Expected: calendar fixture boundaries PASS and every existing Quickshell contract remains green.
- [x] **Step 5: Commit the capsule**
```bash
git add config/dot/quickshell/modules/bar/CalendarIndicator.qml \
config/dot/quickshell/modules/bar/Bar.qml \
config/dot/quickshell/services/CalendarAgenda.qml \
tests/quickshell/calendar-agenda-contract
git commit -m "Add the quiet calendar capsule"
```
---
### Task 6: Documentation, live EDS verification, and delivery
**Files:**
- Modify: `config/dot/hypr/DESKTOP-PARITY.md`
- Modify: `config/dot/hypr/README.md`
- Modify: `setup/packages/hyprland-packages`
- Modify: `docs/superpowers/plans/2026-08-17-calendar-agenda.md`
**Interfaces:**
- Consumes: completed helper, service, Daybook, capsule, and test suite.
- Produces: documented live behavior and a pushed sequence of reviewed commits.
- [x] **Step 1: Update parity and operator documentation**
In DESKTOP-PARITY:
- change Calendar Agenda to Live with EDS/Google/iCloud/Nextcloud wording;
- remove Wi-Fi QR from remaining work;
- remove `qrencode` from the privileged package command;
- keep KDE Connect, Home Assistant, printer UI, and RustDesk lock-screen validation as future work.
In Hyprland README document:
- clock opens Agenda;
- `Super+B` opens Notifications inside the same Daybook;
- event clicks hand off to GNOME Calendar;
- account management remains in GNOME Calendar/Online Accounts;
- the bar capsule's 15-minute to +5-minute window.
Remove `qrencode` from `setup/packages/hyprland-packages` only if `rg -n qrencode .` confirms no remaining runtime consumer.
- [x] **Step 2: Run static verification**
```bash
bash -n tests/quickshell/* config/dot/quickshell/scripts/screen-intelligence
python3 -c 'compile(open("config/dot/quickshell/scripts/calendar-agenda", encoding="utf-8").read(), "calendar-agenda", "exec")'
python3 tests/quickshell/calendar_agenda_bridge_test.py
desktop-file-validate config/local/share/applications/*.desktop
Hyprland --verify-config
git diff --check
```
Expected: every command exits 0 and Hyprland reports `config ok`.
- [x] **Step 3: Run every Quickshell contract**
```bash
for test in tests/quickshell/*; do
"$test"
done
```
Expected: all prior contracts plus both calendar contracts report PASS.
- [x] **Step 4: Verify the live calendar without leaking content**
Reset fixture mode and refresh:
```bash
qs ipc call calendar-agenda reset
qs ipc call calendar-agenda refresh
```
Poll status until `phase` is `ready` or `degraded`. Assert `sourceCount > 0`; report only source/event counts. Open Agenda from the clock path, inspect the real panel locally, test one date containing events, and confirm event clicks launch GNOME Calendar. Do not capture or print a screenshot containing real event details.
Test a Join action only when a recognized URL is already present; opening that URL is an external navigation and requires an explicit click in the local UI. Do not fabricate or modify an event for testing.
- [x] **Step 5: Review logs and the complete diff**
```bash
qs log -n -t 250 --no-color | rg -i 'calendar|qml|error|warn'
git status --short
git diff --stat 5248883..HEAD
git diff --check 5248883..HEAD
```
Expected: no calendar QML errors, no bridge restart loop, no secrets, and only calendar/planned-roadmap changes beyond the already-approved desktop snapshot.
- [x] **Step 6: Mark this plan complete and commit documentation**
Mark completed checkboxes only after their commands actually passed, then:
```bash
git add config/dot/hypr/DESKTOP-PARITY.md \
config/dot/hypr/README.md \
setup/packages/hyprland-packages \
docs/superpowers/plans/2026-08-17-calendar-agenda.md
git commit -m "Document the Panama calendar agenda"
```
- [ ] **Step 7: Push without rewriting history**
```bash
git status --short
git push origin main
```
Expected: clean worktree and `main` advances on `origin`. If Gitea still returns HTTP 502, preserve all commits locally, report the remote outage, and do not force-push or change remote history.