mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
010e8022b0
commit
22b47db8ec
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export type InboxFilter =
|
||||
| "mention"
|
||||
| "needs_action"
|
||||
| "activity"
|
||||
| "agent_activity";
|
||||
| "agent_activity"
|
||||
| "reminders";
|
||||
|
||||
export type InboxItem = {
|
||||
avatarUrl: string | null;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
: "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user