F2 태그 클릭 (v0.2.1 dogfood-feedback Track #5) #6
@@ -103,7 +103,9 @@ H1 이 미달이면 본 항목 ❌ rejected.
|
||||
|
||||
---
|
||||
|
||||
## F2. 태그 클릭 = 즉시 삭제 + undo 부재 (🌱 raw)
|
||||
## F2. 태그 클릭 = 즉시 삭제 + undo 부재 (🚀 promoted)
|
||||
|
||||
**진행 상태:** 🚀 promoted → `docs/superpowers/specs/2026-04-26-f2-tag-click.md`
|
||||
|
||||
**발견:** 2026-04-25 dogfood 중. 슬라이스 v0.4 동작.
|
||||
|
||||
|
||||
31
docs/superpowers/specs/2026-04-26-f2-tag-click.md
Normal file
31
docs/superpowers/specs/2026-04-26-f2-tag-click.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# F2 태그 클릭 Spec (Promoted)
|
||||
|
||||
**Extracted from:** `2026-04-25-dogfood-feedback.md` F2
|
||||
**Plan:** (inline in this PR description)
|
||||
**Status:** 🚀 promoted — implemented 2026-04-26
|
||||
|
||||
## 결정 (mini-brainstorm 결과)
|
||||
|
||||
| 결정 | 값 |
|
||||
|------|-----|
|
||||
| 짧은 클릭 | 필터 (Inbox 가 동일 태그만 표시) |
|
||||
| 칩의 × | 즉시 태그 제거 + 5초 undo 토스트 |
|
||||
| AI/user 태그 | 동일 동작 (시각만 다름) |
|
||||
| undo 정책 | 5초 자동 사라짐, 토스트 클릭 시 즉시 복원 |
|
||||
| Continuity / Pending 카운트 | 전체 기준 (필터 무시) |
|
||||
|
||||
## 범위 (PR 안에 포함됨)
|
||||
|
||||
- `src/renderer/inbox/store.ts` 수정 (`tagFilter` + `setTagFilter`, `selectFilteredNotes` re-export)
|
||||
- `src/renderer/inbox/selectFilteredNotes.ts` 신규 — 순수 셀렉터 (`api.ts` 의 `window.inkling` 부수효과 회피로 vitest `node` 환경 호환)
|
||||
- `src/renderer/inbox/App.tsx` 수정 (필터 배너 + 해제 버튼 + `<TagUndoToast />` 마운트)
|
||||
- `src/renderer/inbox/components/NoteCard.tsx` 수정 (칩 분리: 클릭 = 필터 / × = 제거 + undo)
|
||||
- `src/renderer/inbox/components/TagUndoToast.tsx` (신규 — 모듈 단위 pub/sub 토스트)
|
||||
- `tests/unit/store.tagFilter.test.ts` (신규 — 4 케이스)
|
||||
|
||||
## 후속
|
||||
|
||||
- 다중 태그 필터 (AND/OR)
|
||||
- 태그 rename / merge / 자동완성
|
||||
- 태그 단위 일괄 작업
|
||||
- undo 시 원래 source(`ai`/`user`) 보존 (현재는 `updateAiFields(tags: string[])` API 가 source 정보 미수용 — backend 가 user 로 처리)
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useInbox } from './store.js';
|
||||
import { useInbox, selectFilteredNotes } from './store.js';
|
||||
import { inboxApi } from './api.js';
|
||||
import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
|
||||
import { NoteCard } from './components/NoteCard.js';
|
||||
@@ -7,9 +7,20 @@ import { ContinuityBadge } from './components/ContinuityBadge.js';
|
||||
import { PendingBanner } from './components/PendingBanner.js';
|
||||
import { OllamaBanner } from './components/OllamaBanner.js';
|
||||
import { RecoveryToast } from './components/RecoveryToast.js';
|
||||
import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const { notes, loading, loadInitial, refreshMeta, upsertNote, removeNote, continuity } = useInbox();
|
||||
const {
|
||||
notes,
|
||||
loading,
|
||||
loadInitial,
|
||||
refreshMeta,
|
||||
upsertNote,
|
||||
removeNote,
|
||||
continuity,
|
||||
tagFilter,
|
||||
setTagFilter
|
||||
} = useInbox();
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,6 +35,7 @@ export function App(): React.ReactElement {
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
const filtered = selectFilteredNotes({ notes, tagFilter });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -38,16 +50,51 @@ export function App(): React.ReactElement {
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
{tagFilter !== null && (
|
||||
<div
|
||||
style={{
|
||||
background: '#eaf3ff',
|
||||
color: '#0a4b80',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
margin: '8px 0',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
<span>🔎 필터: <strong>#{tagFilter}</strong></span>
|
||||
<span style={{ color: '#666' }}>({filtered.length}개)</span>
|
||||
<button
|
||||
onClick={() => setTagFilter(null)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#0a4b80',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12
|
||||
}}
|
||||
title="필터 해제"
|
||||
>
|
||||
✕ 해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">첫 기억을 구출해보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
notes.map((n) => (
|
||||
filtered.map((n) => (
|
||||
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
<TagUndoToast />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { useInbox } from '../store.js';
|
||||
import { EditableField } from './EditableField.js';
|
||||
import { IntentBanner } from './IntentBanner.js';
|
||||
import { pushTagUndo } from './TagUndoToast.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
@@ -140,10 +142,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
}
|
||||
|
||||
async function removeTag(tagName: string) {
|
||||
const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
await inboxApi.updateAiFields(note.id, { tags: next });
|
||||
const removed = local.tags.find((t) => t.name === tagName);
|
||||
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
await inboxApi.updateAiFields(note.id, { tags: nextTagNames });
|
||||
const updated = { ...local, tags: local.tags.filter((t) => t.name !== tagName) };
|
||||
setLocal(updated); onUpdated(updated);
|
||||
|
||||
if (removed !== undefined) {
|
||||
pushTagUndo({
|
||||
noteId: note.id,
|
||||
tag: removed,
|
||||
onUndo: async () => {
|
||||
// Restore by re-adding the removed tag.
|
||||
// Note: source flag is reset to 'user' on the backend by `updateAiFields` (it
|
||||
// only takes names), which mirrors current behaviour for any user-driven
|
||||
// tag mutation. Visual restoration matches latest local state.
|
||||
const restoredNames = [...updated.tags.map((t) => t.name), removed.name];
|
||||
await inboxApi.updateAiFields(note.id, { tags: restoredNames });
|
||||
const u2 = { ...updated, tags: [...updated.tags, removed] };
|
||||
setLocal(u2); onUpdated(u2);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function filterByTag(tagName: string): void {
|
||||
useInbox.getState().setTagFilter(tagName);
|
||||
}
|
||||
|
||||
async function saveIntent(next: string) {
|
||||
@@ -213,15 +237,41 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
{local.tags.map((t) => (
|
||||
<span
|
||||
key={t.name}
|
||||
onClick={() => void removeTag(t.name)}
|
||||
style={{
|
||||
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
|
||||
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 12, cursor: 'pointer'
|
||||
padding: '2px 4px 2px 8px',
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
title={t.source === 'ai' ? 'AI 제안 — 클릭으로 제거' : '내가 추가 — 클릭으로 제거'}
|
||||
>
|
||||
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
|
||||
<span
|
||||
onClick={() => filterByTag(t.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={`#${t.name} 노트만 보기`}
|
||||
>
|
||||
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 14,
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
opacity: 0.6
|
||||
}}
|
||||
title="태그 제거"
|
||||
aria-label={`${t.name} 태그 제거`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
85
src/renderer/inbox/components/TagUndoToast.tsx
Normal file
85
src/renderer/inbox/components/TagUndoToast.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
type UndoEntry = {
|
||||
id: number;
|
||||
text: string;
|
||||
onUndo: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type Listener = (entry: UndoEntry | null) => void;
|
||||
|
||||
const TOAST_DURATION_MS = 5000;
|
||||
|
||||
let nextId = 1;
|
||||
const listeners = new Set<Listener>();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function broadcast(entry: UndoEntry | null): void {
|
||||
for (const l of listeners) l(entry);
|
||||
}
|
||||
|
||||
function clearTimerIfAny(): void {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function pushTagUndo(opts: {
|
||||
noteId: string;
|
||||
tag: { name: string };
|
||||
onUndo: () => void | Promise<void>;
|
||||
}): void {
|
||||
const entry: UndoEntry = {
|
||||
id: nextId++,
|
||||
text: `'#${opts.tag.name}' 제거됨 · 되돌리기`,
|
||||
onUndo: opts.onUndo
|
||||
};
|
||||
clearTimerIfAny();
|
||||
broadcast(entry);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
broadcast(null);
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
export function TagUndoToast(): React.ReactElement | null {
|
||||
const [entry, setEntry] = useState<UndoEntry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const l: Listener = (e) => setEntry(e);
|
||||
listeners.add(l);
|
||||
return () => { listeners.delete(l); };
|
||||
}, []);
|
||||
|
||||
if (entry === null) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={async () => {
|
||||
clearTimerIfAny();
|
||||
const e = entry;
|
||||
setEntry(null);
|
||||
broadcast(null);
|
||||
try { await e.onUndo(); } catch { /* swallow — best-effort restore */ }
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: '#333',
|
||||
color: 'white',
|
||||
padding: '8px 14px',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
|
||||
zIndex: 1000
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
{entry.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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