From a584950a4401868a7c0be9989df8472e4273e5d1 Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:40:03 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(calendar):=20quick=20create,=20and=20t?= =?UTF-8?q?he=20new=20event=20shows=20on=20the=20calendar=20as=20you=20typ?= =?UTF-8?q?e=20=E2=86=92=203.151.0-alpha=20(C10c=20of=20#884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+C opens a small, non-modal quick-create box under "+ New event": type a title and press Enter to create it; the date, the times, all day and the calendar can be changed there, and More options hands the draft (and any retry token held) to the full editor without sending anything. A click outside or Escape closes it only while the title is empty; a click in a picker it opened, or on its ghost, isn't outside. A titled draft that goes with the Calendar tab (a click in the sidebar), nothing sent, is handed back, and the box opens on it again when the calendar does. It registers as a dialog layer, so the calendar's keys, the swipe and Ctrl+K stand down, and while a save is out nothing sends, closes or hands over again. A date its field couldn't read is said under it, naming the field, which is marked wrong; it refuses every save until the field is given a date, a save it stops puts focus there, and More options carries the refusal on. The draft is drawn on the grid as a dashed ghost in its calendar's colour (week, day, month and agenda; not in the Terminal look), and a saved one stays until a read of the mirror that started after the save lands. A screen reader names it as the new event and how far it has got: not saved yet, saving, or one PM may already have made. In Month it takes the top lane over its own days only, so bands in other weeks keep their lanes. Month packs the weeks the ghost touches with it on top and every other week as if it weren't there, so a band that runs into its days keeps its lane elsewhere and changes lane only at the edge of the ghost's weeks. Escape beside a non-modal layer (isKeyForDialogOver): a modal dialog always owns the key, and a non-modal layer such as quick create only a key pressed inside it. So a popover, or Settings' model menu, open beside quick create closes on Escape pressed in its panel, on its trigger or on the page, and Escape on the page still leaves fullscreen. Popover panels are marked with data-popover-panel. Part of #884 Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- 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/tauri.conf.json | 2 +- src/components/ModelPicker.test.tsx | 55 +- src/components/ModelPicker.tsx | 10 +- .../calendar/CalendarHeader.test.tsx | 19 +- src/components/calendar/CalendarHeader.tsx | 17 +- src/components/calendar/CalendarView.test.tsx | 376 ++++++ src/components/calendar/CalendarView.tsx | 239 +++- .../calendar/edit/CalendarPicker.tsx | 12 +- src/components/calendar/edit/EventFields.tsx | 4 + .../calendar/edit/NewEventEditor.test.tsx | 229 +++- .../calendar/edit/NewEventEditor.tsx | 112 +- .../calendar/edit/QuickCreatePopover.test.tsx | 1019 +++++++++++++++++ .../calendar/edit/QuickCreatePopover.tsx | 752 ++++++++++++ src/components/calendar/parts/AllDayBand.tsx | 9 +- src/components/calendar/parts/EventCard.tsx | 14 +- src/components/calendar/parts/EventChip.tsx | 11 +- .../calendar/parts/SourceDot.test.tsx | 16 +- src/components/calendar/parts/SourceDot.tsx | 19 +- .../calendar/parts/useMonthGrid.test.tsx | 113 +- src/components/calendar/parts/useMonthGrid.ts | 29 +- .../calendar/views/AgendaView.test.tsx | 88 ++ src/components/calendar/views/AgendaView.tsx | 20 +- .../calendar/views/MonthView.test.tsx | 171 +++ src/components/calendar/views/MonthView.tsx | 56 +- .../calendar/views/TimeGridView.test.tsx | 61 + .../calendar/views/TimeGridView.tsx | 2 + src/components/ui/Popover.test.tsx | 72 ++ src/components/ui/Popover.tsx | 10 +- src/components/ui/TitleBar.test.tsx | 123 ++ src/components/ui/TitleBar.tsx | 7 +- src/lib/calendar-layout.test.ts | 170 +++ src/lib/calendar-layout.ts | 132 ++- src/lib/calendarEdit/createTargets.test.ts | 25 + src/lib/calendarEdit/createTargets.ts | 27 + src/lib/changelog.ts | 9 + src/lib/help.ts | 2 +- src/lib/useDialogLayer.ts | 32 +- 43 files changed, 3904 insertions(+), 178 deletions(-) create mode 100644 src/components/calendar/CalendarView.test.tsx create mode 100644 src/components/calendar/edit/QuickCreatePopover.test.tsx create mode 100644 src/components/calendar/edit/QuickCreatePopover.tsx create mode 100644 src/components/calendar/views/AgendaView.test.tsx create mode 100644 src/components/calendar/views/MonthView.test.tsx create mode 100644 src/components/ui/TitleBar.test.tsx diff --git a/README.md b/README.md index 9551de69..b6acde8b 100644 --- a/README.md +++ b/README.md @@ -78,8 +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 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 + then you can create events on its calendars (+ New event, or press c; Shift+C opens a quick box, + and in the day, week, month and agenda views the new event shows as you type, except in the + Terminal look), 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 diff --git a/design-system-docs/README.md b/design-system-docs/README.md index 02d4a573..cc1f8ff1 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; "+ 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. +- **Calendar** — week/agenda; "+ New event" (or `c`) creates a Google event, and one you organise can be edited from its details. Shift+C opens quick create, a small non-modal box under the button whose draft is drawn as a dashed ghost (`DRAFT_EVENT_CLASS`: `outline-ink3`, dashed inside the edge with `-outline-offset-1` so a clipping list can't cut it, never greyed as past; a dashed ring among Minimal's month dots) in the day, week, month and agenda views, except in the Terminal look, which draws only what's in the calendar; its accessible name starts with what it is (`draftNote`). 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 d27108a3..b81c75e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.150.0-alpha", + "version": "3.151.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.150.0-alpha", + "version": "3.151.0-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index 4f70c260..5bed7a12 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.150.0-alpha", + "version": "3.151.0-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0790bea6..0c72d3b6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.150.0-alpha" +version = "3.151.0-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5344c28a..c5f6d832 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.150.0-alpha" +version = "3.151.0-alpha" description = "PM — a local-first personal manager (Archivist + Personal Assistant)" authors = ["Bobby Yu"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d1b89227..9fb30896 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.150.0-alpha", + "version": "3.151.0-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/ModelPicker.test.tsx b/src/components/ModelPicker.test.tsx index ada36382..995b8ea4 100644 --- a/src/components/ModelPicker.test.tsx +++ b/src/components/ModelPicker.test.tsx @@ -4,10 +4,13 @@ // ModelPicker is a hand-rolled menu inside the Settings dialog (Settings › AI › "Add a model…"). Its // Escape listener sits on `document` and the dialog's on `window`, so without stopping the key one -// Escape closed the menu AND Settings (or raised the unsaved-changes guard). +// Escape closed the menu AND Settings (or raised the unsaved-changes guard). It follows the Popover +// primitive's rule: a modal dialog over it owns the key, a non-modal layer only a key pressed in it. +import { useRef } from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { useDialogLayer } from "../lib/useDialogLayer"; vi.mock("../lib/ipc", () => ({ listModels: () => Promise.resolve([]), @@ -57,4 +60,54 @@ describe("ModelPicker inside a dialog", () => { fireEvent.keyDown(screen.getByText("Add a model…"), { key: "Escape" }); expect(onDialogClose).toHaveBeenCalledTimes(1); }); + + // The calendar's quick create stays open through a click in the sidebar, so it can sit behind + // Settings: a non-modal layer, which owns only a key pressed inside it. + it("closes only itself on Escape beside a non-modal layer", async () => { + function Layer() { + const ref = useRef(null); + useDialogLayer(true, ref); + return
; + } + const onDialogClose = vi.fn(); + render( + <> + + + {}} triggerLabel="Add a model…" /> + + , + ); + fireEvent.click(screen.getByText("Add a model…")); + const search = await screen.findByPlaceholderText("Search models…"); + fireEvent.keyDown(search, { key: "Escape" }); + await waitFor(() => expect(screen.queryByPlaceholderText("Search models…")).toBeNull()); + expect(onDialogClose).not.toHaveBeenCalled(); + fireEvent.keyDown(screen.getByText("Add a model…"), { key: "Escape" }); + expect(onDialogClose).toHaveBeenCalledTimes(1); + }); + + // A modal dialog opened over the open menu (one of Settings' own questions) holds the keyboard. + it("leaves Escape to a modal dialog opened over it", async () => { + const onDialogClose = vi.fn(); + const onQuestionClose = vi.fn(); + const page = (asking: boolean) => ( + + {}} triggerLabel="Add a model…" /> + {asking && ( + + + + )} + + ); + const { rerender } = render(page(false)); + fireEvent.click(screen.getByText("Add a model…")); + await screen.findByPlaceholderText("Search models…"); + rerender(page(true)); + fireEvent.keyDown(screen.getByText("Reset"), { key: "Escape" }); + expect(onQuestionClose).toHaveBeenCalledTimes(1); + expect(screen.queryByPlaceholderText("Search models…")).not.toBeNull(); + expect(onDialogClose).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 1dcf1ae4..5fe6bfcd 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { listModels } from "../lib/ipc"; -import { isCoveredByDialog } from "../lib/useDialogLayer"; +import { isKeyForDialogOver } from "../lib/useDialogLayer"; import type { ModelInfo } from "../lib/types"; import { Button, Input, Skeleton } from "./ui"; @@ -52,9 +52,11 @@ export function ModelPicker({ value, onChange, triggerLabel = "Choose a model… }; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; - // Same rule as the Popover primitive: a dialog opened over this menu owns the key; otherwise - // the menu closes and the key stops here, so the Settings dialog around it stays open. - if (isCoveredByDialog(rootRef.current)) return; + // Same rule as the Popover primitive: a modal dialog opened over this menu owns the key, and a + // non-modal layer beside it (the calendar's quick create, left open behind Settings) only a key + // pressed inside it; otherwise the menu closes and the key stops here, so the Settings dialog + // around it stays open. + if (isKeyForDialogOver(rootRef.current, e.target)) return; e.stopPropagation(); setOpen(false); }; diff --git a/src/components/calendar/CalendarHeader.test.tsx b/src/components/calendar/CalendarHeader.test.tsx index 2c219bbd..2b7dadfa 100644 --- a/src/components/calendar/CalendarHeader.test.tsx +++ b/src/components/calendar/CalendarHeader.test.tsx @@ -7,6 +7,7 @@ // account is connected, and when no calendar can take an event it stays focusable and says why // rather than going grey and silent. +import { createRef, type RefObject } from "react"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -21,11 +22,17 @@ afterEach(cleanup); const REASON = "None of your calendars can take new events."; -function header(over: { onCreate?: () => void; createReason?: string | null } = {}) { +interface Over { + onCreate?: () => void; + createReason?: string | null; + createRef?: RefObject; +} + +function header(over: Over = {}) { return render(element(over)); } -function element(over: { onCreate?: () => void; createReason?: string | null } = {}) { +function element(over: Over = {}) { return ( { expect(screen.queryByRole("status")).toBeNull(); }); - // One primary button on screen: the editor's Save (C1-L5). + // One primary button on screen: the editor's or quick create's Save (C1-L5). it("New event is a secondary button", () => { for (const createReason of [null, REASON]) { header({ onCreate: vi.fn(), createReason }); @@ -128,6 +135,12 @@ describe("CalendarHeader", () => { expect(screen.getByRole("status").textContent).toBe(""); }); + it("hands quick create the New event button, to open under and give focus back to", () => { + const button = createRef(); + header({ onCreate: vi.fn(), createReason: null, createRef: button }); + expect(button.current).toBe(screen.getByRole("button", { name: "+ New event" })); + }); + 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 3d6d6742..7376b1e4 100644 --- a/src/components/calendar/CalendarHeader.tsx +++ b/src/components/calendar/CalendarHeader.tsx @@ -5,15 +5,16 @@ // 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. +// a new Google event, as `c` does, and sits first, where Google's Create does; Shift+C's quick create +// opens under it (`createRef`). 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 or quick create'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. -import { useEffect, useId, useState } from "react"; +import { useEffect, useId, useState, type Ref } from "react"; import { DAY_COUNT_MAX, DAY_COUNT_MIN, @@ -57,6 +58,8 @@ interface Props { onCreate?: () => void; /** Why no new event can be made now, or null when one can. */ createReason?: string | null; + /** The "+ New event" button: quick create opens under it, and gives focus back to it. */ + createRef?: Ref; } /** How long a click's "why not" stays beside the button. */ @@ -92,6 +95,7 @@ export function CalendarHeader({ lastSync, onCreate, createReason = null, + createRef, }: Props) { const { showMeta } = useDepth(); const { system } = useTheme(); @@ -126,8 +130,9 @@ export function CalendarHeader({ > {onCreate && (
- +