Skip to main content

max / goingson

Add a calendar event surface to go-mcp Six tools: list_events, get_event, create_event, bulk_import_events, update_event, delete_event, gated on go.event.{create,bulk_import,update,delete}. A recurring event is one row carrying a rule; occurrences are materialized per window with synthetic ids. list_events defaults to the series view and expands only when asked, since a session entering data needs to know which rules exist. Both modes union the range query with list_recurring: a series that started before the window has no stored row inside it, and a plain range query would report it absent. bulk_import_events keys on (source, source_ref) through the external-ref columns and skips an existing pair rather than upserting, so a re-run cannot clobber an edit made in the app since. update_event overlays only what it is given, and a start with no end moves the event and carries its duration. Writes run the same validate(), all-day snapping, and reminder sanitizing the desktop command layer runs, so the MCP peer cannot store a row the app would have refused. That needed two helpers in core rather than src-tauri: tz.rs, where a second copy would be a second answer to which zone the user is in, and sanitize_reminder_offsets. ensure_project moved to tools/mod.rs so tasks and events resolve a project name the same way. Bare YYYY-MM-DD means local midnight for events, unlike task due dates, which read it as UTC midnight.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 13:42 UTC
Signed with PGP, not checked
Commit: 989afeabdfb73d452a0a08e2a6354df0c4c67d3d
Parent: a1012eb
24 files changed, +1687 insertions, -112 deletions
M Cargo.lock +2 -1
@@ -2194,6 +2194,7 @@
2194 2194 dependencies = [
2195 2195 "async-trait",
2196 2196 "chrono",
2197 + "chrono-tz",
2197 2198 "goingson-core",
2198 2199 "goingson-db-sqlite",
2199 2200 "kberg",
@@ -2225,6 +2226,7 @@
2225 2226 "async-trait",
2226 2227 "chrono",
2227 2228 "chrono-tz",
2229 + "iana-time-zone",
2228 2230 "painhours",
2229 2231 "serde",
2230 2232 "serde_json",
@@ -2268,7 +2270,6 @@
2268 2270 "goingson-core",
2269 2271 "goingson-db-sqlite",
2270 2272 "hex",
2271 - "iana-time-zone",
2272 2273 "ical",
2273 2274 "icalendar",
2274 2275 "keyring",
M Cargo.toml +1
@@ -18,6 +18,7 @@
18 18 # Core dependencies
19 19 chrono = { version = "0.4.43", features = ["serde"] }
20 20 chrono-tz = "0.10"
21 + iana-time-zone = "0.1"
21 22 uuid = { version = "1.16", features = ["v4", "v5", "serde"] }
22 23 serde = { version = "1.0.228", features = ["derive"] }
23 24 serde_json = "1.0.149"
@@ -46,7 +46,6 @@
46 46 # Utilities
47 47 chrono = { workspace = true }
48 48 chrono-tz = { workspace = true }
49 - iana-time-zone = "0.1"
50 49 uuid = { workspace = true }
51 50
52 51 # Email
@@ -9,6 +9,7 @@
9 9 [dependencies]
10 10 chrono = { workspace = true }
11 11 chrono-tz = { workspace = true }
12 + iana-time-zone = { workspace = true }
12 13 uuid = { workspace = true }
13 14 serde = { workspace = true }
14 15 serde_json = { workspace = true }
@@ -22,6 +22,7 @@
22 22 serde = { workspace = true }
23 23 serde_json = { workspace = true }
24 24 chrono = { workspace = true }
25 + chrono-tz = { workspace = true }
25 26 uuid = { workspace = true }
26 27 async-trait = { workspace = true }
27 28 tracing = { workspace = true }
@@ -20,7 +20,8 @@
20 20 - `--db` defaults to the desktop app's database (`com.goingson.app`'s
21 21 `app_data_dir`). The database must already exist; run GoingsOn once to create
22 22 the schema and the single desktop user.
23 - - Reads (`list_projects`, `list_tasks`, `get_task`) are always callable.
23 + - Reads (`list_projects`, `list_tasks`, `get_task`, `list_events`, `get_event`)
24 + are always callable.
24 25 - Writes are refused unless their capability is granted. Grant them with
25 26 `--grant go.task.bulk_import` (repeatable) or `--grant-all` for a fully-trusted
26 27 local session.
@@ -38,6 +39,10 @@
38 39 row marked `truncated`; `get_task` has the full text. The reply carries
39 40 `total` and, while pages remain, `next_offset`. Walk until it is absent.
40 41 - `get_task(id)`: one task with subtasks and annotations.
42 + - `list_events(from?, to?, project?, recurring_only?, expand?, limit?, offset?)`:
43 + calendar events in a window, paged on the same terms as `list_tasks`. Defaults
44 + to the next 30 days. See "Events and recurrence" below for what `expand` does.
45 + - `get_event(id)`: one event with its full description and recurrence rule.
41 46 - `list_problems(source?, status?, project_id?)`: problems ranked by
42 47 `painhours`, most urgent first. Defaults to `Open`; pass `status: "all"` for
43 48 settled ones too.
@@ -55,11 +60,66 @@
55 60 | `report_problems([...], source?)`, the `/audit` and `/fuzz` primitive | `go.problem.report` |
56 61 | `promote_problem(id, description?, priority?)`, problem to task | `go.problem.promote` |
57 62 | `update_problem(id, {status?, project?})`, triage state and attribution | `go.problem.update` |
63 + | `create_event({title, start, end?, all_day?, project?, location?, recurrence?, block_type?, reminders?})` | `go.event.create` |
64 + | `bulk_import_events([...], source?)`, deduped on `(source, source_ref)` | `go.event.bulk_import` |
65 + | `update_event(id, fields)`, overlays only the fields you pass | `go.event.update` |
66 + | `delete_event(id)` | `go.event.delete` |
58 67
59 68 `bulk_import_tasks` dedupes on a `source:` provenance tag (e.g.
60 69 `source:todo.md:42`), so re-running a migration wave does not double-insert.
61 70 Projects referenced by name are resolved, and created if absent.
62 71
72 + ## Events and recurrence
73 +
74 + A recurring event is stored once, as a series carrying a rule. The occurrences
75 + it implies are not rows: they are expanded on demand for a window, with
76 + synthetic ids that exist only inside the reply that produced them.
77 +
78 + `list_events` exposes both views and `expand` picks between them. Off (the
79 + default), each series is one row with its rule under `recurrence`, including a
80 + rendered `display` string like "Every 2 weeks on Mon, Wed". On, you get the
81 + calendar laid out: every occurrence in the window as its own row marked
82 + `is_recurring_instance`, pointing back at the real row through
83 + `recurrence_parent_id`.
84 +
85 + Series-by-default is deliberate. A session filling in a calendar wants to know
86 + which rules exist, not to read 52 copies of the standup. Feed a synthetic
87 + instance id to `get_event` and it will refuse it, naming the parent id instead.
88 +
89 + The window is not a plain SQL range. A series that started two years ago and
90 + still recurs every Monday has no stored row inside next month, so a range query
91 + alone would report it absent and a session would enter a second copy of it.
92 + Both modes union the range query with the recurring series whose rule actually
93 + reaches the window.
94 +
95 + `bulk_import_events` is the primitive for loading a calendar in bulk. It dedupes
96 + on `(source, source_ref)` through the same external-ref columns an `.ics` import
97 + uses, with `source_ref` defaulting to the title. An item that already exists is
98 + **skipped, not rewritten**: unlike `report_problems`, where a re-run is meant to
99 + refresh a finding's score, a re-run here would clobber whatever you edited in the
100 + app since the last import. Change one deliberately with `update_event`.
101 +
102 + `update_event` overlays only the fields you pass. Passing `start` without `end`
103 + moves the event and carries its duration, rather than leaving the old end behind
104 + and inverting the span. An event that is currently all-day stays all-day across a
105 + move unless you say otherwise. Events synced from an external calendar are
106 + read-only and are refused.
107 +
108 + There is no way to edit a single occurrence of a series. An occurrence is not a
109 + row, so `update_event` on a series rewrites the whole series, and `delete_event`
110 + on one deletes every occurrence it implies.
111 +
112 + Rules have no end condition: `RecurrenceRule` carries a pattern, an interval,
113 + weekdays, and a monthly spec, but no `until` or `count`. Every series recurs
114 + forever. Expansion is windowed so this costs nothing to read, but a series that
115 + really does end has to be edited or deleted when it does.
116 +
117 + Dates cross the wire as RFC 3339 timestamps or bare `YYYY-MM-DD`, which means
118 + **local** midnight in the system zone, not UTC. That differs from `due` on a
119 + task, where a bare date is midnight UTC. A deadline barely cares about the zone;
120 + a day on a calendar does, and reading it as UTC files it on the wrong day for
121 + anyone west of Greenwich.
122 +
63 123 ## Problems
64 124
65 125 GoingsOn is the list of solutions. A *problem* is a candidate for work that came
@@ -17,7 +17,6 @@
17 17 pub mod problems;
18 18 pub mod state;
19 19 pub mod syncstore;
20 - pub mod tz;
21 20
22 21 // Desktop-only: file watching for external DB changes
23 22 #[cfg(not(any(target_os = "ios", target_os = "android")))]
@@ -50,6 +50,7 @@
50 50 pub mod repository;
51 51 pub mod search_parser;
52 52 pub mod text_utils;
53 + pub mod tz;
53 54 pub mod urgency;
54 55 pub mod validation;
55 56 pub mod weekly_review;
@@ -89,8 +90,8 @@
89 90 Subtask, SyncAccount, TOKEN_KIND_COMMIT, Task, TaskFilterQuery, TaskSortColumn, TaskStatus,
90 91 TimeSession, TimeSummaryPanel, TimeSummaryProject, TimeTrackingSummary, TokenState,
91 92 UpdateEmailAccount, UpdateEvent, UpdateProject, UpdateTask, User, ViewFilters, ViewType,
92 - WeeklyReview, format_file_size, mime_from_extension, roll_up_time_summary, snap_all_day_span,
93 - split_description,
93 + WeeklyReview, format_file_size, mime_from_extension, roll_up_time_summary,
94 + sanitize_reminder_offsets, snap_all_day_span, split_description,
94 95 };
95 96 pub use parser::{ParseResult, ParsedTask, parse_quick_add, parse_quick_add_with_warnings};
96 97 pub use recurrence::{
@@ -17,6 +17,10 @@
17 17 pub const TASK_SUBTASK_ADD: &str = "go.task.subtask.add";
18 18 pub const TASK_SUBTASK_UPDATE: &str = "go.task.subtask.update";
19 19 pub const TASK_SUBTASK_DELETE: &str = "go.task.subtask.delete";
20 + pub const EVENT_CREATE: &str = "go.event.create";
21 + pub const EVENT_BULK_IMPORT: &str = "go.event.bulk_import";
22 + pub const EVENT_UPDATE: &str = "go.event.update";
23 + pub const EVENT_DELETE: &str = "go.event.delete";
20 24
21 25 pub fn project_create() -> WriteCapability {
22 26 WriteCapability::new(PROJECT_CREATE, "create a project")
@@ -54,3 +58,18 @@
54 58 pub fn task_subtask_delete() -> WriteCapability {
55 59 WriteCapability::new(TASK_SUBTASK_DELETE, "delete a subtask")
56 60 }
61 + pub fn event_create() -> WriteCapability {
62 + WriteCapability::new(EVENT_CREATE, "create one calendar event")
63 + }
64 + pub fn event_bulk_import() -> WriteCapability {
65 + WriteCapability::new(EVENT_BULK_IMPORT, "create many calendar events in one call")
66 + }
67 + pub fn event_update() -> WriteCapability {
68 + WriteCapability::new(EVENT_UPDATE, "update an existing calendar event")
69 + }
70 + pub fn event_delete() -> WriteCapability {
71 + WriteCapability::new(
72 + EVENT_DELETE,
73 + "delete a calendar event and any series it carries",
74 + )
75 + }
@@ -8,7 +8,9 @@
8 8 use std::path::PathBuf;
9 9
10 10 use goingson_core::UserId;
11 - use goingson_db_sqlite::{SqliteProblemRepository, SqliteProjectRepository, SqliteTaskRepository};
11 + use goingson_db_sqlite::{
12 + SqliteEventRepository, SqliteProblemRepository, SqliteProjectRepository, SqliteTaskRepository,
13 + };
12 14 use sqlx::SqlitePool;
13 15 use uuid::Uuid;
14 16
@@ -43,6 +45,10 @@
43 45 pub fn projects(&self) -> SqliteProjectRepository {
44 46 SqliteProjectRepository::new(self.pool.clone())
45 47 }
48 +
49 + pub fn events(&self) -> SqliteEventRepository {
50 + SqliteEventRepository::new(self.pool.clone())
51 + }
46 52 }
47 53
48 54 /// Default location of `goingson.db`, matching Tauri's `app_data_dir` for the