Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/live-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/app/task-calendar-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ describe("Google events alongside task calendars", () => {
calendar.google = {
enabled: true,
signedIn: true,
reconnect: false,
items: [item],
writable: [googleCalendar],
events: [event],
Expand Down
9 changes: 8 additions & 1 deletion src/app/task-calendar-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -909,7 +909,14 @@ export function TaskCalendarView({
{mutationError}
</p>
) : null}
{google.failure ? (
{google.reconnect ? (
<p className="section-copy" role="status">
<Trans>
Google Calendar needs reconnecting. Reconnect in Settings to see
events.
</Trans>
</p>
) : google.failure ? (
<p className="inline-error" role="alert">
{calendarServiceFailureMessage(google.failure)}
</p>
Expand Down
56 changes: 56 additions & 0 deletions src/calendar-service/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(),
Expand Down
38 changes: 34 additions & 4 deletions src/calendar-service/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down Expand Up @@ -105,6 +108,7 @@ export type CalendarServiceFailure =
| "pagination"
| "popup_blocked"
| "rate_limited"
| "reconnect_required"
| "session_expired"
| "unavailable";

Expand All @@ -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<CalendarServiceFailure> {
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";
}
Expand Down Expand Up @@ -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<void>) {
const session = this.#session;
this.#publish({ busy: true, failure: null });
Expand Down
85 changes: 84 additions & 1 deletion src/calendar-service/event-days.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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" };
Expand Down
76 changes: 76 additions & 0 deletions src/calendar-service/event-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("./client")>()),
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<GoogleEvent>) {
const shown = { ...event, ...links };
return render(
<GoogleEventDialog
request={{ kind: "existing", id: shown.id, event: shown, calendar }}
events={[shown]}
writable={[]}
onClose={vi.fn()}
onSaved={vi.fn()}
onChanged={vi.fn()}
/>,
);
}

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();
});
});
Loading
Loading