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:
@@ -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
|
||||||
|
|||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user