chore(release): v0.3.6 — 이동 modal 복원 (v0.3.5 의도 정정)

v0.3.5 의 이동 dropdown 단순화가 사용자 의도와 어긋남.
사용자는 dropdown 의 목적지 중복 (modal 도 목적지 묻기) 만 거슬렸지,
사유 입력 + AI 자동 분류 + 수동 status 선택을 한 곳에서 처리하는 modal 은
보존해야 하는 핵심 UX 였음. 단일 "이동" 버튼 → MoveStatusModal path 로 정정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-11 11:16:14 +09:00
parent 2c6bfebb5b
commit e2058cfdbe
8 changed files with 317 additions and 152 deletions

View File

@@ -3,6 +3,26 @@
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
## [0.3.6] — 2026-05-11
v0.3.5 의 이동 dropdown 단순화가 사용자 의도와 어긋난 점 정정. 이동 modal (사유 + AI 자동 분류 + 수동 status 선택) 은 보존해야 하는 핵심 UX 였음.
### 수정
- **이동 dropdown → 단일 "이동" 버튼 + `MoveStatusModal` 복원.** v0.3.5 에서 dropdown 항목 클릭 = 즉시 setStatus 로 단순화한 path 를 되돌림. 사용자 의도는 dropdown 의 목적지 중복 (modal 도 목적지 묻기) 제거였지, modal 자체 제거가 아니었음. 단일 "이동" 버튼 → modal → 사유 입력 + AI 자동 분류 + 수동 status 선택 path 로 통일.
- **`MoveStatusModal.tsx` + 테스트 6 case 복원** — v0.3.5 에서 dead code 로 판단해 삭제했으나 다시 mount 됨. statusLabel 헬퍼 위치는 modal 내부로 회귀 (orphan `statusLabel.ts` 제거).
- **이동 후 `refreshMeta()` 호출 유지** — v0.3.5 D1 fix (setStatus IPC 가 pushNoteUpdated emit 안 함 → 헤더 탭 count stale) 는 modal `onMoved` callback path 에서도 동일하게 트리거.
### 게이트
- 단위 734 → **736 PASS** (NoteCard 이동 case 3 → 2 + MoveStatusModal 6 복원)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.5 인스톨러 위에 v0.3.6 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.5] — 2026-05-11
v0.3.4 까지 누적된 dogfood UX 결함 7건 hotfix. 사용자가 막혔던 inbox/회고/이동 3건 + 그 부류의 동반 갭 4건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.3.5",
"version": "0.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.3.5",
"version": "0.3.6",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.3.5",
"version": "0.3.6",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",

View File

