desktop: reminders reach you when the window isn't in front
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s

A Rust worker reads due reminders from the local store every 15 s and
announces each occurrence once: always to the main window as a toast, and
as a system notification (tauri-plugin-notification) when that window
isn't focused. The page no longer polls on the desktop; its Notification
went nowhere in WebKitGTK and its timer stopped with the window. The
Reminders page says what each surface actually does.

Core gains store::due_reminders, compared by instant, not by string.

Refs #5171

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:46:02 -04:00
co-authored by Claude Opus 5.5
parent 5989ffc1c6
commit df85535ea2
9 changed files with 942 additions and 25 deletions
+32 -11
View File
@@ -1,23 +1,33 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
import { isDesktop, onReminderDue } from "../desktop/bridge";
import router from "../router";
import { useUiStore } from "./ui";
import type { Note } from "./notes";
// Foreground reminder delivery — NO Web Push / service worker (per operator). While
// the app is open, poll for due reminders and surface each once as a toast (and, if
// the user opted in, a page-context OS notification). Real background delivery is a
// native-client concern.
// Reminder delivery. In a browser it is foreground only — NO Web Push / service
// worker (per operator): while the tab is open, poll for due reminders and surface
// each once as a toast (and, if the user opted in, a page-context OS notification).
//
// The desktop doesn't poll here. Its Rust worker reads the local store, decides
// what is due, raises the system notification itself when the window isn't in
// front, and tells this page so it can show the toast (#5171). The page's
// `Notification` went nowhere in WebKitGTK, and its timer stopped with the window.
const POLL_MS = 45_000;
const RECENT_MS = 15 * 60 * 1000; // on first load, only announce reminders due this recently
export const useReminderStore = defineStore("reminders", () => {
const osSupported = typeof Notification !== "undefined";
const desktop = isDesktop();
// The desktop raises its own system notifications; there is nothing to opt into.
const osSupported = !desktop && typeof Notification !== "undefined";
const osEnabled = ref(osSupported && Notification.permission === "granted");
// note id → the remind_at we already fired, so each occurrence notifies at most once.
const notified = new Map<string, string>();
let timer: ReturnType<typeof setInterval> | undefined;
// The desktop's subscription to its reminder worker, held as the promise so stop()
// can undo one whose registration hasn't finished yet.
let listening: Promise<() => void> | undefined;
let primed = false;
// Single owner of the reminders endpoint — the RemindersView reads its list through
@@ -26,8 +36,17 @@ export const useReminderStore = defineStore("reminders", () => {
return repo.notes.reminders();
}
/** Show the in-app toast for a due reminder; returns the title it showed. */
function toast(id: string, displayTitle: string): string {
const title = displayTitle || "Reminder";
useUiStore().showToast(`⏰ ${title}`, {
label: "Open",
run: () => void router.push({ path: "/", query: { open: id } }),
});
return title;
}
async function check(): Promise<void> {
const ui = useUiStore();
let notes: Note[];
try {
notes = await fetchReminders();
@@ -42,11 +61,7 @@ export const useReminderStore = defineStore("reminders", () => {
notified.set(n.id, n.remind_at);
// Silently prime a stale backlog on first load; only announce recently-due ones.
if (!primed && now - due > RECENT_MS) continue;
const title = n.display_title || "Reminder";
ui.showToast(`⏰ ${title}`, {
label: "Open",
run: () => void router.push({ path: "/", query: { open: n.id } }),
});
const title = toast(n.id, n.display_title);
if (osEnabled.value) {
try {
new Notification("Inkwell reminder", { body: title });
@@ -59,6 +74,10 @@ export const useReminderStore = defineStore("reminders", () => {
}
function start(): void {
if (desktop) {
if (!listening) listening = onReminderDue((r) => void toast(r.id, r.title));
return;
}
if (timer) return;
void check();
timer = setInterval(() => void check(), POLL_MS);
@@ -66,6 +85,8 @@ export const useReminderStore = defineStore("reminders", () => {
function stop(): void {
if (timer) clearInterval(timer);
timer = undefined;
void listening?.then((off) => off());
listening = undefined;
}
async function enableOs(): Promise<void> {
if (!osSupported) return;