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 (