diff --git a/backend/app/schemas/memories.py b/backend/app/schemas/memories.py index 2630347ae..2081c6ada 100644 --- a/backend/app/schemas/memories.py +++ b/backend/app/schemas/memories.py @@ -108,7 +108,7 @@ class MemoryStatusData(BaseModel): unviewed_count: int = 0 latest_memory_id: Optional[str] = None memories_enabled: bool = True - notifications_enabled: bool = True + notifications_enabled: bool = False class GetMemoriesData(BaseModel): diff --git a/backend/app/schemas/user_preferences.py b/backend/app/schemas/user_preferences.py index aaa8e2d23..9f00c94ac 100644 --- a/backend/app/schemas/user_preferences.py +++ b/backend/app/schemas/user_preferences.py @@ -45,7 +45,8 @@ class MemoriesPreferences(BaseModel): """Memory generation and delivery preferences.""" enabled: bool = True - notifications_enabled: bool = True + # Desktop alerts are opt-in; the memory is waiting on the page regardless. + notifications_enabled: bool = False # The story viewer ships muted; the user opts into background audio. story_music_enabled: bool = False # Seconds each photo is held before the story advances. A video slide diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py index 4fbecb986..6ff03cb54 100644 --- a/backend/tests/test_user_preferences.py +++ b/backend/tests/test_user_preferences.py @@ -582,7 +582,8 @@ def test_defaults_appear_when_the_key_is_absent(self): memories = response.json()["user_preferences"]["memories"] assert memories["enabled"] is True - assert memories["notifications_enabled"] is True + # Desktop alerts are opt-in. + assert memories["notifications_enabled"] is False # The story viewer ships muted; audio is opt-in. assert memories["story_music_enabled"] is False assert memories["min_images"] == 5 diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 0fdb46fe4..8690c089f 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -3721,7 +3721,7 @@ "notifications_enabled": { "type": "boolean", "title": "Notifications Enabled", - "default": true + "default": false }, "story_music_enabled": { "type": "boolean", @@ -4353,7 +4353,7 @@ "notifications_enabled": { "type": "boolean", "title": "Notifications Enabled", - "default": true + "default": false } }, "type": "object", diff --git a/frontend/src/components/Memories/MemoryStoryViewer.tsx b/frontend/src/components/Memories/MemoryStoryViewer.tsx index a608c45f3..b8404ada1 100644 --- a/frontend/src/components/Memories/MemoryStoryViewer.tsx +++ b/frontend/src/components/Memories/MemoryStoryViewer.tsx @@ -291,7 +291,7 @@ export const MemoryStoryViewer: React.FC = ({ )} - diff --git a/frontend/src/pages/Memories/MemorySettings.tsx b/frontend/src/pages/Memories/MemorySettings.tsx deleted file mode 100644 index 51889db01..000000000 --- a/frontend/src/pages/Memories/MemorySettings.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React from 'react'; -import { ArrowLeft } from 'lucide-react'; -import { useNavigate } from 'react-router'; - -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import { Slider } from '@/components/ui/Slider'; -import { useUserPreferences } from '@/hooks/useUserPreferences'; -import type { MemoriesPreferences } from '@/api/api-functions/user_preferences'; - -interface ToggleRowProps { - id: string; - label: string; - description: string; - checked: boolean; - disabled?: boolean; - onChange: (checked: boolean) => void; -} - -const ToggleRow: React.FC = ({ - id, - label, - description, - checked, - disabled, - onChange, -}) => ( -
-
- -

{description}

-
- -
-); - -export const MemorySettings: React.FC = () => { - const navigate = useNavigate(); - const { memoriesPreferences, updateMemoriesPreferences, isUpdating } = - useUserPreferences(); - - const patch = (update: Partial) => { - void updateMemoriesPreferences(update).catch(() => undefined); - }; - - return ( -
-
-
- -

Memory settings

-
- - - patch({ enabled: checked })} - /> -
- patch({ notifications_enabled: checked })} - /> - - - - patch({ story_music_enabled: checked })} - /> -
-
-
- - - {memoriesPreferences.slide_duration_seconds}s - -
-

- How long each photo is held before the story advances. -

- - patch({ slide_duration_seconds: value }) - } - /> -
- - - -
- -

- Memories with fewer than {memoriesPreferences.min_images} photos - are skipped, and no memory shows more than{' '} - {memoriesPreferences.max_images}. -

