Calendar reminders as push notifications
A job every minute finds the event reminders that fell due — repeating events and moved occurrences expanded as the calendar view does — and pushes each once to every device with notifications on (sdk.push, new: plugins can notify through the new-mail push delivery). A reminder missed while the worker was down is still sent within 10 minutes; cancelled events and declined invitations stay quiet. Title, then "In 15 minutes, 14:00 · Room 2" in the user's zone and clock format; it stays until dismissed and shows even with the app open; clicking it opens that day (/p/calendar?date=…, new deep link). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,12 @@ class PluginSdk:
|
|||||||
CalDAV/CardDAV/ICS. Returns status + headers + body."""
|
CalDAV/CardDAV/ICS. Returns status + headers + body."""
|
||||||
return PluginHttp()
|
return PluginHttp()
|
||||||
|
|
||||||
|
# --- notifications ------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def push(self) -> PluginPush:
|
||||||
|
"""Notifications on the user's devices (Web Push) — calendar reminders."""
|
||||||
|
return PluginPush(self.name)
|
||||||
|
|
||||||
# --- secret vault -------------------------------------------------------
|
# --- secret vault -------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def vault(self) -> PluginVault:
|
def vault(self) -> PluginVault:
|
||||||
@@ -187,6 +193,39 @@ class PluginSdk:
|
|||||||
return PluginVault()
|
return PluginVault()
|
||||||
|
|
||||||
|
|
||||||
|
class PluginPush:
|
||||||
|
"""``sdk.push``: show a notification on every device where the user turned
|
||||||
|
notifications on (Settings → Accounts → Notifications)."""
|
||||||
|
|
||||||
|
def __init__(self, plugin: str) -> None:
|
||||||
|
self._plugin = plugin
|
||||||
|
|
||||||
|
async def notify(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
url: str = "/mail",
|
||||||
|
tag: str | None = None,
|
||||||
|
sticky: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Send one notification; returns how many devices it went to. ``tag``
|
||||||
|
replaces an earlier notification with the same tag (it is prefixed with
|
||||||
|
the plugin's name); ``sticky`` keeps it on screen until dismissed."""
|
||||||
|
from app.push.service import deliver
|
||||||
|
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"type": f"plugin.{self._plugin}",
|
||||||
|
"title": title[:200],
|
||||||
|
"body": body[:500],
|
||||||
|
"url": url if url.startswith("/") else "/mail",
|
||||||
|
"tag": f"{self._plugin}-{tag}" if tag else self._plugin,
|
||||||
|
"sticky": sticky,
|
||||||
|
}
|
||||||
|
return await deliver(user_id, [payload])
|
||||||
|
|
||||||
|
|
||||||
def sdk(name: str) -> PluginSdk:
|
def sdk(name: str) -> PluginSdk:
|
||||||
"""Return the SDK facade bound to plugin ``name`` (SPEC §11)."""
|
"""Return the SDK facade bound to plugin ``name`` (SPEC §11)."""
|
||||||
return PluginSdk(name)
|
return PluginSdk(name)
|
||||||
|
|||||||
@@ -200,6 +200,26 @@ async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
|
|||||||
pushes were delivered to push services."""
|
pushes were delivered to push services."""
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
|
|
||||||
|
async with session_scope() as db:
|
||||||
|
if not await db.scalar(
|
||||||
|
select(PushSubscription.id).where(PushSubscription.user_id == user_id).limit(1)
|
||||||
|
):
|
||||||
|
return 0
|
||||||
|
messages = await _news(db, user_id, message_ids)
|
||||||
|
if not messages:
|
||||||
|
return 0
|
||||||
|
payloads = _payloads(messages, await _unread(db, user_id))
|
||||||
|
return await deliver(user_id, payloads)
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver(user_id: uuid.UUID, payloads: list[dict[str, object]]) -> int:
|
||||||
|
"""Push ``payloads`` to every device of the user that is still signed in
|
||||||
|
(for new mail, and for plugins: calendar reminders); returns how many were
|
||||||
|
handed to push services. Devices that are gone are forgotten."""
|
||||||
|
from app.db import session_scope
|
||||||
|
|
||||||
|
if not payloads:
|
||||||
|
return 0
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
# Plain values: the rows expire when the key pair is first saved below.
|
# Plain values: the rows expire when the key pair is first saved below.
|
||||||
subs = [
|
subs = [
|
||||||
@@ -210,10 +230,6 @@ async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
|
|||||||
]
|
]
|
||||||
if not subs:
|
if not subs:
|
||||||
return 0
|
return 0
|
||||||
messages = await _news(db, user_id, message_ids)
|
|
||||||
if not messages:
|
|
||||||
return 0
|
|
||||||
payloads = _payloads(messages, await _unread(db, user_id))
|
|
||||||
key = await vapid(db)
|
key = await vapid(db)
|
||||||
subject = _subject(await db.get(AppSettings, 1))
|
subject = _subject(await db.get(AppSettings, 1))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Event reminders as notifications (calendar plugin, sdk.push): due reminders
|
||||||
|
are sent once, repeating events remind on each occurrence, cancelled and
|
||||||
|
declined ones stay quiet."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport
|
||||||
|
from sqlalchemy import delete, text
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
from webmail_plugin_calendar import reminders # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.identity.passwords import hash_password
|
||||||
|
from app.identity.sessions import create_session
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models import User
|
||||||
|
from app.plugins.sdk import PluginPush
|
||||||
|
|
||||||
|
CAL = "/api/plugins/calendar"
|
||||||
|
# Far from any real event in the dev database.
|
||||||
|
MON_9 = datetime(2031, 6, 2, 9, 0, tzinfo=UTC) # a Monday
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def ctx(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> AsyncIterator[tuple[httpx.AsyncClient, uuid.UUID, list[dict[str, Any]], Any]]:
|
||||||
|
sent: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def fake_notify(self: PluginPush, user_id: uuid.UUID, **kw: Any) -> int:
|
||||||
|
sent.append({"user_id": user_id, **kw})
|
||||||
|
return 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(PluginPush, "notify", fake_notify)
|
||||||
|
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||||
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with sm() as s:
|
||||||
|
user = User(
|
||||||
|
handle=f"rem{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
|
||||||
|
)
|
||||||
|
s.add(user)
|
||||||
|
await s.commit()
|
||||||
|
token = await create_session(user.id, "pytest")
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
transport=ASGITransport(app=create_app()),
|
||||||
|
base_url="http://testserver",
|
||||||
|
cookies={"session": token, "csrf": "c"},
|
||||||
|
headers={"X-CSRF": "c"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield client, user.id, sent, sm
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
async with sm() as s:
|
||||||
|
await s.execute(
|
||||||
|
text("DELETE FROM plg_calendar_calendars WHERE user_id = :u"), {"u": str(user.id)}
|
||||||
|
)
|
||||||
|
await s.execute(text("DELETE FROM plugin_kv WHERE user_id = :u"), {"u": str(user.id)})
|
||||||
|
await s.execute(delete(User).where(User.id == user.id))
|
||||||
|
await s.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _event(client: httpx.AsyncClient, **fields: Any) -> str:
|
||||||
|
cal = (await client.get(f"{CAL}/calendars")).json()[0]["id"]
|
||||||
|
r = await client.post(f"{CAL}/events", json={"calendar_id": cal, **fields})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return str(r.json()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_due_reminders_are_sent_once(ctx) -> None: # type: ignore[no-untyped-def]
|
||||||
|
client, user_id, sent, sm = ctx
|
||||||
|
await _event(
|
||||||
|
client,
|
||||||
|
summary="Dentist",
|
||||||
|
location="Main St 4",
|
||||||
|
dtstart=MON_9.isoformat(),
|
||||||
|
dtend=(MON_9 + timedelta(hours=1)).isoformat(),
|
||||||
|
reminders=[10, 60],
|
||||||
|
)
|
||||||
|
called_off = await _event(
|
||||||
|
client, summary="Called off", dtstart=MON_9.isoformat(), reminders=[10]
|
||||||
|
)
|
||||||
|
async with sm() as db: # cancelled by its organizer: never reminds
|
||||||
|
await db.execute(
|
||||||
|
text("UPDATE plg_calendar_events SET status = 'cancelled' WHERE id = :id"),
|
||||||
|
{"id": called_off},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def run(at: datetime) -> int:
|
||||||
|
return await reminders.reminder_job({}, now=at, only_user=user_id)
|
||||||
|
|
||||||
|
assert await run(MON_9 - timedelta(minutes=61)) == 0 # nothing due yet
|
||||||
|
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 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
|
||||||
|
assert sent[-1]["body"] == "In 10 minutes, 09:00 · Main St 4"
|
||||||
|
assert await run(MON_9 - timedelta(minutes=3)) == 0
|
||||||
|
assert len(sent) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repeating_events_remind_on_each_occurrence(ctx) -> None: # type: ignore[no-untyped-def]
|
||||||
|
client, user_id, sent, _ = ctx
|
||||||
|
await _event(
|
||||||
|
client,
|
||||||
|
summary="Weekly sync",
|
||||||
|
dtstart=MON_9.isoformat(),
|
||||||
|
dtend=(MON_9 + timedelta(minutes=30)).isoformat(),
|
||||||
|
rrule="FREQ=WEEKLY;BYDAY=MO",
|
||||||
|
reminders=[15],
|
||||||
|
)
|
||||||
|
for week in range(3):
|
||||||
|
at = MON_9 + timedelta(weeks=week) - timedelta(minutes=15)
|
||||||
|
assert await reminders.reminder_job({}, now=at, only_user=user_id) == 1
|
||||||
|
tags = {s["tag"] for s in sent}
|
||||||
|
assert len(sent) == 3 and len(tags) == 3 # one notification per occurrence
|
||||||
+10
-3
@@ -501,11 +501,13 @@ self.addEventListener("activate", (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- notifications about new mail ---------------------------------------------------
|
// ---- notifications: new mail, calendar reminders ------------------------------------
|
||||||
//
|
//
|
||||||
// The server (app/push/service.py) sends { type: "mail", title, body, tag, url,
|
// The server (app/push/service.py) sends { type: "mail", title, body, tag, url,
|
||||||
// unread } encrypted to this browser. One notification per conversation (`tag`),
|
// unread } encrypted to this browser. One notification per conversation (`tag`),
|
||||||
// replaced when more arrives in it; the app icon's badge shows unread Inbox mail.
|
// replaced when more arrives in it; the app icon's badge shows unread Inbox mail.
|
||||||
|
// A plugin (sdk.push) sends { type: "plugin.<name>", title, body, tag, url,
|
||||||
|
// sticky } — a calendar reminder.
|
||||||
|
|
||||||
/** A window of the app the user is looking at right now. */
|
/** A window of the app the user is looking at right now. */
|
||||||
async function lookingAtApp() {
|
async function lookingAtApp() {
|
||||||
@@ -517,16 +519,21 @@ async function showMail(data) {
|
|||||||
if (typeof data.unread === "number" && self.navigator.setAppBadge) {
|
if (typeof data.unread === "number" && self.navigator.setAppBadge) {
|
||||||
await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {});
|
await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {});
|
||||||
}
|
}
|
||||||
// The app is open in front of the user: the new mail is already on screen.
|
// New mail while the app is open in front of the user: it is already on
|
||||||
// Not on Apple's push service, which withdraws permission from a site whose
|
// screen. (Other notifications — a calendar reminder — show regardless.) Not
|
||||||
|
// on Apple's push service, which withdraws permission from a site whose
|
||||||
// pushes show nothing.
|
// pushes show nothing.
|
||||||
|
if (!data.type || data.type === "mail") {
|
||||||
const sub = await self.registration.pushManager.getSubscription();
|
const sub = await self.registration.pushManager.getSubscription();
|
||||||
const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
|
const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
|
||||||
if (!apple && (await lookingAtApp())) return;
|
if (!apple && (await lookingAtApp())) return;
|
||||||
|
}
|
||||||
await self.registration.showNotification(data.title || "New mail", {
|
await self.registration.showNotification(data.title || "New mail", {
|
||||||
body: data.body || "",
|
body: data.body || "",
|
||||||
tag: data.tag || "new-mail",
|
tag: data.tag || "new-mail",
|
||||||
renotify: true,
|
renotify: true,
|
||||||
|
// A reminder stays until dismissed (where the platform allows it).
|
||||||
|
requireInteraction: Boolean(data.sticky),
|
||||||
icon: "/icon-192.png",
|
icon: "/icon-192.png",
|
||||||
badge: "/badge-96.png",
|
badge: "/badge-96.png",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Event reminders as notifications on the user's devices (Web Push).
|
||||||
|
|
||||||
|
Every minute, the events with reminders that fall due are found — repeating
|
||||||
|
events expanded and moved occurrences honoured by the same code the calendar
|
||||||
|
view uses (queries.expand_range) — and each due reminder is pushed through
|
||||||
|
``sdk.push`` to every device where the user turned notifications on. A
|
||||||
|
reminder is sent once (remembered in the plugin's KV store); one that came due
|
||||||
|
while the worker was down is still sent within CATCH_UP. Cancelled events and
|
||||||
|
invitations the user declined stay quiet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.plugins.sdk import sdk
|
||||||
|
from webmail_plugin_calendar.queries import expand_range
|
||||||
|
|
||||||
|
logger = logging.getLogger("plg.calendar")
|
||||||
|
_SDK = sdk("calendar")
|
||||||
|
|
||||||
|
CATCH_UP = timedelta(minutes=10) # a reminder due this long ago is still sent
|
||||||
|
MAX_LEAD = timedelta(days=8) # reminders this far ahead of an event are looked for
|
||||||
|
_SENT_KEY = "reminders_sent" # KV: {"<event>|<start>|<minutes>": due-time ISO}
|
||||||
|
|
||||||
|
_COLUMNS = (
|
||||||
|
"e.id, e.user_id, e.calendar_id, e.ical_uid, e.summary, e.description, e.location, "
|
||||||
|
"e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, e.exdates, "
|
||||||
|
"e.recurrence_id, e.status, e.my_partstat, e.origin_message_id, e.show_as, "
|
||||||
|
"e.conference_url, e.attendees, e.organizer, COALESCE(e.color, c.color) AS color"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _when(start: datetime, minutes_before: int, all_day: bool, tz: str, hour12: bool) -> str:
|
||||||
|
"""The notification's line under the title: when the event starts."""
|
||||||
|
if all_day: # date-anchored at UTC midnight
|
||||||
|
return f"All day, {start.astimezone(UTC).strftime('%a %d %b')}"
|
||||||
|
local = start.astimezone(_zone(tz))
|
||||||
|
clock = local.strftime("%I:%M %p").lstrip("0") if hour12 else local.strftime("%H:%M")
|
||||||
|
if minutes_before <= 0:
|
||||||
|
return f"Now, {clock}"
|
||||||
|
if minutes_before < 60:
|
||||||
|
return f"In {minutes_before} minutes, {clock}"
|
||||||
|
if minutes_before < 24 * 60:
|
||||||
|
hours = minutes_before // 60
|
||||||
|
return f"In {hours} hour{'s' if hours > 1 else ''}, {clock}"
|
||||||
|
days = minutes_before // (24 * 60)
|
||||||
|
day = "Tomorrow" if days == 1 else local.strftime("%a %d %b")
|
||||||
|
return f"{day}, {clock}"
|
||||||
|
|
||||||
|
|
||||||
|
def due(
|
||||||
|
instances: list[dict[str, Any]], lo: datetime, now: datetime
|
||||||
|
) -> list[tuple[dict[str, Any], int]]:
|
||||||
|
"""The (instance, minutes before) reminders falling due in ``(lo, now]``."""
|
||||||
|
out = []
|
||||||
|
for inst in instances:
|
||||||
|
if inst["status"] == "cancelled" or inst.get("my_partstat") == "declined":
|
||||||
|
continue
|
||||||
|
start = datetime.fromisoformat(inst["start"])
|
||||||
|
for minutes in sorted(set(inst.get("reminders") or [])):
|
||||||
|
fire = start - timedelta(minutes=minutes)
|
||||||
|
if lo < fire <= now:
|
||||||
|
out.append((inst, minutes))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_user(
|
||||||
|
user_id: uuid.UUID, hits: list[tuple[dict[str, Any], int]], now: datetime
|
||||||
|
) -> int:
|
||||||
|
kv = _SDK.kv(user_id)
|
||||||
|
sent: dict[str, str] = dict(await kv.get(_SENT_KEY, {}) or {})
|
||||||
|
settings = await _SDK.accounts.settings_of(user_id)
|
||||||
|
hour12 = str(settings.get("time_format", "24")) == "12"
|
||||||
|
tz = str(settings.get("tz") or "UTC")
|
||||||
|
delivered = 0
|
||||||
|
for inst, minutes in hits:
|
||||||
|
key = f"{inst['id']}|{inst['start']}|{minutes}"
|
||||||
|
if key in sent:
|
||||||
|
continue
|
||||||
|
start = datetime.fromisoformat(inst["start"])
|
||||||
|
body = _when(start, minutes, inst["all_day"], tz, hour12)
|
||||||
|
if inst.get("location"):
|
||||||
|
body += f" · {inst['location']}"
|
||||||
|
title = inst["summary"] or "(no title)"
|
||||||
|
delivered += await _SDK.push.notify(
|
||||||
|
user_id,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
url=f"/p/calendar?date={_day(start, inst['all_day'], tz)}",
|
||||||
|
# One notification per occurrence: a later reminder replaces an earlier one.
|
||||||
|
tag=f"event-{inst['id']}-{inst['start']}",
|
||||||
|
sticky=True,
|
||||||
|
)
|
||||||
|
sent[key] = (start - timedelta(minutes=minutes)).isoformat()
|
||||||
|
# Forget reminders of events long past.
|
||||||
|
horizon = now - timedelta(days=2)
|
||||||
|
sent = {k: v for k, v in sent.items() if datetime.fromisoformat(v) > horizon}
|
||||||
|
await kv.set(_SENT_KEY, sent)
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
|
def _day(start: datetime, all_day: bool, tz: str) -> str:
|
||||||
|
"""The event's day (YYYY-MM-DD) as the user's calendar shows it."""
|
||||||
|
return start.astimezone(UTC if all_day else _zone(tz)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _zone(tz: str) -> ZoneInfo:
|
||||||
|
try:
|
||||||
|
return ZoneInfo(tz or "UTC")
|
||||||
|
except (ZoneInfoNotFoundError, ValueError):
|
||||||
|
return ZoneInfo("UTC")
|
||||||
|
|
||||||
|
|
||||||
|
async def reminder_job(
|
||||||
|
ctx: dict[str, Any], now: datetime | None = None, only_user: uuid.UUID | None = None
|
||||||
|
) -> int:
|
||||||
|
"""Cron job (every minute): push the reminders that fell due. ``now`` and
|
||||||
|
``only_user`` are for tests."""
|
||||||
|
now = now or datetime.now(UTC)
|
||||||
|
lo = now - CATCH_UP
|
||||||
|
hi = now + MAX_LEAD
|
||||||
|
async with _SDK.public_session() as pub:
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await pub.execute(
|
||||||
|
text(
|
||||||
|
f"SELECT {_COLUMNS} FROM plg_calendar_events e "
|
||||||
|
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||||
|
"WHERE e.dav_deleted = false "
|
||||||
|
+ ("AND e.user_id = :uid " if only_user else "")
|
||||||
|
+ "AND ("
|
||||||
|
# events with reminders: repeating ones, or ones starting soon
|
||||||
|
" (e.recurrence_id IS NULL AND e.reminders <> '{}' "
|
||||||
|
" AND (e.rrule IS NOT NULL OR e.dtstart BETWEEN :lo AND :hi))"
|
||||||
|
# moved / cancelled occurrences of repeating events
|
||||||
|
" OR (e.recurrence_id IS NOT NULL "
|
||||||
|
" AND (e.dtstart BETWEEN :lo AND :hi "
|
||||||
|
" OR e.recurrence_id BETWEEN :lo AND :hi)))"
|
||||||
|
),
|
||||||
|
{"lo": lo, "hi": hi, **({"uid": only_user} if only_user else {})},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_user: dict[uuid.UUID, list[Any]] = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
by_user[uuid.UUID(str(r["user_id"]))].append(r)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for user_id, user_rows in by_user.items():
|
||||||
|
base = [r for r in user_rows if r["recurrence_id"] is None]
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
overrides = [r for r in user_rows if r["recurrence_id"] is not None]
|
||||||
|
hits = due(expand_range(base, overrides, lo, hi), lo, now)
|
||||||
|
if not hits:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
total += await _notify_user(user_id, hits, now)
|
||||||
|
except Exception: # noqa: BLE001 - one user's trouble must not stop the rest
|
||||||
|
logger.warning("calendar reminders failed for user %s", user_id, exc_info=True)
|
||||||
|
return total
|
||||||
@@ -1850,6 +1850,18 @@ 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
|
||||||
|
// day, then strips the param so refresh is clean.
|
||||||
|
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";
|
||||||
|
window.history.replaceState({}, "", window.location.pathname);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
render();
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user