feat(v029): 헤더 4탭 (Inbox/완료/보관/휴지통) + count badge

- App.tsx: 기존 2탭 (Inbox/휴지통) → 4탭. setView/counts 사용.
- onNavigate 도 setView 로 위임 (mirror state 동기 갱신).
- App.test: 4탭 렌더 + 클릭 → setView('completed') + aria-pressed (3 cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-09 15:51:59 +09:00
parent 606ac94976
commit 92375edc31
2 changed files with 66 additions and 24 deletions

View File

@@ -23,6 +23,10 @@ export function App(): React.ReactElement {
} = useInbox();
const showSettings = useInbox((s) => s.showSettings);
const setShowSettings = useInbox((s) => s.setShowSettings);
// v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통).
const view = useInbox((s) => s.view);
const counts = useInbox((s) => s.counts);
const setView = useInbox((s) => s.setView);
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
useEffect(() => {
@@ -35,15 +39,8 @@ export function App(): React.ReactElement {
useInbox.setState({ ollamaStatus: status });
});
const unsubNav = inboxApi.onNavigate((view) => {
if (view === 'settings') {
useInbox.getState().setShowSettings(true);
} else if (view === 'inbox') {
useInbox.getState().setShowSettings(false);
if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
} else if (view === 'trash') {
useInbox.getState().setShowSettings(false);
if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
}
// v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신.
useInbox.getState().setView(view);
});
const onFocus = () => { void refreshMeta(); };
window.addEventListener('focus', onFocus);
@@ -72,20 +69,23 @@ export function App(): React.ReactElement {
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
<button
onClick={() => { if (showTrash) void toggleShowTrash(); }}
aria-pressed={!showTrash}
style={tabBtnStyle(!showTrash)}
>
Inbox({notes.length})
</button>
<button
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
aria-pressed={showTrash}
style={tabBtnStyle(showTrash)}
>
({trashCount})
</button>
{(
[
{ key: 'inbox', label: 'Inbox', count: counts.active },
{ key: 'completed', label: '완료', count: counts.completed },
{ key: 'archived', label: '보관', count: counts.archived },
{ key: 'trash', label: '휴지통', count: counts.trashed }
] as const
).map((t) => (
<button
key={t.key}
onClick={() => setView(t.key)}
aria-pressed={view === t.key}
style={tabBtnStyle(view === t.key)}
>
{t.label}({t.count})
</button>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
<ContinuityBadge />

View File

@@ -6,6 +6,8 @@ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/re
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
listNotes: vi.fn(async () => []),
listByStatus: vi.fn(async () => []),
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
getContinuity: vi.fn(async () => ({
weekStart: '', weekCount: 0, weekTarget: 7,
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
@@ -58,7 +60,12 @@ import { inboxApi } from '../../src/renderer/inbox/api.js';
describe('App — settings view', () => {
beforeEach(() => {
cleanup();
useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 });
useInbox.setState({
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
showSettings: false, showTrash: false,
notes: [], trashNotes: [], trashCount: 0
});
});
it('renders SettingsPage when showSettings=true', async () => {
@@ -89,3 +96,38 @@ describe('App — settings view', () => {
await waitFor(() => expect(useInbox.getState().showSettings).toBe(true));
});
});
describe('App header — 4 tabs', () => {
beforeEach(() => {
cleanup();
useInbox.setState({
view: 'inbox',
counts: { active: 5, completed: 3, archived: 2, trashed: 1 },
notes: [], trashNotes: [], trashCount: 0,
showTrash: false, showSettings: false
});
});
it('renders 4 tabs with counts', () => {
render(<App />);
expect(screen.getByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument();
});
it('clicking 완료 tab sets view=completed', () => {
render(<App />);
fireEvent.click(screen.getByRole('button', { name: /완료/ }));
expect(useInbox.getState().view).toBe('completed');
});
it('aria-pressed reflects current view', () => {
useInbox.setState({ view: 'archived' });
render(<App />);
const archivedBtn = screen.getByRole('button', { name: /보관/ });
expect(archivedBtn.getAttribute('aria-pressed')).toBe('true');
const inboxBtn = screen.getByRole('button', { name: /Inbox/ });
expect(inboxBtn.getAttribute('aria-pressed')).toBe('false');
});
});