-
-
-
-
- - - {memoriesPreferences.min_images} - -
- - patch({ - min_images: value, - // Keep the pair valid: the backend rejects a minimum above - // the maximum, which would fail the whole save. - max_images: Math.max(value, memoriesPreferences.max_images), - }) - } - /> -
-
-
- - - {memoriesPreferences.max_images} - -
- - patch({ - max_images: value, - min_images: Math.min(value, memoriesPreferences.min_images), - }) - } - /> -
- -
-
- ); -}; - -export default MemorySettings; diff --git a/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx b/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx index 2822163f9..d7f3a7dd5 100644 --- a/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx +++ b/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx @@ -6,6 +6,8 @@ import { Trash2, Clapperboard, HardDrive, + Sparkles, + Bell, } from 'lucide-react'; import { Label } from '@/components/ui/label'; @@ -24,6 +26,7 @@ import { listen } from '@tauri-apps/api/event'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { useUserPreferences } from '@/hooks/useUserPreferences'; +import type { UpdateUserPreferencesRequest } from '@/api/api-functions/user_preferences'; import { purgeVideoFrameCache } from '@/api/api-functions'; import SettingsCard from './SettingsCard'; import { cn, formatTierLabel } from '@/lib/utils'; @@ -40,13 +43,20 @@ import { */ // Coarse enough to be a meaningful cost tradeoff, fine enough to matter. const FRAME_INTERVAL_OPTIONS = [2, 5, 10, 30]; +// Story pacing: below 3s a photo barely registers, above 10s it drags. +const SLIDE_DURATION_OPTIONS = [3, 5, 7, 10]; +const MIN_IMAGE_OPTIONS = [3, 5, 8, 10]; +const MAX_IMAGE_OPTIONS = [20, 30, 50, 100]; const UserPreferencesCard: React.FC = () => { const { preferences, + memoriesPreferences, updateYoloModelSize, toggleGpuAcceleration, updateVideoFrameInterval, + updateMemoriesPreferences, + isUpdating, refetch, } = useUserPreferences(); const [installedTiers, setInstalledTiers] = useState([]); @@ -58,6 +68,14 @@ const UserPreferencesCard: React.FC = () => { // Collapsed by default: video tagging is a niche setting, so it stays out // of the way until a user with videos goes looking for it. const [videoSettingsOpen, setVideoSettingsOpen] = useState(false); + const [memorySettingsOpen, setMemorySettingsOpen] = useState(false); + + const patchMemories = useCallback( + (update: UpdateUserPreferencesRequest['memories']) => { + void updateMemoriesPreferences(update).catch(console.warn); + }, + [updateMemoriesPreferences], + ); const handlePurgeFrameCache = useCallback(async () => { setPurgeState('purging'); @@ -360,6 +378,237 @@ const UserPreferencesCard: React.FC = () => {
)}
+ + {/* Memories: same collapsible treatment as video tagging, so the + two niche groups read as siblings. */} +
+ + + {memorySettingsOpen && ( +
+ {/* Generate Memories Setting */} +
+
+ +

+ Curate highlights from your library automatically. +

+
+
+ + + patchMemories({ enabled: checked }) + } + /> +
+
+ + {/* Desktop Notifications Setting */} +
+
+ +

+ Get an alert when a new memory is ready. +

+
+
+ + + patchMemories({ notifications_enabled: checked }) + } + /> +
+
+ + {/* Seconds Per Photo Setting */} +
+
+ +

+ How long each photo is held before the story advances. A + video clip runs for its own length instead. +

+
+ + + + + + {SLIDE_DURATION_OPTIONS.map((seconds) => ( + + patchMemories({ slide_duration_seconds: seconds }) + } + > + {seconds} seconds + + ))} + + +
+ + {/* Minimum Photos Setting */} +
+
+ +

+ Groups with fewer photos than this are skipped rather than + turned into a memory. +

