diff --git a/backend/tests/plugins/test_calendar_reminders.py b/backend/tests/plugins/test_calendar_reminders.py index da39d6c..b0d9fb4 100644 --- a/backend/tests/plugins/test_calendar_reminders.py +++ b/backend/tests/plugins/test_calendar_reminders.py @@ -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 sent[-1]["title"] == "Dentist" 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 # The worker was down when the 10-minute reminder came due: sent late, once. assert await run(MON_9 - timedelta(minutes=4)) == 1 diff --git a/frontend/src/components/mail/RightPanel.tsx b/frontend/src/components/mail/RightPanel.tsx index ab530c7..52e4c10 100644 --- a/frontend/src/components/mail/RightPanel.tsx +++ b/frontend/src/components/mail/RightPanel.tsx @@ -19,6 +19,8 @@ import { usePluginContext } from "../../plugins/context"; import { isPreviewable } from "../thread/previewKind"; 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. const AttachmentPreview = lazy(() => @@ -66,6 +68,7 @@ export function RightPanel({ onClose, thread, single = false, + calendarFocus = null, }: { tab: PanelTab; 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 * instead of the tabs, and Contacts starts on all contacts. */ single?: boolean; + /** Calendar: open on this day with this event's details (a reminder). */ + calendarFocus?: CalendarFocus | null; }) { const t = useT(); // "Invite all to new event" (People tab) opens the SHARED calendar event form in @@ -110,7 +115,7 @@ export function RightPanel({ {/* Calendar stays mounted (keeps its state) while hidden, so switching tabs is instant. */}
- +
{tab !== "calendar" && (
@@ -134,13 +139,15 @@ export function RightPanel({ // 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. 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(null); const ctx = usePluginContext("calendar"); // A phone has no use for "Open calendar in new tab". 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 focusN = focus?.n ?? 0; useEffect(() => { let alive = true; let cleanup: (() => void) | void; @@ -151,11 +158,12 @@ function PanelCalendar({ invite }: { invite: InviteSignal | null }) { embed: "panel", newEventGuests: invite?.guests, newTabLink: !mobile, + focus: focus ? { date: focus.date, event: focus.event, start: focus.start } : null, }); }); return () => { alive = false; if (typeof cleanup === "function") cleanup(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ctx, inviteN, mobile]); + }, [ctx, inviteN, focusN, mobile]); return
; } diff --git a/frontend/src/plugins/PluginPage.tsx b/frontend/src/plugins/PluginPage.tsx index d1a8418..5b24261 100644 --- a/frontend/src/plugins/PluginPage.tsx +++ b/frontend/src/plugins/PluginPage.tsx @@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { useT } from "../i18n"; +import { useIsMobile } from "../lib/useIsMobile"; import { loadBundle } from "./bundleLoader"; import { usePluginContext } from "./context"; import { usePluginManifest, usePluginPages } from "./manifest"; @@ -48,11 +49,20 @@ export function PluginPage() { const location = useLocation(); const { data, isLoading } = usePluginManifest(); const pages = usePluginPages(); + const mobile = useIsMobile(); // Sub-path under "/p" (e.g. "/calendar/2026-07"); match the longest page route. const subpath = location.pathname.replace(/^\/p/, "") || "/"; 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 ; + } if (isLoading || !data) { return
{t("common.loading")}
; } diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index de1356b..e9c998f 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -35,7 +35,7 @@ import { BackfillBanner } from "../components/BackfillBanner"; import { Clock, NotSavedOffline, OfflineIndicator } from "../components/Offline"; import { ThreadView } from "../components/thread/ThreadView"; 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 { useT } from "../i18n"; import type { MessageKey } from "../i18n/en"; @@ -873,6 +873,27 @@ export function MailLayout() { // Deep link (e.g. Contacts "Compose"): /mail?compose=1&to= opens a new, // maximized composer prefilled with the recipient, then strips the params. 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(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(() => { if (searchParams.get("compose") !== "1") return; const first = accountsQuery.data?.[0]; @@ -1713,6 +1734,7 @@ export function MailLayout() { onClose={() => setSheetOpen(false)} thread={detailQuery.data} single={sheetSingle} + calendarFocus={calendarFocus} />
@@ -1721,7 +1743,13 @@ export function MailLayout() { <> setRightW((w) => clampRight(w - dx))} />
- setRightOpen(false)} thread={detailQuery.data} /> + setRightOpen(false)} + thread={detailQuery.data} + calendarFocus={calendarFocus} + />
)} diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py index ff8792a..b2a1e15 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py @@ -16,6 +16,7 @@ import uuid from collections import defaultdict from datetime import UTC, datetime, timedelta from typing import Any +from urllib.parse import quote from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import text @@ -94,7 +95,10 @@ async def _notify_user( user_id, title=title, 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. tag=f"event-{inst['id']}-{inst['start']}", sticky=True, diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 5e2d28e..0c516ef 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -456,6 +456,9 @@ export default function mount(el, ctx, props) { // any change here propagates to both places. const embed = !!(props && props.embed === "panel"); 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 // phone, where a new tab is no use). 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/ // searchPanelHtml + the form/popover/confirm overlays exactly as the full app. 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 = `
${panelBar()} ${state.search ? "" : `
${miniMonth()}
`} @@ -1107,6 +1114,8 @@ export default function mount(el, ctx, props) { ${state.confirm ? confirmHtml() : ""}
`; wire(); + const body0 = el.querySelector(".cal-embed-body"); + if (body0 && prevScroll && !state.scrollToAnchor) body0.scrollTop = prevScroll; if (state.scrollToAnchor) { state.scrollToAnchor = false; const a = state.anchor; @@ -1850,20 +1859,46 @@ export default function mount(el, ctx, props) { (async () => { await loadChrome(); await loadCalendars(); - // Deep link (a reminder notification): /p/calendar?date=YYYY-MM-DD opens that - // day, then strips the param so refresh is clean. + // Deep link (a reminder notification): /p/calendar?date=YYYY-MM-DD[&event=id&start=iso] + // 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 { - const day = new URLSearchParams(window.location.search).get("date"); - if (!state.embed && day && /^\d{4}-\d{2}-\d{2}$/.test(day)) { - const [y, m, d] = day.split("-").map(Number); - state.anchor = startOfDay(new Date(y, m - 1, d)); - state.mini = startOfMonth(state.anchor); - state.view = "day"; + const params = new URLSearchParams(window.location.search); + if (!state.embed && params.get("date")) { + target = { date: params.get("date"), event: params.get("event"), start: params.get("start") }; window.history.replaceState({}, "", window.location.pathname); } } 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(); 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 // form with the given guests, using the SAME form as the calendar app. if (state.embed && newEventGuests && newEventGuests.length) {