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:
altair823
2026-04-26 11:25:13 +09:00
parent ab68b19144
commit aad9d403ce
3 changed files with 75 additions and 0 deletions

View 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));
}

View File

@@ -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 });
}
}));

View 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);
});
});