Calendar: note ↔ event links (NotePlan-style, via the URL field) #44

Closed
opened 2026-09-24 15:12:53 +00:00 by kayg · 9 comments
Owner

Owner decision C3 (DESIGN §30): "create an event on any CalDAV server with a deeplink of the note embedded in the URL section of that event" — exactly like NotePlan but using the standard URL property.

  • Action "Create event from note" (and from a log entry): choose account/calendar and time; the event's URL = https://<instance>/n/<calternal-id> (stable across renames; add the /n/<id> redirect route to the note).
  • The note gets an event: frontmatter property (provider account, calendar, UID) pointing back; the popover shows the link both ways.
  • Opening the URL from Apple Calendar lands on the note (web now; universal link later for the Apple app).

Context for the owning job

  • Repo: kayg/calternal (~/Developer/calternal). Read CLAUDE.md, CONTEXT.md and docs/DESIGN.md (§9, §17, §24, §25, §29, §30) first.
  • Vocabulary: a log entry is a retrospective - HH:MM[ - HH:MM] title #tags ^blockid line under ## 📝 Log in a daily note (Notes/Journal/YYYYMMDD-dailynote.md); an event is a scheduled calendar item from an external CalDAV provider. calternal is not a calendar store: log entries are authoritative for calternal's own time data; external events live at their provider.
  • calternal started as a lifelogging app and stays one; Calendar is the lifelog hub (everything by date). The mode tray defaults to Files, Calendar, Photos.
  • Owner rules: file over app; server is the single writer; data loss is unacceptable; performance first but never at the cost of finesse; UI copies Apple Calendar's structure (the owner shared macOS Calendar Week and Day screenshots: Day/Week/Month/Year segmented control top centre, large "September 2026" title with the month bold, ‹ Today › pager top right, all-day lane, hour grid with 09:00-style labels, red circle on today's date, event blocks with a coloured left rule and title/location/time lines, Day view with a mini month and an inspector on the right) plus Fantastical and BusyCal day/week ideas, rendered in the calternal.js design system (copy components verbatim, Claude reviews screenshots side by side); never ship sample/mock data; atomic commits; adversarial testing after API work; good enough, not perfect.
  • Comment on this issue when you start (branch, base SHA), on each finding, when blocked, and when finished (head SHA + gate output). Never close it.
Owner decision C3 (DESIGN §30): "create an event on any CalDAV server with a deeplink of the note embedded in the URL section of that event" — exactly like NotePlan but using the standard URL property. - Action "Create event from note" (and from a log entry): choose account/calendar and time; the event's URL = `https://<instance>/n/<calternal-id>` (stable across renames; add the `/n/<id>` redirect route to the note). - The note gets an `event:` frontmatter property (provider account, calendar, UID) pointing back; the popover shows the link both ways. - Opening the URL from Apple Calendar lands on the note (web now; universal link later for the Apple app). ## Context for the owning job - Repo: kayg/calternal (~/Developer/calternal). Read CLAUDE.md, CONTEXT.md and docs/DESIGN.md (§9, §17, §24, §25, §29, §30) first. - Vocabulary: a **log entry** is a retrospective `- HH:MM[ - HH:MM] title #tags ^blockid` line under `## 📝 Log` in a daily note (`Notes/Journal/YYYYMMDD-dailynote.md`); an **event** is a scheduled calendar item from an external CalDAV provider. calternal is **not** a calendar store: log entries are authoritative for calternal's own time data; external events live at their provider. - calternal started as a lifelogging app and stays one; Calendar is the lifelog hub (everything by date). The mode tray defaults to Files, Calendar, Photos. - Owner rules: file over app; server is the single writer; data loss is unacceptable; performance first but never at the cost of finesse; UI copies Apple Calendar's structure (the owner shared macOS Calendar Week and Day screenshots: Day/Week/Month/Year segmented control top centre, large "September 2026" title with the month bold, ‹ Today › pager top right, all-day lane, hour grid with 09:00-style labels, red circle on today's date, event blocks with a coloured left rule and title/location/time lines, Day view with a mini month and an inspector on the right) plus Fantastical and BusyCal day/week ideas, rendered in the calternal.js design system (copy components verbatim, Claude reviews screenshots side by side); never ship sample/mock data; atomic commits; adversarial testing after API work; good enough, not perfect. - Comment on this issue when you start (branch, base SHA), on each finding, when blocked, and when finished (head SHA + gate output). Never close it.
Author
Owner

