docs/plans/2026-10-08-agenda-redesign.md
116 lines · 7646 bytes
1# Agenda Redesign: Shared Model
2
3The Mac (`Sources/Orgstar/AgendaView.swift`) and iOS (`Sources/OrgstarMobile/AgendaScreen.swift`) agendas are rebuilt on one platform-neutral row model. Item selection stays `Agenda.list`, oracle-tested against Emacs; only the settings below change which items appear.
4
5## Row anatomy
6
7`AgendaRow` (`Sources/OrgPresentation/AgendaRows.swift`):
8
9| Part | Field |
10| --- | --- |
11| Gutter: time or short status | `gutter`, `status`, `icon` |
12| Category line above the title | `category` (entry category, or the event's calendar title) |
13| Title without keyword, `[#A]`, statistics cookies, timestamps, tags; links as descriptions | `title` |
14| Tags | `tags` (inherited first) |
15| TODO keyword | `todo`, `isDone` |
16| Priority letter | `priority` |
17| Statistics cookie | `progress` (`1/3`, `50%`) |
18| Times, `HHMM` | `start`, `end` |
19| Deadline / scheduled day | `planningDay` (`Days` numbering) |
20| Source | `item: AgendaItem?` (for the existing actions), `event: AgendaEvent?`, `isEvent`, `color` (calendar color) |
21
22`AgendaRow.init?(item:day:today:)` returns nil for time-grid and current-time items.
23
24## Gutter rules
25
26Timed rows show `08:00` or `13:50–14:30` (zero-padded, en dash; past midnight wraps). Untimed rows:
27
28| Kind | Gutter | Status |
29| --- | --- | --- |
30| Deadline, on today | `Due today` | due-soon |
31| Deadline, on today, past | `3d late` | overdue |
32| Deadline, on today, upcoming | `Due in 2d` | due-soon |
33| Deadline, on another day | `Due` | overdue if before today, else normal |
34| Scheduled | `Scheduled` | overdue if before today, else normal |
35| Scheduled, carried to today | `Sched. 4d ago` | overdue |
36| Habit | `Habit` | overdue if scheduled before today |
37| Timestamp, diary sexp | `All day` | normal |
38| Range, middle days | `Day 2/5` | normal |
39| Log mode | `Closed`, `Clocked`, `State` (when untimed) | normal |
40| TODO / match list | empty, no icon | normal |
41| Calendar event | `All day`, times, `22:00` on the start day of a multi-day event, `Until 02:00` on its last day | normal |
42
43A done keyword makes the status `done`. Icons: `scheduled`, `deadline`, `habit`, `event`, `timestamp`.
44
45## Ages
46
47`AgendaAge.compact(days)` (`Sources/OrgCore/Agenda/AgendaItemText.swift`): under 14 days `3d`; under 60 whole weeks `2w`; under 365 30-day months `4mo` (at most 11); then 365-day years with remaining months, `1y`, `1y 4mo`. Rounds down; sign ignored.
48
49## Grouping
50
51`AgendaRows.groups(days, events:, today:, showAllDates:, calendar:, keep:) -> [AgendaDayGroup]`, or `AgendaModel.groups(showAllDates:keep:)`.
52
53- Days with no rows are dropped unless `org-agenda-show-all-dates`. Today always stays (`isEmpty` true for the empty state).
54- On today, past-scheduled entries and past deadlines (not habits) go in `overdue`, oldest `planningDay` first, Org's order for ties. Oldest first was chosen over Org's urgency order so the list reads as a backlog.
55- `rows` keep Org's order (`habit-down time-up urgency-down`). All-day events go first, as calendars show them; timed events go among timed entries by start, after entries at the same time and before untimed entries and habits.
56- `keep` is the `AgendaFilter`; events are never filtered.
57- Row ids are unique within a group.
58
59## Now marker
60
61`AgendaDayGroup.nowIndex(now: HHMM) -> Int?`: draw the line before `rows[index]`. It goes before the first timed row starting after now, or after the last timed row when all have started. Nil on other days and when no row is timed (Org adds the line only with timed entries). Rows starting exactly at now count as started.
62
63## Settings (config.toml)
64
65| Key | Default | Read with |
66| --- | --- | --- |
67| `org-agenda-show-all-dates` | `false` (Org's default is `t`) | `OrgPreferences.agendaShowAllDates()` |
68| `org-agenda-skip-scheduled-if-done` | `false` (Org's) | `OrgPreferences.agendaOptions(_:)` |
69| `org-agenda-skip-deadline-if-done` | `false` (Org's) | `OrgPreferences.agendaOptions(_:)` |
70| `[orgstar] calendar-events` | `false` | `OrgPreferences.calendarEvents()` |
71| `[orgstar] calendar-event-calendars` | `""` (all), comma-separated titles or identifiers | `OrgPreferences.calendarEventCalendars()` |
72
73The skip settings are `AgendaOptions.skipScheduledIfDone` / `skipDeadlineIfDone`, oracle-tested. With both nil (the default), done entries show only on their own date, as before. Both screens already apply them through `OrgPreferences.agendaOptions`.
74
75`SKIPPED` in the user's files is not a TODO keyword: `org-todo-keywords` is `TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL` and the files have no `#+TODO`. Org shows such headings as plain past-scheduled entries too. Adding `SKIPPED` after the `|` makes it a done keyword, and the entries leave the agenda.
76
77## Theme keys
78
79New keys in `ThemeSpec.slots`, with light and dark defaults: `todo-next`, `todo-waiting`, `todo-cancelled` (with the existing `todo` and `done`), `priority-a`, `priority-b`, `priority-c` (others fall back to `priority`), `agenda-overdue`, `agenda-due-soon`, `agenda-event`. Today's header stays `agenda-today`. `[theme.category]` sets category colors by name, as `[theme.todo]` sets keyword colors.
80
81Helpers on `ThemeSpec`:
82
83- `TodoClass.of(keyword, isDone:)`: `next` for NEXT, STRT, STARTED, START, DOING, ACTIVE, INPROGRESS; `waiting` for WAIT, WAITING, HOLD, ONHOLD, BLOCKED, DEFERRED, SOMEDAY, MAYBE; `cancelled` for done keywords KILL, KILLED, CANCELLED, CANCELED, CANCEL, SKIPPED, SKIP, ABORTED, NO, WONTFIX; else `todo` / `done`.
84- `todoColor(keyword, isDone:, dark:)`: `[theme.todo]` first, then the class key.
85- `priorityColor(letter, dark:)`, `statusColor(status, dark:)` (`normal` uses `agenda-time`), `eventColor(event, dark:)`.
86- `categoryColor(name, dark:)`: `[theme.category]`, else `categoryHue(name)` (FNV-1a of the UTF-8, mod 360, stable across runs) at HSL saturation 0.60 / lightness 0.40 in light, 0.65 / 0.70 in dark.
87
88## Calendar events
89
90`CalendarEvents` (`Sources/OrgApp/CalendarEvents.swift`) wraps `EKEventStore`:
91
92- `requestAccess() async -> Bool` uses `requestFullAccessToEvents`. EventKit has no read-only level, so reading needs full access. `CalendarEvents.isAuthorized`.
93- `events(from:to:chosen:) -> [AgendaEvent]` and `calendars()` for a picker.
94- `CalendarEventRecord` is what conversion reads; `EKEvent` conforms, and tests use plain values.
95
96`AgendaModel.calendarEvents` (nil reads none) and `calendarNames`: `refresh` fetches events for the agenda span into `AgendaModel.events`. The screens set both from the settings and call `requestAccess()` when `calendar-events` is on.
97
98`NSCalendarsFullAccessUsageDescription` is in `ios/Orgstar/Info.plist`, `scripts/build-ios-app.sh` and `scripts/build-app.sh`. The Mac app is not sandboxed, so it needs no calendar entitlement.
99
100## For the screen rebuilds
101
102```swift
103agenda.calendarEvents = OrgPreferences.calendarEvents() ? CalendarEvents() : nil // keep one instance
104agenda.calendarNames = OrgPreferences.calendarEventCalendars()
105let groups = agenda.groups(showAllDates: OrgPreferences.agendaShowAllDates(), keep: filter.keeps)
106for group in groups {
107 // group.header / group.date(calendar:), group.isToday, group.isEmpty
108 // group.overdue (today), group.rows, group.nowIndex(now:)
109}
110// row.gutter colored by theme.statusColor(row.status, dark:), icon by row.icon,
111// category tint theme.categoryColor(row.category, dark:) or theme.eventColor(row.event!, dark:),
112// keyword theme.todoColor(row.todo!, isDone: row.isDone, dark:), priority theme.priorityColor(_:dark:).
113// Actions take row.item as before.
114```
115
116The TODO and match views (`agenda.list`) can use `AgendaRow(item:day:today:)` per item with `day: agenda.today`.