feat(desktop): reminders notifications, snooze, overlay, and inbox view mode (#1093)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-17 17:47:41 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 010e8022b0
commit 22b47db8ec
23 changed files with 1090 additions and 468 deletions
-8
View File
@@ -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",
+6 -3
View File
@@ -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() {
}}
>
<HuddleProvider>
<RemindMeLaterProvider>
<RemindMeLaterProvider pubkey={identityQuery.data?.pubkey}>
<div
className="buzz-huddle-shell relative h-dvh overflow-hidden overscroll-none"
data-huddle-open={isHuddleDrawerOpen}
@@ -879,7 +883,6 @@ export function AppShell() {
onSelectHome={() => void goHome()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectReminders={() => void goReminders()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSetPresenceStatus={(status) =>
@@ -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,
+8 -16
View File
@@ -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 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")({
component: RemindersRouteComponent,
beforeLoad: () => {
throw redirect({ to: "/" });
},
});
function RemindersRouteComponent() {
return (
<React.Suspense fallback={null}>
<RemindersScreen />
</React.Suspense>
);
}
+2 -1
View File
@@ -17,7 +17,8 @@ export type InboxFilter =
| "mention"
| "needs_action"
| "activity"
| "agent_activity";
| "agent_activity"
| "reminders";
export type InboxItem = {
avatarUrl: string | null;
+28 -2
View File
@@ -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<string | null>(
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 ? (
<InboxListPane
doneSet={effectiveDoneSet}
dueReminderCount={dueReminderCount}
filter={filter}
items={filteredItems}
onFilterChange={setFilter}
@@ -391,6 +416,7 @@ export function HomeView({
handleUserSelectItem(itemId);
markItemRead(itemId);
}}
reminderPubkey={currentPubkey}
selectedId={selectedItemId}
showRightDivider={showListPane && showDetailPane}
/>
+68 -34
View File
@@ -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<HTMLDivElement>(null);
const renderItem = (item: InboxItem) => {
@@ -144,22 +151,27 @@ export function InboxListPane({
>
<TopChromeInsetHeader>
<div className="px-5 py-1">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-[6px]">
<Inbox className="h-4 w-4 shrink-0 text-muted-foreground" />
<h2 className="translate-y-px truncate text-sm font-semibold leading-5 tracking-tight">
Inbox
</h2>
</div>
{/* Cap to the list-column width so the right-aligned dropdown stays
put when the pane goes full-width in reminders mode. */}
<div className="flex min-w-0 max-w-[var(--home-inbox-list-width)] items-center justify-end gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border-border/70 bg-background/70 px-2.5 text-2xs font-medium leading-none text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/60 hover:text-foreground"
data-testid="inbox-filter-trigger"
size="sm"
type="button"
variant="outline"
>
<span>{activeFilter?.label ?? "All"}</span>
{dueReminderCount > 0 ? (
<span
className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
data-testid="inbox-reminder-badge"
>
{dueReminderCount}
</span>
) : null}
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -175,7 +187,18 @@ export function InboxListPane({
key={option.value}
value={option.value}
>
{option.label}
<span className="flex flex-1 items-center justify-between gap-2">
{option.label}
{option.value === "reminders" &&
dueReminderCount > 0 ? (
<span
className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
data-testid="inbox-reminder-badge-option"
>
{dueReminderCount}
</span>
) : null}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@@ -185,32 +208,43 @@ export function InboxListPane({
</div>
</TopChromeInsetHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-testid="home-inbox-list"
ref={scrollRef}
>
{items.length === 0 ? (
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
<div>
<p className="text-sm font-medium text-foreground">
No messages found
</p>
<p className="mt-1 text-sm text-muted-foreground">
Switch back to all mail to see more messages.
</p>
{isReminders ? (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
data-testid="home-inbox-reminders"
>
{reminderPubkey ? (
<RemindersPanel includeDone pubkey={reminderPubkey} />
) : null}
</div>
) : (
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-testid="home-inbox-list"
ref={scrollRef}
>
{items.length === 0 ? (
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
<div>
<p className="text-sm font-medium text-foreground">
No messages found
</p>
<p className="mt-1 text-sm text-muted-foreground">
Switch back to all mail to see more messages.
</p>
</div>
</div>
</div>
) : (
<VirtualizedList
estimateSize={76}
getItemKey={(item) => item.id}
items={items}
renderItem={renderItem}
scrollRef={scrollRef}
/>
)}
</div>
) : (
<VirtualizedList
estimateSize={76}
getItemKey={(item) => item.id}
items={items}
renderItem={renderItem}
scrollRef={scrollRef}
/>
)}
</div>
)}
</section>
);
}
@@ -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"
: "",
+69
View File
@@ -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 };
}
@@ -0,0 +1,181 @@
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([]), []);
});
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"],
);
});
@@ -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 === undefined) 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;
}
@@ -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);
});
@@ -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;
}
@@ -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}
</Button>
@@ -148,32 +81,35 @@ export function RemindMeLaterDialog({
</div>
<div className="space-y-3 border-t pt-3">
<p className="text-sm font-medium flex items-center gap-2">
<p className="flex items-center gap-2 text-sm font-medium">
<CalendarClock className="h-4 w-4" />
Custom date & time
</p>
<div className="flex gap-2">
<Input
aria-label="Reminder date"
className="flex-1"
min={todayDateString()}
onChange={(e) => setCustomDate(e.target.value)}
type="date"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
min={todayDateString()}
className="flex-1"
aria-label="Reminder date"
/>
<Input
aria-label="Reminder time"
className="w-[120px]"
onChange={(e) => setCustomTime(e.target.value)}
type="time"
value={customTime}
onChange={(e) => setCustomTime(e.target.value)}
className="w-[120px]"
aria-label="Reminder time"
/>
</div>
<Button
variant="default"
className="w-full"
disabled={isSubmitting || !customDate || !customTime}
onClick={() => void handleCustomSubmit()}
disabled={create.isPending || customTimestamp === null}
onClick={() => {
if (customTimestamp === null) return;
submit(customTimestamp);
}}
variant="default"
>
Set reminder
</Button>
@@ -200,7 +136,7 @@ export function RemindMeLaterDialog({
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
disabled={create.isPending}
>
Cancel
</Button>
@@ -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<string>;
};
const RemindMeLaterContext = React.createContext<RemindMeLaterContextValue>({
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<string>();
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 (
<RemindMeLaterContext.Provider value={contextValue}>
@@ -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 { complete, snooze, cancel } = useReminderMutations(pubkey);
const isDone = reminder.content.status === "done";
const isActing = complete.isPending || snooze.isPending || cancel.isPending;
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 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 (
<div className="flex items-start gap-3 rounded-md border p-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{reminder.content.target?.preview ||
reminder.content.note ||
"Reminder"}
</p>
{reminder.content.target && reminder.content.note ? (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{reminder.content.note}
</p>
) : null}
{reminder.notBefore ? (
<p
className={`text-xs mt-1 ${isOverdue ? "text-destructive font-medium" : "text-muted-foreground"}`}
className={`mt-1 text-xs ${isOverdue ? "font-medium text-destructive" : "text-muted-foreground"}`}
>
<Clock className="inline h-3 w-3 mr-1" />
<Clock className="mr-1 inline h-3 w-3" />
{formatRelativeTime(reminder.notBefore)}
</p>
) : null}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
disabled={isActing}
onClick={() => void handleComplete()}
title="Complete"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
disabled={isActing}
onClick={() => void handleSnooze()}
title="Snooze 1 hour"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
disabled={isActing}
onClick={() => void handleCancel()}
title="Cancel"
>
<X className="h-4 w-4" />
</Button>
</div>
{isDone ? null : (
<div className="flex shrink-0 items-center gap-1">
<Button
className="h-7 w-7 p-0"
disabled={isActing}
onClick={handleComplete}
size="sm"
title="Complete"
type="button"
variant="ghost"
>
<Check className="h-4 w-4" />
</Button>
<SnoozeMenu disabled={isActing} onSnooze={handleSnooze} />
<Button
className="h-7 w-7 p-0"
disabled={isActing}
onClick={handleCancel}
size="sm"
title="Cancel"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
export function RemindersPanel({ pubkey }: { pubkey: string }) {
const [reminders, setReminders] = React.useState<Reminder[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
/**
* 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],
);
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<number>(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: <Bell className="h-4 w-4" />,
});
}
}
}, 60_000);
return () => window.clearInterval(interval);
}, [reminders]);
const groups = groupReminders(reminders);
const pendingCount = reminders.filter(
(r) => r.content.status === "pending",
).length;
if (isLoading) {
if (remindersQuery.isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading reminders...</p>
@@ -235,11 +148,11 @@ export function RemindersPanel({ pubkey }: { pubkey: string }) {
);
}
if (pendingCount === 0) {
if (groups.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8">
<Bell className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No pending reminders</p>
<p className="text-sm text-muted-foreground">No reminders</p>
<p className="text-xs text-muted-foreground/70">
Use "Remind me later" on any message to create one.
</p>
@@ -248,33 +161,17 @@ export function RemindersPanel({ pubkey }: { pubkey: string }) {
}
return (
<div className="flex h-full flex-col">
<div className="border-b px-4 py-3">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Bell className="h-4 w-4" />
Reminders
<span className="text-xs font-normal text-muted-foreground">
({pendingCount})
</span>
</h2>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{groups.map((group) => (
<div key={group.label} className="space-y-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{group.label}
</h3>
{group.reminders.map((r) => (
<ReminderRow
key={r.id}
reminder={r}
pubkey={pubkey}
onUpdate={() => void loadReminders()}
/>
))}
</div>
))}
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{groups.map((group) => (
<div key={group.label} className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
{group.reminders.map((r) => (
<ReminderRow key={r.id} reminder={r} pubkey={pubkey} />
))}
</div>
))}
</div>
);
}
@@ -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 <RemindersPanel pubkey={identityQuery.data.pubkey} />;
}
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-7 w-7 p-0"
disabled={disabled}
size="sm"
title="Snooze"
type="button"
variant="ghost"
>
<Clock className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
{TIME_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.label}
onSelect={() => onSnooze(preset.getTimestamp())}
>
{preset.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<Popover open={customOpen} onOpenChange={setCustomOpen}>
<PopoverTrigger asChild>
<DropdownMenuItem
onSelect={(event) => {
// Keep the dropdown logic from closing the popover trigger.
event.preventDefault();
setCustomOpen(true);
}}
>
Custom
</DropdownMenuItem>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto space-y-3">
<p className="text-sm font-medium">Snooze until</p>
<div className="flex gap-2">
<Input
aria-label="Snooze date"
className="flex-1"
min={todayDateString()}
onChange={(event) => setCustomDate(event.target.value)}
type="date"
value={customDate}
/>
<Input
aria-label="Snooze time"
className="w-[120px]"
onChange={(event) => setCustomTime(event.target.value)}
type="time"
value={customTime}
/>
</div>
<Button
className="w-full"
disabled={customTimestamp === null}
onClick={() => {
if (customTimestamp === null) return;
onSnooze(customTimestamp);
setCustomOpen(false);
}}
type="button"
>
Snooze
</Button>
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,118 @@
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<Reminder[]>([]);
remindersRef.current = reminders ?? [];
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 (
!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 = () => {
// 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);
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));
};
check();
const interval = window.setInterval(check, POLL_INTERVAL_MS);
return () => window.clearInterval(interval);
}, [pubkey]);
}
@@ -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({
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-reminders-view"
isActive={selectedView === "reminders"}
onClick={onSelectReminders}
tooltip="Reminders"
type="button"
>
<Bell className="h-4 w-4" />
<span>Reminders</span>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
+3
View File
@@ -623,6 +623,9 @@ declare global {
slotId: string;
}) => unknown;
__BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void;
__BUZZ_E2E_QUERY_CLIENT__?: {
invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown;
};
}
}
+73 -55
View File
@@ -6,6 +6,39 @@ import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/reminders";
const MOCK_PUBKEY = "deadbeef".repeat(8);
// The inbox filter dropdown lives in the home pane, not the chat view. Land on
// home and wait for the inbox before reaching for the filter trigger.
async function gotoInboxHome(page: import("@playwright/test").Page) {
await page.goto("/");
await expect(page.getByTestId("home-inbox")).toBeVisible();
}
// Reminders is reached by opening the inbox filter dropdown and selecting the
// "Reminders" option — there is no standalone nav entry or view-mode slider.
async function openRemindersFilter(page: import("@playwright/test").Page) {
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Reminders" }).click();
}
// The reminders query mounts (for the badge) before tests seed events, so a
// bare seed lands behind its cached empty result. Invalidate after seeding to
// force the refetch that picks up the mock events.
async function seedReminders(
page: import("@playwright/test").Page,
events: unknown[],
) {
await page.evaluate((seeded) => {
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.(
seeded as Parameters<
NonNullable<typeof window.__BUZZ_E2E_SEED_MOCK_REMINDERS__>
>[0],
);
window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["reminders"],
});
}, events);
}
function mockReminderEvent(opts: {
id: string;
dTag: string;
@@ -32,18 +65,21 @@ test.describe("reminders screenshots", () => {
await installMockBridge(page);
});
test("01 — sidebar shows Reminders nav item", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
test("01 — inbox filter dropdown shows Reminders option", async ({
page,
}) => {
await gotoInboxHome(page);
const remindersNav = page.getByTestId("open-reminders-view");
await expect(remindersNav).toBeVisible();
await page.getByTestId("inbox-filter-trigger").click();
const remindersOption = page.getByRole("menuitemradio", {
name: "Reminders",
});
await expect(remindersOption).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/01-sidebar-reminders-nav.png`,
clip: { x: 0, y: 0, width: 256, height: 720 },
path: `${SHOTS}/01-inbox-filter-reminders-option.png`,
clip: { x: 0, y: 0, width: 900, height: 720 },
});
});
@@ -106,12 +142,10 @@ test.describe("reminders screenshots", () => {
});
test("04 — Reminders panel empty state", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await gotoInboxHome(page);
await page.getByTestId("open-reminders-view").click();
await expect(page.getByText("No pending reminders")).toBeVisible();
await openRemindersFilter(page);
await expect(page.getByText("No reminders")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
@@ -123,9 +157,7 @@ test.describe("reminders screenshots", () => {
test("05 — Reminders panel with active pending reminder", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await gotoInboxHome(page);
// Seed a pending reminder due in the future
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
@@ -140,21 +172,16 @@ test.describe("reminders screenshots", () => {
status: "pending",
});
await page.evaluate(
({ event }) => {
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([event]);
},
{
event: mockReminderEvent({
id: "reminder-active-01",
dTag: "rem-active-01",
content: reminderContent,
notBefore: futureTimestamp,
}),
},
);
await seedReminders(page, [
mockReminderEvent({
id: "reminder-active-01",
dTag: "rem-active-01",
content: reminderContent,
notBefore: futureTimestamp,
}),
]);
await page.getByTestId("open-reminders-view").click();
await openRemindersFilter(page);
await expect(page.getByText("Follow up on this message")).toBeVisible();
await waitForAnimations(page);
@@ -165,9 +192,7 @@ test.describe("reminders screenshots", () => {
});
test("06 — Reminders panel with fired/overdue reminder", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await gotoInboxHome(page);
// Seed a reminder that has already fired (notBefore in the past)
const pastTimestamp = Math.floor(Date.now() / 1000) - 7200;
@@ -195,29 +220,22 @@ test.describe("reminders screenshots", () => {
status: "pending",
});
await page.evaluate(
({ events }) => {
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.(events);
},
{
events: [
mockReminderEvent({
id: "reminder-overdue-01",
dTag: "rem-overdue-01",
content: overdueContent,
notBefore: pastTimestamp,
}),
mockReminderEvent({
id: "reminder-upcoming-01",
dTag: "rem-upcoming-01",
content: activeContent,
notBefore: futureTimestamp,
}),
],
},
);
await seedReminders(page, [
mockReminderEvent({
id: "reminder-overdue-01",
dTag: "rem-overdue-01",
content: overdueContent,
notBefore: pastTimestamp,
}),
mockReminderEvent({
id: "reminder-upcoming-01",
dTag: "rem-upcoming-01",
content: activeContent,
notBefore: futureTimestamp,
}),
]);
await page.getByTestId("open-reminders-view").click();
await openRemindersFilter(page);
await expect(page.getByText("Reply to Alice")).toBeVisible();
await expect(page.getByRole("heading", { name: "Overdue" })).toBeVisible();
await waitForAnimations(page);
+21 -5
View File
@@ -1,15 +1,31 @@
import type { Page } from "@playwright/test";
/**
* Wait for all in-flight CSS/Web animations on the page to finish.
* Wait for finishing CSS/Web animations on the page to complete, bounded by a
* short timeout.
*
* Radix UI components animate in via CSS transitions. Playwright's
* `toBeVisible()` resolves mid-animation, producing greyed-out or
* partially-rendered screenshots. Call this before any `page.screenshot()`
* or `locator.screenshot()` to guarantee a fully-rendered frame.
*
* Looping animations (spinners, pulsing presence dots) never settle, and
* animations can also start or restart between sampling and awaiting their
* `.finished` promise so an unbounded `Promise.all` can hang until
* Playwright aborts the `evaluate`. Race the settle against a short ceiling:
* once the in-flight transitions have had time to land, take the frame.
*/
export async function waitForAnimations(page: Page): Promise<void> {
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
);
export async function waitForAnimations(
page: Page,
timeoutMs = 1000,
): Promise<void> {
await page.evaluate((ceiling) => {
const settled = Promise.all(
document.getAnimations().map((a) => a.finished.catch(() => undefined)),
);
const ceilingHit = new Promise<void>((resolve) =>
setTimeout(resolve, ceiling),
);
return Promise.race([settled.then(() => undefined), ceilingHit]);
}, timeoutMs);
}