diff --git a/DESIGN.md b/DESIGN.md index ea425f90..9e322aae 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -493,6 +493,24 @@ Today Search More through and unfilled, and says "Declined" before its time. Working-location entries are not events and are not drawn. One calendar that cannot be read shows the failure and leaves the other calendars' events in place. +- Fill means the time is held. An invitation not yet answered is an outline in + its calendar's tone and says "Not answered"; one answered maybe is drawn as a + tentative event and says "Maybe". A timed event Google marks free has a + lighter fill and says "Free"; all-day events, free by default, are left + alone. Out-of-office time is hatched, a broken bar in Agenda, and says "Out + of office"; focus time says "Focus time". A note is left out when the title + already says it. These notes sit before the time in a row and in the + accessible name. +- An event with a video call shows a small camera beside its title, and its + editor offers "Join meeting" with the host the link leads to, since the link + comes with the invitation. "Open in Google Calendar" leads only to Google + Calendar's own pages. Both open outside the app. Rows stay one control: the + links are in the editor, not in the row. +- Events are read again when the person returns to the app after a minute or + more away, and every five minutes while a calendar is in front of them. A + later read that cannot reach Google leaves what is shown in place. +- When Google access has lapsed the calendar says so once and points to + Settings, where the connection shows "Needs reconnecting". - A timed event lasting twenty-four hours or more is drawn with the all-day entries in every layout, as Google Calendar draws it, and names its start time where its bar begins. Its times change in the event editor, not by diff --git a/docs/live-testing.md b/docs/live-testing.md index 052fdfa5..ba96b3d4 100644 --- a/docs/live-testing.md +++ b/docs/live-testing.md @@ -32,6 +32,14 @@ The browser suite is the small trust core, not a substitute for exercising the a middle and the last day, and its bar among the all-day entries in Week and Month. Decline an invitation, add a working location and recolour a calendar in Google, then check the struck-through row, the absent location entry and the colour. + Leave one invitation unanswered and answer another maybe; mark a timed event + free; add focus time and out-of-office time; check each reads apart from an + accepted meeting in Agenda and Week. Open an event with a video call: Join + meeting names the call's host and opens it outside the app, and Open in + Google Calendar opens that event. Change an event in Google, switch away for + over a minute and return: the change appears without a reload. Revoke + TaskNotes Calendar's access in the Google account: the calendar asks to + reconnect, and Settings shows Needs reconnecting. Demo task-calendar checks and mocked event tests do not prove real Google writes. - **Open and edit:** change title, notes and a custom property; switch adjacent tasks, close/reopen, and confirm the saved values. In LAB, check real confirmation diff --git a/src/app/task-calendar-view.test.tsx b/src/app/task-calendar-view.test.tsx index 3ec1d5ee..8f0c81ba 100644 --- a/src/app/task-calendar-view.test.tsx +++ b/src/app/task-calendar-view.test.tsx @@ -196,6 +196,7 @@ describe("Google events alongside task calendars", () => { calendar.google = { enabled: true, signedIn: true, + reconnect: false, items: [item], writable: [googleCalendar], events: [event], diff --git a/src/app/task-calendar-view.tsx b/src/app/task-calendar-view.tsx index 183a40e4..5af49e14 100644 --- a/src/app/task-calendar-view.tsx +++ b/src/app/task-calendar-view.tsx @@ -909,7 +909,14 @@ export function TaskCalendarView({ {mutationError}

) : null} - {google.failure ? ( + {google.reconnect ? ( +

+ + Google Calendar needs reconnecting. Reconnect in Settings to see + events. + +

+ ) : google.failure ? (

{calendarServiceFailureMessage(google.failure)}

diff --git a/src/calendar-service/client.test.ts b/src/calendar-service/client.test.ts index a9480bb7..7d4890a8 100644 --- a/src/calendar-service/client.test.ts +++ b/src/calendar-service/client.test.ts @@ -448,6 +448,8 @@ describe("CalendarServiceClient", () => { ...event("b"), event_type: "a_later_kind", self_response: null, + web_link: "https://www.google.com/calendar/event?eid=abc", + conference_link: null, a_later_field: { nested: true }, }, event("c"), @@ -473,6 +475,17 @@ describe("CalendarServiceClient", () => { ["a_later_kind", null], [undefined, undefined], ]); + expect( + events.map(({ transparency, web_link, conference_link }) => [ + transparency, + web_link, + conference_link, + ]), + ).toEqual([ + ["transparent", undefined, undefined], + [undefined, "https://www.google.com/calendar/event?eid=abc", null], + [undefined, undefined, undefined], + ]); }); it("drops a cancelled event from a range", async () => { @@ -552,6 +565,49 @@ describe("CalendarServiceClient", () => { expect(failure).toMatchObject({ failure: "conflict" }); }); + it("tells lapsed Google access from a conflict and stops showing calendars", async () => { + const path = `/v1/calendars/${CALENDAR}/events`; + const { client } = harness({ + ...signInRoutes, + [`GET ${path}`]: () => [409, { code: "reconnect_required", message: "" }], + [`PATCH ${path}/e1_a`]: () => [409, { code: "conflict", message: "" }], + }); + await signIn(client); + expect(client.getSnapshot().calendars).toHaveLength(2); + + await expect( + client.updateEvent(event("a"), { title: "New" }), + ).rejects.toMatchObject({ failure: "conflict" }); + expect(client.getSnapshot().connection?.status).toBe("active"); + + await expect( + client.events( + CALENDAR, + new Date("2026-11-01T00:00:00Z"), + new Date("2026-11-08T00:00:00Z"), + new AbortController().signal, + ), + ).rejects.toMatchObject({ failure: "reconnect_required" }); + expect(client.getSnapshot()).toMatchObject({ + sessionId: "session-1", + connection: { id: CONNECTION, status: "reconnect_required" }, + calendars: [], + }); + }); + + it("says Google access lapsed when the calendars cannot be listed", async () => { + const { client } = harness({ + ...signInRoutes, + "GET /v1/calendars": () => [409, { code: "reconnect_required" }], + }); + await signIn(client); + expect(client.getSnapshot()).toMatchObject({ + failure: "reconnect_required", + connection: { status: "reconnect_required" }, + calendars: [], + }); + }); + it("starts from the primary calendar and the ones shown in Google Calendar", () => { const calendar = (name: string, extra: object = {}) => ({ id: crypto.randomUUID(), diff --git a/src/calendar-service/client.ts b/src/calendar-service/client.ts index 1171341e..16d8d0b7 100644 --- a/src/calendar-service/client.ts +++ b/src/calendar-service/client.ts @@ -48,6 +48,9 @@ const eventSchema = z.discriminatedUnion("status", [ // service adds later cannot fail a page. event_type: z.string().optional(), self_response: z.string().nullable().optional(), + transparency: z.string().optional(), + web_link: z.string().nullable().optional(), + conference_link: z.string().nullable().optional(), start: boundarySchema, end: boundarySchema, }), @@ -105,6 +108,7 @@ export type CalendarServiceFailure = | "pagination" | "popup_blocked" | "rate_limited" + | "reconnect_required" | "session_expired" | "unavailable"; @@ -124,9 +128,21 @@ export interface CalendarServiceSnapshot { selected: string[]; } -function failureForStatus(status: number): CalendarServiceFailure { +const errorSchema = z.object({ code: z.string() }); + +async function failureFor(response: Response): Promise { + const { status } = response; if (status === 404) return "not_found"; - if (status === 409) return "conflict"; + if (status === 409) { + // A stale event and lapsed Google access both answer 409; only the code + // tells them apart. A service that predates the code says "conflict". + const error = errorSchema.safeParse( + await response.json().catch(() => null), + ); + return error.success && error.data.code === "reconnect_required" + ? "reconnect_required" + : "conflict"; + } if (status === 429) return "rate_limited"; return status >= 400 && status < 500 ? "invalid_request" : "unavailable"; } @@ -295,14 +311,28 @@ export class CalendarServiceClient { this.forget(); throw new CalendarServiceError("session_expired"); } - if (!response.ok) - throw new CalendarServiceError(failureForStatus(response.status)); + if (!response.ok) { + const failure = await failureFor(response); + session.signal.throwIfAborted(); + if (failure === "reconnect_required") this.#lapsed(); + throw new CalendarServiceError(failure); + } if (!schema) return undefined as T; const value = schema.parse(await response.json()); session.signal.throwIfAborted(); return value; } + /** Google withdrew access: nothing can be read until the person reconnects. */ + #lapsed() { + const { connection } = this.#snapshot; + if (connection) + this.#publish({ + connection: { ...connection, status: "reconnect_required" }, + calendars: [], + }); + } + async #run(action: () => Promise) { const session = this.#session; this.#publish({ busy: true, failure: null }); diff --git a/src/calendar-service/event-days.test.ts b/src/calendar-service/event-days.test.ts index d7ea265b..96370e25 100644 --- a/src/calendar-service/event-days.test.ts +++ b/src/calendar-service/event-days.test.ts @@ -7,7 +7,12 @@ import { shiftDay, spansWholeDays, } from "./event-days"; -import { eventClock, eventDayLabel, eventDescription } from "./event-labels"; +import { + eventClock, + eventDayLabel, + eventDescription, + eventNotes, +} from "./event-labels"; import { googleItem, googleItems } from "./use-google-calendar-overlay"; const base = { @@ -200,6 +205,84 @@ describe("a Google event as a calendar item", () => { } }); + it("draws an invitation by the person's answer and names it", () => { + const event = timed(at(14, 9), at(14, 10)); + const drawn = (self_response: string | null, status = "confirmed") => + googleItem( + { ...event, status: status as "confirmed", self_response }, + calendar, + 0, + ).className; + expect(drawn("needs_action")).toEqual([ + "google-calendar-event", + "is-unanswered", + ]); + expect(drawn("tentative")).toEqual([ + "google-calendar-event", + "is-tentative", + ]); + expect(drawn(null, "tentative")).toEqual([ + "google-calendar-event", + "is-tentative", + ]); + expect(drawn("accepted")).toEqual(["google-calendar-event"]); + expect(drawn("an_answer_added_later")).toEqual(["google-calendar-event"]); + expect(eventNotes({ ...event, self_response: "needs_action" })).toEqual([ + "Not answered", + ]); + expect(eventNotes({ ...event, self_response: "tentative" })).toEqual([ + "Maybe", + ]); + expect(eventNotes({ ...event, self_response: "accepted" })).toEqual([]); + }); + + it("draws time marked free, focus time and out of office apart from meetings", () => { + const event = timed(at(14, 9), at(14, 10)); + const free = { ...event, transparency: "transparent" }; + expect(googleItem(free, calendar, 0).className).toContain("is-free"); + expect(eventNotes(free)).toEqual(["Free"]); + // An all-day event is free by default in Google and holds no hours anyway. + const holiday = { + ...base, + start: on("2026-11-14"), + end: on("2026-11-15"), + transparency: "transparent", + }; + expect(googleItem(holiday, calendar, 0).className).toEqual([ + "google-calendar-event", + ]); + expect(eventNotes(holiday)).toEqual([]); + // Declined says enough. + expect(eventNotes({ ...free, self_response: "declined" })).toEqual([ + "Declined", + ]); + expect( + googleItem({ ...free, self_response: "declined" }, calendar, 0).className, + ).toEqual(["google-calendar-event", "is-declined"]); + + const away = { ...event, title: "Leave", event_type: "out_of_office" }; + expect(googleItem(away, calendar, 0).className).toContain( + "is-out-of-office", + ); + expect(eventDescription(away, calendar, "9am – 10am")).toBe( + "Leave, Out of office, 9am – 10am, Home", + ); + expect( + eventNotes({ ...event, title: "Deep work", event_type: "focus_time" }), + ).toEqual(["Focus time"]); + // Google's own title for these already says what they are. + expect( + eventNotes({ ...event, title: " Focus time", event_type: "focus_time" }), + ).toEqual([]); + expect( + eventNotes({ + ...away, + title: "Out of office", + self_response: "needs_action", + }), + ).toEqual(["Not answered"]); + }); + it("leaves out working locations and events of calendars it does not know", () => { const event = timed(at(14, 9), at(14, 10)); const other = { ...calendar, id: "0f0e9a52-3a55-4c5e-9d1c-3d8c6d6c3f12" }; diff --git a/src/calendar-service/event-dialog.test.tsx b/src/calendar-service/event-dialog.test.tsx new file mode 100644 index 00000000..ebc617b4 --- /dev/null +++ b/src/calendar-service/event-dialog.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { render, screen } from "../test/render"; +import { describe, expect, it, vi } from "vitest"; +import type { GoogleEvent } from "./client"; + +vi.mock("./client", async (original) => ({ + ...(await original()), + calendarServiceClient: {}, +})); + +const { GoogleEventDialog } = await import("./event-dialog"); + +const calendar = { + id: "6f0e9a52-3a55-4c5e-9d1c-3d8c6d6c3f11", + name: "Work", + can_write: false, + is_primary: true, +}; +const event: GoogleEvent = { + status: "confirmed", + id: "e1_event", + etag: '"1"', + calendar_id: calendar.id, + title: "Planning", + description: null, + location: null, + recurrence: null, + start: { kind: "date", date: "2026-11-09" }, + end: { kind: "date", date: "2026-11-10" }, +}; + +function open(links: Partial) { + const shown = { ...event, ...links }; + return render( + , + ); +} + +describe("an event's links in its dialog", () => { + it("joins the call and opens Google Calendar in a new tab, cut off from this one", () => { + open({ + conference_link: "https://meet.google.com/abc-defg-hij", + web_link: "https://www.google.com/calendar/event?eid=abc", + }); + const join = screen.getByRole("link", { name: /Join meeting/ }); + expect(join).toHaveAttribute( + "href", + "https://meet.google.com/abc-defg-hij", + ); + expect(join).toHaveTextContent("meet.google.com"); + const google = screen.getByRole("link", { + name: "Open in Google Calendar", + }); + expect(google).toHaveAttribute( + "href", + "https://www.google.com/calendar/event?eid=abc", + ); + for (const link of [join, google]) { + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + } + }); + + it("shows no links for an event that has none, even on a read-only calendar", () => { + open({ conference_link: "javascript:alert(1)", web_link: null }); + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.queryByRole("navigation")).toBeNull(); + }); +}); diff --git a/src/calendar-service/event-dialog.tsx b/src/calendar-service/event-dialog.tsx index 9a57ea48..702d9d2f 100644 --- a/src/calendar-service/event-dialog.tsx +++ b/src/calendar-service/event-dialog.tsx @@ -1,4 +1,5 @@ import { useLingui } from "@lingui/react/macro"; +import { ArrowUpRight, Video } from "lucide-react"; import { useRef, useState } from "react"; import { useOverlay } from "../components/overlays/use-overlay"; import { TaskNotesSelectField } from "../components/tasknotes-controls"; @@ -19,6 +20,7 @@ import { withAllDay, type EventDraft, } from "./event-draft"; +import { eventLinks } from "./event-links"; import { calendarServiceFailureMessage } from "./failure-message"; import type { EventEditorRequest } from "./use-google-calendar-overlay"; @@ -103,6 +105,7 @@ export function GoogleEventDialog({ } const inputType = draft.allDay ? "date" : "datetime-local"; + const links = event ? eventLinks(event) : null; return (