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
+24
View File
@@ -295,3 +295,27 @@ export async function onCaptured(handler: () => void): Promise<() => void> {
if (!events) return () => {};
return events.listen(CAPTURED_EVENT, () => handler());
}
/** A reminder the desktop's reminder worker found due (desktop/src-tauri/src/reminders.rs). */
export interface DueReminder {
id: string;
title: string;
remind_at: string;
due_ms: number;
}
/** Fired at the main window for each reminder occurrence, once. */
const REMINDER_DUE_EVENT = "inkwell://reminder-due";
/**
* Run `handler` for each reminder the desktop announces. On the desktop the Rust
* worker decides what is due, and raises the system notification itself when the
* window isn't in front; the page only shows its toast.
*
* Returns an unlisten function, or a no-op where there is no event API.
*/
export async function onReminderDue(handler: (reminder: DueReminder) => void): Promise<() => void> {
const events = window.__TAURI__?.event;
if (!events) return () => {};
return events.listen<DueReminder>(REMINDER_DUE_EVENT, (e) => handler(e.payload));
}
+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;
+8 -3
View File
@@ -7,9 +7,16 @@ import AsyncState from "../components/AsyncState.vue";
import EmptyState from "../components/EmptyState.vue";
import NoteEditor from "../components/NoteEditor.vue";
import NoteGrid from "../components/NoteGrid.vue";
import { isDesktop } from "../desktop/bridge";
const reminders = useReminderStore();
// What this surface actually does with a due reminder. The desktop's worker raises
// system notifications while it runs; a browser tab can only alert while it is open.
const deliveryNote = isDesktop()
? "Due reminders show as system notifications while Inkwell is running, even minimised."
: "Due reminders pop up while this tab is open. The desktop and Android apps alert you in the background.";
const { items, loading, error, load } = useNoteList(() => reminders.fetchReminders(), "Couldn't load reminders.");
const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({
@@ -37,9 +44,7 @@ onMounted(load);
</button>
<span v-else-if="reminders.osEnabled" class="text-xs text-neutral-400">Notifications on ✓</span>
</div>
<p class="mb-5 text-xs text-neutral-400">
Due reminders pop up while Inkwell is open. Background alerts arrive with the desktop &amp; mobile apps.
</p>
<p class="mb-5 text-xs text-neutral-400">{{ deliveryNote }}</p>
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't load reminders" @retry="load">
<EmptyState