From d234b829772093be7fb4e5f83b80c87ca14236d9 Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Mon, 12 Jan 2026 21:10:03 +0700 Subject: [PATCH 01/13] feat: persist quote filter settings across sessions Remember user's quote filter selections (enabled categories, custom quotes toggle, favorites-only mode, and active collection filters) so they are restored when reopening the extension. - Add filter settings to Settings interface in types.ts - Add default values to DEFAULT_SETTINGS in constants.ts - Update quote-store to load filters from settings on initialize - Persist filter changes when toggle methods are called - Filter out deleted collection IDs on load - Update test fixtures and mocks for new settings --- .../src/stores/quote-store.test.ts | 6 ++ .../src/stores/quote-store.ts | 79 +++++++++++++++---- packages/shared/src/constants.ts | 43 +++++----- packages/shared/src/types.ts | 5 ++ .../src/fixtures/settings.fixture.ts | 6 +- 5 files changed, 103 insertions(+), 36 deletions(-) diff --git a/apps/browser-extension/src/stores/quote-store.test.ts b/apps/browser-extension/src/stores/quote-store.test.ts index 6ec5643..257f53d 100644 --- a/apps/browser-extension/src/stores/quote-store.test.ts +++ b/apps/browser-extension/src/stores/quote-store.test.ts @@ -1,5 +1,6 @@ import * as storage from '@cuewise/storage'; import { quoteFactory } from '@cuewise/test-utils/factories'; +import { defaultSettings } from '@cuewise/test-utils/fixtures'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SEED_QUOTES } from '../data/seed-quotes'; import { @@ -26,6 +27,8 @@ vi.mock('@cuewise/storage', () => ({ setCurrentQuote: vi.fn(), getCollections: vi.fn(), setCollections: vi.fn(), + getSettings: vi.fn(), + setSettings: vi.fn(), })); // Mock toast store @@ -47,6 +50,9 @@ describe('Quote Store', () => { // Default mock for collections (empty by default) vi.mocked(storage.getCollections).mockResolvedValue([]); + // Default mock for settings + vi.mocked(storage.getSettings).mockResolvedValue(defaultSettings); + vi.mocked(storage.setSettings).mockResolvedValue({ success: true }); }); describe('initialize', () => { diff --git a/apps/browser-extension/src/stores/quote-store.ts b/apps/browser-extension/src/stores/quote-store.ts index 36f010c..31c4aa4 100644 --- a/apps/browser-extension/src/stores/quote-store.ts +++ b/apps/browser-extension/src/stores/quote-store.ts @@ -2,6 +2,7 @@ import { ALL_QUOTE_CATEGORIES, type BulkImportResult, type CSVQuoteRow, + DEFAULT_SETTINGS, generateId, getRandomQuote, logger, @@ -13,9 +14,11 @@ import { getCollections, getCurrentQuote, getQuotes, + getSettings, setCollections, setCurrentQuote, setQuotes, + setSettings, } from '@cuewise/storage'; import { create } from 'zustand'; import { SEED_QUOTES } from '../data/seed-quotes'; @@ -28,13 +31,13 @@ interface QuoteStore { error: string | null; quoteHistory: string[]; // Array of quote IDs in viewing order historyIndex: number; // Current position in history (0 = most recent) - enabledCategories: QuoteCategory[]; // Categories to show (session-only, not persisted) - showCustomQuotes: boolean; // Show custom quotes in filter (session-only) - showFavoritesOnly: boolean; // Show only favorite quotes (session-only) + enabledCategories: QuoteCategory[]; // Categories to show (persisted to settings) + showCustomQuotes: boolean; // Show custom quotes in filter (persisted to settings) + showFavoritesOnly: boolean; // Show only favorite quotes (persisted to settings) // Collections state collections: QuoteCollection[]; - activeCollectionIds: string[]; // Enabled collection filters (session-only) + activeCollectionIds: string[]; // Enabled collection filters (persisted to settings) // Actions initialize: () => Promise; @@ -65,10 +68,10 @@ interface QuoteStore { ) => Promise; deleteQuote: (quoteId: string) => Promise; incrementViewCount: (quoteId: string) => Promise; - setEnabledCategories: (categories: QuoteCategory[]) => void; - toggleCategory: (category: QuoteCategory) => void; - toggleCustomQuotes: () => void; - toggleFavoritesOnly: () => void; + setEnabledCategories: (categories: QuoteCategory[]) => Promise; + toggleCategory: (category: QuoteCategory) => Promise; + toggleCustomQuotes: () => Promise; + toggleFavoritesOnly: () => Promise; // Bulk operations bulkDelete: (quoteIds: string[]) => Promise; @@ -90,14 +93,34 @@ interface QuoteStore { addQuoteToCollection: (quoteId: string, collectionId: string) => Promise; removeQuoteFromCollection: (quoteId: string, collectionId: string) => Promise; addQuotesToCollection: (quoteIds: string[], collectionId: string) => Promise; - toggleCollection: (collectionId: string) => void; - setActiveCollectionIds: (collectionIds: string[]) => void; + toggleCollection: (collectionId: string) => Promise; + setActiveCollectionIds: (collectionIds: string[]) => Promise; getQuotesInCollection: (collectionId: string) => Quote[]; // CSV Import bulkAddQuotes: (quoteRows: CSVQuoteRow[], collectionId?: string) => Promise; } +/** + * Persists current filter settings to storage. + * Called when filter state changes (categories, custom, favorites, collections). + */ +async function persistFilterSettings(state: QuoteStore): Promise { + try { + const currentSettings = await getSettings(); + const updatedSettings = { + ...currentSettings, + quoteFilterEnabledCategories: state.enabledCategories, + quoteFilterShowCustomQuotes: state.showCustomQuotes, + quoteFilterShowFavoritesOnly: state.showFavoritesOnly, + quoteFilterActiveCollectionIds: state.activeCollectionIds, + }; + await setSettings(updatedSettings); + } catch (error) { + logger.error('Error persisting filter settings', error); + } +} + export const useQuoteStore = create((set, get) => ({ quotes: [], currentQuote: null, @@ -127,6 +150,20 @@ export const useQuoteStore = create((set, get) => ({ // Get collections from storage const collections = await getCollections(); + // Load persisted filter settings + const settings = await getSettings(); + const enabledCategories = + settings?.quoteFilterEnabledCategories ?? DEFAULT_SETTINGS.quoteFilterEnabledCategories; + const showCustomQuotes = + settings?.quoteFilterShowCustomQuotes ?? DEFAULT_SETTINGS.quoteFilterShowCustomQuotes; + const showFavoritesOnly = + settings?.quoteFilterShowFavoritesOnly ?? DEFAULT_SETTINGS.quoteFilterShowFavoritesOnly; + // Filter out any collection IDs that no longer exist + const collectionIds = new Set(collections.map((c) => c.id)); + const activeCollectionIds = ( + settings?.quoteFilterActiveCollectionIds ?? DEFAULT_SETTINGS.quoteFilterActiveCollectionIds + ).filter((id) => collectionIds.has(id)); + // Get current quote or select a random one let currentQuote = await getCurrentQuote(); if (!currentQuote || currentQuote.isHidden) { @@ -145,6 +182,10 @@ export const useQuoteStore = create((set, get) => ({ quoteHistory, historyIndex: 0, collections, + enabledCategories, + showCustomQuotes, + showFavoritesOnly, + activeCollectionIds, isLoading: false, }); @@ -439,11 +480,12 @@ export const useQuoteStore = create((set, get) => ({ } }, - setEnabledCategories: (categories: QuoteCategory[]) => { + setEnabledCategories: async (categories: QuoteCategory[]) => { set({ enabledCategories: categories }); + await persistFilterSettings(get()); }, - toggleCategory: (category: QuoteCategory) => { + toggleCategory: async (category: QuoteCategory) => { const { enabledCategories } = get(); const isEnabled = enabledCategories.includes(category); @@ -452,16 +494,19 @@ export const useQuoteStore = create((set, get) => ({ } else { set({ enabledCategories: [...enabledCategories, category] }); } + await persistFilterSettings(get()); }, - toggleCustomQuotes: () => { + toggleCustomQuotes: async () => { const { showCustomQuotes } = get(); set({ showCustomQuotes: !showCustomQuotes }); + await persistFilterSettings(get()); }, - toggleFavoritesOnly: () => { + toggleFavoritesOnly: async () => { const { showFavoritesOnly } = get(); set({ showFavoritesOnly: !showFavoritesOnly }); + await persistFilterSettings(get()); }, // Bulk operations @@ -820,17 +865,19 @@ export const useQuoteStore = create((set, get) => ({ } }, - toggleCollection: (collectionId: string) => { + toggleCollection: async (collectionId: string) => { const { activeCollectionIds } = get(); if (activeCollectionIds.includes(collectionId)) { set({ activeCollectionIds: activeCollectionIds.filter((id) => id !== collectionId) }); } else { set({ activeCollectionIds: [...activeCollectionIds, collectionId] }); } + await persistFilterSettings(get()); }, - setActiveCollectionIds: (collectionIds: string[]) => { + setActiveCollectionIds: async (collectionIds: string[]) => { set({ activeCollectionIds: collectionIds }); + await persistFilterSettings(get()); }, getQuotesInCollection: (collectionId: string) => { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index cf3c641..87cbcbb 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -10,6 +10,25 @@ import type { YoutubePlaylist, } from './types'; +// Quote categories with display names +export const QUOTE_CATEGORIES: Record = { + inspiration: 'Inspiration', + learning: 'Learning', + productivity: 'Productivity', + mindfulness: 'Mindfulness', + success: 'Success', + creativity: 'Creativity', + resilience: 'Resilience', + leadership: 'Leadership', + health: 'Health', + growth: 'Growth', +}; + +// All quote categories as an array (for filters) +export const ALL_QUOTE_CATEGORIES: QuoteCategory[] = Object.keys( + QUOTE_CATEGORIES +) as QuoteCategory[]; + // Default settings export const DEFAULT_SETTINGS: Settings = { pomodoroWorkDuration: 25, @@ -53,27 +72,13 @@ export const DEFAULT_SETTINGS: Settings = { enableQuoteAnimation: false, // Disabled by default (can be CPU-intensive) // Focus Position focusPosition: 'center', // Center goals section by default + // Quote Filter Persistence + quoteFilterEnabledCategories: ALL_QUOTE_CATEGORIES, + quoteFilterShowCustomQuotes: true, + quoteFilterShowFavoritesOnly: false, + quoteFilterActiveCollectionIds: [], }; -// Quote categories with display names -export const QUOTE_CATEGORIES: Record = { - inspiration: 'Inspiration', - learning: 'Learning', - productivity: 'Productivity', - mindfulness: 'Mindfulness', - success: 'Success', - creativity: 'Creativity', - resilience: 'Resilience', - leadership: 'Leadership', - health: 'Health', - growth: 'Growth', -}; - -// All quote categories as an array (for filters) -export const ALL_QUOTE_CATEGORIES: QuoteCategory[] = Object.keys( - QUOTE_CATEGORIES -) as QuoteCategory[]; - // Category colors (for UI) export const CATEGORY_COLORS: Record = { inspiration: '#8B5CF6', // purple diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 9653033..c7e98c3 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -204,6 +204,11 @@ export interface Settings { enableQuoteAnimation: boolean; // Enable smart-ticker animation for quotes (default false) // Focus Position focusPosition: FocusPosition; // Vertical position of focus/goals section (default 'center') + // Quote Filter Persistence + quoteFilterEnabledCategories: QuoteCategory[]; // Enabled categories for quote filter (default all) + quoteFilterShowCustomQuotes: boolean; // Show custom quotes in filter (default true) + quoteFilterShowFavoritesOnly: boolean; // Show only favorites (default false) + quoteFilterActiveCollectionIds: string[]; // Active collection IDs for filter (default []) } // Storage keys diff --git a/packages/test-utils/src/fixtures/settings.fixture.ts b/packages/test-utils/src/fixtures/settings.fixture.ts index bf25a5d..e5a2ac7 100644 --- a/packages/test-utils/src/fixtures/settings.fixture.ts +++ b/packages/test-utils/src/fixtures/settings.fixture.ts @@ -1,4 +1,4 @@ -import type { Settings } from '@cuewise/shared'; +import { ALL_QUOTE_CATEGORIES, type Settings } from '@cuewise/shared'; export const defaultSettings: Settings = { pomodoroWorkDuration: 25, @@ -37,4 +37,8 @@ export const defaultSettings: Settings = { quoteDisplayMode: 'bottom', enableQuoteAnimation: false, focusPosition: 'center', + quoteFilterEnabledCategories: ALL_QUOTE_CATEGORIES, + quoteFilterShowCustomQuotes: true, + quoteFilterShowFavoritesOnly: false, + quoteFilterActiveCollectionIds: [], }; From e889bf560b0078584d5895b58d9696592df3038c Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Mon, 12 Jan 2026 23:43:29 +0700 Subject: [PATCH 02/13] fix: add user warning when filter settings fail to persist Previously, persistFilterSettings would silently catch and log errors without informing the user. Now displays a toast warning so users know their filter changes may not persist. --- apps/browser-extension/src/stores/quote-store.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/browser-extension/src/stores/quote-store.ts b/apps/browser-extension/src/stores/quote-store.ts index 31c4aa4..c244212 100644 --- a/apps/browser-extension/src/stores/quote-store.ts +++ b/apps/browser-extension/src/stores/quote-store.ts @@ -104,6 +104,7 @@ interface QuoteStore { /** * Persists current filter settings to storage. * Called when filter state changes (categories, custom, favorites, collections). + * Shows a warning toast if persistence fails (non-blocking - filter still works in memory). */ async function persistFilterSettings(state: QuoteStore): Promise { try { @@ -118,6 +119,9 @@ async function persistFilterSettings(state: QuoteStore): Promise { await setSettings(updatedSettings); } catch (error) { logger.error('Error persisting filter settings', error); + useToastStore + .getState() + .warning('Failed to save filter preferences. Your changes may not persist.'); } } From 04d2014e1cd7011776f07fc7d2f652467c5a0db9 Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Mon, 12 Jan 2026 23:48:26 +0700 Subject: [PATCH 03/13] test: add filter persistence tests for quote store Add comprehensive tests for filter settings persistence: - Loading persisted filter categories from settings - Loading persisted showCustomQuotes, showFavoritesOnly - Loading persisted activeCollectionIds - Filtering out deleted collection IDs on load - Using default values when settings are null - Persisting changes for toggleCategory, toggleCustomQuotes, toggleFavoritesOnly, toggleCollection, setEnabledCategories, and setActiveCollectionIds - Error handling with toast warning when persistence fails --- .../src/stores/quote-store.test.ts | 330 +++++++++++++++++- 1 file changed, 328 insertions(+), 2 deletions(-) diff --git a/apps/browser-extension/src/stores/quote-store.test.ts b/apps/browser-extension/src/stores/quote-store.test.ts index 257f53d..9926d04 100644 --- a/apps/browser-extension/src/stores/quote-store.test.ts +++ b/apps/browser-extension/src/stores/quote-store.test.ts @@ -1,3 +1,4 @@ +import { ALL_QUOTE_CATEGORIES, type QuoteCollection, type Settings } from '@cuewise/shared'; import * as storage from '@cuewise/storage'; import { quoteFactory } from '@cuewise/test-utils/factories'; import { defaultSettings } from '@cuewise/test-utils/fixtures'; @@ -31,11 +32,17 @@ vi.mock('@cuewise/storage', () => ({ setSettings: vi.fn(), })); -// Mock toast store +// Mock toast store with all methods +const mockToastError = vi.fn(); +const mockToastWarning = vi.fn(); +const mockToastSuccess = vi.fn(); + vi.mock('./toast-store', () => ({ useToastStore: { getState: () => ({ - error: vi.fn(), + error: mockToastError, + warning: mockToastWarning, + success: mockToastSuccess, }), }, })); @@ -47,6 +54,9 @@ describe('Quote Store', () => { // Clear all mocks vi.clearAllMocks(); + mockToastError.mockClear(); + mockToastWarning.mockClear(); + mockToastSuccess.mockClear(); // Default mock for collections (empty by default) vi.mocked(storage.getCollections).mockResolvedValue([]); @@ -512,4 +522,320 @@ describe('Quote Store', () => { expect(result?.id).toBe(favoriteInspiration.id); }); }); + + describe('Filter Persistence', () => { + describe('initialize - loading persisted settings', () => { + it('should load persisted filter categories from settings', async () => { + const mockQuotes = quoteFactory.buildList(3); + const customSettings: Settings = { + ...defaultSettings, + quoteFilterEnabledCategories: ['inspiration', 'productivity'], + }; + + vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); + vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); + vi.mocked(storage.getSettings).mockResolvedValue(customSettings); + + await useQuoteStore.getState().initialize(); + + const state = useQuoteStore.getState(); + expect(state.enabledCategories).toEqual(['inspiration', 'productivity']); + }); + + it('should load persisted showCustomQuotes from settings', async () => { + const mockQuotes = quoteFactory.buildList(3); + const customSettings: Settings = { + ...defaultSettings, + quoteFilterShowCustomQuotes: false, + }; + + vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); + vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); + vi.mocked(storage.getSettings).mockResolvedValue(customSettings); + + await useQuoteStore.getState().initialize(); + + const state = useQuoteStore.getState(); + expect(state.showCustomQuotes).toBe(false); + }); + + it('should load persisted showFavoritesOnly from settings', async () => { + const mockQuotes = quoteFactory.buildList(3); + const customSettings: Settings = { + ...defaultSettings, + quoteFilterShowFavoritesOnly: true, + }; + + vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); + vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); + vi.mocked(storage.getSettings).mockResolvedValue(customSettings); + + await useQuoteStore.getState().initialize(); + + const state = useQuoteStore.getState(); + expect(state.showFavoritesOnly).toBe(true); + }); + + it('should load persisted activeCollectionIds from settings', async () => { + const mockQuotes = quoteFactory.buildList(3); + const mockCollections: QuoteCollection[] = [ + { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, + { id: 'col-2', name: 'Collection 2', createdAt: new Date().toISOString() }, + ]; + const customSettings: Settings = { + ...defaultSettings, + quoteFilterActiveCollectionIds: ['col-1', 'col-2'], + }; + + vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); + vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); + vi.mocked(storage.getCollections).mockResolvedValue(mockCollections); + vi.mocked(storage.getSettings).mockResolvedValue(customSettings); + + await useQuoteStore.getState().initialize(); + + const state = useQuoteStore.getState(); + expect(state.activeCollectionIds).toEqual(['col-1', 'col-2']); + }); + + it('should filter out deleted collection IDs on load', async () => { + const mockQuotes = quoteFactory.buildList(3); + const mockCollections: QuoteCollection[] = [ + { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, + // col-2 has been deleted + ]; + const customSettings: Settings = { + ...defaultSettings, + quoteFilterActiveCollectionIds: ['col-1', 'col-2', 'col-3'], + }; + + vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); + vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); + vi.mocked(storage.getCollections).mockResolvedValue(mockCollections); + vi.mocked(storage.getSettings).mockResolvedValue(customSettings); + + await useQuoteStore.getState().initialize(); + + const state = useQuoteStore.getState(); + // Only col-1 should remain as col-2 and col-3 don't exist + expect(state.activeCollectionIds).toEqual(['col-1']); + }); + + it('should use default values when settings are null', async () => { + const mockQuotes = quoteFactory.buildList(3); + + vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); + vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); + vi.mocked(storage.getSettings).mockResolvedValue(null as unknown as Settings); + + await useQuoteStore.getState().initialize(); + + const state = useQuoteStore.getState(); + expect(state.enabledCategories).toEqual(ALL_QUOTE_CATEGORIES); + expect(state.showCustomQuotes).toBe(true); + expect(state.showFavoritesOnly).toBe(false); + expect(state.activeCollectionIds).toEqual([]); + }); + }); + + describe('toggleCategory - persistence', () => { + it('should persist enabled categories when toggling a category off', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + enabledCategories: [...ALL_QUOTE_CATEGORIES], + isLoading: false, + }); + + await useQuoteStore.getState().toggleCategory('inspiration'); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterEnabledCategories: expect.not.arrayContaining(['inspiration']), + }) + ); + }); + + it('should persist enabled categories when toggling a category on', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + enabledCategories: ['productivity'], + isLoading: false, + }); + + await useQuoteStore.getState().toggleCategory('inspiration'); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterEnabledCategories: expect.arrayContaining(['productivity', 'inspiration']), + }) + ); + }); + }); + + describe('setEnabledCategories - persistence', () => { + it('should persist enabled categories', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + enabledCategories: [...ALL_QUOTE_CATEGORIES], + isLoading: false, + }); + + await useQuoteStore.getState().setEnabledCategories(['inspiration', 'creativity']); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterEnabledCategories: ['inspiration', 'creativity'], + }) + ); + expect(useQuoteStore.getState().enabledCategories).toEqual(['inspiration', 'creativity']); + }); + }); + + describe('toggleCustomQuotes - persistence', () => { + it('should persist showCustomQuotes when toggling', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + showCustomQuotes: true, + isLoading: false, + }); + + await useQuoteStore.getState().toggleCustomQuotes(); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterShowCustomQuotes: false, + }) + ); + expect(useQuoteStore.getState().showCustomQuotes).toBe(false); + }); + }); + + describe('toggleFavoritesOnly - persistence', () => { + it('should persist showFavoritesOnly when toggling', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + showFavoritesOnly: false, + isLoading: false, + }); + + await useQuoteStore.getState().toggleFavoritesOnly(); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterShowFavoritesOnly: true, + }) + ); + expect(useQuoteStore.getState().showFavoritesOnly).toBe(true); + }); + }); + + describe('toggleCollection - persistence', () => { + it('should persist activeCollectionIds when toggling a collection on', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + activeCollectionIds: [], + isLoading: false, + }); + + await useQuoteStore.getState().toggleCollection('col-1'); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterActiveCollectionIds: ['col-1'], + }) + ); + expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-1']); + }); + + it('should persist activeCollectionIds when toggling a collection off', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + activeCollectionIds: ['col-1', 'col-2'], + isLoading: false, + }); + + await useQuoteStore.getState().toggleCollection('col-1'); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterActiveCollectionIds: ['col-2'], + }) + ); + expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-2']); + }); + }); + + describe('setActiveCollectionIds - persistence', () => { + it('should persist activeCollectionIds', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + activeCollectionIds: [], + isLoading: false, + }); + + await useQuoteStore.getState().setActiveCollectionIds(['col-1', 'col-2']); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterActiveCollectionIds: ['col-1', 'col-2'], + }) + ); + expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-1', 'col-2']); + }); + }); + + describe('persistFilterSettings - error handling', () => { + it('should show warning toast when persistence fails', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + showFavoritesOnly: false, + isLoading: false, + }); + + // Make setSettings fail + vi.mocked(storage.setSettings).mockRejectedValue(new Error('Storage error')); + + await useQuoteStore.getState().toggleFavoritesOnly(); + + // State should still be updated in memory + expect(useQuoteStore.getState().showFavoritesOnly).toBe(true); + + // Warning toast should be shown + expect(mockToastWarning).toHaveBeenCalledWith( + 'Failed to save filter preferences. Your changes may not persist.' + ); + }); + + it('should show warning toast when getSettings fails during persistence', async () => { + const mockQuotes = quoteFactory.buildList(3); + useQuoteStore.setState({ + quotes: mockQuotes, + enabledCategories: [...ALL_QUOTE_CATEGORIES], + isLoading: false, + }); + + // Make getSettings fail during persistence (after initialize) + vi.mocked(storage.getSettings).mockRejectedValue(new Error('Cannot read settings')); + + await useQuoteStore.getState().toggleCategory('inspiration'); + + // State should still be updated in memory + expect(useQuoteStore.getState().enabledCategories).not.toContain('inspiration'); + + // Warning toast should be shown + expect(mockToastWarning).toHaveBeenCalledWith( + 'Failed to save filter preferences. Your changes may not persist.' + ); + }); + }); + }); }); From cf74eb49a19c64a7f69142bd55a06def6f61576a Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Tue, 13 Jan 2026 00:22:35 +0700 Subject: [PATCH 04/13] fix: persist filter settings when collection is deleted When a collection that was an active filter is deleted, the updated activeCollectionIds was not being persisted to settings storage. This caused data inconsistency between memory and storage. Added persistFilterSettings call after updating state in deleteCollection, and added tests to verify the persistence behavior. --- .../src/stores/quote-store.test.ts | 54 +++++++++++++++++++ .../src/stores/quote-store.ts | 3 ++ 2 files changed, 57 insertions(+) diff --git a/apps/browser-extension/src/stores/quote-store.test.ts b/apps/browser-extension/src/stores/quote-store.test.ts index 9926d04..cda98b1 100644 --- a/apps/browser-extension/src/stores/quote-store.test.ts +++ b/apps/browser-extension/src/stores/quote-store.test.ts @@ -792,6 +792,60 @@ describe('Quote Store', () => { }); }); + describe('deleteCollection - persistence', () => { + it('should persist updated activeCollectionIds when deleting an active collection', async () => { + const mockCollections: QuoteCollection[] = [ + { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, + { id: 'col-2', name: 'Collection 2', createdAt: new Date().toISOString() }, + ]; + const mockQuotes = quoteFactory.buildList(3); + + vi.mocked(storage.setCollections).mockResolvedValue({ success: true }); + + useQuoteStore.setState({ + quotes: mockQuotes, + collections: mockCollections, + activeCollectionIds: ['col-1', 'col-2'], + isLoading: false, + }); + + await useQuoteStore.getState().deleteCollection('col-1'); + + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterActiveCollectionIds: ['col-2'], + }) + ); + expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-2']); + }); + + it('should not call setSettings when deleted collection was not in active filters', async () => { + const mockCollections: QuoteCollection[] = [ + { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, + { id: 'col-2', name: 'Collection 2', createdAt: new Date().toISOString() }, + ]; + const mockQuotes = quoteFactory.buildList(3); + + vi.mocked(storage.setCollections).mockResolvedValue({ success: true }); + + useQuoteStore.setState({ + quotes: mockQuotes, + collections: mockCollections, + activeCollectionIds: ['col-2'], // col-1 is not active + isLoading: false, + }); + + await useQuoteStore.getState().deleteCollection('col-1'); + + // Should still persist because activeCollectionIds state changed (even if same values) + expect(storage.setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + quoteFilterActiveCollectionIds: ['col-2'], + }) + ); + }); + }); + describe('persistFilterSettings - error handling', () => { it('should show warning toast when persistence fails', async () => { const mockQuotes = quoteFactory.buildList(3); diff --git a/apps/browser-extension/src/stores/quote-store.ts b/apps/browser-extension/src/stores/quote-store.ts index c244212..8e1155c 100644 --- a/apps/browser-extension/src/stores/quote-store.ts +++ b/apps/browser-extension/src/stores/quote-store.ts @@ -752,6 +752,9 @@ export const useQuoteStore = create((set, get) => ({ error: null, }); + // Persist updated filter settings (collection removed from active filters) + await persistFilterSettings(get()); + useToastStore.getState().success('Collection deleted'); return true; } catch (error) { From 502b4a35ebc82d4d518c3342c8b1f39c0946525e Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Tue, 13 Jan 2026 00:25:26 +0700 Subject: [PATCH 05/13] refactor: make filter persistence tests lean and compact - Add helper functions to reduce test setup duplication - Consolidate 4 initialize tests into 1 comprehensive test - Merge toggle method tests into single describe block - Remove redundant assertions and test cases - Reduce test file by ~100 lines while maintaining coverage --- .../src/stores/quote-store.test.ts | 294 ++++-------------- 1 file changed, 67 insertions(+), 227 deletions(-) diff --git a/apps/browser-extension/src/stores/quote-store.test.ts b/apps/browser-extension/src/stores/quote-store.test.ts index cda98b1..62a7bc1 100644 --- a/apps/browser-extension/src/stores/quote-store.test.ts +++ b/apps/browser-extension/src/stores/quote-store.test.ts @@ -524,106 +524,62 @@ describe('Quote Store', () => { }); describe('Filter Persistence', () => { - describe('initialize - loading persisted settings', () => { - it('should load persisted filter categories from settings', async () => { - const mockQuotes = quoteFactory.buildList(3); - const customSettings: Settings = { - ...defaultSettings, - quoteFilterEnabledCategories: ['inspiration', 'productivity'], - }; - - vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); - vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); - vi.mocked(storage.getSettings).mockResolvedValue(customSettings); - - await useQuoteStore.getState().initialize(); - - const state = useQuoteStore.getState(); - expect(state.enabledCategories).toEqual(['inspiration', 'productivity']); - }); + // Helper to set up initialize mocks + const setupInitializeMocks = ( + settings: Partial, + collections: QuoteCollection[] = [] + ) => { + const mockQuotes = quoteFactory.buildList(3); + vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); + vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); + vi.mocked(storage.getCollections).mockResolvedValue(collections); + vi.mocked(storage.getSettings).mockResolvedValue({ ...defaultSettings, ...settings }); + }; - it('should load persisted showCustomQuotes from settings', async () => { - const mockQuotes = quoteFactory.buildList(3); - const customSettings: Settings = { - ...defaultSettings, - quoteFilterShowCustomQuotes: false, - }; + // Helper to set up toggle test state + const setupToggleState = (state: Record) => { + useQuoteStore.setState({ quotes: quoteFactory.buildList(3), isLoading: false, ...state }); + }; - vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); - vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); - vi.mocked(storage.getSettings).mockResolvedValue(customSettings); + describe('initialize - loading persisted settings', () => { + it('should load all persisted filter settings', async () => { + const collections = [ + { id: 'col-1', name: 'C1', createdAt: new Date().toISOString() }, + { id: 'col-2', name: 'C2', createdAt: new Date().toISOString() }, + ]; + setupInitializeMocks( + { + quoteFilterEnabledCategories: ['inspiration', 'productivity'], + quoteFilterShowCustomQuotes: false, + quoteFilterShowFavoritesOnly: true, + quoteFilterActiveCollectionIds: ['col-1', 'col-2'], + }, + collections + ); await useQuoteStore.getState().initialize(); const state = useQuoteStore.getState(); + expect(state.enabledCategories).toEqual(['inspiration', 'productivity']); expect(state.showCustomQuotes).toBe(false); - }); - - it('should load persisted showFavoritesOnly from settings', async () => { - const mockQuotes = quoteFactory.buildList(3); - const customSettings: Settings = { - ...defaultSettings, - quoteFilterShowFavoritesOnly: true, - }; - - vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); - vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); - vi.mocked(storage.getSettings).mockResolvedValue(customSettings); - - await useQuoteStore.getState().initialize(); - - const state = useQuoteStore.getState(); expect(state.showFavoritesOnly).toBe(true); - }); - - it('should load persisted activeCollectionIds from settings', async () => { - const mockQuotes = quoteFactory.buildList(3); - const mockCollections: QuoteCollection[] = [ - { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, - { id: 'col-2', name: 'Collection 2', createdAt: new Date().toISOString() }, - ]; - const customSettings: Settings = { - ...defaultSettings, - quoteFilterActiveCollectionIds: ['col-1', 'col-2'], - }; - - vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); - vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); - vi.mocked(storage.getCollections).mockResolvedValue(mockCollections); - vi.mocked(storage.getSettings).mockResolvedValue(customSettings); - - await useQuoteStore.getState().initialize(); - - const state = useQuoteStore.getState(); expect(state.activeCollectionIds).toEqual(['col-1', 'col-2']); }); it('should filter out deleted collection IDs on load', async () => { - const mockQuotes = quoteFactory.buildList(3); - const mockCollections: QuoteCollection[] = [ - { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, - // col-2 has been deleted - ]; - const customSettings: Settings = { - ...defaultSettings, - quoteFilterActiveCollectionIds: ['col-1', 'col-2', 'col-3'], - }; - - vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); - vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); - vi.mocked(storage.getCollections).mockResolvedValue(mockCollections); - vi.mocked(storage.getSettings).mockResolvedValue(customSettings); + const collections = [{ id: 'col-1', name: 'C1', createdAt: new Date().toISOString() }]; + setupInitializeMocks( + { quoteFilterActiveCollectionIds: ['col-1', 'col-2', 'col-3'] }, + collections + ); await useQuoteStore.getState().initialize(); - const state = useQuoteStore.getState(); - // Only col-1 should remain as col-2 and col-3 don't exist - expect(state.activeCollectionIds).toEqual(['col-1']); + expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-1']); }); it('should use default values when settings are null', async () => { const mockQuotes = quoteFactory.buildList(3); - vi.mocked(storage.getQuotes).mockResolvedValue(mockQuotes); vi.mocked(storage.getCurrentQuote).mockResolvedValue(mockQuotes[0]); vi.mocked(storage.getSettings).mockResolvedValue(null as unknown as Settings); @@ -638,17 +594,10 @@ describe('Quote Store', () => { }); }); - describe('toggleCategory - persistence', () => { - it('should persist enabled categories when toggling a category off', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - enabledCategories: [...ALL_QUOTE_CATEGORIES], - isLoading: false, - }); - + describe('toggle methods - persistence', () => { + it('should persist when toggling category off', async () => { + setupToggleState({ enabledCategories: [...ALL_QUOTE_CATEGORIES] }); await useQuoteStore.getState().toggleCategory('inspiration'); - expect(storage.setSettings).toHaveBeenCalledWith( expect.objectContaining({ quoteFilterEnabledCategories: expect.not.arrayContaining(['inspiration']), @@ -656,155 +605,75 @@ describe('Quote Store', () => { ); }); - it('should persist enabled categories when toggling a category on', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - enabledCategories: ['productivity'], - isLoading: false, - }); - + it('should persist when toggling category on', async () => { + setupToggleState({ enabledCategories: ['productivity'] }); await useQuoteStore.getState().toggleCategory('inspiration'); - expect(storage.setSettings).toHaveBeenCalledWith( expect.objectContaining({ quoteFilterEnabledCategories: expect.arrayContaining(['productivity', 'inspiration']), }) ); }); - }); - - describe('setEnabledCategories - persistence', () => { - it('should persist enabled categories', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - enabledCategories: [...ALL_QUOTE_CATEGORIES], - isLoading: false, - }); + it('should persist setEnabledCategories', async () => { + setupToggleState({ enabledCategories: [...ALL_QUOTE_CATEGORIES] }); await useQuoteStore.getState().setEnabledCategories(['inspiration', 'creativity']); - expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterEnabledCategories: ['inspiration', 'creativity'], - }) + expect.objectContaining({ quoteFilterEnabledCategories: ['inspiration', 'creativity'] }) ); - expect(useQuoteStore.getState().enabledCategories).toEqual(['inspiration', 'creativity']); }); - }); - - describe('toggleCustomQuotes - persistence', () => { - it('should persist showCustomQuotes when toggling', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - showCustomQuotes: true, - isLoading: false, - }); + it('should persist toggleCustomQuotes', async () => { + setupToggleState({ showCustomQuotes: true }); await useQuoteStore.getState().toggleCustomQuotes(); - expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterShowCustomQuotes: false, - }) + expect.objectContaining({ quoteFilterShowCustomQuotes: false }) ); - expect(useQuoteStore.getState().showCustomQuotes).toBe(false); }); - }); - - describe('toggleFavoritesOnly - persistence', () => { - it('should persist showFavoritesOnly when toggling', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - showFavoritesOnly: false, - isLoading: false, - }); + it('should persist toggleFavoritesOnly', async () => { + setupToggleState({ showFavoritesOnly: false }); await useQuoteStore.getState().toggleFavoritesOnly(); - expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterShowFavoritesOnly: true, - }) + expect.objectContaining({ quoteFilterShowFavoritesOnly: true }) ); - expect(useQuoteStore.getState().showFavoritesOnly).toBe(true); }); - }); - - describe('toggleCollection - persistence', () => { - it('should persist activeCollectionIds when toggling a collection on', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - activeCollectionIds: [], - isLoading: false, - }); + it('should persist toggleCollection on', async () => { + setupToggleState({ activeCollectionIds: [] }); await useQuoteStore.getState().toggleCollection('col-1'); - expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterActiveCollectionIds: ['col-1'], - }) + expect.objectContaining({ quoteFilterActiveCollectionIds: ['col-1'] }) ); - expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-1']); }); - it('should persist activeCollectionIds when toggling a collection off', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - activeCollectionIds: ['col-1', 'col-2'], - isLoading: false, - }); - + it('should persist toggleCollection off', async () => { + setupToggleState({ activeCollectionIds: ['col-1', 'col-2'] }); await useQuoteStore.getState().toggleCollection('col-1'); - expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterActiveCollectionIds: ['col-2'], - }) + expect.objectContaining({ quoteFilterActiveCollectionIds: ['col-2'] }) ); - expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-2']); }); - }); - - describe('setActiveCollectionIds - persistence', () => { - it('should persist activeCollectionIds', async () => { - const mockQuotes = quoteFactory.buildList(3); - useQuoteStore.setState({ - quotes: mockQuotes, - activeCollectionIds: [], - isLoading: false, - }); + it('should persist setActiveCollectionIds', async () => { + setupToggleState({ activeCollectionIds: [] }); await useQuoteStore.getState().setActiveCollectionIds(['col-1', 'col-2']); - expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterActiveCollectionIds: ['col-1', 'col-2'], - }) + expect.objectContaining({ quoteFilterActiveCollectionIds: ['col-1', 'col-2'] }) ); - expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-1', 'col-2']); }); }); describe('deleteCollection - persistence', () => { - it('should persist updated activeCollectionIds when deleting an active collection', async () => { - const mockCollections: QuoteCollection[] = [ - { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, - { id: 'col-2', name: 'Collection 2', createdAt: new Date().toISOString() }, + it('should persist updated activeCollectionIds when deleting active collection', async () => { + const collections = [ + { id: 'col-1', name: 'C1', createdAt: new Date().toISOString() }, + { id: 'col-2', name: 'C2', createdAt: new Date().toISOString() }, ]; - const mockQuotes = quoteFactory.buildList(3); - vi.mocked(storage.setCollections).mockResolvedValue({ success: true }); - useQuoteStore.setState({ - quotes: mockQuotes, - collections: mockCollections, + quotes: quoteFactory.buildList(3), + collections, activeCollectionIds: ['col-1', 'col-2'], isLoading: false, }); @@ -812,36 +681,7 @@ describe('Quote Store', () => { await useQuoteStore.getState().deleteCollection('col-1'); expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterActiveCollectionIds: ['col-2'], - }) - ); - expect(useQuoteStore.getState().activeCollectionIds).toEqual(['col-2']); - }); - - it('should not call setSettings when deleted collection was not in active filters', async () => { - const mockCollections: QuoteCollection[] = [ - { id: 'col-1', name: 'Collection 1', createdAt: new Date().toISOString() }, - { id: 'col-2', name: 'Collection 2', createdAt: new Date().toISOString() }, - ]; - const mockQuotes = quoteFactory.buildList(3); - - vi.mocked(storage.setCollections).mockResolvedValue({ success: true }); - - useQuoteStore.setState({ - quotes: mockQuotes, - collections: mockCollections, - activeCollectionIds: ['col-2'], // col-1 is not active - isLoading: false, - }); - - await useQuoteStore.getState().deleteCollection('col-1'); - - // Should still persist because activeCollectionIds state changed (even if same values) - expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ - quoteFilterActiveCollectionIds: ['col-2'], - }) + expect.objectContaining({ quoteFilterActiveCollectionIds: ['col-2'] }) ); }); }); From 882ed87ee18491f459e7e4234c0018a6f4b5bc97 Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Tue, 13 Jan 2026 00:36:11 +0700 Subject: [PATCH 06/13] chore: add changeset for filter persistence feature --- .changeset/persist-quote-filters.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/persist-quote-filters.md diff --git a/.changeset/persist-quote-filters.md b/.changeset/persist-quote-filters.md new file mode 100644 index 0000000..926c67a --- /dev/null +++ b/.changeset/persist-quote-filters.md @@ -0,0 +1,12 @@ +--- +"@cuewise/browser-extension": minor +"@cuewise/shared": minor +--- + +Persist quote filter settings across browser sessions + +- Remember enabled categories, custom quotes toggle, favorites-only mode +- Remember active collection filters +- Filter out deleted collection IDs on load +- Show warning toast if filter preferences fail to save +- Persist filter state when deleting collections From c755dfadbd7c75ccc99db518e6008aba1939787e Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Tue, 13 Jan 2026 14:27:45 +0700 Subject: [PATCH 07/13] fix: show filter-specific message when favorites/collections filter results in no quotes Update isFiltered check to include showFavoritesOnly and activeCollectionIds so users see helpful guidance when their filter settings result in no matching quotes. --- .../src/components/QuoteDisplay.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/browser-extension/src/components/QuoteDisplay.tsx b/apps/browser-extension/src/components/QuoteDisplay.tsx index 2d8781e..16fcfad 100644 --- a/apps/browser-extension/src/components/QuoteDisplay.tsx +++ b/apps/browser-extension/src/components/QuoteDisplay.tsx @@ -31,13 +31,23 @@ export const QuoteDisplay: React.FC = ({ const [timeRemaining, setTimeRemaining] = useState(quoteChangeInterval); // State values - use useShallow to prevent re-renders when unrelated state changes - const { currentQuote, isLoading, error, enabledCategories, showCustomQuotes } = useQuoteStore( + const { + currentQuote, + isLoading, + error, + enabledCategories, + showCustomQuotes, + showFavoritesOnly, + activeCollectionIds, + } = useQuoteStore( useShallow((state) => ({ currentQuote: state.currentQuote, isLoading: state.isLoading, error: state.error, enabledCategories: state.enabledCategories, showCustomQuotes: state.showCustomQuotes, + showFavoritesOnly: state.showFavoritesOnly, + activeCollectionIds: state.activeCollectionIds, })) ); @@ -53,7 +63,11 @@ export const QuoteDisplay: React.FC = ({ const setEnabledCategories = useQuoteStore((state) => state.setEnabledCategories); const toggleCustomQuotes = useQuoteStore((state) => state.toggleCustomQuotes); - const isFiltered = enabledCategories.length < ALL_QUOTE_CATEGORIES.length || !showCustomQuotes; + const isFiltered = + enabledCategories.length < ALL_QUOTE_CATEGORIES.length || + !showCustomQuotes || + showFavoritesOnly || + activeCollectionIds.length > 0; // Countdown timer for auto-refresh useEffect(() => { From c75fd8dee3f0dd21e1b50124c229e28f183877b5 Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Tue, 20 Jan 2026 20:34:21 +0700 Subject: [PATCH 08/13] refactor: change filter logic from exclusive to additive All filters (categories, custom, favorites, collections) now use additive logic: - Quote passes if it matches ANY enabled filter criteria - Favorites filter renamed from showFavoritesOnly to showFavorites - Collections can now work standalone without categories - Updated tests to reflect new additive behavior --- .../src/components/CategoryFilter.test.tsx | 64 +++++++++---------- .../src/components/CategoryFilter.tsx | 28 ++++---- .../src/components/QuoteDisplay.tsx | 6 +- .../__fixtures__/quote-display.fixtures.ts | 8 +-- .../__fixtures__/quote-store.fixtures.ts | 17 +++-- .../src/stores/quote-store.test.ts | 51 ++++++++------- .../src/stores/quote-store.ts | 24 +++---- packages/shared/src/constants.ts | 2 +- packages/shared/src/types.ts | 2 +- packages/shared/src/utils.test.ts | 29 +++++---- packages/shared/src/utils.ts | 52 +++++++++------ .../src/fixtures/settings.fixture.ts | 2 +- 12 files changed, 151 insertions(+), 134 deletions(-) diff --git a/apps/browser-extension/src/components/CategoryFilter.test.tsx b/apps/browser-extension/src/components/CategoryFilter.test.tsx index ba942a7..451165e 100644 --- a/apps/browser-extension/src/components/CategoryFilter.test.tsx +++ b/apps/browser-extension/src/components/CategoryFilter.test.tsx @@ -12,11 +12,11 @@ vi.mock('../stores/quote-store', () => ({ interface MockCategoryFilterStore { enabledCategories: string[]; showCustomQuotes: boolean; - showFavoritesOnly: boolean; + showFavorites: boolean; setEnabledCategories: Mock; toggleCategory: Mock; toggleCustomQuotes: Mock; - toggleFavoritesOnly: Mock; + toggleFavorites: Mock; collections: unknown[]; activeCollectionIds: string[]; toggleCollection: Mock; @@ -30,11 +30,11 @@ function createMockStore( return { enabledCategories: [...ALL_QUOTE_CATEGORIES], showCustomQuotes: true, - showFavoritesOnly: false, + showFavorites: false, setEnabledCategories: vi.fn(), toggleCategory: vi.fn(), toggleCustomQuotes: vi.fn(), - toggleFavoritesOnly: vi.fn(), + toggleFavorites: vi.fn(), collections: [], activeCollectionIds: [], toggleCollection: vi.fn(), @@ -481,11 +481,11 @@ describe('CategoryFilter', () => { expect(screen.getByText('Favorites Only')).toBeInTheDocument(); }); - it('should call toggleFavoritesOnly when Favorites Only clicked', async () => { + it('should call toggleFavorites when Favorites Only clicked', async () => { const user = userEvent.setup(); - const toggleFavoritesOnly = vi.fn(); + const toggleFavorites = vi.fn(); vi.mocked(useQuoteStore).mockImplementation( - createSelectorMock(createMockStore({ toggleFavoritesOnly })) + createSelectorMock(createMockStore({ toggleFavorites })) ); render(); @@ -493,14 +493,14 @@ describe('CategoryFilter', () => { await user.click(screen.getByTitle('Filter categories (11/11)')); await user.click(screen.getByText('Favorites Only')); - expect(toggleFavoritesOnly).toHaveBeenCalledTimes(1); + expect(toggleFavorites).toHaveBeenCalledTimes(1); }); - it('should show badge when showFavoritesOnly is true', () => { + it('should show badge when showFavorites is true', () => { vi.mocked(useQuoteStore).mockImplementation( createSelectorMock( createMockStore({ - showFavoritesOnly: true, + showFavorites: true, }) ) ); @@ -511,12 +511,12 @@ describe('CategoryFilter', () => { expect(screen.getByText('11')).toBeInTheDocument(); }); - it('should show favorites checkbox as checked when showFavoritesOnly is true', async () => { + it('should show favorites checkbox as checked when showFavorites is true', async () => { const user = userEvent.setup(); vi.mocked(useQuoteStore).mockImplementation( createSelectorMock( createMockStore({ - showFavoritesOnly: true, + showFavorites: true, }) ) ); @@ -530,16 +530,16 @@ describe('CategoryFilter', () => { expect(checkboxDiv?.className).toContain('bg-primary-600'); }); - it('should call toggleFavoritesOnly on Select All when favorites is enabled', async () => { + it('should call toggleFavorites on Select All when favorites is enabled', async () => { const user = userEvent.setup(); - const toggleFavoritesOnly = vi.fn(); + const toggleFavorites = vi.fn(); const setEnabledCategories = vi.fn(); vi.mocked(useQuoteStore).mockImplementation( createSelectorMock( createMockStore({ enabledCategories: ['inspiration'], - showFavoritesOnly: true, - toggleFavoritesOnly, + showFavorites: true, + toggleFavorites, setEnabledCategories, }) ) @@ -550,12 +550,12 @@ describe('CategoryFilter', () => { await user.click(screen.getByTitle('Filter categories (2/11)')); await user.click(screen.getByText('Select All')); - expect(toggleFavoritesOnly).toHaveBeenCalledTimes(1); + expect(toggleFavorites).toHaveBeenCalledTimes(1); }); - it('should not call toggleFavoritesOnly on Select All when favorites is disabled', async () => { + it('should not call toggleFavorites on Select All when favorites is disabled', async () => { const user = userEvent.setup(); - const toggleFavoritesOnly = vi.fn(); + const toggleFavorites = vi.fn(); const setEnabledCategories = vi.fn(); const toggleCustomQuotes = vi.fn(); vi.mocked(useQuoteStore).mockImplementation( @@ -563,8 +563,8 @@ describe('CategoryFilter', () => { createMockStore({ enabledCategories: ['inspiration'], showCustomQuotes: false, - showFavoritesOnly: false, - toggleFavoritesOnly, + showFavorites: false, + toggleFavorites, setEnabledCategories, toggleCustomQuotes, }) @@ -576,19 +576,19 @@ describe('CategoryFilter', () => { await user.click(screen.getByTitle('Filter categories (1/11)')); await user.click(screen.getByText('Select All')); - expect(toggleFavoritesOnly).not.toHaveBeenCalled(); + expect(toggleFavorites).not.toHaveBeenCalled(); }); - it('should call toggleFavoritesOnly on Clear All when favorites is enabled', async () => { + it('should call toggleFavorites on Clear All when favorites is enabled', async () => { const user = userEvent.setup(); - const toggleFavoritesOnly = vi.fn(); + const toggleFavorites = vi.fn(); const setEnabledCategories = vi.fn(); const toggleCustomQuotes = vi.fn(); vi.mocked(useQuoteStore).mockImplementation( createSelectorMock( createMockStore({ - showFavoritesOnly: true, - toggleFavoritesOnly, + showFavorites: true, + toggleFavorites, setEnabledCategories, toggleCustomQuotes, }) @@ -600,19 +600,19 @@ describe('CategoryFilter', () => { await user.click(screen.getByTitle('Filter categories (11/11)')); await user.click(screen.getByText('Clear All')); - expect(toggleFavoritesOnly).toHaveBeenCalledTimes(1); + expect(toggleFavorites).toHaveBeenCalledTimes(1); }); - it('should not call toggleFavoritesOnly on Clear All when favorites is disabled', async () => { + it('should not call toggleFavorites on Clear All when favorites is disabled', async () => { const user = userEvent.setup(); - const toggleFavoritesOnly = vi.fn(); + const toggleFavorites = vi.fn(); const setEnabledCategories = vi.fn(); const toggleCustomQuotes = vi.fn(); vi.mocked(useQuoteStore).mockImplementation( createSelectorMock( createMockStore({ - showFavoritesOnly: false, - toggleFavoritesOnly, + showFavorites: false, + toggleFavorites, setEnabledCategories, toggleCustomQuotes, }) @@ -624,7 +624,7 @@ describe('CategoryFilter', () => { await user.click(screen.getByTitle('Filter categories (11/11)')); await user.click(screen.getByText('Clear All')); - expect(toggleFavoritesOnly).not.toHaveBeenCalled(); + expect(toggleFavorites).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/browser-extension/src/components/CategoryFilter.tsx b/apps/browser-extension/src/components/CategoryFilter.tsx index 2b2c6d1..760abfa 100644 --- a/apps/browser-extension/src/components/CategoryFilter.tsx +++ b/apps/browser-extension/src/components/CategoryFilter.tsx @@ -18,8 +18,8 @@ export const CategoryFilter: React.FC = ({ compact = false toggleCategory, showCustomQuotes, toggleCustomQuotes, - showFavoritesOnly, - toggleFavoritesOnly, + showFavorites, + toggleFavorites, collections, activeCollectionIds, toggleCollection, @@ -33,7 +33,7 @@ export const CategoryFilter: React.FC = ({ compact = false const allEnabled = enabledCategories.length === ALL_QUOTE_CATEGORIES.length && showCustomQuotes && - !showFavoritesOnly && + !showFavorites && activeCollectionIds.length === 0; // Get quote count for a collection @@ -63,8 +63,8 @@ export const CategoryFilter: React.FC = ({ compact = false if (!showCustomQuotes) { toggleCustomQuotes(); } - if (showFavoritesOnly) { - toggleFavoritesOnly(); + if (showFavorites) { + toggleFavorites(); } if (activeCollectionIds.length > 0) { setActiveCollectionIds([]); @@ -76,8 +76,8 @@ export const CategoryFilter: React.FC = ({ compact = false if (showCustomQuotes) { toggleCustomQuotes(); } - if (showFavoritesOnly) { - toggleFavoritesOnly(); + if (showFavorites) { + toggleFavorites(); } if (activeCollectionIds.length > 0) { setActiveCollectionIds([]); @@ -193,33 +193,29 @@ export const CategoryFilter: React.FC = ({ compact = false {/* Favorites Only Option */} diff --git a/apps/browser-extension/src/components/QuoteDisplay.tsx b/apps/browser-extension/src/components/QuoteDisplay.tsx index 16fcfad..11c43a9 100644 --- a/apps/browser-extension/src/components/QuoteDisplay.tsx +++ b/apps/browser-extension/src/components/QuoteDisplay.tsx @@ -37,7 +37,7 @@ export const QuoteDisplay: React.FC = ({ error, enabledCategories, showCustomQuotes, - showFavoritesOnly, + showFavorites, activeCollectionIds, } = useQuoteStore( useShallow((state) => ({ @@ -46,7 +46,7 @@ export const QuoteDisplay: React.FC = ({ error: state.error, enabledCategories: state.enabledCategories, showCustomQuotes: state.showCustomQuotes, - showFavoritesOnly: state.showFavoritesOnly, + showFavorites: state.showFavorites, activeCollectionIds: state.activeCollectionIds, })) ); @@ -66,7 +66,7 @@ export const QuoteDisplay: React.FC = ({ const isFiltered = enabledCategories.length < ALL_QUOTE_CATEGORIES.length || !showCustomQuotes || - showFavoritesOnly || + showFavorites || activeCollectionIds.length > 0; // Countdown timer for auto-refresh diff --git a/apps/browser-extension/src/components/__fixtures__/quote-display.fixtures.ts b/apps/browser-extension/src/components/__fixtures__/quote-display.fixtures.ts index 0eca192..c4ccd9a 100644 --- a/apps/browser-extension/src/components/__fixtures__/quote-display.fixtures.ts +++ b/apps/browser-extension/src/components/__fixtures__/quote-display.fixtures.ts @@ -20,7 +20,7 @@ export interface MockQuoteStore { historyIndex: number; enabledCategories: QuoteCategory[]; showCustomQuotes: boolean; - showFavoritesOnly: boolean; + showFavorites: boolean; collections: unknown[]; activeCollectionIds: string[]; initialize: Mock; @@ -39,7 +39,7 @@ export interface MockQuoteStore { setEnabledCategories: Mock; toggleCategory: Mock; toggleCustomQuotes: Mock; - toggleFavoritesOnly: Mock; + toggleFavorites: Mock; toggleCollection: Mock; setActiveCollectionIds: Mock; } @@ -61,7 +61,7 @@ export function createMockStore(overrides: Partial = {}): MockQu historyIndex: 0, enabledCategories: [...ALL_QUOTE_CATEGORIES], showCustomQuotes: true, - showFavoritesOnly: false, + showFavorites: false, collections: [], activeCollectionIds: [], initialize: overrides.initialize || (vi.fn() as Mock), @@ -80,7 +80,7 @@ export function createMockStore(overrides: Partial = {}): MockQu setEnabledCategories: overrides.setEnabledCategories || (vi.fn() as Mock), toggleCategory: overrides.toggleCategory || (vi.fn() as Mock), toggleCustomQuotes: overrides.toggleCustomQuotes || (vi.fn() as Mock), - toggleFavoritesOnly: overrides.toggleFavoritesOnly || (vi.fn() as Mock), + toggleFavorites: overrides.toggleFavorites || (vi.fn() as Mock), toggleCollection: overrides.toggleCollection || (vi.fn() as Mock), setActiveCollectionIds: overrides.setActiveCollectionIds || (vi.fn() as Mock), ...overrides, diff --git a/apps/browser-extension/src/stores/__fixtures__/quote-store.fixtures.ts b/apps/browser-extension/src/stores/__fixtures__/quote-store.fixtures.ts index 294f8b3..82b10ed 100644 --- a/apps/browser-extension/src/stores/__fixtures__/quote-store.fixtures.ts +++ b/apps/browser-extension/src/stores/__fixtures__/quote-store.fixtures.ts @@ -1,4 +1,4 @@ -import { ALL_QUOTE_CATEGORIES, type Quote, type QuoteCategory } from '@cuewise/shared'; +import type { Quote, QuoteCategory } from '@cuewise/shared'; import { quoteFactory } from '@cuewise/test-utils/factories'; import type { Mock } from 'vitest'; @@ -318,13 +318,15 @@ export function createForwardHistoryClearScenario() { } /** - * Creates a test scenario for favorites filter with store state ready to use + * Creates a test scenario for favorites filter with store state ready to use. + * Uses OR filter logic: when showFavorites=true and other filters disabled, + * only favorites will be returned. */ export function createFavoritesScenario(options: { - showFavoritesOnly: boolean; + showFavorites: boolean; hasFavorites?: boolean; }) { - const { showFavoritesOnly, hasFavorites = true } = options; + const { showFavorites, hasFavorites = true } = options; const favoriteQuotes = hasFavorites ? quoteFactory.buildList(2, { isFavorite: true }) : []; const nonFavoriteQuotes = quoteFactory.buildList(2, { isFavorite: false }); @@ -339,9 +341,10 @@ export function createFavoritesScenario(options: { historyIndex: 0, isLoading: false, error: null, - showFavoritesOnly, - enabledCategories: [...ALL_QUOTE_CATEGORIES] as QuoteCategory[], - showCustomQuotes: true, + showFavorites, + // Disable other filters to test favorites in isolation with OR logic + enabledCategories: [] as QuoteCategory[], + showCustomQuotes: false, }, favoriteQuotes, nonFavoriteQuotes, diff --git a/apps/browser-extension/src/stores/quote-store.test.ts b/apps/browser-extension/src/stores/quote-store.test.ts index 62a7bc1..bcb85c2 100644 --- a/apps/browser-extension/src/stores/quote-store.test.ts +++ b/apps/browser-extension/src/stores/quote-store.test.ts @@ -475,17 +475,17 @@ describe('Quote Store', () => { }); describe('Favorites Filter', () => { - it('should toggle showFavoritesOnly state', () => { - useQuoteStore.setState({ showFavoritesOnly: false }); - useQuoteStore.getState().toggleFavoritesOnly(); - expect(useQuoteStore.getState().showFavoritesOnly).toBe(true); + it('should toggle showFavorites state', () => { + useQuoteStore.setState({ showFavorites: false }); + useQuoteStore.getState().toggleFavorites(); + expect(useQuoteStore.getState().showFavorites).toBe(true); - useQuoteStore.getState().toggleFavoritesOnly(); - expect(useQuoteStore.getState().showFavoritesOnly).toBe(false); + useQuoteStore.getState().toggleFavorites(); + expect(useQuoteStore.getState().showFavorites).toBe(false); }); - it('should only return favorites when filter enabled', async () => { - const { state } = createFavoritesScenario({ showFavoritesOnly: true }); + it('should include favorites when filter enabled', async () => { + const { state } = createFavoritesScenario({ showFavorites: true }); useQuoteStore.setState(state); await useQuoteStore.getState().refreshQuote(); @@ -493,8 +493,8 @@ describe('Quote Store', () => { expect(useQuoteStore.getState().currentQuote?.isFavorite).toBe(true); }); - it('should return null when no favorites exist', async () => { - const { state } = createFavoritesScenario({ showFavoritesOnly: true, hasFavorites: false }); + it('should return null when no favorites exist and only favorites enabled', async () => { + const { state } = createFavoritesScenario({ showFavorites: true, hasFavorites: false }); useQuoteStore.setState(state); await useQuoteStore.getState().refreshQuote(); @@ -502,8 +502,8 @@ describe('Quote Store', () => { expect(useQuoteStore.getState().currentQuote).toBeNull(); }); - it('should combine with category filter', async () => { - const { quotes, favoriteInspiration, currentQuote } = createCategoryFavoritesScenario(); + it('should combine with category filter using OR logic', async () => { + const { quotes, currentQuote } = createCategoryFavoritesScenario(); useQuoteStore.setState({ quotes, currentQuote, @@ -511,7 +511,7 @@ describe('Quote Store', () => { historyIndex: 0, isLoading: false, error: null, - showFavoritesOnly: true, + showFavorites: true, enabledCategories: ['inspiration'], showCustomQuotes: true, }); @@ -519,7 +519,10 @@ describe('Quote Store', () => { await useQuoteStore.getState().refreshQuote(); const result = useQuoteStore.getState().currentQuote; - expect(result?.id).toBe(favoriteInspiration.id); + // With OR logic, result should be either inspiration OR favorite (or both) + const isInspiration = result?.category === 'inspiration'; + const isFavorite = result?.isFavorite === true; + expect(isInspiration || isFavorite).toBe(true); }); }); @@ -551,7 +554,7 @@ describe('Quote Store', () => { { quoteFilterEnabledCategories: ['inspiration', 'productivity'], quoteFilterShowCustomQuotes: false, - quoteFilterShowFavoritesOnly: true, + quoteFilterShowFavorites: true, quoteFilterActiveCollectionIds: ['col-1', 'col-2'], }, collections @@ -562,7 +565,7 @@ describe('Quote Store', () => { const state = useQuoteStore.getState(); expect(state.enabledCategories).toEqual(['inspiration', 'productivity']); expect(state.showCustomQuotes).toBe(false); - expect(state.showFavoritesOnly).toBe(true); + expect(state.showFavorites).toBe(true); expect(state.activeCollectionIds).toEqual(['col-1', 'col-2']); }); @@ -589,7 +592,7 @@ describe('Quote Store', () => { const state = useQuoteStore.getState(); expect(state.enabledCategories).toEqual(ALL_QUOTE_CATEGORIES); expect(state.showCustomQuotes).toBe(true); - expect(state.showFavoritesOnly).toBe(false); + expect(state.showFavorites).toBe(false); expect(state.activeCollectionIds).toEqual([]); }); }); @@ -631,11 +634,11 @@ describe('Quote Store', () => { ); }); - it('should persist toggleFavoritesOnly', async () => { - setupToggleState({ showFavoritesOnly: false }); - await useQuoteStore.getState().toggleFavoritesOnly(); + it('should persist toggleFavorites', async () => { + setupToggleState({ showFavorites: false }); + await useQuoteStore.getState().toggleFavorites(); expect(storage.setSettings).toHaveBeenCalledWith( - expect.objectContaining({ quoteFilterShowFavoritesOnly: true }) + expect.objectContaining({ quoteFilterShowFavorites: true }) ); }); @@ -691,17 +694,17 @@ describe('Quote Store', () => { const mockQuotes = quoteFactory.buildList(3); useQuoteStore.setState({ quotes: mockQuotes, - showFavoritesOnly: false, + showFavorites: false, isLoading: false, }); // Make setSettings fail vi.mocked(storage.setSettings).mockRejectedValue(new Error('Storage error')); - await useQuoteStore.getState().toggleFavoritesOnly(); + await useQuoteStore.getState().toggleFavorites(); // State should still be updated in memory - expect(useQuoteStore.getState().showFavoritesOnly).toBe(true); + expect(useQuoteStore.getState().showFavorites).toBe(true); // Warning toast should be shown expect(mockToastWarning).toHaveBeenCalledWith( diff --git a/apps/browser-extension/src/stores/quote-store.ts b/apps/browser-extension/src/stores/quote-store.ts index 8e1155c..02a723c 100644 --- a/apps/browser-extension/src/stores/quote-store.ts +++ b/apps/browser-extension/src/stores/quote-store.ts @@ -33,7 +33,7 @@ interface QuoteStore { historyIndex: number; // Current position in history (0 = most recent) enabledCategories: QuoteCategory[]; // Categories to show (persisted to settings) showCustomQuotes: boolean; // Show custom quotes in filter (persisted to settings) - showFavoritesOnly: boolean; // Show only favorite quotes (persisted to settings) + showFavorites: boolean; // Include favorites in filter (persisted to settings) // Collections state collections: QuoteCollection[]; @@ -71,7 +71,7 @@ interface QuoteStore { setEnabledCategories: (categories: QuoteCategory[]) => Promise; toggleCategory: (category: QuoteCategory) => Promise; toggleCustomQuotes: () => Promise; - toggleFavoritesOnly: () => Promise; + toggleFavorites: () => Promise; // Bulk operations bulkDelete: (quoteIds: string[]) => Promise; @@ -113,7 +113,7 @@ async function persistFilterSettings(state: QuoteStore): Promise { ...currentSettings, quoteFilterEnabledCategories: state.enabledCategories, quoteFilterShowCustomQuotes: state.showCustomQuotes, - quoteFilterShowFavoritesOnly: state.showFavoritesOnly, + quoteFilterShowFavorites: state.showFavorites, quoteFilterActiveCollectionIds: state.activeCollectionIds, }; await setSettings(updatedSettings); @@ -134,7 +134,7 @@ export const useQuoteStore = create((set, get) => ({ historyIndex: 0, enabledCategories: [...ALL_QUOTE_CATEGORIES], showCustomQuotes: true, - showFavoritesOnly: false, + showFavorites: false, collections: [], activeCollectionIds: [], @@ -160,8 +160,8 @@ export const useQuoteStore = create((set, get) => ({ settings?.quoteFilterEnabledCategories ?? DEFAULT_SETTINGS.quoteFilterEnabledCategories; const showCustomQuotes = settings?.quoteFilterShowCustomQuotes ?? DEFAULT_SETTINGS.quoteFilterShowCustomQuotes; - const showFavoritesOnly = - settings?.quoteFilterShowFavoritesOnly ?? DEFAULT_SETTINGS.quoteFilterShowFavoritesOnly; + const showFavorites = + settings?.quoteFilterShowFavorites ?? DEFAULT_SETTINGS.quoteFilterShowFavorites; // Filter out any collection IDs that no longer exist const collectionIds = new Set(collections.map((c) => c.id)); const activeCollectionIds = ( @@ -188,7 +188,7 @@ export const useQuoteStore = create((set, get) => ({ collections, enabledCategories, showCustomQuotes, - showFavoritesOnly, + showFavorites, activeCollectionIds, isLoading: false, }); @@ -214,7 +214,7 @@ export const useQuoteStore = create((set, get) => ({ historyIndex, enabledCategories, showCustomQuotes, - showFavoritesOnly, + showFavorites, activeCollectionIds, } = get(); @@ -224,7 +224,7 @@ export const useQuoteStore = create((set, get) => ({ currentQuote?.id, enabledCategories, showCustomQuotes, - showFavoritesOnly, + showFavorites, activeCollectionIds ); @@ -507,9 +507,9 @@ export const useQuoteStore = create((set, get) => ({ await persistFilterSettings(get()); }, - toggleFavoritesOnly: async () => { - const { showFavoritesOnly } = get(); - set({ showFavoritesOnly: !showFavoritesOnly }); + toggleFavorites: async () => { + const { showFavorites } = get(); + set({ showFavorites: !showFavorites }); await persistFilterSettings(get()); }, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 87cbcbb..d1758f7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -75,7 +75,7 @@ export const DEFAULT_SETTINGS: Settings = { // Quote Filter Persistence quoteFilterEnabledCategories: ALL_QUOTE_CATEGORIES, quoteFilterShowCustomQuotes: true, - quoteFilterShowFavoritesOnly: false, + quoteFilterShowFavorites: false, quoteFilterActiveCollectionIds: [], }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c7e98c3..d71aba7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -207,7 +207,7 @@ export interface Settings { // Quote Filter Persistence quoteFilterEnabledCategories: QuoteCategory[]; // Enabled categories for quote filter (default all) quoteFilterShowCustomQuotes: boolean; // Show custom quotes in filter (default true) - quoteFilterShowFavoritesOnly: boolean; // Show only favorites (default false) + quoteFilterShowFavorites: boolean; // Include favorites in filter (default false) quoteFilterActiveCollectionIds: string[]; // Active collection IDs for filter (default []) } diff --git a/packages/shared/src/utils.test.ts b/packages/shared/src/utils.test.ts index e360a89..ef0ea12 100644 --- a/packages/shared/src/utils.test.ts +++ b/packages/shared/src/utils.test.ts @@ -694,8 +694,8 @@ describe('Import Utilities', () => { }); }); - describe('getRandomQuote with favorites filtering', () => { - it('should filter quotes to favorites only when showFavoritesOnly is true', () => { + describe('getRandomQuote with favorites filtering (OR logic)', () => { + it('should include favorites when showFavorites is true (even without categories)', () => { const quotes = [ { id: '1', @@ -719,14 +719,15 @@ describe('Import Utilities', () => { }, ]; - const result = getRandomQuote(quotes, undefined, undefined, true, true); + // No categories enabled, but favorites enabled - should return only favorites + const result = getRandomQuote(quotes, undefined, [], false, true); expect(result).not.toBeNull(); expect(result?.id).toBe('1'); expect(result?.isFavorite).toBe(true); }); - it('should return null when showFavoritesOnly is true but no favorites exist', () => { + it('should return null when showFavorites is true but no favorites exist and no other filters', () => { const quotes = [ { id: '1', @@ -740,12 +741,13 @@ describe('Import Utilities', () => { }, ]; - const result = getRandomQuote(quotes, undefined, undefined, true, true); + // No categories, no custom, only favorites filter - but no favorites exist + const result = getRandomQuote(quotes, undefined, [], false, true); expect(result).toBeNull(); }); - it('should combine favorites filter with category filter (AND logic)', () => { + it('should combine favorites filter with category filter using OR logic', () => { const quotes = [ { id: '1', @@ -779,16 +781,17 @@ describe('Import Utilities', () => { }, ]; - // Only productivity + favorites only - const result = getRandomQuote(quotes, undefined, ['productivity'], true, true); + // productivity category OR favorites - should return quotes 2 and 3 (both productivity) and quote 1 (favorite) + const result = getRandomQuote(quotes, undefined, ['productivity'], false, true); expect(result).not.toBeNull(); - expect(result?.id).toBe('2'); - expect(result?.category).toBe('productivity'); - expect(result?.isFavorite).toBe(true); + // Result should be either productivity OR favorite + const isProductivity = result?.category === 'productivity'; + const isFavorite = result?.isFavorite === true; + expect(isProductivity || isFavorite).toBe(true); }); - it('should return all quotes when showFavoritesOnly is false', () => { + it('should return category quotes when showFavorites is false', () => { const quotes = [ { id: '1', @@ -812,7 +815,7 @@ describe('Import Utilities', () => { }, ]; - const result = getRandomQuote(quotes, undefined, undefined, true, false); + const result = getRandomQuote(quotes, undefined, ['inspiration'], true, false); expect(result).not.toBeNull(); }); diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 377c975..9592dd1 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -297,50 +297,62 @@ export function getRandomItem(array: T[]): T { /** * Filter out hidden and get a random quote from a list * Excludes the current quote if provided to prevent consecutive duplicates - * Optionally filters by enabled categories, custom quotes, and favorites + * Optionally filters by enabled categories, custom quotes, favorites, and collections + * + * Filter logic uses OR - quote passes if it matches ANY enabled filter: + * - Matches an enabled category, OR + * - Is a custom quote (if showCustom enabled), OR + * - Is a favorite (if showFavorites enabled), OR + * - Is in an enabled collection (if collections are active) */ export function getRandomQuote( quotes: Quote[], currentQuoteId?: string, enabledCategories?: QuoteCategory[], showCustom = true, - showFavoritesOnly = false, + showFavorites = false, collectionIds?: string[] ): Quote | null { let visibleQuotes = quotes.filter((q) => !q.isHidden); - // Filter by collections if provided (quote must be in at least one of the enabled collections) - if (collectionIds && collectionIds.length > 0) { - visibleQuotes = visibleQuotes.filter((q) => - q.collectionIds?.some((id) => collectionIds.includes(id)) - ); - } + // Check if any filters are enabled + const hasCollectionFilter = collectionIds && collectionIds.length > 0; + const hasCategoryFilter = enabledCategories !== undefined && enabledCategories.length > 0; + const hasNoFiltersEnabled = + !hasCategoryFilter && !showCustom && !showFavorites && !hasCollectionFilter; - // Filter by favorites if enabled - if (showFavoritesOnly) { - visibleQuotes = visibleQuotes.filter((q) => q.isFavorite); + // If no filters are enabled at all, return null + if (enabledCategories !== undefined && hasNoFiltersEnabled) { + return null; } - // Filter by enabled categories if provided - // An empty array means no categories are enabled, so return null - if (enabledCategories !== undefined) { - if (enabledCategories.length === 0 && !showCustom) { - return null; - } + // Apply OR filter - quote passes if it matches ANY enabled criteria + if (enabledCategories !== undefined || hasCollectionFilter) { visibleQuotes = visibleQuotes.filter((q) => { + // Quote is in an enabled collection + if (hasCollectionFilter && q.collectionIds?.some((id) => collectionIds.includes(id))) { + return true; + } + // Favorite quotes pass if showFavorites is enabled + if (q.isFavorite && showFavorites) { + return true; + } // Custom quotes pass if showCustom is enabled if (q.isCustom && showCustom) { return true; } // Non-custom quotes pass if their category is enabled - if (!q.isCustom && enabledCategories.includes(q.category)) { + if (!q.isCustom && enabledCategories && enabledCategories.includes(q.category)) { return true; } return false; }); - } else if (!showCustom) { - // If no category filter but showCustom is false, exclude custom quotes + } else if (!showCustom && !showFavorites) { + // If no filters active but showCustom and showFavorites are false, exclude custom quotes visibleQuotes = visibleQuotes.filter((q) => !q.isCustom); + } else if (!showCustom) { + // Exclude custom quotes but keep favorites if enabled + visibleQuotes = visibleQuotes.filter((q) => !q.isCustom || (q.isFavorite && showFavorites)); } if (visibleQuotes.length === 0) return null; diff --git a/packages/test-utils/src/fixtures/settings.fixture.ts b/packages/test-utils/src/fixtures/settings.fixture.ts index e5a2ac7..feab65d 100644 --- a/packages/test-utils/src/fixtures/settings.fixture.ts +++ b/packages/test-utils/src/fixtures/settings.fixture.ts @@ -39,6 +39,6 @@ export const defaultSettings: Settings = { focusPosition: 'center', quoteFilterEnabledCategories: ALL_QUOTE_CATEGORIES, quoteFilterShowCustomQuotes: true, - quoteFilterShowFavoritesOnly: false, + quoteFilterShowFavorites: false, quoteFilterActiveCollectionIds: [], }; From eec55650a873d6c010e78d389022f8adc2dcded1 Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Tue, 20 Jan 2026 20:48:36 +0700 Subject: [PATCH 09/13] feat: add copy ai prompt buttons for quote generation - add "Copy AI Prompt" button to CSV Import modal for generating new quotes - add "Copy AI Prompt" button to collection view for generating complementary quotes - prompts include CSV format, available categories, and existing quotes context - collection prompt helps AI understand existing quotes to avoid duplicates --- .../src/components/CSVImportModal.tsx | 73 +++++++++++++-- .../src/components/CollectionList.tsx | 90 ++++++++++++++++--- 2 files changed, 145 insertions(+), 18 deletions(-) diff --git a/apps/browser-extension/src/components/CSVImportModal.tsx b/apps/browser-extension/src/components/CSVImportModal.tsx index c6d8539..741f089 100644 --- a/apps/browser-extension/src/components/CSVImportModal.tsx +++ b/apps/browser-extension/src/components/CSVImportModal.tsx @@ -1,8 +1,10 @@ import { + ALL_QUOTE_CATEGORIES, type CSVParseResult, type CSVQuoteRow, generateQuoteCSVTemplate, parseQuotesCSV, + QUOTE_CATEGORIES, validateCSVFile, } from '@cuewise/shared'; import { cn } from '@cuewise/ui'; @@ -10,6 +12,7 @@ import { AlertCircle, AlertTriangle, CheckCircle2, + Copy, Download, FileSpreadsheet, FolderPlus, @@ -19,6 +22,7 @@ import { import type React from 'react'; import { useRef, useState } from 'react'; import { useQuoteStore } from '../stores/quote-store'; +import { useToastStore } from '../stores/toast-store'; interface CSVImportModalProps { onClose: () => void; @@ -138,6 +142,48 @@ export const CSVImportModal: React.FC = ({ onClose }) => { URL.revokeObjectURL(url); }; + // Copy AI prompt to clipboard + const handleCopyAIPrompt = async () => { + const categoryList = ALL_QUOTE_CATEGORIES.map( + (cat) => ` - ${cat}: ${QUOTE_CATEGORIES[cat]}` + ).join('\n'); + + const prompt = `Generate a CSV file with motivational quotes for a personal quote collection app. + +## CSV Format +The CSV must have these columns: +- text (required): The quote text +- author (required): Who said/wrote the quote +- category (optional): One of the categories below +- source (optional): Book, speech, or reference where the quote is from +- notes (optional): Personal notes about the quote + +## Available Categories +${categoryList} + +## Requirements +1. Generate 20-30 high-quality, meaningful quotes +2. Include a mix of categories for variety +3. Use accurate attributions (don't make up authors) +4. Include the source when known (book title, speech name, etc.) +5. Output as valid CSV with proper escaping for quotes containing commas + +## Example Output +text,author,category,source,notes +"The only way to do great work is to love what you do.",Steve Jobs,success,Stanford Commencement Speech 2005, +"Be the change you wish to see in the world.",Mahatma Gandhi,inspiration,,Often misattributed +"The mind is everything. What you think you become.",Buddha,mindfulness,, + +Please generate the CSV now, focusing on [SPECIFY YOUR THEME OR TOPIC HERE - e.g., "stoic philosophy", "entrepreneurship", "mindfulness and meditation", "leadership wisdom"].`; + + try { + await navigator.clipboard.writeText(prompt); + useToastStore.getState().success('AI prompt copied to clipboard'); + } catch { + useToastStore.getState().error('Failed to copy to clipboard'); + } + }; + // Handle import const handleImport = async () => { if (!parseResult || parseResult.valid.length === 0) { @@ -281,14 +327,25 @@ export const CSVImportModal: React.FC = ({ onClose }) => { className="hidden" /> - +
+ + + +
) : (
diff --git a/apps/browser-extension/src/components/CollectionList.tsx b/apps/browser-extension/src/components/CollectionList.tsx index bb725ba..ded8126 100644 --- a/apps/browser-extension/src/components/CollectionList.tsx +++ b/apps/browser-extension/src/components/CollectionList.tsx @@ -1,8 +1,14 @@ -import type { Quote, QuoteCollection } from '@cuewise/shared'; -import { ArrowLeft, Edit2, FolderOpen, ListPlus, Plus, Trash2 } from 'lucide-react'; +import { + ALL_QUOTE_CATEGORIES, + QUOTE_CATEGORIES, + type Quote, + type QuoteCollection, +} from '@cuewise/shared'; +import { ArrowLeft, Copy, Edit2, FolderOpen, ListPlus, Plus, Trash2 } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; import { useQuoteStore } from '../stores/quote-store'; +import { useToastStore } from '../stores/toast-store'; import { AddQuotesToCollectionModal } from './AddQuotesToCollectionModal'; import { CollectionForm } from './CollectionForm'; import { ConfirmationDialog } from './ConfirmationDialog'; @@ -73,6 +79,59 @@ export const CollectionList: React.FC = () => { setSelectedCollectionId(null); }; + const handleCopyAIPrompt = async (collection: QuoteCollection, collectionQuotes: Quote[]) => { + const categoryList = ALL_QUOTE_CATEGORIES.map( + (cat) => ` - ${cat}: ${QUOTE_CATEGORIES[cat]}` + ).join('\n'); + + const existingQuotesList = + collectionQuotes.length > 0 + ? collectionQuotes.map((q) => ` - "${q.text}" — ${q.author} (${q.category})`).join('\n') + : ' (No quotes yet)'; + + const prompt = `Generate more quotes for my "${collection.name}" collection. + +## Collection Details +- **Name**: ${collection.name} +${collection.description ? `- **Description**: ${collection.description}` : ''} + +## Existing Quotes in Collection (${collectionQuotes.length} quotes) +${existingQuotesList} + +## Your Task +Generate 10-15 NEW quotes that complement this collection. The new quotes should: +1. Match the theme/vibe of the existing quotes +2. NOT duplicate any existing quotes +3. Offer variety in authors and perspectives +4. Be authentic, verified quotes (not made up) + +## Output Format (CSV) +Provide the quotes in CSV format with these columns: +- text (required): The quote text (without surrounding quotes) +- author (required): Who said/wrote the quote +- category (required): One of the categories below +- source (optional): Book, speech, or reference +- notes (optional): Why this quote fits the collection + +## Available Categories +${categoryList} + +## Example Output +\`\`\`csv +text,author,category,source,notes +The only way to do great work is to love what you do,Steve Jobs,inspiration,Stanford commencement speech 2005,Fits the motivational theme +\`\`\` + +Please generate the CSV data now:`; + + try { + await navigator.clipboard.writeText(prompt); + useToastStore.getState().success('AI prompt copied to clipboard'); + } catch { + useToastStore.getState().error('Failed to copy prompt'); + } + }; + // Show quotes in selected collection if (selectedCollection) { const collectionQuotes = getQuotesInCollection(selectedCollection.id); @@ -95,14 +154,25 @@ export const CollectionList: React.FC = () => {

{selectedCollection.description}

)}
- +
+ + +
{/* Quotes in Collection */} From ca3bc3243d3e3d5e13c850982fa0abe0372e20c2 Mon Sep 17 00:00:00 2001 From: Kestutis Kasiulynas Date: Tue, 20 Jan 2026 20:57:23 +0700 Subject: [PATCH 10/13] feat: add paste csv text option to import modal - add tabbed interface to switch between file upload and text paste - add textarea for pasting AI-generated CSV content directly - add parse csv button to validate and preview pasted text - move copy ai prompt button to paste text tab for better UX --- .../src/components/CSVImportModal.tsx | 235 ++++++++++++++---- 1 file changed, 186 insertions(+), 49 deletions(-) diff --git a/apps/browser-extension/src/components/CSVImportModal.tsx b/apps/browser-extension/src/components/CSVImportModal.tsx index 741f089..f5fa8fa 100644 --- a/apps/browser-extension/src/components/CSVImportModal.tsx +++ b/apps/browser-extension/src/components/CSVImportModal.tsx @@ -12,6 +12,7 @@ import { AlertCircle, AlertTriangle, CheckCircle2, + ClipboardPaste, Copy, Download, FileSpreadsheet, @@ -29,16 +30,23 @@ interface CSVImportModalProps { } type CollectionMode = 'none' | 'new' | 'existing'; +type InputMode = 'file' | 'paste'; export const CSVImportModal: React.FC = ({ onClose }) => { const { collections, createCollection, bulkAddQuotes } = useQuoteStore(); const fileInputRef = useRef(null); + // Input mode state + const [inputMode, setInputMode] = useState('file'); + // File state const [selectedFile, setSelectedFile] = useState(null); const [parseResult, setParseResult] = useState(null); const [fileError, setFileError] = useState(null); + // Paste state + const [pastedText, setPastedText] = useState(''); + // Collection state const [collectionMode, setCollectionMode] = useState('none'); const [newCollectionName, setNewCollectionName] = useState(''); @@ -128,6 +136,67 @@ export const CSVImportModal: React.FC = ({ onClose }) => { } }; + // Handle pasted text parsing + const handleParsePastedText = () => { + if (!pastedText.trim()) { + setFileError('Please paste some CSV text'); + setParseResult(null); + return; + } + + setFileError(null); + setImportComplete(false); + + try { + const result = parseQuotesCSV(pastedText); + setParseResult(result); + + // If a source is common among quotes, suggest it as collection name + if (result.valid.length > 0) { + const sources = result.valid + .map((q) => q.source) + .filter((s): s is string => !!s && s.trim() !== ''); + if (sources.length > 0) { + const sourceCounts = sources.reduce( + (acc, s) => { + acc[s] = (acc[s] || 0) + 1; + return acc; + }, + {} as Record + ); + const mostCommon = Object.entries(sourceCounts).sort((a, b) => b[1] - a[1])[0]; + if (mostCommon && mostCommon[1] >= result.valid.length / 2) { + setNewCollectionName(mostCommon[0]); + } + } + } + } catch { + setFileError('Failed to parse CSV text'); + } + }; + + // Clear pasted text + const handleClearPaste = () => { + setPastedText(''); + setParseResult(null); + setFileError(null); + setImportComplete(false); + }; + + // Handle input mode switch + const handleInputModeChange = (mode: InputMode) => { + setInputMode(mode); + // Clear state when switching modes + setSelectedFile(null); + setPastedText(''); + setParseResult(null); + setFileError(null); + setImportComplete(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + // Download template const handleDownloadTemplate = () => { const template = generateQuoteCSVTemplate(); @@ -298,45 +367,119 @@ Please generate the CSV now, focusing on [SPECIFY YOUR THEME OR TOPIC HERE - e.g {/* Content */}
- {/* File Upload Area */} - {!selectedFile ? ( -
- -
+ {/* Input Mode Tabs */} +
+ + +
+ + {/* File Upload Mode */} + {inputMode === 'file' && + (!selectedFile ? ( +
- +
+ +
+
+ ) : ( +
+
+ + {selectedFile.name} +
+ +
+ ))} + + {/* Paste Text Mode */} + {inputMode === 'paste' && ( +
+
+