From f33aef7d9700fd2207dca3bcf503e58e18509385 Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:38:19 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat(calendar):=20create=20events=20in=20yo?= =?UTF-8?q?ur=20Google=20calendars=20=E2=86=92=203.150.0-alpha=20(C10b=20o?= =?UTF-8?q?f=20#884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "+ New event" in the calendar header, and the c key, open the full editor in create mode: a calendar picker (writable calendars on editing-on accounts, grouped by account), the last calendar created in as the default, Google's defaultEventLength per account (read at connect and at each sync), and notices for a start outside the synced months or a hidden calendar. Save, Ctrl/Cmd+S and Enter in the title share one saving guard; an answer that never arrived locks the draft as sent and offers Check again under the same retry token, and outlives the editor as a notice. Release builds no longer refuse the create command. Part of #884 Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- SYNC-SET.md | 2 +- design-system-docs/README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/calendar.rs | 85 ++- src-tauri/src/commands/calendar_edit.rs | 48 +- src-tauri/src/commands/calendars.rs | 52 +- src-tauri/tauri.conf.json | 2 +- src/components/CalendarConnection.tsx | 4 +- .../calendar/CalendarHeader.test.tsx | 83 +++ src/components/calendar/CalendarHeader.tsx | 67 +- src/components/calendar/CalendarView.tsx | 91 ++- .../calendar/edit/CalendarPicker.test.tsx | 232 +++++++ .../calendar/edit/CalendarPicker.tsx | 165 +++++ src/components/calendar/edit/EventEditor.tsx | 334 ++-------- src/components/calendar/edit/EventFields.tsx | 311 ++++++++++ .../calendar/edit/NewEventEditor.test.tsx | 439 ++++++++++++++ .../calendar/edit/NewEventEditor.tsx | 570 ++++++++++++++++++ .../calendar/edit/WriteNotices.test.tsx | 37 ++ src/components/calendar/edit/WriteNotices.tsx | 25 +- .../calendar/edit/useEventWrites.test.ts | 205 +++++++ .../calendar/edit/useEventWrites.ts | 120 +++- src/lib/calendarEdit/createTargets.test.ts | 225 +++++++ src/lib/calendarEdit/createTargets.ts | 149 +++++ src/lib/calendarEdit/editReasons.test.ts | 123 ++++ src/lib/calendarEdit/editReasons.ts | 155 +++++ src/lib/calendarEdit/eventDraft.test.ts | 161 +++++ src/lib/calendarEdit/eventDraft.ts | 145 +++++ src/lib/calendarNavKey.test.ts | 23 + src/lib/calendarNavKey.ts | 17 +- src/lib/calendarPrefs.test.ts | 61 +- src/lib/calendarPrefs.ts | 13 + src/lib/changelog.ts | 8 + src/lib/help.ts | 8 +- src/lib/ipc.ts | 3 +- 38 files changed, 3606 insertions(+), 377 deletions(-) create mode 100644 src/components/calendar/CalendarHeader.test.tsx create mode 100644 src/components/calendar/edit/CalendarPicker.test.tsx create mode 100644 src/components/calendar/edit/CalendarPicker.tsx create mode 100644 src/components/calendar/edit/EventFields.tsx create mode 100644 src/components/calendar/edit/NewEventEditor.test.tsx create mode 100644 src/components/calendar/edit/NewEventEditor.tsx create mode 100644 src/lib/calendarEdit/createTargets.test.ts create mode 100644 src/lib/calendarEdit/createTargets.ts diff --git a/README.md b/README.md index 4d0aa5ec..9551de69 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ not an exhaustive list; the in-app **What's New** is the complete, current recor own working hours — with schedule-aware chat answers and an automatic "Due soon" when an event names a project. Your milestones and pinboard timelines show alongside as markers you can toggle off. PM only reads your calendars unless you turn on editing for a Google account; - then you can change and delete events you organise without guests, repeating ones included. - Every change - goes straight to Google, and a save never overwrites a field someone changed there in the - meantime. + then you can create events on its calendars (+ New event, or press c), and change and delete + events you organise without guests, repeating ones included. Every change goes straight to + Google, a save never overwrites a field someone changed there in the meantime, and a new + event whose save loses the connection is looked for in Google before it's ever sent again. - **Bring your own model.** Choose your model through OpenRouter — separate chat and background models, spend at a glance, and zero-data-retention requested on every call. The list offers only models a provider will actually serve on those terms, so a model can't diff --git a/SYNC-SET.md b/SYNC-SET.md index ee2de2a6..280cb261 100644 --- a/SYNC-SET.md +++ b/SYNC-SET.md @@ -45,7 +45,7 @@ Two rules that fall out of the classes: | `conversations` | truth | Chat titles and project scope. The turn text is authoritatively in the vault; this row is not recomputable from it alone. | | `messages` | truth | Bodies mirror the vault, but `citations`, `retrieved_chunk_ids` and `model` exist only here. | | `chat_sessions` | derived | Session↔document mapping, rolling summary, cursors, last prompt size. Re-derivable — but regenerating the summary is a **billable** model call. | -| `settings` | mixed | Key-namespaced grab-bag. Truth: user preferences (retrieval k, reranking, backup frequency/retention), the per-Google-account calendar editing choice (`calendar_editing`, keyed by account email; editing also needs the account's keychain token, so a restore onto another machine shows it as needing Google's permission until reconnected) **and the three webview blobs that hold user content** — `milestone_ui` (the per-project milestone sort, keyed by PROJECT NAME, plus the show-completed toggle), `calendar_ui` (the hidden-calendar set, whose ids are routinely the account's email address) and `backup_ui` (dismissed reconcile banners, keyed by destination + cloud account). Those three moved out of the webview's plaintext `localStorage` and are allowlisted to the webview in `settings.rs::WEBVIEW_PREFS`; they each get their own key because `project_ui` is rewritten whole on every divider drag. Truth too, briefly: `calendar_split_journal`, the record of a "this and following" calendar change from just before its first write until it's settled (Google calendar and event ids, which can hold the account's address, the original's repeat rules and start, and a plan digest; never a title or description). A `.pmbackup` carries it verbatim; PM reads Google again before acting on any entry, so a stale or restored one can only refuse a change until Google shows otherwise, and is never sent anywhere. Device: local model scan dir, external CLI paths, sync cursors, last-run timestamps, the On battery policy (`local_llm_power_threshold`, `local_llm_power_roles`, `local_llm_power_cloud_consent`) and `local_llm_battery_idle_minutes` — about this machine's battery and consent to bill this device's key; a `.pmbackup` still carries them verbatim. Derived: the briefing cache, layout caches, the retrieval config stamp, the filing seed vocabulary (`filing_seed_vocabulary` — machine-guessed from the unreviewed backlog, regenerable, and travels verbatim in a `.pmbackup`), `calendar_default_length`, each Google account's default event length (minutes), keyed by account email and read from Google at each sync (from C10b of #884; until then nothing writes it but a disconnect clearing it); a `.pmbackup` carries it verbatim. **There is no key-prefix convention today** — see Open decisions. | +| `settings` | mixed | Key-namespaced grab-bag. Truth: user preferences (retrieval k, reranking, backup frequency/retention), the per-Google-account calendar editing choice (`calendar_editing`, keyed by account email; editing also needs the account's keychain token, so a restore onto another machine shows it as needing Google's permission until reconnected) **and the three webview blobs that hold user content** — `milestone_ui` (the per-project milestone sort, keyed by PROJECT NAME, plus the show-completed toggle), `calendar_ui` (the hidden-calendar set and the last calendar an event was created in, whose ids are routinely the account's email address) and `backup_ui` (dismissed reconcile banners, keyed by destination + cloud account). Those three moved out of the webview's plaintext `localStorage` and are allowlisted to the webview in `settings.rs::WEBVIEW_PREFS`; they each get their own key because `project_ui` is rewritten whole on every divider drag. Truth too, briefly: `calendar_split_journal`, the record of a "this and following" calendar change from just before its first write until it's settled (Google calendar and event ids, which can hold the account's address, the original's repeat rules and start, and a plan digest; never a title or description). A `.pmbackup` carries it verbatim; PM reads Google again before acting on any entry, so a stale or restored one can only refuse a change until Google shows otherwise, and is never sent anywhere. Device: local model scan dir, external CLI paths, sync cursors, last-run timestamps, the On battery policy (`local_llm_power_threshold`, `local_llm_power_roles`, `local_llm_power_cloud_consent`) and `local_llm_battery_idle_minutes` — about this machine's battery and consent to bill this device's key; a `.pmbackup` still carries them verbatim. Derived: the briefing cache, layout caches, the retrieval config stamp, the filing seed vocabulary (`filing_seed_vocabulary` — machine-guessed from the unreviewed backlog, regenerable, and travels verbatim in a `.pmbackup`), `calendar_default_length`, each Google account's default event length (minutes), keyed by account email and read from Google when an account connects and at each sync (a read that fails keeps the last value; a disconnect clears it); a `.pmbackup` carries it verbatim. **There is no key-prefix convention today** — see Open decisions. | | `projects` | truth | Triage the user set: deadline, size, blocked-by, parent, importance, active date. | | `project_milestones` | truth | Multi-deadline project structure, incl. status and external-source anchoring. | | `project_activity` | truth | An emit-only historical record of what happened when. Nothing can recompute a past event. | diff --git a/design-system-docs/README.md b/design-system-docs/README.md index 4f99081b..02d4a573 100644 --- a/design-system-docs/README.md +++ b/design-system-docs/README.md @@ -164,7 +164,7 @@ Drive it with a single `depth` value; gate optional blocks on it. Don't fork lay Slate = cards; Terminal = table. Depth scales it from name+status only → +meta → full table with cost/active. - **Project** — file list beside a project-scoped chat thread (two-pane). Treatment per System. - **Documents** — ingestion: a drop zone + recent items with type/date/index-state. Rules / cards / mono table per System. -- **Calendar** — week/agenda; a Google event you organise can be edited from its details. Serif agenda / day cards / mono list per System. +- **Calendar** — week/agenda; "+ New event" (or `c`) creates a Google event, and one you organise can be edited from its details. Serif agenda / day cards / mono list per System. - **Review** — queue of proposed actions as inline approval cards (Approve / Edit / Dismiss). - **Settings** — appearance (System / Accent / Depth / Mode), models & keys (key field → keychain), help-mode toggle. Editorial = ruled sections, Slate = card panel, Terminal = key/value list. - **Command palette** (`⌘K`) — overlay with a query line + result rows. diff --git a/package-lock.json b/package-lock.json index 646ebc3b..d27108a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.149.1-alpha", + "version": "3.150.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.149.1-alpha", + "version": "3.150.0-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index bf6f2651..4f70c260 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.149.1-alpha", + "version": "3.150.0-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b55d2532..0790bea6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.149.1-alpha" +version = "3.150.0-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c10ddd4c..5344c28a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.149.1-alpha" +version = "3.150.0-alpha" description = "PM — a local-first personal manager (Archivist + Personal Assistant)" authors = ["Bobby Yu"] edition = "2021" diff --git a/src-tauri/src/calendar.rs b/src-tauri/src/calendar.rs index d0dc5308..28734139 100644 --- a/src-tauri/src/calendar.rs +++ b/src-tauri/src/calendar.rs @@ -266,10 +266,41 @@ fn primary_calendar_id(value: &serde_json::Value) -> Option { /// `settings` key: `{ "": minutes }`, Google's default length for a new /// event on each account. The `calendar_editing` shape: one key for every account, so a change is -/// one read-modify-write under the DB lock. Read from Google at each sync from C10b; until then -/// nothing stores one, and a disconnect only clears it. +/// one read-modify-write under the DB lock. Read from Google when an account connects and at each +/// sync ([`fetch_default_length`]); a disconnect clears it. pub const DEFAULT_LENGTH_KEY: &str = "calendar_default_length"; +/// Google's default length for a new event on the account `token_key` signs in as, in minutes +/// (`settings.get('defaultEventLength')`, readable with the read-only calendar permission every +/// account has), or `None` when it can't be read. Network only: callers hold no lock across it. +pub async fn fetch_default_length(token_key: &str) -> Option { + let url = format!("{CALENDAR_API}/users/me/settings/defaultEventLength"); + let value = google::authorized_get(token_key, &url).await.ok()?; + parse_default_length(&value) +} + +/// `{"value": "30", …}` is 30. Google documents a number of minutes, as a string; anything else +/// (missing, not a string, not a number, zero, or over a day) reads as `None`, and the editor keeps +/// its hour. +pub fn parse_default_length(v: &serde_json::Value) -> Option { + v.get("value")? + .as_str()? + .trim() + .parse::() + .ok() + .filter(|m| (1..=1440).contains(m)) +} + +/// Store what a read of one account's default length found, under the caller's lock: a value Google +/// gave replaces the last one; a read that failed (`None`) changes nothing, so the last good value +/// stays rather than falling back to an hour. +pub fn remember_default_length(conn: &Connection, email: &str, read: Option) -> Result<()> { + match read { + Some(minutes) => set_default_length(conn, email, Some(minutes)), + None => Ok(()), + } +} + /// Every account's stored default length. A value PM can't read is none at all: the editor then /// uses an hour, as it would before the first sync. pub fn default_lengths(conn: &Connection) -> Result> { @@ -3531,4 +3562,54 @@ mod tests { clear_default_lengths(&conn).unwrap(); assert!(default_lengths(&conn).unwrap().is_empty()); } + + #[test] + fn default_length_reads_googles_minutes_string() { + use serde_json::json; + assert_eq!(parse_default_length(&json!({ "value": "30" })), Some(30)); + assert_eq!(parse_default_length(&json!({ "value": " 45 " })), Some(45)); + assert_eq!( + parse_default_length(&json!({ "kind": "calendar#setting", "value": "1440" })), + Some(1440) + ); + } + + #[test] + fn an_odd_default_length_reads_as_none() { + use serde_json::json; + let odd = [ + json!({ "value": "0" }), + json!({ "value": "-5" }), + json!({ "value": "1441" }), + json!({ "value": "half an hour" }), + json!({ "value": "" }), + json!({ "value": 30 }), + json!({ "kind": "calendar#setting" }), + json!("30"), + ]; + for (i, v) in odd.iter().enumerate() { + assert_eq!(parse_default_length(v), None, "case {i}"); + } + } + + /// A sync whose read failed leaves the last good length, never the hour the editor falls back to. + #[test] + fn a_failed_default_length_read_keeps_the_last_one() { + let dir = tempfile::tempdir().unwrap(); + let conn = crate::db::open(&dir.path().join("pm.sqlite"), DB_KEY).unwrap(); + remember_default_length(&conn, "me@example.com", Some(30)).unwrap(); + remember_default_length(&conn, "me@example.com", None).unwrap(); + assert_eq!( + default_lengths(&conn).unwrap().get("me@example.com"), + Some(&30) + ); + remember_default_length(&conn, "Me@Example.com", Some(25)).unwrap(); + assert_eq!( + default_lengths(&conn).unwrap().get("me@example.com"), + Some(&25) + ); + // Nothing read for an account with none stored stores nothing. + remember_default_length(&conn, "new@example.com", None).unwrap(); + assert_eq!(default_lengths(&conn).unwrap().len(), 1); + } } diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index 2dbb8aeb..ad64960a 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -849,8 +849,6 @@ async fn save_event( // --- creating an event (C10) --- -/// C10a only: release builds refuse creates until the UI ships (C10b). -const CREATE_NOT_YET: &str = "Creating events from PM isn't available yet."; /// A calendar PM doesn't sync: a new event there would land nothing in the mirror. const NOT_SYNCED: &str = "PM doesn't sync this calendar, so it won't create events in it. Choose \ it in Settings › Connectors first."; @@ -902,12 +900,6 @@ pub async fn create_calendar_event( ) -> Result { require_main_window(&window)?; require_owner(&app)?; - // C10a only (§16): no UI creates yet, so release builds refuse; C10b removes this. - if !cfg!(debug_assertions) { - return Ok(WriteOutcome::Failed { - message: CREATE_NOT_YET.into(), - }); - } let state = app.state::(); let target = match resolve_calendar( &*state.conn()?, @@ -14129,10 +14121,10 @@ mod tests { assert!(host.nothing_recorded()); } - /// C10a ships the create command inert: a release build refuses it before reading anything, - /// and only after the window and owner checks every write makes (C10 §16). + /// Creating events ships in every build (C10b): the command makes the window and owner checks + /// every write makes before it reads anything or sends, and no build refuses it outright. #[test] - fn the_create_command_is_refused_in_release_builds_until_its_ui_ships() { + fn the_create_command_checks_the_window_and_owner_before_anything_else() { let source = include_str!("calendar_edit.rs"); let shipped = source .split("#[cfg(test)]\nmod tests {") @@ -14144,19 +14136,39 @@ mod tests { let shell = &shipped[start..]; let shell = &shell[..shell.find("\n}\n").unwrap_or(shell.len())]; let at = |needle: &str| shell.find(needle).unwrap_or(usize::MAX); - let gate = at("if !cfg!(debug_assertions) {"); assert!( - at("require_owner(&app)?;") < gate, - "the gate comes after the window and owner checks" + at("require_main_window(&window)?;") < at("require_owner(&app)?;") + && at("require_owner(&app)?;") < at("state.conn()") + && at("state.conn()") < at("create_event(&ctx"), + "the window and owner checks come before reading anything or sending" ); assert!( - gate < at("message: CREATE_NOT_YET.into()") - && at("message: CREATE_NOT_YET.into()") < at("state.conn()") - && at("state.conn()") < at("create_event(&ctx"), - "a release build refuses before reading anything or sending" + !shell.contains("if !cfg!(debug_assertions)"), + "no build refuses creating events outright" ); } + /// The webview ends a retry token on two of the create's refusals, which it knows only by their + /// words: its copies of them must be these very strings (C10 critiques: the token drops only on + /// saved, gone, these two). + #[test] + fn the_webview_knows_the_words_that_end_a_retry_token() { + let words = include_str!("../../../src/lib/calendarEdit/editReasons.ts"); + for (name, text) in [ + ("CREATE_LOST", CREATE_LOST), + ("CREATE_CHANGED", CREATE_CHANGED), + ] { + let line = format!("export const {name} ="); + let at = words.find(&line).expect("the webview names it"); + let rest = &words[at..]; + let decl = &rest[..rest.find(";\n").unwrap_or(rest.len())]; + assert!( + decl.contains(&format!("\"{text}\"")), + "{name}: the webview's copy differs from the backend's" + ); + } + } + /// The retry's table: two empty looks before a re-send, a cancelled copy gone, anything it /// can't read keeping the create held. #[test] diff --git a/src-tauri/src/commands/calendars.rs b/src-tauri/src/commands/calendars.rs index 5eb976d5..9e465801 100644 --- a/src-tauri/src/commands/calendars.rs +++ b/src-tauri/src/commands/calendars.rs @@ -219,8 +219,13 @@ async fn do_connect_google_calendar( secrets::set_google_client_for_account(&email, id, secret)?; } google::save_consented_token(&google_calendar_token_key(&email), &token).await?; + // Google's default length for a new event, read now so a new account has it before its first + // full sync (C10b); one that can't be read leaves any stored one, and the editor uses an hour. + let default_length = calendar::fetch_default_length(&google_calendar_token_key(&email)).await; let conn = state.conn()?; calendar::upsert_source(&conn, &account, "google", Some(&email), &email)?; + // A nicety, so a store that can't take it never fails the connect. + let _ = calendar::remember_default_length(&conn, &email, default_length); // A read-only connect asked Google for reading only, so it turns editing off even when the // account had it: only the "Turn on editing" consent sets the choice. calendar_editing::set_choice(&conn, &email, None)?; @@ -805,7 +810,8 @@ fn write_fetched_events( /// truncated page-run or an unreachable account can never delete a real calendar (its selected/quiet /// choices and mirrored events). Best-effort per account: a failed list fetch is skipped here, and the /// account's state is still settled by the event-sync pass. Never holds the DB lock across a fetch -/// (rule #4). ICS feeds carry no separate list to reconcile (one feed is one calendar). +/// (rule #4). ICS feeds carry no separate list to reconcile (one feed is one calendar). A Google +/// account whose list came back also has its default event length read again, for new events. async fn reconcile_calendar_lists(app: &AppHandle) { let accounts: Vec = { let state = app.state::(); @@ -834,19 +840,37 @@ async fn reconcile_calendar_lists(app: &AppHandle) { let Ok((items, complete)) = fetched else { continue; }; - let state = app.state::(); - let Ok(conn) = state.conn() else { - continue; - }; - let _ = calendar::register_calendars( - &conn, - &acc.id, - &acc.provider, - &items, - complete, - // A newly-discovered calendar is shown by default (selected); the user can untick it. - |_| true, - ); + { + let state = app.state::(); + let Ok(conn) = state.conn() else { + continue; + }; + let _ = calendar::register_calendars( + &conn, + &acc.id, + &acc.provider, + &items, + complete, + // A newly-discovered calendar is shown by default (selected); the user can untick it. + |_| true, + ); + } + if acc.provider == "google" { + // Google's default length for a new event (A28, C10b): one more GET per account beside + // the list's, read with no lock held. A failed read keeps the last good value. + let read = calendar::fetch_default_length(&google_calendar_token_key(&email)).await; + let state = app.state::(); + let Ok(conn) = state.conn() else { + continue; + }; + // Disconnected during the read: its length went with it, and stays gone. + let connected = calendar::list_sources(&conn, Some("google")) + .map(|sources| sources.iter().any(|a| a.id == acc.id)) + .unwrap_or(false); + if connected { + let _ = calendar::remember_default_length(&conn, &email, read); + } + } } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 60cad018..d1b89227 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PM", - "version": "3.149.1-alpha", + "version": "3.150.0-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/CalendarConnection.tsx b/src/components/CalendarConnection.tsx index 1b8a3b64..1a51ebba 100644 --- a/src/components/CalendarConnection.tsx +++ b/src/components/CalendarConnection.tsx @@ -621,12 +621,12 @@ const EDITING_COPY: Record< { text: string; action: string; enable: boolean; paused?: boolean } > = { off: { - text: "Read-only. Turn on editing to change and delete this account’s events from PM. Google asks you to allow two things: leave both ticked.", + text: "Read-only. Turn on editing to create, change and delete this account’s events from PM. Google asks you to allow two things: leave both ticked.", action: "Turn on editing…", enable: true, }, on: { - text: "Editing on: PM can change and delete events you organise without guests on this account’s calendars, repeating ones included.", + text: "Editing on: PM can create events on this account’s calendars, and change and delete events you organise without guests there, repeating ones included.", action: "Turn off editing", enable: false, paused: true, diff --git a/src/components/calendar/CalendarHeader.test.tsx b/src/components/calendar/CalendarHeader.test.tsx new file mode 100644 index 00000000..eb5897ed --- /dev/null +++ b/src/components/calendar/CalendarHeader.test.tsx @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +// @vitest-environment jsdom +// +// The header's one control that changes a calendar (#884, C10): "+ New event" shows once a Google +// account is connected, and when no calendar can take an event it stays focusable and says why +// rather than going grey and silent. + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../theme/ThemeContext", async (importOriginal) => ({ + ...(await importOriginal()), + useTheme: () => ({ system: "slate", depth: "standard" }), +})); + +import { CalendarHeader } from "./CalendarHeader"; + +afterEach(cleanup); + +const REASON = "None of your calendars can take new events."; + +function header(over: { onCreate?: () => void; createReason?: string | null } = {}) { + render( + , + ); +} + +describe("CalendarHeader", () => { + it("New event is disabled with its reason when no calendar can take one", () => { + const onCreate = vi.fn(); + header({ onCreate, createReason: REASON }); + const button = screen.getByRole("button", { name: "+ New event" }); + // Still focusable, so a keyboard reaches the reason; marked, not disabled. + expect(button.disabled).toBe(false); + expect(button.getAttribute("aria-disabled")).toBe("true"); + const described = document.getElementById(button.getAttribute("aria-describedby") ?? ""); + expect(described?.textContent).toBe(REASON); + expect(screen.getByRole("status").textContent).toBe(""); + // A click says why, and starts nothing. + fireEvent.click(button); + expect(onCreate).not.toHaveBeenCalled(); + expect(screen.getByRole("status").textContent).toBe(REASON); + }); + + it("New event starts one when a calendar can take it", () => { + const onCreate = vi.fn(); + header({ onCreate, createReason: null }); + const button = screen.getByRole("button", { name: "+ New event" }); + expect(button.getAttribute("aria-disabled")).toBeNull(); + expect(button.getAttribute("aria-keyshortcuts")).toBe("c"); + fireEvent.click(button); + expect(onCreate).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("has no New event without a Google account", () => { + header(); + expect(screen.queryByRole("button", { name: "+ New event" })).toBeNull(); + }); +}); diff --git a/src/components/calendar/CalendarHeader.tsx b/src/components/calendar/CalendarHeader.tsx index 62db52be..a5fe13f9 100644 --- a/src/components/calendar/CalendarHeader.tsx +++ b/src/components/calendar/CalendarHeader.tsx @@ -1,14 +1,19 @@ // SPDX-FileCopyrightText: 2026 Bobby Yu // SPDX-License-Identifier: AGPL-3.0-or-later -// The calendar's shared chrome — one header, no second sidebar. View switcher (active = accent), -// ‹ Today › nav, the date-range label, the Calendars dropdown, and Refresh-now. +// The calendar's shared chrome — one header, no second sidebar. "+ New event", ‹ Today › nav, the +// date-range label, the view switcher (active = accent), and Refresh-now. +// +// "+ New event" (#884, C10) is the one control here that changes a calendar: it opens the editor for +// a new Google event, as `c` does, and sits first, where Google's Create does. It shows once a Google +// account is connected; when no calendar can take an event it stays focusable and says why (its +// tooltip, and a click), rather than going grey and silent. It's secondary, so the editor's Save stays +// the one primary button on screen. Everything else here only changes what you see. // // The last-synced time rides ON the Refresh button rather than beside it: it is the one fact that -// tells you whether pressing Refresh is worth it. The old separate "Read-only" badge is gone — it was -// a static, prop-free span nothing derived from, and the calendar has no edit affordances to -// disambiguate; the fact is still stated in the help card, the empty state and the Connectors copy. +// tells you whether pressing Refresh is worth it. +import { useEffect, useId, useState } from "react"; import { DAY_COUNT_MAX, DAY_COUNT_MIN, @@ -18,7 +23,7 @@ import { } from "../../lib/calendarPrefs"; import { formatSyncedShort, formatWhen } from "../../lib/format"; import { useDepth, useTheme, type Coords } from "../../theme"; -import { Button, SegmentedControl, Select, type SegOption } from "../ui"; +import { Button, SegmentedControl, Select, Tooltip, VisuallyHidden, type SegOption } from "../ui"; import { MiniCalendarPopover } from "./MiniCalendarPopover"; import { RangeControl } from "./RangeControl"; @@ -48,8 +53,15 @@ interface Props { onRefresh: () => void; syncing: boolean; lastSync: string | null; + /** Start a new event. Absent: no Google account is connected, and there's no button. */ + onCreate?: () => void; + /** Why no new event can be made now, or null when one can. */ + createReason?: string | null; } +/** How long a click's "why not" stays beside the button. */ +const SAID_MS = 6000; + const VIEW_LABEL: Record = { month: "Month", week: "Week", @@ -78,9 +90,23 @@ export function CalendarHeader({ onRefresh, syncing, lastSync, + onCreate, + createReason = null, }: Props) { const { showMeta } = useDepth(); const { system } = useTheme(); + const reasonId = useId(); + // A click on New event while none can be made: the reason, said beside it for a moment (a + // tooltip shows only on hover or focus, and a click doesn't focus a button in WebKit). + const [said, setSaid] = useState(false); + useEffect(() => { + if (!said) return; + const t = setTimeout(() => setSaid(false), SAID_MS); + return () => clearTimeout(t); + }, [said]); + useEffect(() => { + if (createReason === null) setSaid(false); + }, [createReason]); // The last successful sync: clock time if it was today, else the date. Null when unset/unparseable. const synced = lastSync ? formatSyncedShort(lastSync) || null : null; // The Work/Day/24h scale only drives the pixel time-grid (Slate/Editorial). Terminal renders @@ -97,6 +123,35 @@ export function CalendarHeader({ className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-rule px-4 py-2" data-help="calendar-header" > + {onCreate && ( +
+ + + + {createReason !== null && ( + <> + {createReason} + + {said ? createReason : ""} + + + )} +
+ )} +
); diff --git a/src/components/calendar/edit/CalendarPicker.test.tsx b/src/components/calendar/edit/CalendarPicker.test.tsx new file mode 100644 index 00000000..101c0b78 --- /dev/null +++ b/src/components/calendar/edit/CalendarPicker.test.tsx @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +// @vitest-environment jsdom +// +// The calendar a new event goes to (#884, C10): only calendars PM may create on, grouped by account +// as radios, the accounts that can't named by why, a calendar that can't hold a private event greyed +// out for a private draft, and a hidden one still offered and marked. + +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Calendar, CalendarAccount, CalendarOverview } from "../../../lib/types"; +import { createTargets } from "../../../lib/calendarEdit/createTargets"; + +vi.mock("../../../theme/ThemeContext", async (importOriginal) => ({ + ...(await importOriginal()), + useTheme: () => ({ system: "slate", depth: "standard" }), +})); + +import { CalendarPicker } from "./CalendarPicker"; + +const ME = "gcal:me@x.com"; +const WORK = "gcal:work@x.com"; + +function account(id: string, provider = "google"): CalendarAccount { + const email = id.slice(id.indexOf(":") + 1); + return { id, provider, email, label: email, state: "ok", last_synced_at: null }; +} + +function calendar(source: string, remote: string, over: Partial = {}): Calendar { + return { + id: `${source}:${remote}`, + source_id: source, + provider: "google", + remote_id: remote, + name: remote, + color: null, + selected: true, + is_primary: false, + quiet: false, + kind: null, + access_role: "owner", + time_zone: "Europe/London", + default_reminders: [], + conference_types: [], + data_owner: null, + ...over, + }; +} + +const overview: CalendarOverview = { + google_client_configured: true, + microsoft_client_configured: false, + accounts: [ + account(ME), + account(WORK), + account("gcal:off@x.com"), + account("gcal:paused@x.com"), + account("gcal:consent@x.com"), + account("ics:feed", "other"), + ], + calendars: [ + calendar(ME, "me@x.com", { name: "Personal", is_primary: true }), + calendar(ME, "family", { name: "Family", access_role: "writerWithoutPrivateAccess" }), + calendar(ME, "holidays", { name: "Holidays", access_role: "reader" }), + calendar(ME, "old", { name: "Old project", selected: false }), + calendar(WORK, "work@x.com", { name: "Work", is_primary: true }), + calendar("gcal:off@x.com", "off@x.com", { name: "Off account" }), + calendar("ics:feed", "feed", { name: "Feed", provider: "other", access_role: null }), + ], + last_sync: null, + window_days: 7, + mirror_start: "2026-09-01T00:00:00Z", + mirror_end: "2027-11-01T00:00:00Z", + editing: { + [ME]: "on", + [WORK]: "on", + "gcal:paused@x.com": "paused", + "gcal:consent@x.com": "needs_consent", + }, + default_length: {}, +}; + +// jsdom ships no ResizeObserver, and the list's popover (escapeClipping) builds one to re-place +// itself; placement isn't what these assert. +beforeAll(() => { + if (!("ResizeObserver" in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +afterEach(cleanup); + +function open(over: Partial[0]> = {}) { + const onChange = vi.fn(); + render( +