diff --git a/README.md b/README.md index b6acde8b..eabdebc9 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ not an exhaustive list; the in-app **What's New** is the complete, current recor 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; 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 + Terminal look), repeating ones too (Google's choices, or your own with Custom…), 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. diff --git a/package-lock.json b/package-lock.json index 155f25b2..1df6f4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.152.1-alpha", + "version": "3.153.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.152.1-alpha", + "version": "3.153.0-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index 53e8e37b..def2699d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.152.1-alpha", + "version": "3.153.0-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bd5de3f8..0c6174bf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.152.1-alpha" +version = "3.153.0-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b2dbebca..ead2a9ea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.152.1-alpha" +version = "3.153.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 57497bb5..120968ac 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.152.1-alpha", + "version": "3.153.0-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/CalendarConnection.tsx b/src/components/CalendarConnection.tsx index 1a51ebba..81f4492c 100644 --- a/src/components/CalendarConnection.tsx +++ b/src/components/CalendarConnection.tsx @@ -626,7 +626,7 @@ const EDITING_COPY: Record< enable: true, }, on: { - 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.", + text: "Editing on: PM can create events, one-off or repeating, 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/CalendarView.test.tsx b/src/components/calendar/CalendarView.test.tsx index 16c85dbf..3dbfdce6 100644 --- a/src/components/calendar/CalendarView.test.tsx +++ b/src/components/calendar/CalendarView.test.tsx @@ -44,6 +44,8 @@ const ipc = vi.hoisted(() => ({ updateCalendarEvent: vi.fn(), eventFlags: vi.fn(), openUrl: vi.fn(), + recurrencePresets: vi.fn(), + describeRecurrence: vi.fn(), })); // What the views and the header are shown: the System, the hidden calendars, and what the Year view // was handed (it lays nothing out without a measured pane, so it's a stand-in that keeps its events). @@ -208,6 +210,8 @@ beforeEach(() => { ipc.listHeldDeletes.mockResolvedValue([]); ipc.onCalendarDeleteSettled.mockResolvedValue(() => {}); ipc.eventFlags.mockResolvedValue([]); + // The Repeat row in quick create and the full editor: no ready-made rules, so just its own rows. + ipc.recurrencePresets.mockResolvedValue([]); }); afterEach(() => { diff --git a/src/components/calendar/edit/CalendarPicker.test.tsx b/src/components/calendar/edit/CalendarPicker.test.tsx index a0763f97..5cd8c579 100644 --- a/src/components/calendar/edit/CalendarPicker.test.tsx +++ b/src/components/calendar/edit/CalendarPicker.test.tsx @@ -253,6 +253,17 @@ describe("CalendarPicker", () => { ); }); + // A click on a Mac leaves focus where it was (jsdom's click does the same). + it("Escape closes the list with focus on the button, even after a click that didn't focus it", async () => { + const { trigger, list } = open(); + await waitFor(() => + expect(document.activeElement).toBe(within(list).getByRole("radio", { name: "Personal" })), + ); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + expect(screen.queryByRole("group", { name: "Calendars that can take a new event" })).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + it("closes when it's disabled, and doesn't come back by itself", () => { const props = { targets: createTargets(overview), diff --git a/src/components/calendar/edit/CalendarPicker.tsx b/src/components/calendar/edit/CalendarPicker.tsx index 37126063..dedfcf1f 100644 --- a/src/components/calendar/edit/CalendarPicker.tsx +++ b/src/components/calendar/edit/CalendarPicker.tsx @@ -111,7 +111,12 @@ export function CalendarPicker({ aria-label={`Calendar: ${label}`} aria-expanded={expanded} disabled={disabled} - onClick={toggle} + onClick={() => { + // A click on a Mac leaves focus where it was. The button takes it, so the list's Escape + // hands it back here. + triggerRef.current?.focus(); + toggle(); + }} > {picked && ( ({ + ...(await importOriginal()), + useTheme: () => ({ system: "slate", depth: "standard" }), +})); + +import { Dialog } from "../../ui"; +import { CustomRecurrenceDialog } from "./CustomRecurrenceDialog"; + +// jsdom ships no ResizeObserver, and the end date's picker (escapeClipping) builds one. +beforeAll(() => { + if (!("ResizeObserver" in globalThis)) { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +}); + +afterEach(() => { + cleanup(); + vi.resetAllMocks(); +}); + +const T2 = "2026-10-13"; // a Tuesday + +function open(rule: RepeatRule | null = null) { + const onDone = vi.fn(); + const onClose = vi.fn(); + render(); + return { onDone, onClose }; +} + +const done = () => screen.getByRole("button", { name: "Done" }); +const interval = () => screen.getByLabelText("Repeat every"); +const unit = () => screen.getByLabelText("Unit"); +const count = () => screen.getByLabelText("Occurrences"); +const endDate = () => screen.getByLabelText("End date"); +const day = (name: string) => screen.getByRole("button", { name }); + +/** Type `text` into a date field and leave it, as the user would. */ +function typeDate(input: HTMLInputElement, text: string) { + input.focus(); + fireEvent.change(input, { target: { value: text } }); + fireEvent.blur(input); +} + +describe("CustomRecurrenceDialog", () => { + it("focus starts on Repeat every", () => { + open(); + expect(screen.getByRole("dialog", { name: "Custom recurrence" })).toBeTruthy(); + expect(document.activeElement).toBe(interval()); + }); + + it("After 731 is rejected and Done stays disabled", () => { + const { onDone } = open(); + fireEvent.click(screen.getByLabelText("After")); + fireEvent.change(count(), { target: { value: "731" } }); + expect( + screen.getByText("Google allows at most 730 occurrences of a repeating event."), + ).toBeTruthy(); + expect(done().disabled).toBe(true); + fireEvent.click(done()); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("After 730 is accepted", () => { + const { onDone } = open(); + // The count is off until its row is chosen. + expect(count().disabled).toBe(true); + fireEvent.click(screen.getByLabelText("After")); + expect(count().disabled).toBe(false); + fireEvent.change(count(), { target: { value: "730" } }); + fireEvent.click(done()); + expect(onDone).toHaveBeenCalledWith({ + pattern: { kind: "weekly", days: ["tue"] }, + interval: 1, + ends: { kind: "after", count: 730 }, + }); + // "occurrence" for one. + fireEvent.change(count(), { target: { value: "1" } }); + expect(screen.getByText("occurrence")).toBeTruthy(); + }); + + // The end date has no lower limit: a date before the start is said, not silently moved to it + // (C11 critiques C1-M5, C2-M3). + it("an Ends date before the start is rejected", () => { + const { onDone } = open(); + expect(endDate().disabled).toBe(true); + fireEvent.click(screen.getByLabelText("On")); + typeDate(endDate(), "01-10-2026"); + expect(endDate().value).toBe("01-10-2026"); + expect(screen.getByText("The end date is before the first occurrence.")).toBeTruthy(); + expect(done().disabled).toBe(true); + // On the start day itself, it's fine. + typeDate(endDate(), "13-10-2026"); + fireEvent.click(done()); + expect(onDone).toHaveBeenCalledWith({ + pattern: { kind: "weekly", days: ["tue"] }, + interval: 1, + ends: { kind: "on_date", date: T2 }, + }); + }); + + // The field shows its date again after text it can't read: Done must not end the rule on it. + it("an end date it can't read waits until it's given one", () => { + const { onDone } = open(); + fireEvent.click(screen.getByLabelText("On")); + typeDate(endDate(), "31-02-2027"); + expect( + screen.getByText( + "End date: “31-02-2027” isn't a date PM can read. Type the end date again as dd-mm-yyyy, or pick it from the calendar.", + ), + ).toBeTruthy(); + expect(endDate().getAttribute("aria-invalid")).toBe("true"); + expect(done().disabled).toBe(true); + // Ending another way doesn't need the date. + fireEvent.click(screen.getByLabelText("Never")); + expect(done().disabled).toBe(false); + fireEvent.click(screen.getByLabelText("On")); + expect(done().disabled).toBe(true); + typeDate(endDate(), "20-12-2026"); + fireEvent.click(done()); + expect(onDone).toHaveBeenCalledWith( + expect.objectContaining({ ends: { kind: "on_date", date: "2026-12-20" } }), + ); + }); + + it("weekday toggles are aria-pressed and turning the last one off says to pick one", () => { + open(); + expect(screen.getByRole("group", { name: "Repeat on" })).toBeTruthy(); + // Monday first; the start's own day is on. + const names = Array.from( + screen.getByRole("group", { name: "Repeat on" }).querySelectorAll("button"), + ).map((b) => [b.textContent, b.getAttribute("aria-label"), b.getAttribute("aria-pressed")]); + expect(names).toEqual([ + ["M", "Monday", "false"], + ["T", "Tuesday", "true"], + ["W", "Wednesday", "false"], + ["T", "Thursday", "false"], + ["F", "Friday", "false"], + ["S", "Saturday", "false"], + ["S", "Sunday", "false"], + ]); + fireEvent.click(day("Tuesday")); + expect(day("Tuesday").getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByText("Pick at least one day of the week.")).toBeTruthy(); + expect(done().disabled).toBe(true); + fireEvent.click(day("Friday")); + fireEvent.click(day("Monday")); + expect(done().disabled).toBe(false); + }); + + it("the unit's plural follows the interval", () => { + open(); + const units = () => Array.from(unit().options).map((o) => o.text); + expect(units()).toEqual(["day", "week", "month", "year"]); + fireEvent.change(interval(), { target: { value: "2" } }); + expect(units()).toEqual(["days", "weeks", "months", "years"]); + fireEvent.change(interval(), { target: { value: "100" } }); + expect(screen.getByText("Repeat every 1 to 99.")).toBeTruthy(); + expect(done().disabled).toBe(true); + }); + + it("Done hands back the rule; Cancel hands back nothing", () => { + const first = open(); + fireEvent.change(interval(), { target: { value: "2" } }); + fireEvent.click(day("Monday")); + fireEvent.click(done()); + expect(first.onDone).toHaveBeenCalledWith({ + pattern: { kind: "weekly", days: ["mon", "tue"] }, + interval: 2, + ends: { kind: "never" }, + }); + expect(first.onClose).not.toHaveBeenCalled(); + cleanup(); + const second = open(); + fireEvent.change(unit(), { target: { value: "year" } }); + expect(screen.getByText("Annually on 13 October")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(second.onClose).toHaveBeenCalledTimes(1); + expect(second.onDone).not.toHaveBeenCalled(); + }); + + it("Escape closes only the custom dialog", () => { + const outerClose = vi.fn(); + function Harness() { + const [custom, setCustom] = useState(true); + return ( + +

The editor

+ {custom && ( + setCustom(false)} + onClose={() => setCustom(false)} + /> + )} +
+ ); + } + render(); + expect(screen.getByRole("dialog", { name: "Custom recurrence" })).toBeTruthy(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Custom recurrence" })).toBeNull(); + expect(outerClose).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "New event" })).toBeTruthy(); + }); + + it("opening on a monthly last-Friday rule keeps that choice", () => { + const { onDone } = open({ + pattern: { kind: "monthly_weekday", nth: "last", weekday: "fri" }, + interval: 1, + ends: { kind: "never" }, + }); + const monthly = screen.getByLabelText("Monthly on"); + expect(Array.from(monthly.options).map((o) => o.text)).toEqual([ + "Monthly on day 13", + "Monthly on the second Tuesday", + "Monthly on the last Friday", + ]); + expect(monthly.selectedOptions[0].text).toBe("Monthly on the last Friday"); + // Picking another and back keeps it on offer. + fireEvent.change(monthly, { target: { value: "0" } }); + fireEvent.change(monthly, { target: { value: "2" } }); + fireEvent.click(done()); + expect(onDone).toHaveBeenCalledWith({ + pattern: { kind: "monthly_weekday", nth: "last", weekday: "fri" }, + interval: 1, + ends: { kind: "never" }, + }); + }); +}); diff --git a/src/components/calendar/edit/CustomRecurrenceDialog.tsx b/src/components/calendar/edit/CustomRecurrenceDialog.tsx new file mode 100644 index 00000000..3c2ad329 --- /dev/null +++ b/src/components/calendar/edit/CustomRecurrenceDialog.tsx @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Google Calendar's "Custom recurrence" dialog, in PM's words (#884, C11): repeat every N days, weeks, +// months or years; on the weekdays chosen, or the month's day or numbered weekday; ending never, on a +// date, or after a number of times. Done hands the rule back and changes nothing else; Cancel, ✕ +// and Escape hand back nothing. +// +// It is rendered inside the editor's own dialog, so it alone is topmost: one Escape closes only it, +// and Tab stays in it. Where it differs from Google's: the weekdays start on Monday, as PM's weeks +// do; and the end date has no lower limit, so a date before the start is said rather than silently +// moved to it (C11 critiques C1-M5, C2-M3). What the fields can't make is said in one line, with +// the backend's own words, and Done waits until it's fixed. + +import { useId, useState } from "react"; +import type { DayCode, RepeatRule } from "../../../lib/types"; +import { + formFromRule, + monthlyChoices, + monthlyLabel, + repeatProblemText, + ruleFromForm, + WEEKDAYS, + yearlyLabel, + type RepeatForm, + type RepeatUnit, +} from "../../../lib/calendarEdit/recurrenceDraft"; +import { badDateText } from "../../../lib/calendarEdit/editReasons"; +import { Button, Dialog, Select, cn } from "../../ui"; +import { DateField } from "../../DateField"; + +interface Props { + /** The rule to start from (the Repeat row's), or null for Google's defaults. */ + rule: RepeatRule | null; + /** The event's start date (YYYY-MM-DD): the defaults, the monthly choices and the yearly date + * come from it. */ + startDate: string; + onDone: (rule: RepeatRule) => void; + onClose: () => void; +} + +const UNITS: { value: RepeatUnit; one: string; many: string }[] = [ + { value: "day", one: "day", many: "days" }, + { value: "week", one: "week", many: "weeks" }, + { value: "month", one: "month", many: "months" }, + { value: "year", one: "year", many: "years" }, +]; + +/** A small number field, in the editor's field tokens. */ +const NUMBER = + "w-16 rounded-[var(--radius-sm)] border border-border2 bg-surface px-2 py-1 text-sm text-ink2 outline-none transition focus:border-accent disabled:cursor-not-allowed disabled:bg-transparent disabled:text-ink3"; + +/** Rendered only while open (the parent mounts it), so its fields start afresh each time. */ +export function CustomRecurrenceDialog({ rule, startDate, onDone, onClose }: Props) { + const [first] = useState(() => formFromRule(rule, startDate)); + const [form, setForm] = useState(first); + // Text the end date field couldn't read: it shows its date again, so Done waits until it's given + // one, typed or picked, rather than ending on a date the user was changing. + const [badEnd, setBadEnd] = useState(null); + const id = useId(); + const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + const made = ruleFromForm(form, startDate); + const problem = + form.ends === "on_date" && badEnd !== null + ? badDateText("End date", badEnd) + : made.ok + ? null + : repeatProblemText(made.problem); + const one = form.interval.trim() === "1"; + // The rule's own monthly choice stays on offer when the start date's are others (a rule from + // another app, or Google's own "last" on a day outside the month's last week). + const monthly = monthlyChoices(startDate, first.monthly); + const monthlyIndex = monthly.findIndex((c) => + c.by === "day" + ? form.monthly.by === "day" && form.monthly.day === c.day + : form.monthly.by === "weekday" && + form.monthly.nth === c.nth && + form.monthly.weekday === c.weekday, + ); + const problemId = `${id}-problem`; + const ends = `${id}-ends`; + + function toggle(day: DayCode) { + set({ + days: form.days.includes(day) ? form.days.filter((d) => d !== day) : [...form.days, day], + }); + } + + function done() { + if (made.ok && problem === null) onDone(made.rule); + } + + return ( + + + + + } + > +
+
+ + set({ interval: e.currentTarget.value })} + /> + +
+ + {form.unit === "week" && ( +
+ Repeat on +
+ {WEEKDAYS.map((d) => { + const on = form.days.includes(d.code); + return ( + + ); + })} +
+
+ )} + + {form.unit === "month" && ( + + )} + + {/* Google's dialog offers no yearly choices: the date comes from the start. */} + {form.unit === "year" &&

{yearlyLabel(form.yearly, startDate)}

} + +
+ Ends +
+ set({ ends: "never" })} + /> + +
+
+ set({ ends: "on_date" })} + /> + + setBadEnd(null)} + onCommit={(date) => set({ date })} + /> +
+
+ set({ ends: "after" })} + /> + + set({ count: e.currentTarget.value })} + /> + + {form.count.trim() === "1" ? "occurrence" : "occurrences"} + +
+
+ + {/* Always mounted, so a screen reader hears the line as it comes and goes. */} +
+ {problem !== null && ( +

+ {problem} +

+ )} +
+
+
+ ); +} diff --git a/src/components/calendar/edit/EventFields.tsx b/src/components/calendar/edit/EventFields.tsx index cee52eab..e35e4cd2 100644 --- a/src/components/calendar/edit/EventFields.tsx +++ b/src/components/calendar/edit/EventFields.tsx @@ -3,9 +3,11 @@ // The event fields both editors share (#884): changing an event (`EventEditor`) and creating one // (`NewEventEditor`) show the same title, When, zone and choice controls, so they live here once. +// The Repeat row is here too, for creating (C11): the full editor and quick create both draw it. // // Time fields never use `` (WebKitGTK has no widget): TimeField and DateField. +import type { ReactNode } from "react"; import type { EventVisibility, ShowAs, TimeDraft } from "../../../lib/types"; import { dateFieldId, @@ -14,10 +16,18 @@ import { type EndChoice, } from "../../../lib/calendarEdit/eventDraft"; import { badDateText } from "../../../lib/calendarEdit/editReasons"; +import { + movedStartText, + startDateOf, + type RepeatValue, +} from "../../../lib/calendarEdit/recurrenceDraft"; +import { cityOf } from "../../../lib/zoneLabel"; import { Button, Input, SegmentedControl, Select, Textarea, Toggle, useFieldA11y } from "../../ui"; import { DateField } from "../../DateField"; import { TimeField } from "../../TimeField"; import { ZoneSearchList } from "../ZoneSearchList"; +import { RecurrenceSelect } from "./RecurrenceSelect"; +import type { RepeatState } from "./useRepeat"; export type Timed = Extract; @@ -198,6 +208,7 @@ export function WhenFields({ onSwitchKind, canSwitchKind, onPickZone, + repeat, }: { /** What its date fields' ids start with (`dateFieldId`), so a save can find one. */ id: string; @@ -222,6 +233,9 @@ export function WhenFields({ onSwitchKind: () => void; canSwitchKind: boolean; onPickZone: (which: "both" | "start" | "end") => void; + /** The Repeat row (`RepeatRow`), drawn last: Google puts "Does not repeat" under the date and + * time, and inside this fieldset it is off whenever the times are. */ + repeat?: ReactNode; }) { const t = draft.time; const names = DATE_FIELD[t.kind]; @@ -239,7 +253,8 @@ export function WhenFields({ onAccept: () => onAcceptDate(half), }); return ( -
+ // Never wider than the editor: a fieldset is as wide as its widest content unless told otherwise. +
When