From 8d7486bc8cdbd648b4f618f27de1b65575e0ec5d Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 16:36:49 -0400 Subject: [PATCH] feat(notifications): settings notification preferences --- apps/next/src/app/(app)/settings/layout.tsx | 11 +- .../app/(app)/settings/notifications/page.tsx | 19 +++ .../notification-preferences-panel.tsx | 115 +++++++++++++++ .../notification-preferences.test.tsx | 117 +++++++++++++++ packages/backend/convex/notifications.ts | 67 +++++++++ .../backend/tests/unit/notifications.test.ts | 135 ++++++++++++++++++ 6 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 apps/next/src/app/(app)/settings/notifications/page.tsx create mode 100644 apps/next/src/components/settings/notification-preferences-panel.tsx create mode 100644 apps/next/tests/component/notification-preferences.test.tsx diff --git a/apps/next/src/app/(app)/settings/layout.tsx b/apps/next/src/app/(app)/settings/layout.tsx index 218d154..639fb03 100644 --- a/apps/next/src/app/(app)/settings/layout.tsx +++ b/apps/next/src/app/(app)/settings/layout.tsx @@ -3,7 +3,15 @@ import type { ReactNode } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react'; +import { + Bell, + Brain, + FileCog, + Github, + ServerCog, + Shield, + User, +} from 'lucide-react'; import { cn } from '@spoon/ui'; @@ -12,6 +20,7 @@ const settingsItems = [ { href: '/settings/integrations', label: 'Integrations', icon: Github }, { href: '/settings/ai-providers', label: 'AI providers', icon: Brain }, { href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog }, + { href: '/settings/notifications', label: 'Notifications', icon: Bell }, { href: '/settings/worker', label: 'Worker', icon: ServerCog }, { href: '/settings/security', label: 'Security', icon: Shield }, ]; diff --git a/apps/next/src/app/(app)/settings/notifications/page.tsx b/apps/next/src/app/(app)/settings/notifications/page.tsx new file mode 100644 index 0000000..42732cc --- /dev/null +++ b/apps/next/src/app/(app)/settings/notifications/page.tsx @@ -0,0 +1,19 @@ +import { NotificationPreferencesPanel } from '@/components/settings/notification-preferences-panel'; + +const SettingsNotificationsPage = () => { + return ( +
+
+

Notifications

+

+ Choose which notifications also reach you by email. In-app + notifications always show in the bell; these toggles only control the + email copies. +

+
+ +
+ ); +}; + +export default SettingsNotificationsPage; diff --git a/apps/next/src/components/settings/notification-preferences-panel.tsx b/apps/next/src/components/settings/notification-preferences-panel.tsx new file mode 100644 index 0000000..31b3ebc --- /dev/null +++ b/apps/next/src/components/settings/notification-preferences-panel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery } from 'convex/react'; +import { toast } from 'sonner'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { Card, Label, Switch } from '@spoon/ui'; + +type PreferenceField = + | 'emailEnabled' + | 'emailMaintenance' + | 'emailAgentTurnFinished' + | 'emailAgentNeedsInput' + | 'emailSyncFailed' + | 'emailConnectionReauth'; + +const perKindToggles: { + field: Exclude; + label: string; + description: string; +}[] = [ + { + field: 'emailMaintenance', + label: 'Maintenance threads', + description: 'A maintenance review thread opens for one of your Spoons.', + }, + { + field: 'emailAgentTurnFinished', + label: 'Agent finished a turn', + description: 'An agent finishes a turn and changes are ready to review.', + }, + { + field: 'emailAgentNeedsInput', + label: 'Agent needs your input', + description: 'An agent pauses and needs your input to continue.', + }, + { + field: 'emailSyncFailed', + label: 'A sync fails', + description: 'A scheduled sync with upstream fails for one of your Spoons.', + }, + { + field: 'emailConnectionReauth', + label: 'A connection needs re-authentication', + description: 'A git connection is revoked and needs to be reconnected.', + }, +]; + +export const NotificationPreferencesPanel = () => { + // Render directly from the reactive query: after a save resolves, the query + // re-emits the stored value, so there is no local copy to fall out of sync. + const prefs = useQuery(api.notifications.getPreferences); + const updatePreferences = useMutation(api.notifications.updatePreferences); + const [saving, setSaving] = useState(false); + + const onToggle = async (field: PreferenceField, next: boolean) => { + setSaving(true); + try { + await updatePreferences({ [field]: next }); + toast.success('Preferences saved.'); + } catch { + toast.error('Could not save preferences.'); + } finally { + setSaving(false); + } + }; + + if (prefs === undefined) { + return ( + +

Loading preferences…

+
+ ); + } + + const perKindDisabled = saving || !prefs.emailEnabled; + + return ( + +
+
+ +

+ Send email for your notifications. Turn this off to silence every + kind below. +

+
+ void onToggle('emailEnabled', next)} + /> +
+ {perKindToggles.map(({ field, label, description }) => ( +
+
+ +

{description}

+
+ void onToggle(field, next)} + /> +
+ ))} +
+ ); +}; diff --git a/apps/next/tests/component/notification-preferences.test.tsx b/apps/next/tests/component/notification-preferences.test.tsx new file mode 100644 index 0000000..4500ae5 --- /dev/null +++ b/apps/next/tests/component/notification-preferences.test.tsx @@ -0,0 +1,117 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { NotificationPreferencesPanel } from '../../src/components/settings/notification-preferences-panel'; + +// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on +// every access, so `===` comparisons in the mocks below would never match. +vi.mock('@spoon/backend/convex/_generated/api.js', () => ({ + api: { + notifications: { + getPreferences: 'notifications:getPreferences', + updatePreferences: 'notifications:updatePreferences', + }, + }, +})); + +const { mockUpdatePreferences, mockUseMutation, mockUseQuery } = vi.hoisted( + () => ({ + mockUpdatePreferences: vi.fn(), + mockUseMutation: vi.fn(), + mockUseQuery: vi.fn(), + }), +); + +vi.mock('convex/react', () => ({ + useMutation: mockUseMutation, + useQuery: mockUseQuery, +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +// Pass-through UI primitives so the switches are deterministically present and +// clickable under jsdom (Radix pointer behavior is flaky here). We exercise the +// panel's logic, not the design-system components. +vi.mock('@spoon/ui', () => ({ + Card: ({ children }: { children?: ReactNode }) =>
{children}
, + Label: ({ children }: { children?: ReactNode }) => , + Switch: ({ + checked, + onCheckedChange, + disabled, + ...props + }: { + checked?: boolean; + onCheckedChange?: (value: boolean) => void; + disabled?: boolean; + } & ComponentProps<'button'>) => ( +