Starting issue #44 on branch job/event-links, based on 6a1d2aa7f1771e84cb2fd38c0c019e3df0f0527a. I am tracing the notes journal writer and calendar client first, then I will implement the reciprocal note/event link using the existing writers.

Starting issue #44 on branch `job/event-links`, based on `6a1d2aa7f1771e84cb2fd38c0c019e3df0f0527a`. I am tracing the notes journal writer and calendar client first, then I will implement the reciprocal note/event link using the existing writers.
Author
Owner

The Calendar client already makes provider writes conditionally: new resources use If-None-Match: *, while edits and deletes use the cached ETag with If-Match. A new Event has no prior ETag, so the note/log conversion endpoints will use the existing conditional create method; this avoids inventing an unconditional provider write.

The Calendar client already makes provider writes conditionally: new resources use `If-None-Match: *`, while edits and deletes use the cached ETag with `If-Match`. A new Event has no prior ETag, so the note/log conversion endpoints will use the existing conditional create method; this avoids inventing an unconditional provider write.
Author
Owner

The server's SPA fallback serves index.html for every non-API path, including /n/<calternal-id>. The stable Note deep link therefore already reaches the web app; no server redirect route is needed. Calendar requests will use the configured canonical public URL rather than a request Host header.

The server's SPA fallback serves `index.html` for every non-API path, including `/n/<calternal-id>`. The stable Note deep link therefore already reaches the web app; no server redirect route is needed. Calendar requests will use the configured canonical public URL rather than a request Host header.
Author
Owner

Implementation decision for the unspecified event: property cardinality: Note properties are exposed as scalar strings, so event: stores one canonical Calendar Event URL. Repeating the same link is idempotent; linking a different Event to that Note returns 409 instead of storing a JSON string that would not be a directly usable link. The CalDAV Event's standard URL points to /n/<calternal-id>, and the Note's event: value points to /e/<account-id>/<calendar-id>/<UID>. Both URLs use the configured public instance URL.

Implementation decision for the unspecified `event:` property cardinality: Note properties are exposed as scalar strings, so `event:` stores one canonical Calendar Event URL. Repeating the same link is idempotent; linking a different Event to that Note returns 409 instead of storing a JSON string that would not be a directly usable link. The CalDAV Event's standard `URL` points to `/n/<calternal-id>`, and the Note's `event:` value points to `/e/<account-id>/<calendar-id>/<UID>`. Both URLs use the configured public instance URL.
Author
Owner

The create-from-Note and create-from-Log endpoints take the destination calendar_id, source note_id or log_id, RFC 3339 start and end instants, and an IANA timezone. This keeps Event timing explicit and lets the Calendar Plugin store a zoned CalDAV Event. The generated OpenAPI contract records the request and response types.

The create-from-Note and create-from-Log endpoints take the destination `calendar_id`, source `note_id` or `log_id`, RFC 3339 `start` and `end` instants, and an IANA `timezone`. This keeps Event timing explicit and lets the Calendar Plugin store a zoned CalDAV Event. The generated OpenAPI contract records the request and response types.
Author
Owner

OpenAPI export succeeded once the ignored production UI bundle was built; apps/web/ source stayed unchanged. It showed that the checked-in contract and TypeScript snapshot were behind the live server routes, so regeneration updated both full snapshots (including the new Note/Log Event operations). The generator’s duplicate-operation-ID check passed, and the committed snapshots now pass bash packages/api-client/check-generated.sh (exit 0). Commit: b8387de.

