diff --git a/src/renderer/inbox/selectFilteredNotes.ts b/src/renderer/inbox/selectFilteredNotes.ts new file mode 100644 index 0000000..7e679f7 --- /dev/null +++ b/src/renderer/inbox/selectFilteredNotes.ts @@ -0,0 +1,17 @@ +import type { Note } from '@shared/types'; + +/** + * Pure selector for tag-based filtering. + * + * Extracted out of `store.ts` so unit tests can import it under the + * vitest `node` environment without pulling in `api.ts`, which touches + * `window.inkling` at module-load time. + */ +export function selectFilteredNotes(state: { + notes: Note[]; + tagFilter: string | null; +}): Note[] { + if (state.tagFilter === null) return state.notes; + const target = state.tagFilter; + return state.notes.filter((n) => n.tags.some((t) => t.name === target)); +} diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 2581906..65641c4 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -2,16 +2,20 @@ import { create } from 'zustand'; import type { Note, WeeklyContinuity } from '@shared/types'; import { inboxApi } from './api.js'; +export { selectFilteredNotes } from './selectFilteredNotes.js'; + interface InboxState { notes: Note[]; continuity: WeeklyContinuity; pendingCount: number; ollamaStatus: { ok: boolean; reason?: string }; loading: boolean; + tagFilter: string | null; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; removeNote: (id: string) => void; + setTagFilter: (tag: string | null) => void; } const emptyContinuity: WeeklyContinuity = { @@ -25,6 +29,7 @@ export const useInbox = create((set, get) => ({ pendingCount: 0, ollamaStatus: { ok: true }, loading: false, + tagFilter: null, async loadInitial() { set({ loading: true }); const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([ @@ -55,5 +60,8 @@ export const useInbox = create((set, get) => ({ }, removeNote(id) { set({ notes: get().notes.filter((n) => n.id !== id) }); + }, + setTagFilter(tag) { + set({ tagFilter: tag }); } })); diff --git a/tests/unit/store.tagFilter.test.ts b/tests/unit/store.tagFilter.test.ts new file mode 100644 index 0000000..e4f6765 --- /dev/null +++ b/tests/unit/store.tagFilter.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { selectFilteredNotes } from '../../src/renderer/inbox/selectFilteredNotes.js'; +import type { Note } from '@shared/types'; + +function sample(id: string, tags: string[]): Note { + return { + id, + rawText: 'x', + aiTitle: null, + aiSummary: null, + aiStatus: 'done', + aiError: null, + aiProvider: null, + aiGeneratedAt: null, + titleEditedByUser: false, + summaryEditedByUser: false, + userIntent: null, + intentPromptedAt: null, + dueDate: null, + dueDateEditedByUser: false, + createdAt: '2026-04-26T00:00:00Z', + updatedAt: '2026-04-26T00:00:00Z', + tags: tags.map((name) => ({ name, source: 'ai' as const })), + media: [] + }; +} + +describe('selectFilteredNotes', () => { + it('returns all notes when filter is null', () => { + const notes = [sample('a', ['x']), sample('b', ['y'])]; + expect(selectFilteredNotes({ notes, tagFilter: null }).length).toBe(2); + }); + + it('filters to notes containing the tag', () => { + const notes = [sample('a', ['pr', 'review']), sample('b', ['demo'])]; + const out = selectFilteredNotes({ notes, tagFilter: 'pr' }); + expect(out.length).toBe(1); + expect(out[0]!.id).toBe('a'); + }); + + it('empty filter result when tag not present', () => { + const notes = [sample('a', ['x'])]; + expect(selectFilteredNotes({ notes, tagFilter: 'missing' }).length).toBe(0); + }); + + it('matches against any tag of the note', () => { + const notes = [sample('a', ['alpha', 'beta', 'gamma'])]; + expect(selectFilteredNotes({ notes, tagFilter: 'gamma' }).length).toBe(1); + }); +});