feat(inbox): tagFilter store + pure selectFilteredNotes + tests
Adds `tagFilter: string | null` and `setTagFilter` to the inbox store, plus an extracted pure `selectFilteredNotes` selector so unit tests can import it under vitest's `node` environment without dragging `api.ts` (which touches `window.inkling` at module load). Tests cover four cases: null filter passes through, single-tag match, no-match empty result, and any-tag-matches semantics. F2 dogfood feedback step 1/3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
src/renderer/inbox/selectFilteredNotes.ts
Normal file
17
src/renderer/inbox/selectFilteredNotes.ts
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
removeNote: (id: string) => void;
|
||||
setTagFilter: (tag: string | null) => void;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -25,6 +29,7 @@ export const useInbox = create<InboxState>((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<InboxState>((set, get) => ({
|
||||
},
|
||||
removeNote(id) {
|
||||
set({ notes: get().notes.filter((n) => n.id !== id) });
|
||||
},
|
||||
setTagFilter(tag) {
|
||||
set({ tagFilter: tag });
|
||||
}
|
||||
}));
|
||||
|
||||
50
tests/unit/store.tagFilter.test.ts
Normal file
50
tests/unit/store.tagFilter.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user