OpenAPI export succeeded once the ignored production UI bundle was built; `apps/web/` source stayed unchanged. It showed that the checked-in contract and TypeScript snapshot were behind the live server routes, so regeneration updated both full snapshots (including the new Note/Log Event operations). The generator’s duplicate-operation-ID check passed, and the committed snapshots now pass `bash packages/api-client/check-generated.sh` (exit 0). Commit: `b8387de`.
Author
Owner

Live adversarial follow-up: POST /api/v1/calendar/events/from-log returned 201, but the source Log's Markdown still contained only its Log line and no Calendar Event child. The Notes writer used replace_log_entry, which updates only the top-level line and drops changes to sub_block_raw; replace_log_entry_block already exists for byte-preserving child writes. I am changing the Journal writer to use that block-preserving helper and adding a regression test. The Log this child links are present in Markdown, so that separate write path is intact.

Live adversarial follow-up: POST `/api/v1/calendar/events/from-log` returned 201, but the source Log's Markdown still contained only its Log line and no Calendar Event child. The Notes writer used `replace_log_entry`, which updates only the top-level line and drops changes to `sub_block_raw`; `replace_log_entry_block` already exists for byte-preserving child writes. I am changing the Journal writer to use that block-preserving helper and adding a regression test. The `Log this` child links are present in Markdown, so that separate write path is intact.
Author
Owner

Completed on branch job/event-links at head 78ff3f1e3db42baa697a56783c4ddd96a2471408.

Issue #44 now creates an Event from a Note or Log, writes its stable source URL into iCalendar, and writes the Calendar Event URL back into Note frontmatter or the Journal child block. Both Note and Log links are queryable. A live probe found that Journal PUT updated only the Log line and dropped child-link edits; the provider now uses the existing child-block writer. The regression test confirms the child is persisted and indexed. The /n/<id> app route already existed, so no server route change was needed.

Gates:

  • cargo fmt --all --check: exit 0; no output.
  • cargo clippy --workspace --all-targets -- -D warnings: exit 0. Output:
    Checking calternal-plugin-notes v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/notes)
   Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/calternal-server)
    Checking calternal-plugin-files v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/files)
    Checking calternal-collab v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/calternal-collab)
    Checking calternal-plugin-calendar v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/calendar)
    Checking calternal-plugin-video v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/video)
    Checking calternal-plugin-notifications v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/notifications)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 23.89s
  • cargo test --workspace: exit 101. Final failure excerpt:
---- wire::tests::full_app_setup_session_config_and_backup stdout ----

thread 'wire::tests::full_app_setup_session_config_and_backup' (3479050) panicked at crates/calternal-server/src/wire.rs:1752:51:
called `Result::unwrap()` on an `Err` value: InvalidEnvironmentValue
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    wire::tests::full_app_setup_session_config_and_backup

test result: FAILED. 11 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s

error: test failed, to rerun pass `-p calternal-server --bin calternal-server`

Notes (29 tests) and Calendar (35 tests plus cache/protocol integration suites) passed. The remaining failure is in the unchanged server setup test, outside this job's allowed server edits.

  • bash packages/api-client/check-generated.sh: exit 0. Output:
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 56s
     Running `target-calendar-test/debug/calternal-server openapi`
$ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts
✨ openapi-typescript 7.13.0
🚀 ../../contracts/openapi.json → src/generated.ts [1.6s]
  • bash tests/adversarial/run.sh: exit 1. The new Calendar flows had no findings. The harness still reports task storm latency and failures from the unrelated archive/purge/transfer/deletion probes. Findings:
