Calendar reminders open the event; phones get the Calendar panel

A reminder notification links to its day, event and occurrence
(/p/calendar?date=…&event=…&start=…). The calendar opens on that day with the
event's details showing — the full calendar on a computer; on a phone any
calendar link leads to Mail with the Calendar panel slid in (the big-screen
page never opens there), scrolled to the event, details open.

The panel's agenda kept no scroll position across re-renders: opening an
event's details lower down jumped the list back to the top. It stays put now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 22:07:40 +02:00
co-authored by Claude Opus 5.5
parent 0e3793a4d6
commit aa617a3316
6 changed files with 101 additions and 16 deletions
@@ -104,7 +104,7 @@ async def test_due_reminders_are_sent_once(ctx) -> None: # type: ignore[no-unty
assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder
assert sent[-1]["title"] == "Dentist" assert sent[-1]["title"] == "Dentist"
assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4" assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4"
assert sent[-1]["url"] == "/p/calendar?date=2031-06-02" and sent[-1]["sticky"] is True assert sent[-1]["url"].startswith("/p/calendar?date=2031-06-02&event=") and sent[-1]["sticky"] is True
assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again
# The worker was down when the 10-minute reminder came due: sent late, once. # The worker was down when the 10-minute reminder came due: sent late, once.
assert await run(MON_9 - timedelta(minutes=4)) == 1 assert await run(MON_9 - timedelta(minutes=4)) == 1
+12 -4
View File
@@ -19,6 +19,8 @@ import { usePluginContext } from "../../plugins/context";
import { isPreviewable } from "../thread/previewKind"; import { isPreviewable } from "../thread/previewKind";
interface InviteSignal { guests: string[]; n: number } interface InviteSignal { guests: string[]; n: number }
/** A day (YYYY-MM-DD) and optionally one event occurrence to show in the calendar. */
export interface CalendarFocus { date: string; event?: string; start?: string; n: number }
// Lazy: the preview bundles pdf.js — loaded only when a preview is opened. // Lazy: the preview bundles pdf.js — loaded only when a preview is opened.
const AttachmentPreview = lazy(() => const AttachmentPreview = lazy(() =>
@@ -66,6 +68,7 @@ export function RightPanel({
onClose, onClose,
thread, thread,
single = false, single = false,
calendarFocus = null,
}: { }: {
tab: PanelTab; tab: PanelTab;
onTab: (t: PanelTab) => void; onTab: (t: PanelTab) => void;
@@ -74,6 +77,8 @@ export function RightPanel({
/** Opened as Calendar or Contacts on its own (the phone's menu): a title /** Opened as Calendar or Contacts on its own (the phone's menu): a title
* instead of the tabs, and Contacts starts on all contacts. */ * instead of the tabs, and Contacts starts on all contacts. */
single?: boolean; single?: boolean;
/** Calendar: open on this day with this event's details (a reminder). */
calendarFocus?: CalendarFocus | null;
}) { }) {
const t = useT(); const t = useT();
// "Invite all to new event" (People tab) opens the SHARED calendar event form in // "Invite all to new event" (People tab) opens the SHARED calendar event form in
@@ -110,7 +115,7 @@ export function RightPanel({
</div> </div>
{/* Calendar stays mounted (keeps its state) while hidden, so switching tabs is instant. */} {/* Calendar stays mounted (keeps its state) while hidden, so switching tabs is instant. */}
<div className={`min-h-0 flex-1 ${tab === "calendar" ? "" : "hidden"}`}> <div className={`min-h-0 flex-1 ${tab === "calendar" ? "" : "hidden"}`}>
<PanelCalendar invite={invite} /> <PanelCalendar invite={invite} focus={calendarFocus} />
</div> </div>
{tab !== "calendar" && ( {tab !== "calendar" && (
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
@@ -134,13 +139,15 @@ export function RightPanel({
// This is the shared surface: the agenda, mini-month, event popover, event form // This is the shared surface: the agenda, mini-month, event popover, event form
// and search results are the calendar app's own code, so future changes propagate. // and search results are the calendar app's own code, so future changes propagate.
const CAL_BUNDLE = "/api/plugins/calendar/ui/page.js"; const CAL_BUNDLE = "/api/plugins/calendar/ui/page.js";
function PanelCalendar({ invite }: { invite: InviteSignal | null }) { function PanelCalendar({ invite, focus }: { invite: InviteSignal | null; focus: CalendarFocus | null }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const ctx = usePluginContext("calendar"); const ctx = usePluginContext("calendar");
// A phone has no use for "Open calendar in new tab". // A phone has no use for "Open calendar in new tab".
const mobile = useIsMobile(); const mobile = useIsMobile();
// Remount when a new "Invite all" fires so the form opens with those guests. // Remount when a new "Invite all" fires so the form opens with those guests,
// and when asked to show a day / event (a reminder notification).
const inviteN = invite?.n ?? 0; const inviteN = invite?.n ?? 0;
const focusN = focus?.n ?? 0;
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
let cleanup: (() => void) | void; let cleanup: (() => void) | void;
@@ -151,11 +158,12 @@ function PanelCalendar({ invite }: { invite: InviteSignal | null }) {
embed: "panel", embed: "panel",
newEventGuests: invite?.guests, newEventGuests: invite?.guests,
newTabLink: !mobile, newTabLink: !mobile,
focus: focus ? { date: focus.date, event: focus.event, start: focus.start } : null,
}); });
}); });
return () => { alive = false; if (typeof cleanup === "function") cleanup(); }; return () => { alive = false; if (typeof cleanup === "function") cleanup(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx, inviteN, mobile]); }, [ctx, inviteN, focusN, mobile]);
return <div ref={ref} className="h-full" />; return <div ref={ref} className="h-full" />;
} }
+10
View File
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useIsMobile } from "../lib/useIsMobile";
import { loadBundle } from "./bundleLoader"; import { loadBundle } from "./bundleLoader";
import { usePluginContext } from "./context"; import { usePluginContext } from "./context";
import { usePluginManifest, usePluginPages } from "./manifest"; import { usePluginManifest, usePluginPages } from "./manifest";
@@ -48,11 +49,20 @@ export function PluginPage() {
const location = useLocation(); const location = useLocation();
const { data, isLoading } = usePluginManifest(); const { data, isLoading } = usePluginManifest();
const pages = usePluginPages(); const pages = usePluginPages();
const mobile = useIsMobile();
// Sub-path under "/p" (e.g. "/calendar/2026-07"); match the longest page route. // Sub-path under "/p" (e.g. "/calendar/2026-07"); match the longest page route.
const subpath = location.pathname.replace(/^\/p/, "") || "/"; const subpath = location.pathname.replace(/^\/p/, "") || "/";
const match = pages.find((p) => subpath === p.route || subpath.startsWith(`${p.route}/`)); const match = pages.find((p) => subpath === p.route || subpath.startsWith(`${p.route}/`));
// The calendar page is made for a big screen: a phone gets Mail with the
// Calendar panel slid in instead (a reminder's link included, with its day and
// event).
if (mobile && subpath.startsWith("/calendar")) {
const params = new URLSearchParams(location.search);
params.set("panel", "calendar");
return <Navigate to={`/mail?${params.toString()}`} replace />;
}
if (isLoading || !data) { if (isLoading || !data) {
return <div className="p-8 text-app-muted">{t("common.loading")}</div>; return <div className="p-8 text-app-muted">{t("common.loading")}</div>;
} }
+30 -2
View File
@@ -35,7 +35,7 @@ import { BackfillBanner } from "../components/BackfillBanner";
import { Clock, NotSavedOffline, OfflineIndicator } from "../components/Offline"; import { Clock, NotSavedOffline, OfflineIndicator } from "../components/Offline";
import { ThreadView } from "../components/thread/ThreadView"; import { ThreadView } from "../components/thread/ThreadView";
import { MailboxDisplay } from "../components/mail/MailboxDisplay"; import { MailboxDisplay } from "../components/mail/MailboxDisplay";
import { RightPanel, type PanelTab } from "../components/mail/RightPanel"; import { RightPanel, type CalendarFocus, type PanelTab } from "../components/mail/RightPanel";
import { ScheduledSendPane } from "../components/mail/ScheduledSendPane"; import { ScheduledSendPane } from "../components/mail/ScheduledSendPane";
import { useT } from "../i18n"; import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en"; import type { MessageKey } from "../i18n/en";
@@ -873,6 +873,27 @@ export function MailLayout() {
// Deep link (e.g. Contacts "Compose"): /mail?compose=1&to=<email> opens a new, // Deep link (e.g. Contacts "Compose"): /mail?compose=1&to=<email> opens a new,
// maximized composer prefilled with the recipient, then strips the params. // maximized composer prefilled with the recipient, then strips the params.
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// ?panel=calendar[&date=&event=&start=]: the phone's way to the calendar (the
// calendar page sends it here; a reminder's link carries its day and event).
const [calendarFocus, setCalendarFocus] = useState<CalendarFocus | null>(null);
useEffect(() => {
if (searchParams.get("panel") !== "calendar") return;
const date = searchParams.get("date");
setCalendarFocus(
date
? {
date,
event: searchParams.get("event") ?? undefined,
start: searchParams.get("start") ?? undefined,
n: Date.now(),
}
: null,
);
if (mobile) openFromMenu("calendar");
else openPanel("calendar");
setSearchParams({}, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (searchParams.get("compose") !== "1") return; if (searchParams.get("compose") !== "1") return;
const first = accountsQuery.data?.[0]; const first = accountsQuery.data?.[0];
@@ -1713,6 +1734,7 @@ export function MailLayout() {
onClose={() => setSheetOpen(false)} onClose={() => setSheetOpen(false)}
thread={detailQuery.data} thread={detailQuery.data}
single={sheetSingle} single={sheetSingle}
calendarFocus={calendarFocus}
/> />
</div> </div>
</> </>
@@ -1721,7 +1743,13 @@ export function MailLayout() {
<> <>
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} /> <Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border"> <div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
<RightPanel tab={rightTab} onTab={setRightTab} onClose={() => setRightOpen(false)} thread={detailQuery.data} /> <RightPanel
tab={rightTab}
onTab={setRightTab}
onClose={() => setRightOpen(false)}
thread={detailQuery.data}
calendarFocus={calendarFocus}
/>
</div> </div>
</> </>
)} )}
@@ -16,6 +16,7 @@ import uuid
from collections import defaultdict from collections import defaultdict
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from urllib.parse import quote
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import text from sqlalchemy import text
@@ -94,7 +95,10 @@ async def _notify_user(
user_id, user_id,
title=title, title=title,
body=body, body=body,
url=f"/p/calendar?date={_day(start, inst['all_day'], tz)}", url=(
f"/p/calendar?date={_day(start, inst['all_day'], tz)}"
f"&event={inst['id']}&start={quote(inst['start'])}"
),
# One notification per occurrence: a later reminder replaces an earlier one. # One notification per occurrence: a later reminder replaces an earlier one.
tag=f"event-{inst['id']}-{inst['start']}", tag=f"event-{inst['id']}-{inst['start']}",
sticky=True, sticky=True,
@@ -456,6 +456,9 @@ export default function mount(el, ctx, props) {
// any change here propagates to both places. // any change here propagates to both places.
const embed = !!(props && props.embed === "panel"); const embed = !!(props && props.embed === "panel");
const newEventGuests = (props && props.newEventGuests) || null; const newEventGuests = (props && props.newEventGuests) || null;
// Open on a day, with one event's details showing (a reminder notification):
// { date: "YYYY-MM-DD", event: id, start: ISO of the occurrence }.
const focus = (props && props.focus) || null;
// Embed: the host can drop the "Open calendar in new tab" footer (Mail on a // Embed: the host can drop the "Open calendar in new tab" footer (Mail on a
// phone, where a new tab is no use). // phone, where a new tab is no use).
const newTabLink = !(props && props.newTabLink === false); const newTabLink = !(props && props.newTabLink === false);
@@ -1095,6 +1098,10 @@ export default function mount(el, ctx, props) {
// Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/ // Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/
// searchPanelHtml + the form/popover/confirm overlays exactly as the full app. // searchPanelHtml + the form/popover/confirm overlays exactly as the full app.
function renderEmbed() { function renderEmbed() {
// Re-rendering replaces the agenda: keep where it was scrolled to (else
// opening an event's details lower down jumped the list back to the top).
const prevBody = el.querySelector(".cal-embed-body");
const prevScroll = prevBody ? prevBody.scrollTop : 0;
el.innerHTML = `<div class="cal-app cal-embed"> el.innerHTML = `<div class="cal-app cal-embed">
${panelBar()} ${panelBar()}
${state.search ? "" : `<div class="cal-embed-mini">${miniMonth()}</div>`} ${state.search ? "" : `<div class="cal-embed-mini">${miniMonth()}</div>`}
@@ -1107,6 +1114,8 @@ export default function mount(el, ctx, props) {
${state.confirm ? confirmHtml() : ""} ${state.confirm ? confirmHtml() : ""}
</div>`; </div>`;
wire(); wire();
const body0 = el.querySelector(".cal-embed-body");
if (body0 && prevScroll && !state.scrollToAnchor) body0.scrollTop = prevScroll;
if (state.scrollToAnchor) { if (state.scrollToAnchor) {
state.scrollToAnchor = false; state.scrollToAnchor = false;
const a = state.anchor; const a = state.anchor;
@@ -1850,20 +1859,46 @@ export default function mount(el, ctx, props) {
(async () => { (async () => {
await loadChrome(); await loadChrome();
await loadCalendars(); await loadCalendars();
// Deep link (a reminder notification): /p/calendar?date=YYYY-MM-DD opens that // Deep link (a reminder notification): /p/calendar?date=YYYY-MM-DD[&event=id&start=iso]
// day, then strips the param so refresh is clean. // opens that day with the event's details; in the panel, the same comes as
// props.focus. The params are stripped so a refresh is clean.
let target = focus;
try { try {
const day = new URLSearchParams(window.location.search).get("date"); const params = new URLSearchParams(window.location.search);
if (!state.embed && day && /^\d{4}-\d{2}-\d{2}$/.test(day)) { if (!state.embed && params.get("date")) {
const [y, m, d] = day.split("-").map(Number); target = { date: params.get("date"), event: params.get("event"), start: params.get("start") };
state.anchor = startOfDay(new Date(y, m - 1, d));
state.mini = startOfMonth(state.anchor);
state.view = "day";
window.history.replaceState({}, "", window.location.pathname); window.history.replaceState({}, "", window.location.pathname);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
if (target && /^\d{4}-\d{2}-\d{2}$/.test(target.date || "")) {
const [y, m, d] = target.date.split("-").map(Number);
state.anchor = startOfDay(new Date(y, m - 1, d));
state.mini = startOfMonth(state.anchor);
if (state.embed) state.scrollToAnchor = true;
else state.view = "day";
}
await loadEvents(); await loadEvents();
render(); render();
if (target && target.event) {
const ev = state.events.find((x) => x.id === target.event && (!target.start || x.start === target.start))
|| state.events.find((x) => x.id === target.event);
if (ev) {
// In the panel, wait for it to finish sliding in (a phone's sheet), then
// bring the event into view so the details sit beside it.
await new Promise((r) => setTimeout(r, state.embed ? 400 : 50));
const block = el.querySelector(`[data-ev="${ev.id}"][data-start="${ev.start}"]`);
if (block) {
block.scrollIntoView({ block: "center" });
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
openEvent(ev, block);
} else {
const view = el.querySelector(".cal-view") || el;
const r = view.getBoundingClientRect();
state.eventPopover = { ev, rect: { top: r.top + 24, left: r.left + 16, right: r.left + 60, bottom: r.top + 44 } };
render();
}
}
}
// Embed mode ("Invite all to new event" from Mail's People tab): open a new-event // Embed mode ("Invite all to new event" from Mail's People tab): open a new-event
// form with the given guests, using the SAME form as the calendar app. // form with the given guests, using the SAME form as the calendar app.
if (state.embed && newEventGuests && newEventGuests.length) { if (state.embed && newEventGuests && newEventGuests.length) {