+
+ + + + + + {MIN_IMAGE_OPTIONS.map((count) => ( + + patchMemories({ + min_images: count, + // Keep the pair valid: the backend rejects a + // minimum above the maximum, failing the save. + max_images: Math.max( + count, + memoriesPreferences.max_images, + ), + }) + } + > + {count} photos + + ))} + + +
+ + {/* Maximum Photos Setting */} +
+
+ +

+ No memory shows more photos than this, however many the + occasion produced. +

+
+ + + + + + {MAX_IMAGE_OPTIONS.map((count) => ( + + patchMemories({ + max_images: count, + min_images: Math.min( + count, + memoriesPreferences.min_images, + ), + }) + } + > + {count} photos + + ))} + + +
+
+ )} +
); diff --git a/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx b/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx new file mode 100644 index 000000000..87799263b --- /dev/null +++ b/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from '@/test-utils'; +import userEvent from '@testing-library/user-event'; + +import UserPreferencesCard from '../UserPreferencesCard'; +import type { MemoriesPreferences } from '@/api/api-functions/user_preferences'; + +const mockUpdateMemoriesPreferences = jest.fn().mockResolvedValue(undefined); +let mockMemories: MemoriesPreferences; +let mockIsUpdating = false; + +jest.mock('@/hooks/useUserPreferences', () => ({ + useUserPreferences: () => ({ + preferences: { + YOLO_model_size: 'nano', + GPU_Acceleration: false, + Video_Frame_Interval: 5, + memories: mockMemories, + }, + memoriesPreferences: mockMemories, + isLoading: false, + updateYoloModelSize: jest.fn().mockResolvedValue(undefined), + toggleGpuAcceleration: jest.fn().mockResolvedValue(undefined), + updateVideoFrameInterval: jest.fn().mockResolvedValue(undefined), + updateMemoriesPreferences: mockUpdateMemoriesPreferences, + refetch: jest.fn().mockResolvedValue(undefined), + isUpdating: mockIsUpdating, + }), +})); + +const memoriesWith = ( + overrides: Partial = {}, +): MemoriesPreferences => ({ + enabled: true, + notifications_enabled: false, + story_music_enabled: false, + slide_duration_seconds: 5, + min_images: 5, + max_images: 30, + weights: { + favourite: 0.22, + known_people: 0.2, + event_strength: 0.18, + face_presence: 0.12, + semantic_confidence: 0.1, + gps_novelty: 0.1, + in_album: 0.08, + }, + ...overrides, +}); + +/** Expand the collapsible group; its controls are not mounted until then. */ +const openPanel = async (user: ReturnType) => { + await user.click( + screen.getByRole('button', { name: /Control how memories are generated/i }), + ); +}; + +const trigger = (id: string) => + document.getElementById(id) as HTMLButtonElement; + +const choose = async ( + user: ReturnType, + id: string, + option: RegExp, +) => { + await user.click(trigger(id)); + await user.click(screen.getByRole('menuitem', { name: option })); +}; + +beforeEach(() => { + mockUpdateMemoriesPreferences.mockClear(); + mockMemories = memoriesWith(); + mockIsUpdating = false; +}); + +describe('UserPreferencesCard memories panel', () => { + it('sends the toggles as patches', async () => { + const user = userEvent.setup(); + render(); + await openPanel(user); + + await user.click( + screen.getByRole('switch', { name: /Generate Memories/i }), + ); + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + enabled: false, + }); + }); + + it('locks notifications behind the generate toggle', async () => { + mockMemories = memoriesWith({ enabled: false }); + const user = userEvent.setup(); + render(); + await openPanel(user); + + // Alerting about memories that are never generated is a dead control. + expect( + screen.getByRole('switch', { name: /Desktop Notifications/i }), + ).toBeDisabled(); + }); + + it('raises the maximum when a larger minimum is chosen', async () => { + // Reachable only from values the old sliders allowed; the dropdown + // options alone cannot put min above max. + mockMemories = memoriesWith({ min_images: 2, max_images: 5 }); + const user = userEvent.setup(); + render(); + await openPanel(user); + + await choose(user, 'memories-min', /^8 photos$/); + + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + min_images: 8, + max_images: 8, + }); + }); + + it('lowers the minimum when a smaller maximum is chosen', async () => { + mockMemories = memoriesWith({ min_images: 40, max_images: 50 }); + const user = userEvent.setup(); + render(); + await openPanel(user); + + await choose(user, 'memories-max', /^20 photos$/); + + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + max_images: 20, + min_images: 20, + }); + }); + + it('leaves a valid pair alone', async () => { + const user = userEvent.setup(); + render(); + await openPanel(user); + + await choose(user, 'memories-min', /^3 photos$/); + + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + min_images: 3, + max_images: 30, + }); + }); + + it('shows a stored value that is not one of the options', async () => { + mockMemories = memoriesWith({ + slide_duration_seconds: 12, + min_images: 40, + max_images: 50, + }); + const user = userEvent.setup(); + render(); + await openPanel(user); + + expect(trigger('memories-duration')).toHaveTextContent('12s'); + expect(trigger('memories-min')).toHaveTextContent('40 photos'); + expect(trigger('memories-max')).toHaveTextContent('50 photos'); + }); + + it('sends the chosen slide duration', async () => { + const user = userEvent.setup(); + render(); + await openPanel(user); + + await choose(user, 'memories-duration', /^7 seconds$/); + + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + slide_duration_seconds: 7, + }); + }); + + it('disables every control while a save is pending', async () => { + mockIsUpdating = true; + const user = userEvent.setup(); + render(); + await openPanel(user); + + expect( + screen.getByRole('switch', { name: /Generate Memories/i }), + ).toBeDisabled(); + expect( + screen.getByRole('switch', { name: /Desktop Notifications/i }), + ).toBeDisabled(); + expect(trigger('memories-duration')).toBeDisabled(); + expect(trigger('memories-min')).toBeDisabled(); + expect(trigger('memories-max')).toBeDisabled(); + }); +}); diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index cfca00e6a..267452dd5 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -11,7 +11,6 @@ import { AITagging } from '@/pages/AITagging/AITagging'; import { PersonImages } from '@/pages/PersonImages/PersonImages'; import { ComingSoon } from '@/pages/ComingSoon/ComingSoon'; import { Memories } from '@/pages/Memories/Memories'; -import { MemorySettings } from '@/pages/Memories/MemorySettings'; import { ModelManager } from '@/pages/ModelManager/ModelManager'; import { SearchResults } from '@/pages/SearchResults/SearchResults'; @@ -28,9 +27,6 @@ export const AppRoutes: React.FC = () => { } /> } /> } /> - {/* Declared before the index route so "memories/settings" is not - swallowed as a memory id. */} - } /> } /> } /> } />