==== FINDINGS 17
 - Task storm 5 :: SLOW 7.5s status 201
 - Task storm 8 :: SLOW 5.4s status 201
 - Task storm 9 :: SLOW 6.8s status 201
 - Task storm 10 :: SLOW 5.7s status 201
 - Task storm 11 :: SLOW 7.2s status 201
 - Task storm 12 :: SLOW 9.1s status 201
 - Task storm 13 :: SLOW 9.4s status 201
 - Task storm 14 :: SLOW 8.2s status 201
 - Task storm 15 :: SLOW 9.2s status 201
 - Task storm 16 :: SLOW 9.0s status 201
 - Task storm 17 :: SLOW 9.3s status 201
 - Task storm 18 :: SLOW 11.3s status 201
 - Task storm 19 :: SLOW 11.2s status 201
 - Task storm 20 :: SLOW 12.1s status 201
 - Task storm 21 :: SLOW 12.1s status 201
 - Task storm 22 :: SLOW 12.0s status 201
 - Task storm 23 :: SLOW 11.9s status 201
==== ROUND 2 FINDINGS 13
 - zero-day archive rejected :: expected 400, got 403 b''
 - self-transfer rejected :: expected 400, got 403 b''
 - archive user Home :: expected 204, got 403 b''
 - archive listing :: archived user 01a0d603-b878-761c-9887-34dbd38ae426 is not listed: []
 - archive location :: archived Home remained in the user's live Home tree
 - purge user Home :: expected 204, got 403 b''
 - purge Home :: purged Home remained on disk
 - transfer user Home :: expected 204, got 403 b''
 - transfer location :: transferred Home is missing or changed under the target user's Home
 - deleted user's share :: outgoing Share remained after user deletion
 - deleted user's public link revoked :: expected (404, 410), got 200 b'{"title":"kept.txt","owner_name":"Mallory","kind":"file","size":17,"permissions":{"view":true,"download":true,"upload":false,"edit":false,"linked_notes":false},'
 - deleted user's session revoked :: expected 401, got 200 b'{"id":"01a0d603-aed1-759d-9dc6-5b557f24f61f","username":"mallory","display_name":"Mallory","role":"member","disabled":false}'
 - deleted users :: deleted users remain in admin list: {'01a0d603-b878-761c-9887-34dbd38ae426', '01a0d603-c323-7369-8e7b-da94948927b2', '01a0d603-aed1-759d-9dc6-5b557f24f61f', '01a0d603-9e55-73fa-8305-e36a3eef47df'}

Decisions not covered by DESIGN.md: event: is a scalar URL, so one Note links to one Calendar Event; the configured public URL supplies canonical deep-link origins; an existing different Event link returns conflict instead of replacing it. These choices are also recorded in the implementation comments and tests.

Completed on branch `job/event-links` at head `78ff3f1e3db42baa697a56783c4ddd96a2471408`. Issue #44 now creates an Event from a Note or Log, writes its stable source URL into iCalendar, and writes the Calendar Event URL back into Note frontmatter or the Journal child block. Both Note and Log links are queryable. A live probe found that Journal PUT updated only the Log line and dropped child-link edits; the provider now uses the existing child-block writer. The regression test confirms the child is persisted and indexed. The `/n/<id>` app route already existed, so no server route change was needed. Gates: - `cargo fmt --all --check`: exit 0; no output. - `cargo clippy --workspace --all-targets -- -D warnings`: exit 0. Output: ``` Checking calternal-plugin-notes v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/notes) Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/calternal-server) Checking calternal-plugin-files v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/files) Checking calternal-collab v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/calternal-collab) Checking calternal-plugin-calendar v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/calendar) Checking calternal-plugin-video v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/video) Checking calternal-plugin-notifications v0.0.1 (/home/kayg/Developer/calternal-wt/event-links/crates/plugins/notifications) Finished `dev` profile [unoptimized + debuginfo] target(s) in 23.89s ``` - `cargo test --workspace`: exit 101. Final failure excerpt: ``` ---- wire::tests::full_app_setup_session_config_and_backup stdout ---- thread 'wire::tests::full_app_setup_session_config_and_backup' (3479050) panicked at crates/calternal-server/src/wire.rs:1752:51: called `Result::unwrap()` on an `Err` value: InvalidEnvironmentValue note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: wire::tests::full_app_setup_session_config_and_backup test result: FAILED. 11 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s error: test failed, to rerun pass `-p calternal-server --bin calternal-server` ``` Notes (29 tests) and Calendar (35 tests plus cache/protocol integration suites) passed. The remaining failure is in the unchanged server setup test, outside this job's allowed server edits. - `bash packages/api-client/check-generated.sh`: exit 0. Output: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 56s Running `target-calendar-test/debug/calternal-server openapi` $ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts ✨ openapi-typescript 7.13.0 🚀 ../../contracts/openapi.json → src/generated.ts [1.6s] ``` - `bash tests/adversarial/run.sh`: exit 1. The new Calendar flows had no findings. The harness still reports task storm latency and failures from the unrelated archive/purge/transfer/deletion probes. Findings: ``` ==== FINDINGS 17 - Task storm 5 :: SLOW 7.5s status 201 - Task storm 8 :: SLOW 5.4s status 201 - Task storm 9 :: SLOW 6.8s status 201 - Task storm 10 :: SLOW 5.7s status 201 - Task storm 11 :: SLOW 7.2s status 201 - Task storm 12 :: SLOW 9.1s status 201 - Task storm 13 :: SLOW 9.4s status 201 - Task storm 14 :: SLOW 8.2s status 201 - Task storm 15 :: SLOW 9.2s status 201 - Task storm 16 :: SLOW 9.0s status 201 - Task storm 17 :: SLOW 9.3s status 201 - Task storm 18 :: SLOW 11.3s status 201 - Task storm 19 :: SLOW 11.2s status 201 - Task storm 20 :: SLOW 12.1s status 201 - Task storm 21 :: SLOW 12.1s status 201 - Task storm 22 :: SLOW 12.0s status 201 - Task storm 23 :: SLOW 11.9s status 201 ==== ROUND 2 FINDINGS 13 - zero-day archive rejected :: expected 400, got 403 b'' - self-transfer rejected :: expected 400, got 403 b'' - archive user Home :: expected 204, got 403 b'' - archive listing :: archived user 01a0d603-b878-761c-9887-34dbd38ae426 is not listed: [] - archive location :: archived Home remained in the user's live Home tree - purge user Home :: expected 204, got 403 b'' - purge Home :: purged Home remained on disk - transfer user Home :: expected 204, got 403 b'' - transfer location :: transferred Home is missing or changed under the target user's Home - deleted user's share :: outgoing Share remained after user deletion - deleted user's public link revoked :: expected (404, 410), got 200 b'{"title":"kept.txt","owner_name":"Mallory","kind":"file","size":17,"permissions":{"view":true,"download":true,"upload":false,"edit":false,"linked_notes":false},' - deleted user's session revoked :: expected 401, got 200 b'{"id":"01a0d603-aed1-759d-9dc6-5b557f24f61f","username":"mallory","display_name":"Mallory","role":"member","disabled":false}' - deleted users :: deleted users remain in admin list: {'01a0d603-b878-761c-9887-34dbd38ae426', '01a0d603-c323-7369-8e7b-da94948927b2', '01a0d603-aed1-759d-9dc6-5b557f24f61f', '01a0d603-9e55-73fa-8305-e36a3eef47df'} ``` Decisions not covered by DESIGN.md: `event:` is a scalar URL, so one Note links to one Calendar Event; the configured public URL supplies canonical deep-link origins; an existing different Event link returns conflict instead of replacing it. These choices are also recorded in the implementation comments and tests.
Author
Owner

Completed on dev in b05051e41e (Merge job/event-links: note/log ↔ event links and "Log this" (#44, #47)).

Completed on dev in b05051e41e1163d0f4aad094ba7ac2dd28f21552 (Merge job/event-links: note/log ↔ event links and "Log this" (#44, #47)).
kayg closed this issue 2026-10-01 05:08:47 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#44
No description provided.