@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { inboxApi } from '../api.js';
import type { NoteStatus } from '@shared/types';
interface Props {
noteId: string;
rawText: string;
summary: string;
onClose: () => void;
onMoved: (status: NoteStatus, reason: string | null) => void;
}
/**
* v0.2.9 Cut B Task 7 — 메모 이동 Modal.
*
* 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
*/
export function MoveStatusModal({
noteId,
onClose,
onMoved
}: Props): React.ReactElement {
const [reason, setReason] = useState('');
const [recommendation, setRecommendation] = useState<{
status: NoteStatus;
rationale: string;
} | null>(null);
const [classifying, setClassifying] = useState(false);
async function move(status: NoteStatus): Promise<void> {
const trimmedReason = reason.trim() === '' ? null : reason.trim();
await inboxApi.setStatus(noteId, status, trimmedReason);
onMoved(status, trimmedReason);
}
async function classify(): Promise<void> {
setClassifying(true);
setRecommendation(null);
try {
const r = await inboxApi.classifyStatus(noteId, reason);
setRecommendation({ status: r.recommended, rationale: r.rationale });
} finally {
setClassifying(false);
}
}
return (
<div
role="dialog"
aria-label="이동"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100
}}
>
<div
style={{
background: '#fff',
padding: 16,
borderRadius: 8,
minWidth: 400,
maxWidth: 520
}}
>
<h2 style={{ fontSize: 16, margin: '0 0 12px' }}> </h2>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="이동 사유 (선택사항)"
rows={2}
style={{ width: '100%', padding: 6, fontSize: 13, boxSizing: 'border-box' }}
/>
<div
style={{
display: 'flex',
gap: 8,
marginTop: 8,
flexWrap: 'wrap',
alignItems: 'center'
}}
>
<button onClick={() => void classify()} disabled={classifying}>
{classifying ? '분류 중...' : 'AI 자동 분류'}
</button>
<button onClick={() => void move('completed')}></button>
<button onClick={() => void move('archived')}></button>
<button onClick={() => void move('trashed')}></button>
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
</button>
</div>
{recommendation !== null && (
<div
style={{
marginTop: 12,
padding: 8,
background: '#f0f8ff',
borderRadius: 4,
fontSize: 12
}}
>
<div>
AI : <strong>{statusLabel(recommendation.status)}</strong>
</div>
<div style={{ marginTop: 4 }}>: {recommendation.rationale}</div>
<div style={{ marginTop: 8 }}>
<button onClick={() => void move(recommendation.status)}>
({statusLabel(recommendation.status)})
</button>
</div>
</div>
)}
</div>
</div>
);
}
export function statusLabel(s: NoteStatus): string {
switch (s) {
case 'active':
return '활성';
case 'completed':
return '완료';
case 'archived':
return '보관';
case 'trashed':
return '휴지통';
}
}
/**
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
*/
export function statusLabelWithParticle(s: NoteStatus): string {
const label = statusLabel(s);
const last = label.charCodeAt(label.length - 1);
// 한글 syllable block 외 → "로" default
if (last < 0xAC00 || last > 0xD7A3) return `${label}`;
const jongseong = (last - 0xAC00) % 28;
return jongseong === 0 ? `${label}` : `${label}으로`;
}

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import type { Note, NoteStatus } from '@shared/types';
import React, { useState } from 'react';
import type { Note } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
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';
import { statusLabelWithParticle } from './statusLabel.js';
import { MoveStatusModal } from './MoveStatusModal.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
@@ -116,38 +116,14 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown. dropdown 항목 클릭 = 해당 status 로 즉시 이동.
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
// 이동 modal 열림 여부. 클릭 시 MoveStatusModal 에서 사유 + AI 분류 + 수동 분류 선택.
const [moveOpen, setMoveOpen] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const possibleTargets: NoteStatus[] = (
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
).filter((s) => s !== local.status);
React.useEffect(() => { setLocal(note); }, [note]);
// 이동 dropdown 외부 클릭 / Escape 로 닫기. menuOpen=true 일 때만 listener 활성.
useEffect(() => {
if (!menuOpen) return;
function onMouseDown(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setMenuOpen(false);
}
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [menuOpen]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
async function saveTitle(next: string) {
@@ -443,71 +419,23 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
alignItems: 'center'
}}
>
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
현재 status 와 다른 3개 목적지만 표시. */}
<div ref={menuRef} style={{ position: 'relative' }}>
<button
onClick={() => setMenuOpen((o) => !o)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{menuOpen && (
<div
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 2,
background: '#fff',
border: '1px solid #ccc',
borderRadius: 4,
padding: 4,
zIndex: 10,
minWidth: 140,
boxShadow: '0 2px 6px rgba(0,0,0,0.08)'
}}
>
{possibleTargets.map((t) => (
<button
key={t}
onClick={async () => {
setMenuOpen(false);
await inboxApi.setStatus(local.id, t, null);
const updated = { ...local, status: t, moveReason: null };
setLocal(updated);
onUpdated(updated);
if (t !== local.status) onDeleted?.();
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: '6px 8px',
fontSize: 12,
cursor: 'pointer'
}}
>
{statusLabelWithParticle(t)}
</button>
))}
</div>
)}
</div>
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
<button
onClick={() => setMoveOpen(true)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
{isTrash && (
@@ -535,6 +463,25 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
</div>
</div>
{moveOpen && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
onClose={() => setMoveOpen(false)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/완료/보관/휴지통 view 의 list 가 status 별로 필터되므로 status 변경 시 onDeleted 호출.
if (newStatus !== local.status) onDeleted?.();
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
setMoveOpen(false);
}}
/>
)}
{showRevisions && (
<RevisionHistoryModal
noteId={local.id}

View File

@@ -1,26 +0,0 @@
import type { NoteStatus } from '@shared/types';
export function statusLabel(s: NoteStatus): string {
switch (s) {
case 'active':
return '활성';
case 'completed':
return '완료';
case 'archived':
return '보관';
case 'trashed':
return '휴지통';
}
}
/**
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
*/
export function statusLabelWithParticle(s: NoteStatus): string {
const label = statusLabel(s);
const last = label.charCodeAt(label.length - 1);
if (last < 0xAC00 || last > 0xD7A3) return `${label}`;
const jongseong = (last - 0xAC00) % 28;
return jongseong === 0 ? `${label}` : `${label}으로`;
}

View File

@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
const { mockSetStatus, mockClassify } = vi.hoisted(() => ({
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
mockClassify: vi.fn(async () => ({
recommended: 'completed' as const,
rationale: '결재 끝'
}))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
setStatus: mockSetStatus,
classifyStatus: mockClassify
}
}));
import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal';
describe('MoveStatusModal', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders reason textarea + 4 buttons + AI classify button', () => {
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={vi.fn()}
/>
);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
});
it('clicking 완료 calls setStatus with reason', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
});
});
it('AI 자동 분류 → recommendation 표시 + 확정 → setStatus', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
await screen.findByText(/AI 추천/);
expect(screen.getByText(/이유: 결재 끝/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /확정/ }));
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
});
it('빈 사유 → null reason 전달', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.click(screen.getByRole('button', { name: '보관' }));
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
});
});

View File

@@ -128,23 +128,19 @@ describe('NoteCard — ai_status=disabled fallback (v0.2.9 Cut B Task 13)', () =
});
});
describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
describe('NoteCard — 이동 버튼', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('이동 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => {
// baseNote.status = 'active' → 완료/보관/휴지통 만 표시
it('이동 클릭 → MoveStatusModal 열림', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관으로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
});
it('메뉴 항목 클릭 → 즉시 setStatus 호출 (modal 없음)', async () => {
it('Modal 내부 "완료" 버튼 → setStatus 호출 + onUpdated + onDeleted + refreshMeta', async () => {
const onUpdated = vi.fn();
const onDeleted = vi.fn();
render(
@@ -156,33 +152,13 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
/>
);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
expect(onUpdated).toHaveBeenCalled();
// status 변경 → 현재 view (inbox) 에서 제거되어야 함.
expect(onDeleted).toHaveBeenCalled();
// 헤더 탭 count 동기화.
expect(mockRefreshMeta).toHaveBeenCalled();
});
// modal 미존재 검증.
expect(screen.queryByRole('dialog', { name: '이동' })).toBeNull();
});
it('이동 메뉴 외부 클릭 시 dropdown 닫힘', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
});
it('이동 메뉴 열린 상태에서 Escape → dropdown 닫힘', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
});
});