From 01e300ee805a285bd797ac9d5787f86bd217cab3 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 14:27:24 -0400 Subject: [PATCH 1/5] feat(desktop): reminders notifications, snooze, overlay, inbox view Builds four reminder surfaces on a shared react-query spine: - A single useRemindersQuery keyed by pubkey, invalidated in all four mutation paths (create/snooze/complete/cancel), so the badge, channel overlay, panel, and fire-on-due detection can never disagree. - App-level watermark fire-on-due: a persisted lastReminderCheck timestamp, firing pending reminders that newly cross not_before since the watermark, coalesced into one toast, seeded to now on first launch so history is not replayed. Pure predicates live in lib/reminderFilters.ts so the highest-risk detection logic is unit-testable without react-query. - Slack-style snooze dropdown over a shared TIME_PRESETS module with a future-time validator guarding the custom date/time surface. - Blue-tint overlay on channel messages that have an active reminder. - Reminders relocated into the inbox as a ?view=reminders mode rather than a standalone route: it renders RemindersPanel rows directly and never enters the FeedItem selection model, so the detail-pane/selectedItemId collision is dissolved by construction. /reminders redirects to /?view=reminders to preserve history and bookmarks; the Bell sidebar item is removed. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/AppShell.helpers.ts | 8 - desktop/src/app/AppShell.tsx | 9 +- .../src/app/navigation/useAppNavigation.ts | 12 - desktop/src/app/routes/index.tsx | 2 + desktop/src/app/routes/reminders.tsx | 24 +- desktop/src/features/home/lib/inbox.ts | 3 +- desktop/src/features/home/ui/HomeView.tsx | 30 +- .../src/features/home/ui/InboxListPane.tsx | 103 +++--- .../src/features/messages/ui/MessageRow.tsx | 4 +- desktop/src/features/reminders/hooks.ts | 69 +++++ .../reminders/lib/reminderFilters.test.mjs | 172 ++++++++++ .../features/reminders/lib/reminderFilters.ts | 95 ++++++ .../reminders/lib/timePresets.test.mjs | 78 +++++ .../src/features/reminders/lib/timePresets.ts | 66 ++++ .../reminders/ui/RemindMeLaterDialog.tsx | 146 +++------ .../reminders/ui/RemindMeLaterProvider.tsx | 26 +- .../features/reminders/ui/RemindersPanel.tsx | 293 ++++++------------ .../features/reminders/ui/RemindersScreen.tsx | 12 - .../src/features/reminders/ui/SnoozeMenu.tsx | 111 +++++++ .../reminders/useReminderNotifications.ts | 101 ++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 16 - 21 files changed, 968 insertions(+), 412 deletions(-) create mode 100644 desktop/src/features/reminders/hooks.ts create mode 100644 desktop/src/features/reminders/lib/reminderFilters.test.mjs create mode 100644 desktop/src/features/reminders/lib/reminderFilters.ts create mode 100644 desktop/src/features/reminders/lib/timePresets.test.mjs create mode 100644 desktop/src/features/reminders/lib/timePresets.ts delete mode 100644 desktop/src/features/reminders/ui/RemindersScreen.tsx create mode 100644 desktop/src/features/reminders/ui/SnoozeMenu.tsx create mode 100644 desktop/src/features/reminders/useReminderNotifications.ts diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 0a10e72db2e..8ce0b8ca19c 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -5,7 +5,6 @@ export type AppView = | "home" | "channel" | "agents" - | "reminders" | "workflows" | "pulse" | "projects"; @@ -86,13 +85,6 @@ export function deriveShellRoute(pathname: string): { }; } - if (pathname === "/reminders") { - return { - selectedChannelId: null, - selectedView: "reminders", - }; - } - return { selectedChannelId: null, selectedView: "home", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 99eccf41007..cca89f4716b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -66,6 +66,7 @@ import { } from "@/features/settings/ui/SettingsPanels"; import { HuddleBar, HuddleProvider } from "@/features/huddle"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; +import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; @@ -115,7 +116,6 @@ export function AppShell() { goChannel, goHome, goProjects, - goReminders, goPulse, goSettings, goWorkflows, @@ -163,6 +163,10 @@ export function AppShell() { const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey); const { feedProfilesQuery, homeFeedQuery, notificationSettings } = useHomeFeedNotifications(identityQuery.data?.pubkey); + useReminderNotifications( + identityQuery.data?.pubkey, + notificationSettings.settings, + ); const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); @@ -714,7 +718,7 @@ export function AppShell() { }} > - +
void goHome()} onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} - onSelectReminders={() => void goReminders()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} onSetPresenceStatus={(status) => diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2062b1dd392..0788bea197e 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -90,17 +90,6 @@ export function useAppNavigation() { [commitNavigation], ); - const goReminders = React.useCallback( - (behavior?: NavigationBehavior) => - commitNavigation( - { - to: "/reminders", - }, - behavior, - ), - [commitNavigation], - ); - const goProject = React.useCallback( (projectId: string, behavior?: NavigationBehavior) => commitNavigation( @@ -272,7 +261,6 @@ export function useAppNavigation() { goProject, goProjects, goPulse, - goReminders, goSettings, goWorkflow, goWorkflows, diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index d3b49e85e7a..9f92e7d4707 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -12,6 +12,7 @@ import { useIdentityQuery } from "@/shared/api/hooks"; type HomeRouteSearch = { item?: string; + view?: "reminders"; }; function validateHomeSearch(search: Record): HomeRouteSearch { @@ -20,6 +21,7 @@ function validateHomeSearch(search: Record): HomeRouteSearch { typeof search.item === "string" && search.item.length > 0 ? search.item : undefined, + view: search.view === "reminders" ? "reminders" : undefined, }; } diff --git a/desktop/src/app/routes/reminders.tsx b/desktop/src/app/routes/reminders.tsx index 37599762b4d..ba341fe7a76 100644 --- a/desktop/src/app/routes/reminders.tsx +++ b/desktop/src/app/routes/reminders.tsx @@ -1,19 +1,11 @@ -import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; - -const RemindersScreen = React.lazy(async () => { - const module = await import("@/features/reminders/ui/RemindersScreen"); - return { default: module.RemindersScreen }; -}); +import { createFileRoute, redirect } from "@tanstack/react-router"; +// Reminders is now a view mode inside the inbox (`/?view=reminders`), not a +// standalone screen. This redirect preserves existing history entries and +// bookmarks pointing at `/reminders` — they land in the inbox Reminders view +// instead of dead-ending. export const Route = createFileRoute("/reminders")({ - component: RemindersRouteComponent, + beforeLoad: () => { + throw redirect({ to: "/", search: { view: "reminders" } }); + }, }); - -function RemindersRouteComponent() { - return ( - - - - ); -} diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 3e40b187ae3..99d4fe31ba4 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -17,7 +17,8 @@ export type InboxFilter = | "mention" | "needs_action" | "activity" - | "agent_activity"; + | "agent_activity" + | "reminders"; export type InboxItem = { avatarUrl: string | null; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index f28e15901f3..fd5085bb31b 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -37,6 +37,10 @@ import { import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { + countDueReminders, + useRemindersQuery, +} from "@/features/reminders/hooks"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { HomeFeedResponse } from "@/shared/api/types"; import { KIND_REACTION } from "@/shared/constants/kinds"; @@ -81,7 +85,14 @@ export function HomeView({ // background data loads must never trigger navigations. const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } = useHistorySearchState(INBOX_SEARCH_KEYS); - const urlSelectedItemId = inboxSearchValues.item; + const isReminders = filter === "reminders"; + const isMessagesMode = !isReminders; + const remindersQuery = useRemindersQuery(currentPubkey); + const dueReminderCount = countDueReminders(remindersQuery.data ?? []); + // `?item=` is Messages-mode-only machinery: a reminder never enters the + // FeedItem selection model, so reload while in Reminders mode keeps a stale + // `?item=` unconsumed and does not snap back to a feed-item detail view. + const urlSelectedItemId = isMessagesMode ? inboxSearchValues.item : null; const [selectedItemId, setSelectedItemId] = React.useState( urlSelectedItemId, ); @@ -251,6 +262,14 @@ export function HomeView({ return localReplies.filter((reply) => !contextIds.has(reply.id)); }, [contextMessages, localRepliesByItemId, selectedItem]); React.useEffect(() => { + // Auto-selection is Messages-mode-only: in Reminders mode no FeedItem is + // ever selected, so default-selecting one behind the reminders list would + // be wasted work and could drive narrow-viewport detail off a stale feed + // selection. + if (!isMessagesMode) { + return; + } + // While the feed is loading (e.g. a reload restoring `?item=` from the // URL) the selected item simply hasn't arrived yet — don't clobber it. if (isLoading || !feed) { @@ -278,6 +297,7 @@ export function HomeView({ filteredItems, homeInboxWidthPx, isLoading, + isMessagesMode, isNarrowHomeViewport, selectedItemId, ]); @@ -347,8 +367,12 @@ export function HomeView({ selectedItem.item.pubkey.trim().toLowerCase(); const isSinglePanelDetailView = isNarrowHomeViewport && selectedItemId !== null; + // Reminders mode is single-pane: the reminders list renders inline row + // actions and never drives the FeedItem detail pane, so the detail column is + // not rendered at all (no empty pane on wide viewports). const showListPane = !isSinglePanelDetailView; - const showDetailPane = !isNarrowHomeViewport || isSinglePanelDetailView; + const showDetailPane = + isMessagesMode && (!isNarrowHomeViewport || isSinglePanelDetailView); const maxEffectiveInboxListWidthPx = homeInboxWidthPx > 0 ? Math.max( @@ -384,6 +408,7 @@ export function HomeView({ {showListPane ? ( diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index c4131d8beb0..9a79cc8abe7 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Inbox } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import * as React from "react"; import { @@ -6,6 +6,7 @@ import { type InboxFilter, type InboxItem, } from "@/features/home/lib/inbox"; +import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; @@ -27,6 +28,7 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "needs_action", label: "Needs Action" }, { value: "activity", label: "Activity" }, { value: "agent_activity", label: "Agents" }, + { value: "reminders", label: "Reminders" }, ]; type InboxListPaneProps = { @@ -37,6 +39,8 @@ type InboxListPaneProps = { onSelect: (itemId: string) => void; selectedId: string | null; showRightDivider?: boolean; + dueReminderCount: number; + reminderPubkey?: string; }; export function InboxListPane({ @@ -47,8 +51,11 @@ export function InboxListPane({ onSelect, selectedId, showRightDivider = false, + dueReminderCount, + reminderPubkey, }: InboxListPaneProps) { const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); + const isReminders = filter === "reminders"; const scrollRef = React.useRef(null); const renderItem = (item: InboxItem) => { @@ -144,13 +151,7 @@ export function InboxListPane({ >
-
-
- -

- Inbox -

-
+
- onFilterChange(value as InboxFilter) - } + onValueChange={(value) => onFilterChange(value as InboxFilter)} value={filter} > {FILTER_OPTIONS.map((option) => ( @@ -175,7 +182,18 @@ export function InboxListPane({ key={option.value} value={option.value} > - {option.label} + + {option.label} + {option.value === "reminders" && + dueReminderCount > 0 ? ( + + {dueReminderCount} + + ) : null} + ))} @@ -185,32 +203,43 @@ export function InboxListPane({
-
- {items.length === 0 ? ( -
-
-

- No messages found -

-

- Switch back to all mail to see more messages. -

+ {isReminders ? ( +
+ {reminderPubkey ? ( + + ) : null} +
+ ) : ( +
+ {items.length === 0 ? ( +
+
+

+ No messages found +

+

+ Switch back to all mail to see more messages. +

+
-
- ) : ( - item.id} - items={items} - renderItem={renderItem} - scrollRef={scrollRef} - /> - )} -
+ ) : ( + item.id} + items={items} + renderItem={renderItem} + scrollRef={scrollRef} + /> + )} +
+ )} ); } diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 83ece19c7ff..869ef0cb4b6 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -90,7 +90,8 @@ export const MessageRow = React.memo( errorMessage: reactionErrorMessage, select: handleReactionSelect, } = useReactionHandler(message, onToggleReaction); - const { openReminder } = useRemindLater(); + const { openReminder, activeReminderEventIds } = useRemindLater(); + const hasActiveReminder = activeReminderEventIds.has(message.id); const mentionNames = React.useMemo( () => resolveMentionNames(message.tags, profiles), [profiles, message.tags], @@ -392,6 +393,7 @@ export const MessageRow = React.memo( ? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50" : "px-2", "flex items-start gap-2.5", + hasActiveReminder ? "bg-blue-500/10" : "", highlighted ? "-mx-4 rounded-none px-6 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-8" : "", diff --git a/desktop/src/features/reminders/hooks.ts b/desktop/src/features/reminders/hooks.ts new file mode 100644 index 00000000000..879015e89d4 --- /dev/null +++ b/desktop/src/features/reminders/hooks.ts @@ -0,0 +1,69 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + cancelReminder, + completeReminder, + createReminder, + fetchReminders, + snoozeReminder, +} from "@/features/reminders/lib/reminderService"; +import { countDue } from "@/features/reminders/lib/reminderFilters"; +import type { + Reminder, + ReminderTarget, +} from "@/features/reminders/lib/reminderTypes"; + +export const remindersQueryKey = (pubkey: string) => + ["reminders", pubkey] as const; + +/** Re-exported so the inbox badge has one import for the due count. */ +export const countDueReminders = countDue; + +/** + * The single source of truth for a user's reminders. Badge, channel overlay, + * panel, and fire-on-due detection all read this one query, so invalidating it + * (see {@link useReminderMutations}) keeps every surface consistent. + */ +export function useRemindersQuery(pubkey: string | undefined) { + return useQuery({ + enabled: Boolean(pubkey), + queryKey: remindersQueryKey(pubkey ?? ""), + queryFn: () => fetchReminders(pubkey ?? ""), + staleTime: 30_000, + }); +} + +/** + * Wraps every reminder write so the shared query is invalidated on success — + * the consistency spine the panel/badge/overlay all depend on. A mutation that + * skipped invalidation would leave those surfaces stale until the next refetch. + */ +export function useReminderMutations(pubkey: string) { + const queryClient = useQueryClient(); + const invalidate = () => + queryClient.invalidateQueries({ queryKey: remindersQueryKey(pubkey) }); + + const create = useMutation({ + mutationFn: (input: { + target: ReminderTarget; + notBefore: number; + note?: string; + }) => createReminder(input.target, input.notBefore, input.note), + onSuccess: invalidate, + }); + const complete = useMutation({ + mutationFn: (reminder: Reminder) => completeReminder(pubkey, reminder), + onSuccess: invalidate, + }); + const snooze = useMutation({ + mutationFn: (input: { reminder: Reminder; notBefore: number }) => + snoozeReminder(pubkey, input.reminder, input.notBefore), + onSuccess: invalidate, + }); + const cancel = useMutation({ + mutationFn: (reminder: Reminder) => cancelReminder(pubkey, reminder), + onSuccess: invalidate, + }); + + return { create, complete, snooze, cancel }; +} diff --git a/desktop/src/features/reminders/lib/reminderFilters.test.mjs b/desktop/src/features/reminders/lib/reminderFilters.test.mjs new file mode 100644 index 00000000000..aca5382d6db --- /dev/null +++ b/desktop/src/features/reminders/lib/reminderFilters.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countDue, + dueSince, + groupReminders, + isDue, +} from "./reminderFilters.ts"; + +/** + * Build a Reminder fixture. `notBefore` and `status` are the only fields the + * filters read besides `createdAt` (for done-group ordering). + */ +function reminder({ id = "r", notBefore, status = "pending", createdAt = 0 }) { + return { + id, + eventId: `${id}-evt`, + notBefore, + createdAt, + content: { status }, + }; +} + +const NOW = 1_000; + +test("isDue_pending_reminder_at_now_is_due", () => { + assert.equal(isDue(reminder({ notBefore: NOW }), NOW), true); +}); + +test("isDue_pending_reminder_in_future_is_not_due", () => { + assert.equal(isDue(reminder({ notBefore: NOW + 1 }), NOW), false); +}); + +test("isDue_done_reminder_in_past_is_not_due", () => { + assert.equal( + isDue(reminder({ notBefore: NOW - 100, status: "done" }), NOW), + false, + ); +}); + +test("isDue_reminder_without_notBefore_is_not_due", () => { + assert.equal(isDue(reminder({ notBefore: undefined }), NOW), false); +}); + +test("countDue_counts_only_due_pending_reminders", () => { + const reminders = [ + reminder({ id: "a", notBefore: NOW - 1 }), + reminder({ id: "b", notBefore: NOW }), + reminder({ id: "c", notBefore: NOW + 1 }), + reminder({ id: "d", notBefore: NOW - 1, status: "done" }), + reminder({ id: "e", notBefore: NOW - 1, status: "cancelled" }), + ]; + assert.equal(countDue(reminders, NOW), 2); +}); + +test("countDue_empty_list_returns_zero", () => { + assert.equal(countDue([], NOW), 0); +}); + +// dueSince — the watermark fire window. Strict lower bound `>`, inclusive `<=`. +test("dueSince_fires_reminder_crossing_window_since_watermark", () => { + const due = dueSince([reminder({ notBefore: 50 })], 0, 100); + assert.equal(due.length, 1); +}); + +test("dueSince_excludes_reminder_at_exactly_the_watermark", () => { + // notBefore === watermark fails the strict `>`: the seed-to-now first-launch + // case must not replay an already-due reminder as a toast. + const due = dueSince([reminder({ notBefore: 100 })], 100, 200); + assert.equal(due.length, 0); +}); + +test("dueSince_includes_reminder_at_exactly_now", () => { + const due = dueSince([reminder({ notBefore: 100 })], 0, 100); + assert.equal(due.length, 1); +}); + +test("dueSince_excludes_future_reminder_past_now", () => { + const due = dueSince([reminder({ notBefore: 150 })], 0, 100); + assert.equal(due.length, 0); +}); + +test("dueSince_excludes_non_pending_in_window", () => { + const due = dueSince( + [ + reminder({ id: "done", notBefore: 50, status: "done" }), + reminder({ id: "cancel", notBefore: 50, status: "cancelled" }), + ], + 0, + 100, + ); + assert.equal(due.length, 0); +}); + +test("dueSince_excludes_reminder_without_notBefore", () => { + const due = dueSince([reminder({ notBefore: undefined })], 0, 100); + assert.equal(due.length, 0); +}); + +test("dueSince_empty_watermark_window_returns_empty", () => { + // watermark === now: no new reminder can have crossed since last check. + const due = dueSince([reminder({ notBefore: 50 })], 100, 100); + assert.equal(due.length, 0); +}); + +// groupReminders — bucketing. It reads the real wall clock for "now", so +// fixtures are anchored to real time. "Today" = up to local end-of-day. +const realNow = Math.floor(Date.now() / 1_000); +const realEndOfTodaySecs = (() => { + const d = new Date(); + d.setHours(23, 59, 59, 999); + return Math.floor(d.getTime() / 1_000); +})(); + +test("groupReminders_buckets_overdue_today_upcoming", () => { + // A timestamp strictly between now and end-of-day, robust to running near + // midnight: the midpoint can never coincide with either boundary. + const todaySecs = Math.floor((realNow + realEndOfTodaySecs) / 2); + const groups = groupReminders([ + reminder({ id: "over", notBefore: realNow - 100 }), + reminder({ id: "today", notBefore: todaySecs }), + reminder({ id: "soon", notBefore: realEndOfTodaySecs + 86_400 }), + ]); + const labels = groups.map((g) => g.label); + assert.deepEqual(labels, ["Overdue", "Today", "Upcoming"]); +}); + +test("groupReminders_omits_empty_buckets", () => { + const groups = groupReminders([reminder({ notBefore: realNow - 100 })]); + assert.deepEqual( + groups.map((g) => g.label), + ["Overdue"], + ); +}); + +test("groupReminders_excludes_done_when_includeDone_false", () => { + const groups = groupReminders([ + reminder({ notBefore: realNow - 100, status: "done" }), + ]); + assert.equal(groups.length, 0); +}); + +test("groupReminders_appends_completed_group_when_includeDone_true", () => { + const groups = groupReminders( + [ + reminder({ id: "over", notBefore: realNow - 100 }), + reminder({ id: "d1", status: "done", createdAt: 1 }), + reminder({ id: "d2", status: "done", createdAt: 2 }), + ], + true, + ); + const completed = groups.find((g) => g.label === "Completed"); + assert.ok(completed); + // Done reminders are sorted newest-first by createdAt. + assert.deepEqual( + completed.reminders.map((r) => r.id), + ["d2", "d1"], + ); +}); + +test("groupReminders_never_surfaces_cancelled_reminders", () => { + const groups = groupReminders( + [reminder({ notBefore: realNow - 100, status: "cancelled" })], + true, + ); + assert.equal(groups.length, 0); +}); + +test("groupReminders_empty_list_returns_empty", () => { + assert.deepEqual(groupReminders([]), []); +}); diff --git a/desktop/src/features/reminders/lib/reminderFilters.ts b/desktop/src/features/reminders/lib/reminderFilters.ts new file mode 100644 index 00000000000..fca2b22c617 --- /dev/null +++ b/desktop/src/features/reminders/lib/reminderFilters.ts @@ -0,0 +1,95 @@ +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; + +const nowSeconds = () => Math.floor(Date.now() / 1_000); + +/** A pending reminder whose `notBefore` has arrived (`<= now`). */ +export function isDue(reminder: Reminder, now: number): boolean { + return ( + reminder.content.status === "pending" && + reminder.notBefore !== undefined && + reminder.notBefore <= now + ); +} + +/** + * Count pending reminders that are due or overdue — the single definition + * shared by the inbox badge and the fire-on-due hook so the two surfaces can + * never disagree on what "due" means. + */ +export function countDue( + reminders: readonly Reminder[], + now: number = nowSeconds(), +): number { + return reminders.filter((r) => isDue(r, now)).length; +} + +/** + * Pending reminders that newly crossed `notBefore` since `watermark` — the + * fire-on-due window. The strict `>` lower bound is deliberate: a reminder + * already past at the seeded watermark (first launch) never fires a toast, so + * history is not replayed. The upper bound (`<= now`) excludes future ones. + */ +export function dueSince( + reminders: readonly Reminder[], + watermark: number, + now: number, +): Reminder[] { + return reminders.filter( + (r) => + r.content.status === "pending" && + r.notBefore !== undefined && + r.notBefore > watermark && + r.notBefore <= now, + ); +} + +export type ReminderGroup = { + label: string; + reminders: Reminder[]; +}; + +/** + * Bucket pending reminders into Overdue/Today/Upcoming, and — when + * `includeDone` is set — append a Completed group of done reminders. Cancelled + * reminders are never surfaced. + */ +export function groupReminders( + reminders: Reminder[], + includeDone = false, +): ReminderGroup[] { + const now = nowSeconds(); + const endOfToday = new Date(); + endOfToday.setHours(23, 59, 59, 999); + const endOfTodaySecs = Math.floor(endOfToday.getTime() / 1_000); + + const overdue: Reminder[] = []; + const today: Reminder[] = []; + const upcoming: Reminder[] = []; + const done: Reminder[] = []; + + for (const r of reminders) { + if (r.content.status === "done") { + if (includeDone) done.push(r); + continue; + } + if (r.content.status !== "pending") continue; + if (!r.notBefore) continue; + if (r.notBefore <= now) { + overdue.push(r); + } else if (r.notBefore <= endOfTodaySecs) { + today.push(r); + } else { + upcoming.push(r); + } + } + + done.sort((a, b) => b.createdAt - a.createdAt); + + const groups: ReminderGroup[] = []; + if (overdue.length > 0) groups.push({ label: "Overdue", reminders: overdue }); + if (today.length > 0) groups.push({ label: "Today", reminders: today }); + if (upcoming.length > 0) + groups.push({ label: "Upcoming", reminders: upcoming }); + if (done.length > 0) groups.push({ label: "Completed", reminders: done }); + return groups; +} diff --git a/desktop/src/features/reminders/lib/timePresets.test.mjs b/desktop/src/features/reminders/lib/timePresets.test.mjs new file mode 100644 index 00000000000..b11b7ada783 --- /dev/null +++ b/desktop/src/features/reminders/lib/timePresets.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + TIME_PRESETS, + parseCustomDateTime, + todayDateString, +} from "./timePresets.ts"; + +const nowSeconds = () => Math.floor(Date.now() / 1_000); + +test("TIME_PRESETS_every_preset_returns_strictly_future_timestamp", () => { + const now = nowSeconds(); + for (const preset of TIME_PRESETS) { + assert.ok( + preset.getTimestamp() > now, + `${preset.label} must be strictly in the future`, + ); + } +}); + +test("TIME_PRESETS_relative_offsets_match_their_labels", () => { + const before = nowSeconds(); + const byLabel = Object.fromEntries( + TIME_PRESETS.map((p) => [p.label, p.getTimestamp()]), + ); + // Allow a 2s window for clock drift across the getTimestamp calls. + assert.ok(Math.abs(byLabel["In 30 minutes"] - (before + 30 * 60)) <= 2); + assert.ok(Math.abs(byLabel["In 1 hour"] - (before + 60 * 60)) <= 2); + assert.ok(Math.abs(byLabel["In 3 hours"] - (before + 3 * 60 * 60)) <= 2); +}); + +test("TIME_PRESETS_9am_presets_land_on_a_9am_boundary", () => { + for (const label of ["Tomorrow at 9am", "Next Monday at 9am"]) { + const preset = TIME_PRESETS.find((p) => p.label === label); + const d = new Date(preset.getTimestamp() * 1_000); + assert.equal(d.getHours(), 9); + assert.equal(d.getMinutes(), 0); + } +}); + +test("TIME_PRESETS_next_monday_lands_on_a_monday", () => { + const preset = TIME_PRESETS.find((p) => p.label === "Next Monday at 9am"); + const d = new Date(preset.getTimestamp() * 1_000); + assert.equal(d.getDay(), 1); // Monday +}); + +test("parseCustomDateTime_future_datetime_returns_timestamp", () => { + const future = new Date(Date.now() + 24 * 60 * 60 * 1_000); + const date = `${future.getFullYear()}-${String(future.getMonth() + 1).padStart(2, "0")}-${String(future.getDate()).padStart(2, "0")}`; + const result = parseCustomDateTime(date, "14:30"); + assert.ok(result !== null); + assert.ok(result > nowSeconds()); +}); + +test("parseCustomDateTime_past_datetime_returns_null", () => { + // One year ago — unambiguously past regardless of run time. + const past = new Date(Date.now() - 365 * 24 * 60 * 60 * 1_000); + const date = `${past.getFullYear()}-${String(past.getMonth() + 1).padStart(2, "0")}-${String(past.getDate()).padStart(2, "0")}`; + assert.equal(parseCustomDateTime(date, "09:00"), null); +}); + +test("parseCustomDateTime_empty_inputs_return_null", () => { + assert.equal(parseCustomDateTime("", "09:00"), null); + assert.equal(parseCustomDateTime("2099-01-01", ""), null); + assert.equal(parseCustomDateTime("", ""), null); +}); + +test("parseCustomDateTime_malformed_inputs_return_null", () => { + assert.equal(parseCustomDateTime("not-a-date", "09:00"), null); + assert.equal(parseCustomDateTime("2099-01-01", "99:99"), null); +}); + +test("todayDateString_returns_today_in_YYYY_MM_DD_local", () => { + const now = new Date(); + const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + assert.equal(todayDateString(), expected); +}); diff --git a/desktop/src/features/reminders/lib/timePresets.ts b/desktop/src/features/reminders/lib/timePresets.ts new file mode 100644 index 00000000000..90400e2d085 --- /dev/null +++ b/desktop/src/features/reminders/lib/timePresets.ts @@ -0,0 +1,66 @@ +/** + * Shared reminder time presets — the single source of truth for both the + * create dialog and the snooze dropdown. Each preset returns a Unix timestamp + * (seconds) strictly in the future. + */ +export type TimePreset = { + label: string; + getTimestamp: () => number; +}; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1_000); +} + +/** + * Next occurrence of `dayOffset` days from now at 9am local time. If that + * instant is already past (e.g. it is after 9am and offset is 0), roll to the + * following day so the result is always in the future. + */ +function nextDayAt9am(dayOffset: number): number { + const now = new Date(); + const target = new Date(now); + target.setDate(target.getDate() + dayOffset); + target.setHours(9, 0, 0, 0); + if (target.getTime() <= now.getTime()) { + target.setDate(target.getDate() + 1); + } + return Math.floor(target.getTime() / 1_000); +} + +export const TIME_PRESETS: TimePreset[] = [ + { label: "In 30 minutes", getTimestamp: () => nowSeconds() + 30 * 60 }, + { label: "In 1 hour", getTimestamp: () => nowSeconds() + 60 * 60 }, + { label: "In 3 hours", getTimestamp: () => nowSeconds() + 3 * 60 * 60 }, + { label: "Tomorrow at 9am", getTimestamp: () => nextDayAt9am(1) }, + { + label: "Next Monday at 9am", + getTimestamp: () => { + const daysUntilMonday = (8 - new Date().getDay()) % 7 || 7; + return nextDayAt9am(daysUntilMonday); + }, + }, +]; + +/** Today as `YYYY-MM-DD` in local time, for the custom date input `min`. */ +export function todayDateString(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Parse a `YYYY-MM-DD` + `HH:MM` pair into a future Unix timestamp (seconds), + * or null if the inputs are malformed or not strictly in the future. The shared + * guard for both create and snooze custom surfaces: the native time input has + * no `min`, so a past time would otherwise fire immediately. + */ +export function parseCustomDateTime(date: string, time: string): number | null { + if (!date || !time) return null; + const timestamp = Math.floor(new Date(`${date}T${time}`).getTime() / 1_000); + if (Number.isNaN(timestamp)) return null; + if (timestamp <= nowSeconds()) return null; + return timestamp; +} diff --git a/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx b/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx index f17ea279335..83e4cc7c1ab 100644 --- a/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx +++ b/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx @@ -2,10 +2,15 @@ import { CalendarClock, Clock } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import { createReminder } from "@/features/reminders/lib/reminderService"; +import { useReminderMutations } from "@/features/reminders/hooks"; +import { + parseCustomDateTime, + TIME_PRESETS, + todayDateString, +} from "@/features/reminders/lib/timePresets"; import type { ReminderTarget } from "@/features/reminders/lib/reminderTypes"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; import { Dialog, DialogContent, @@ -14,59 +19,9 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; -type TimePreset = { - label: string; - getTimestamp: () => number; -}; - -function getNextWeekday9am(dayOffset: number): number { - const now = new Date(); - const target = new Date(now); - target.setDate(target.getDate() + dayOffset); - target.setHours(9, 0, 0, 0); - if (target.getTime() <= now.getTime()) { - target.setDate(target.getDate() + 1); - } - return Math.floor(target.getTime() / 1_000); -} - -const TIME_PRESETS: TimePreset[] = [ - { - label: "In 30 minutes", - getTimestamp: () => Math.floor(Date.now() / 1_000) + 30 * 60, - }, - { - label: "In 1 hour", - getTimestamp: () => Math.floor(Date.now() / 1_000) + 60 * 60, - }, - { - label: "In 3 hours", - getTimestamp: () => Math.floor(Date.now() / 1_000) + 3 * 60 * 60, - }, - { - label: "Tomorrow at 9am", - getTimestamp: () => getNextWeekday9am(1), - }, - { - label: "Next Monday at 9am", - getTimestamp: () => { - const now = new Date(); - const daysUntilMonday = (8 - now.getDay()) % 7 || 7; - return getNextWeekday9am(daysUntilMonday); - }, - }, -]; - -function todayDateString(): string { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - export function RemindMeLaterDialog({ open, onOpenChange, @@ -76,48 +31,26 @@ export function RemindMeLaterDialog({ onOpenChange: (open: boolean) => void; target: ReminderTarget | null; }) { + const pubkey = useIdentityQuery().data?.pubkey ?? ""; + const { create } = useReminderMutations(pubkey); const [note, setNote] = React.useState(""); - const [isSubmitting, setIsSubmitting] = React.useState(false); const [customDate, setCustomDate] = React.useState(todayDateString); const [customTime, setCustomTime] = React.useState("09:00"); + const customTimestamp = parseCustomDateTime(customDate, customTime); - const handleSelect = async (preset: TimePreset) => { - if (!target || isSubmitting) return; - setIsSubmitting(true); - try { - await createReminder(target, preset.getTimestamp(), note || undefined); - toast.success("Reminder set"); - onOpenChange(false); - setNote(""); - } catch (error) { - toast.error("Failed to create reminder"); - console.error("[RemindMeLaterDialog] create failed:", error); - } finally { - setIsSubmitting(false); - } - }; - - const handleCustomSubmit = async () => { - if (!target || isSubmitting || !customDate || !customTime) return; - setIsSubmitting(true); - try { - const timestamp = Math.floor( - new Date(`${customDate}T${customTime}`).getTime() / 1_000, - ); - if (Number.isNaN(timestamp)) { - toast.error("Invalid date or time"); - return; - } - await createReminder(target, timestamp, note || undefined); - toast.success("Reminder set"); - onOpenChange(false); - setNote(""); - } catch (error) { - toast.error("Failed to create reminder"); - console.error("[RemindMeLaterDialog] custom create failed:", error); - } finally { - setIsSubmitting(false); - } + const submit = (notBefore: number) => { + if (!target || create.isPending) return; + create.mutate( + { target, notBefore, note: note || undefined }, + { + onSuccess: () => { + toast.success("Reminder set"); + onOpenChange(false); + setNote(""); + }, + onError: () => toast.error("Failed to create reminder"), + }, + ); }; return ( @@ -139,8 +72,8 @@ export function RemindMeLaterDialog({ key={preset.label} variant="outline" className="justify-start" - disabled={isSubmitting} - onClick={() => void handleSelect(preset)} + disabled={create.isPending} + onClick={() => submit(preset.getTimestamp())} > {preset.label} @@ -148,32 +81,35 @@ export function RemindMeLaterDialog({
-

+

Custom date & time

setCustomDate(e.target.value)} type="date" value={customDate} - onChange={(e) => setCustomDate(e.target.value)} - min={todayDateString()} - className="flex-1" - aria-label="Reminder date" /> setCustomTime(e.target.value)} type="time" value={customTime} - onChange={(e) => setCustomTime(e.target.value)} - className="w-[120px]" - aria-label="Reminder time" />
@@ -200,7 +136,7 @@ export function RemindMeLaterDialog({ diff --git a/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx b/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx index 4aa4483f4f4..61c884ef1db 100644 --- a/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx +++ b/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx @@ -1,14 +1,18 @@ import * as React from "react"; +import { useRemindersQuery } from "@/features/reminders/hooks"; import type { ReminderTarget } from "@/features/reminders/lib/reminderTypes"; import { RemindMeLaterDialog } from "./RemindMeLaterDialog"; type RemindMeLaterContextValue = { openReminder: (target: ReminderTarget) => void; + /** Event IDs of messages with a pending reminder, for channel tinting. */ + activeReminderEventIds: ReadonlySet; }; const RemindMeLaterContext = React.createContext({ openReminder: () => {}, + activeReminderEventIds: new Set(), }); export function useRemindLater() { @@ -16,8 +20,10 @@ export function useRemindLater() { } export function RemindMeLaterProvider({ + pubkey, children, }: { + pubkey?: string; children: React.ReactNode; }) { const [open, setOpen] = React.useState(false); @@ -28,7 +34,25 @@ export function RemindMeLaterProvider({ setOpen(true); }, []); - const contextValue = React.useMemo(() => ({ openReminder }), [openReminder]); + const remindersQuery = useRemindersQuery(pubkey); + const reminders = remindersQuery.data; + const activeReminderEventIds = React.useMemo(() => { + const ids = new Set(); + for (const reminder of reminders ?? []) { + if ( + reminder.content.status === "pending" && + reminder.content.target?.eventId + ) { + ids.add(reminder.content.target.eventId); + } + } + return ids; + }, [reminders]); + + const contextValue = React.useMemo( + () => ({ openReminder, activeReminderEventIds }), + [openReminder, activeReminderEventIds], + ); return ( diff --git a/desktop/src/features/reminders/ui/RemindersPanel.tsx b/desktop/src/features/reminders/ui/RemindersPanel.tsx index 57aab417100..aff838cc93f 100644 --- a/desktop/src/features/reminders/ui/RemindersPanel.tsx +++ b/desktop/src/features/reminders/ui/RemindersPanel.tsx @@ -1,14 +1,14 @@ -import { Bell, Check, Clock, RotateCcw, X } from "lucide-react"; +import { Bell, Check, Clock, X } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { - cancelReminder, - completeReminder, - fetchReminders, - snoozeReminder, -} from "@/features/reminders/lib/reminderService"; + useRemindersQuery, + useReminderMutations, +} from "@/features/reminders/hooks"; +import { groupReminders } from "@/features/reminders/lib/reminderFilters"; import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { SnoozeMenu } from "@/features/reminders/ui/SnoozeMenu"; import { Button } from "@/shared/ui/button"; function formatRelativeTime(timestamp: number): string { @@ -29,205 +29,118 @@ function formatRelativeTime(timestamp: number): string { return `in ${Math.floor(diff / 86400)}d`; } -type ReminderGroup = { - label: string; - reminders: Reminder[]; -}; - -function groupReminders(reminders: Reminder[]): ReminderGroup[] { - const now = Math.floor(Date.now() / 1_000); - const endOfToday = new Date(); - endOfToday.setHours(23, 59, 59, 999); - const endOfTodaySecs = Math.floor(endOfToday.getTime() / 1_000); - - const overdue: Reminder[] = []; - const today: Reminder[] = []; - const upcoming: Reminder[] = []; - - for (const r of reminders) { - if (r.content.status !== "pending") continue; - if (!r.notBefore) continue; - if (r.notBefore <= now) { - overdue.push(r); - } else if (r.notBefore <= endOfTodaySecs) { - today.push(r); - } else { - upcoming.push(r); - } - } - - const groups: ReminderGroup[] = []; - if (overdue.length > 0) groups.push({ label: "Overdue", reminders: overdue }); - if (today.length > 0) groups.push({ label: "Today", reminders: today }); - if (upcoming.length > 0) - groups.push({ label: "Upcoming", reminders: upcoming }); - return groups; -} - function ReminderRow({ reminder, pubkey, - onUpdate, }: { reminder: Reminder; pubkey: string; - onUpdate: () => void; }) { - const [isActing, setIsActing] = React.useState(false); - - const handleComplete = async () => { - setIsActing(true); - try { - await completeReminder(pubkey, reminder); - toast.success("Reminder completed"); - onUpdate(); - } catch { - toast.error("Failed to complete reminder"); - } finally { - setIsActing(false); - } + const { complete, snooze, cancel } = useReminderMutations(pubkey); + const isDone = reminder.content.status === "done"; + const isActing = complete.isPending || snooze.isPending || cancel.isPending; + + const handleComplete = () => { + complete.mutate(reminder, { + onSuccess: () => toast.success("Reminder completed"), + onError: () => toast.error("Failed to complete reminder"), + }); }; - const handleSnooze = async () => { - setIsActing(true); - try { - const newNotBefore = Math.floor(Date.now() / 1_000) + 3600; - await snoozeReminder(pubkey, reminder, newNotBefore); - toast.success("Snoozed for 1 hour"); - onUpdate(); - } catch { - toast.error("Failed to snooze reminder"); - } finally { - setIsActing(false); - } + const handleSnooze = (notBefore: number) => { + snooze.mutate( + { reminder, notBefore }, + { + onSuccess: () => toast.success("Reminder snoozed"), + onError: () => toast.error("Failed to snooze reminder"), + }, + ); }; - const handleCancel = async () => { - setIsActing(true); - try { - await cancelReminder(pubkey, reminder); - toast.success("Reminder cancelled"); - onUpdate(); - } catch { - toast.error("Failed to cancel reminder"); - } finally { - setIsActing(false); - } + const handleCancel = () => { + cancel.mutate(reminder, { + onSuccess: () => toast.success("Reminder cancelled"), + onError: () => toast.error("Failed to cancel reminder"), + }); }; - const isOverdue = reminder.notBefore - ? reminder.notBefore <= Math.floor(Date.now() / 1_000) - : false; + const isOverdue = + !isDone && reminder.notBefore + ? reminder.notBefore <= Math.floor(Date.now() / 1_000) + : false; return (
-
-

+

+

{reminder.content.target?.preview || reminder.content.note || "Reminder"}

{reminder.content.target && reminder.content.note ? ( -

+

{reminder.content.note}

) : null} {reminder.notBefore ? (

- + {formatRelativeTime(reminder.notBefore)}

) : null}
-
- - - -
+ {isDone ? null : ( +
+ + + +
+ )}
); } -export function RemindersPanel({ pubkey }: { pubkey: string }) { - const [reminders, setReminders] = React.useState([]); - const [isLoading, setIsLoading] = React.useState(true); - - const loadReminders = React.useCallback(async () => { - try { - const fetched = await fetchReminders(pubkey); - setReminders(fetched); - } catch (error) { - console.error("[RemindersPanel] fetch failed:", error); - } finally { - setIsLoading(false); - } - }, [pubkey]); - - React.useEffect(() => { - void loadReminders(); - }, [loadReminders]); - - // Due-detection: check every 60s and show toast for newly due reminders. - const lastDueCheckRef = React.useRef(Math.floor(Date.now() / 1_000)); - React.useEffect(() => { - const interval = window.setInterval(() => { - const now = Math.floor(Date.now() / 1_000); - const lastCheck = lastDueCheckRef.current; - lastDueCheckRef.current = now; - - for (const r of reminders) { - if (r.content.status !== "pending") continue; - if (!r.notBefore) continue; - if (r.notBefore > lastCheck && r.notBefore <= now) { - toast("Reminder due", { - description: - r.content.target?.preview || - r.content.note || - "A reminder is waiting", - icon: , - }); - } - } - }, 60_000); - - return () => window.clearInterval(interval); - }, [reminders]); - - const groups = groupReminders(reminders); - const pendingCount = reminders.filter( - (r) => r.content.status === "pending", - ).length; +/** + * Renders a user's reminders as grouped rows. `includeDone` adds a Completed + * group (used by the inbox Reminders view); omit it for pending-only surfaces. + */ +export function RemindersPanel({ + pubkey, + includeDone = false, +}: { + pubkey: string; + includeDone?: boolean; +}) { + const remindersQuery = useRemindersQuery(pubkey); + const reminders = remindersQuery.data; + const groups = React.useMemo( + () => groupReminders(reminders ?? [], includeDone), + [reminders, includeDone], + ); - if (isLoading) { + if (remindersQuery.isLoading) { return (

Loading reminders...

@@ -235,11 +148,11 @@ export function RemindersPanel({ pubkey }: { pubkey: string }) { ); } - if (pendingCount === 0) { + if (groups.length === 0) { return (
-

No pending reminders

+

No reminders

Use "Remind me later" on any message to create one.

@@ -248,33 +161,17 @@ export function RemindersPanel({ pubkey }: { pubkey: string }) { } return ( -
-
-

- - Reminders - - ({pendingCount}) - -

-
-
- {groups.map((group) => ( -
-

- {group.label} -

- {group.reminders.map((r) => ( - void loadReminders()} - /> - ))} -
- ))} -
+
+ {groups.map((group) => ( +
+

+ {group.label} +

+ {group.reminders.map((r) => ( + + ))} +
+ ))}
); } diff --git a/desktop/src/features/reminders/ui/RemindersScreen.tsx b/desktop/src/features/reminders/ui/RemindersScreen.tsx deleted file mode 100644 index 69d269542e8..00000000000 --- a/desktop/src/features/reminders/ui/RemindersScreen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useIdentityQuery } from "@/shared/api/hooks"; -import { RemindersPanel } from "./RemindersPanel"; - -export function RemindersScreen() { - const identityQuery = useIdentityQuery(); - - if (!identityQuery.data?.pubkey) { - return null; - } - - return ; -} diff --git a/desktop/src/features/reminders/ui/SnoozeMenu.tsx b/desktop/src/features/reminders/ui/SnoozeMenu.tsx new file mode 100644 index 00000000000..f6b4b5799c7 --- /dev/null +++ b/desktop/src/features/reminders/ui/SnoozeMenu.tsx @@ -0,0 +1,111 @@ +import { Clock } from "lucide-react"; +import * as React from "react"; + +import { + parseCustomDateTime, + TIME_PRESETS, + todayDateString, +} from "@/features/reminders/lib/timePresets"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; + +/** + * Clock-icon dropdown of snooze presets plus a "Custom…" popover with a native + * date/time picker. Calls `onSnooze` with a future Unix timestamp (seconds). + * The custom surface uses the shared {@link parseCustomDateTime} guard so a + * past time is rejected rather than firing immediately. + */ +export function SnoozeMenu({ + disabled, + onSnooze, +}: { + disabled?: boolean; + onSnooze: (notBefore: number) => void; +}) { + const [customOpen, setCustomOpen] = React.useState(false); + const [customDate, setCustomDate] = React.useState(todayDateString); + const [customTime, setCustomTime] = React.useState("09:00"); + + const customTimestamp = parseCustomDateTime(customDate, customTime); + + return ( + + + + + + {TIME_PRESETS.map((preset) => ( + onSnooze(preset.getTimestamp())} + > + {preset.label} + + ))} + + + + { + // Keep the dropdown logic from closing the popover trigger. + event.preventDefault(); + setCustomOpen(true); + }} + > + Custom… + + + +

Snooze until

+
+ setCustomDate(event.target.value)} + type="date" + value={customDate} + /> + setCustomTime(event.target.value)} + type="time" + value={customTime} + /> +
+ +
+
+
+
+ ); +} diff --git a/desktop/src/features/reminders/useReminderNotifications.ts b/desktop/src/features/reminders/useReminderNotifications.ts new file mode 100644 index 00000000000..184bf19d456 --- /dev/null +++ b/desktop/src/features/reminders/useReminderNotifications.ts @@ -0,0 +1,101 @@ +import * as React from "react"; + +import { useRemindersQuery } from "@/features/reminders/hooks"; +import { dueSince } from "@/features/reminders/lib/reminderFilters"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { + requestDockBounce, + sendDesktopNotification, +} from "@/features/notifications/lib/desktop"; +import type { NotificationSettings } from "@/features/notifications/hooks"; +import { + playNotificationSound, + resolveSlotSound, +} from "@/features/notifications/lib/sound"; + +const WATERMARK_STORAGE_PREFIX = "buzz:lastReminderCheck:"; +const POLL_INTERVAL_MS = 30_000; + +function watermarkStorageKey(pubkey: string): string { + return `${WATERMARK_STORAGE_PREFIX}${pubkey.trim().toLowerCase()}`; +} + +/** + * Read the persisted watermark, seeding it to `now` on first-ever launch. + * Seeding to `now` (not 0) is deliberate: a 0 seed would replay the user's + * entire reminder history as toasts. A reminder already due at first launch + * fails the strict `notBefore > watermark` test and surfaces only in the + * panel/badge, never as a toast — see the plan's behavioral note. + */ +function readWatermark(pubkey: string): number { + const key = watermarkStorageKey(pubkey); + const stored = window.localStorage.getItem(key); + if (stored !== null) { + const parsed = Number(stored); + if (Number.isFinite(parsed)) return parsed; + } + const now = Math.floor(Date.now() / 1_000); + window.localStorage.setItem(key, String(now)); + return now; +} + +/** + * App-level fire-on-due detection. On launch and every {@link POLL_INTERVAL_MS} + * it fires reminders newly crossing their `not_before` since the persisted + * watermark, coalescing multiple due reminders into one toast, then advances + * the watermark. The toast respects `desktopEnabled` + the `needs_action` + * alert slot. This hook is the sole detector — mount it once at app level. + */ +export function useReminderNotifications( + pubkey: string | undefined, + settings: NotificationSettings, +): void { + const reminders = useRemindersQuery(pubkey).data; + const remindersRef = React.useRef([]); + remindersRef.current = reminders ?? []; + const settingsRef = React.useRef(settings); + settingsRef.current = settings; + + const fire = React.useEffectEvent((due: Reminder[]) => { + const current = settingsRef.current; + if ( + !current.desktopEnabled || + !current.slotAlertsEnabled.needs_action || + due.length === 0 + ) { + return; + } + + const body = + due.length === 1 + ? (due[0].content.target?.preview ?? + due[0].content.note ?? + "A reminder is waiting") + : `${due.length} reminders are due`; + + void sendDesktopNotification({ + title: "Reminder due", + body, + }).then((didSend) => { + if (!didSend) return; + playNotificationSound(resolveSlotSound(current, "needs_action")); + void requestDockBounce(); + }); + }); + + React.useEffect(() => { + if (!pubkey) return; + + const check = () => { + const watermark = readWatermark(pubkey); + const now = Math.floor(Date.now() / 1_000); + const due = dueSince(remindersRef.current, watermark, now); + fire(due); + window.localStorage.setItem(watermarkStorageKey(pubkey), String(now)); + }; + + check(); + const interval = window.setInterval(check, POLL_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [pubkey]); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index d0c51d3106c..9262d27e335 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -3,7 +3,6 @@ import { Activity, ArrowDown, ArrowUp, - Bell, Bot, FolderGit2, Home, @@ -101,7 +100,6 @@ type AppSidebarProps = { | "home" | "channel" | "agents" - | "reminders" | "workflows" | "pulse" | "projects"; @@ -142,7 +140,6 @@ type AppSidebarProps = { onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; - onSelectReminders: () => void; onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; @@ -204,7 +201,6 @@ export function AppSidebar({ onSelectAgents, onSelectProjects, onSelectPulse, - onSelectReminders, onSelectWorkflows, onSelectHome, onSelectChannel, @@ -539,18 +535,6 @@ export function AppSidebar({ ) : null} - - - - Reminders - - Date: Wed, 17 Jun 2026 14:38:49 -0400 Subject: [PATCH 2/5] refactor: align groupReminders notBefore guard and document watermark advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold two advisory review MINORs. groupReminders guarded on `!r.notBefore`, dropping epoch-0 timestamps, while isDue/dueSince use `!== undefined` — align all three so the lib reads consistently (epoch 0 is unreachable; no behavior change). Document that the watermark advances even when the toast is suppressed so a future reader does not mistake the no-backlog-replay design for a bug. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/reminders/lib/reminderFilters.test.mjs | 9 +++++++++ desktop/src/features/reminders/lib/reminderFilters.ts | 2 +- .../src/features/reminders/useReminderNotifications.ts | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/reminders/lib/reminderFilters.test.mjs b/desktop/src/features/reminders/lib/reminderFilters.test.mjs index aca5382d6db..6a1770a8de6 100644 --- a/desktop/src/features/reminders/lib/reminderFilters.test.mjs +++ b/desktop/src/features/reminders/lib/reminderFilters.test.mjs @@ -170,3 +170,12 @@ test("groupReminders_never_surfaces_cancelled_reminders", () => { test("groupReminders_empty_list_returns_empty", () => { assert.deepEqual(groupReminders([]), []); }); + +test("groupReminders_buckets_epoch_zero_notBefore_as_overdue", () => { + // Guards on `notBefore !== undefined`, matching isDue/dueSince: 0 is kept. + const groups = groupReminders([reminder({ notBefore: 0 })]); + assert.deepEqual( + groups.map((g) => g.label), + ["Overdue"], + ); +}); diff --git a/desktop/src/features/reminders/lib/reminderFilters.ts b/desktop/src/features/reminders/lib/reminderFilters.ts index fca2b22c617..57474f244f3 100644 --- a/desktop/src/features/reminders/lib/reminderFilters.ts +++ b/desktop/src/features/reminders/lib/reminderFilters.ts @@ -73,7 +73,7 @@ export function groupReminders( continue; } if (r.content.status !== "pending") continue; - if (!r.notBefore) continue; + if (r.notBefore === undefined) continue; if (r.notBefore <= now) { overdue.push(r); } else if (r.notBefore <= endOfTodaySecs) { diff --git a/desktop/src/features/reminders/useReminderNotifications.ts b/desktop/src/features/reminders/useReminderNotifications.ts index 184bf19d456..f339e1b013f 100644 --- a/desktop/src/features/reminders/useReminderNotifications.ts +++ b/desktop/src/features/reminders/useReminderNotifications.ts @@ -91,6 +91,10 @@ export function useReminderNotifications( const now = Math.floor(Date.now() / 1_000); const due = dueSince(remindersRef.current, watermark, now); fire(due); + // Advance unconditionally, even when fire() suppressed the toast + // (notifications off or needs_action slot muted). Re-enabling later must + // not backlog-replay reminders that came due while muted — same no-replay + // rationale as seed-to-now. Suppressed reminders still show in panel/badge. window.localStorage.setItem(watermarkStorageKey(pubkey), String(now)); }; From e2a833e259273c4a0a709e319a177b99428d69fd Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 14:43:33 -0400 Subject: [PATCH 3/5] fix(reminders): guard watermark advance until query resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mount, useRemindersQuery is still loading (data === undefined) so remindersRef.current is []. Without a guard, check() advances the watermark past reminders that came due while the app was closed — the "missed-while-asleep" window — permanently preventing their toast from firing. Track query resolution via a ref and defer the watermark advance when the array is empty AND the query has not resolved yet. First-launch-with-no-reminders still advances correctly: the query resolves to [] with queryResolvedRef === true, so the guard passes. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/reminders/useReminderNotifications.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/desktop/src/features/reminders/useReminderNotifications.ts b/desktop/src/features/reminders/useReminderNotifications.ts index f339e1b013f..1aa8ef7dc63 100644 --- a/desktop/src/features/reminders/useReminderNotifications.ts +++ b/desktop/src/features/reminders/useReminderNotifications.ts @@ -56,6 +56,13 @@ export function useReminderNotifications( const settingsRef = React.useRef(settings); settingsRef.current = settings; + // Track whether the query has resolved at least once. On mount, + // useRemindersQuery is still loading (data === undefined), so + // remindersRef.current is []. Without this guard, check() would advance + // the watermark past any reminders that came due while the app was closed. + const queryResolvedRef = React.useRef(false); + if (reminders !== undefined) queryResolvedRef.current = true; + const fire = React.useEffectEvent((due: Reminder[]) => { const current = settingsRef.current; if ( @@ -87,6 +94,11 @@ export function useReminderNotifications( if (!pubkey) return; const check = () => { + // Defer until the query has resolved at least once — an empty array from + // an unresolved query must not advance the watermark past reminders that + // came due while the app was closed (the "missed-while-asleep" window). + if (remindersRef.current.length === 0 && !queryResolvedRef.current) return; + const watermark = readWatermark(pubkey); const now = Math.floor(Date.now() / 1_000); const due = dueSince(remindersRef.current, watermark, now); From 6f406c6572f842b1b9e74adfa51f6e02f319ab92 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 17:05:47 -0400 Subject: [PATCH 4/5] test(reminders): drive screenshot spec through filter dropdown The screenshot spec still navigated via the deleted view-mode slider, so it landed in the chat view where the filter dropdown is not mounted. Drive it through the inbox filter dropdown instead, seed reminders past the badge query's cached-empty result by invalidating the ["reminders"] query, and finalize the dropdown by dropping the "?view=reminders" URL persistence to local state. Harden waitForAnimations with a bounded race so looping animations (presence dots) and animations that restart mid-sample no longer hang the evaluate until Playwright aborts. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/routes/index.tsx | 2 - desktop/src/app/routes/reminders.tsx | 10 +- .../src/features/home/ui/InboxListPane.tsx | 5 +- .../reminders/useReminderNotifications.ts | 3 +- desktop/src/testing/e2eBridge.ts | 3 + .../tests/e2e/reminders-screenshots.spec.ts | 130 ++++++++++-------- desktop/tests/helpers/animations.ts | 26 +++- 7 files changed, 109 insertions(+), 70 deletions(-) diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 9f92e7d4707..d3b49e85e7a 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -12,7 +12,6 @@ import { useIdentityQuery } from "@/shared/api/hooks"; type HomeRouteSearch = { item?: string; - view?: "reminders"; }; function validateHomeSearch(search: Record): HomeRouteSearch { @@ -21,7 +20,6 @@ function validateHomeSearch(search: Record): HomeRouteSearch { typeof search.item === "string" && search.item.length > 0 ? search.item : undefined, - view: search.view === "reminders" ? "reminders" : undefined, }; } diff --git a/desktop/src/app/routes/reminders.tsx b/desktop/src/app/routes/reminders.tsx index ba341fe7a76..83f01e45e11 100644 --- a/desktop/src/app/routes/reminders.tsx +++ b/desktop/src/app/routes/reminders.tsx @@ -1,11 +1,11 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -// Reminders is now a view mode inside the inbox (`/?view=reminders`), not a -// standalone screen. This redirect preserves existing history entries and -// bookmarks pointing at `/reminders` — they land in the inbox Reminders view -// instead of dead-ending. +// Reminders is now a filter option inside the inbox dropdown, selected via +// local state rather than the URL. This redirect preserves existing history +// entries and bookmarks pointing at `/reminders` so they land in the inbox +// instead of dead-ending; the user re-selects Reminders from the filter. export const Route = createFileRoute("/reminders")({ beforeLoad: () => { - throw redirect({ to: "/", search: { view: "reminders" } }); + throw redirect({ to: "/" }); }, }); diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 9a79cc8abe7..48195ab968f 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -156,6 +156,7 @@ export function InboxListPane({