chore(release): v0.3.5 — dogfood UX hotfix 7건
v0.3.4 까지 누적된 dogfood UX 결함 hotfix. 사용자 직접 보고 3건 (inbox 재진입, 회고 탈출, 이동 modal 중복) + 동반 갭 4건 (count stale, 필터 잔류, 초기 로드 불일치, 배너 컨텍스트 누수). 데이터/마이그레이션 변경 없음 (스키마 v8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
CHANGELOG.md
28
CHANGELOG.md
@@ -3,6 +3,34 @@
|
|||||||
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
||||||
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
||||||
|
|
||||||
|
## [0.3.5] — 2026-05-11
|
||||||
|
|
||||||
|
v0.3.4 까지 누적된 dogfood UX 결함 7건 hotfix. 사용자가 막혔던 inbox/회고/이동 3건 + 그 부류의 동반 갭 4건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
- **Inbox 탭 진입 실패: 다른 보관함에서 inbox 로 못 돌아옴.** `setView('inbox')` 가 reload 를 호출 안 해서 `notes` state 가 이전 view 의 status 로 stale. `loadByView` 시그니처에 `'inbox' → 'active'` 매핑 추가 + setView 의 reload 분기에 inbox 포함.
|
||||||
|
- **회고 view 탈출 불가.** `ReviewView` 가 App 의 헤더를 우회 (early return) 해서 사용자가 뒤로 갈 길이 없던 문제. `← 돌아가기` 버튼 추가 — 클릭 시 `setView('inbox')`.
|
||||||
|
- **이동 dropdown 의 modal 중복 질문.** dropdown 에서 "완료로 이동" 선택해도 `MoveStatusModal` 이 떠서 목적지를 재확인. dropdown 클릭 = `inboxApi.setStatus(id, target, null)` 즉시 호출로 단순화. modal path 자체 제거 (`MoveStatusModal.tsx` + 동반 테스트 삭제, `statusLabel` 헬퍼는 별도 `statusLabel.ts` 로 분리).
|
||||||
|
- **이동 후 헤더 탭 count stale.** `setStatus` IPC 가 `pushNoteUpdated` emit 을 안 해서 `refreshMeta` 가 트리거되지 않던 잠재 버그. dropdown 이동 path 끝에 `refreshMeta()` 명시 호출 추가.
|
||||||
|
- **View 전환 시 검색/태그 필터 잔류.** `setView` 가 `searchResults`/`searchQuery`/`tagFilter` 를 reset 안 해서 이전 view 의 필터가 완료/보관/휴지통/회고에 잘못 적용. 한 번에 reset.
|
||||||
|
- **Inbox 첫 로드와 탭 reload 결과 불일치.** `loadInitial` 이 `listNotes()` (= `deleted_at IS NULL` = active+completed+archived 혼재) 사용 → 헤더 inbox count (active 만) 와 list 불일치. `listByStatus('active', limit:50)` 로 통일.
|
||||||
|
- **AI 배너가 completed/archived 탭에서도 노출.** OllamaBanner/PendingBanner/FailedBanner/ExpiryBanner/RecallBanner/RecoveryToast 가 `!showTrash` 만 체크해서 active 무관 컨텍스트에서도 그림. `view === 'inbox'` 분기로 한정.
|
||||||
|
|
||||||
|
### 갱신
|
||||||
|
|
||||||
|
- **이동 dropdown UX** — 메뉴 열린 상태에서 외부 클릭 / Escape 로 닫힘 (mousedown + keydown listener, useEffect 로 menuOpen=true 일 때만 활성).
|
||||||
|
|
||||||
|
### 게이트
|
||||||
|
|
||||||
|
- 단위 738 → **734 PASS** (MoveStatusModal 6 case 삭제 + NoteCard 메뉴 case 1 → 3 (직접 이동/외부 클릭/Escape) 로 재구성)
|
||||||
|
- typecheck 0 errors (src)
|
||||||
|
- 신규 npm dependency 0
|
||||||
|
|
||||||
|
### 업그레이드
|
||||||
|
|
||||||
|
v0.3.4 인스톨러 위에 v0.3.5 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||||
|
|
||||||
## [0.3.4] — 2026-05-11
|
## [0.3.4] — 2026-05-11
|
||||||
|
|
||||||
v0.3.0 Cut E (양방향 sync) dogfood 의 결과로, 사용자가 conflict 시나리오에 막힌 순간 도움받을 곳이 부재한 갭을 메운 cut. 3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말. PR #33 머지.
|
v0.3.0 Cut E (양방향 sync) dogfood 의 결과로, 사용자가 conflict 시나리오에 막힌 순간 도움받을 곳이 부재한 갭을 메운 cut. 3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말. PR #33 머지.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "inkling",
|
"name": "inkling",
|
||||||
"version": "0.3.3",
|
"version": "0.3.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "inkling",
|
"name": "inkling",
|
||||||
"version": "0.3.3",
|
"version": "0.3.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "12.9.0",
|
"better-sqlite3": "12.9.0",
|
||||||
"electron-log": "5.2.0",
|
"electron-log": "5.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "inkling",
|
"name": "inkling",
|
||||||
"version": "0.3.4",
|
"version": "0.3.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||||
|
|||||||
@@ -153,15 +153,21 @@ export function App(): React.ReactElement {
|
|||||||
<main className="main">
|
<main className="main">
|
||||||
{!showTrash && (
|
{!showTrash && (
|
||||||
<>
|
<>
|
||||||
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
|
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
|
||||||
<RecoveryToast
|
completed/archived 에서는 무관 컨텐츠라 숨김. */}
|
||||||
show={showRecovery}
|
{view === 'inbox' && (
|
||||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
<>
|
||||||
/>
|
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
|
||||||
<PendingBanner />
|
<RecoveryToast
|
||||||
<FailedBanner />
|
show={showRecovery}
|
||||||
<ExpiryBanner />
|
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||||
<RecallBanner />
|
/>
|
||||||
|
<PendingBanner />
|
||||||
|
<FailedBanner />
|
||||||
|
<ExpiryBanner />
|
||||||
|
<RecallBanner />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{tagFilter !== null && (
|
{tagFilter !== null && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
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}으로`;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import type { Note, NoteStatus } from '@shared/types';
|
import type { Note, NoteStatus } from '@shared/types';
|
||||||
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
|
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
|
||||||
import { inboxApi } from '../api.js';
|
import { inboxApi } from '../api.js';
|
||||||
@@ -6,7 +6,7 @@ import { useInbox } from '../store.js';
|
|||||||
import { EditableField } from './EditableField.js';
|
import { EditableField } from './EditableField.js';
|
||||||
import { IntentBanner } from './IntentBanner.js';
|
import { IntentBanner } from './IntentBanner.js';
|
||||||
import { pushTagUndo } from './TagUndoToast.js';
|
import { pushTagUndo } from './TagUndoToast.js';
|
||||||
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
|
import { statusLabelWithParticle } from './statusLabel.js';
|
||||||
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
|
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -116,9 +116,9 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
|||||||
const [local, setLocal] = useState(note);
|
const [local, setLocal] = useState(note);
|
||||||
const isAiDisabled = local.aiStatus === 'disabled';
|
const isAiDisabled = local.aiStatus === 'disabled';
|
||||||
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
|
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
|
||||||
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
|
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown. dropdown 항목 클릭 = 해당 status 로 즉시 이동.
|
||||||
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [editingRaw, setEditingRaw] = useState(false);
|
const [editingRaw, setEditingRaw] = useState(false);
|
||||||
const [draftRaw, setDraftRaw] = useState('');
|
const [draftRaw, setDraftRaw] = useState('');
|
||||||
const [showRevisions, setShowRevisions] = useState(false);
|
const [showRevisions, setShowRevisions] = useState(false);
|
||||||
@@ -129,6 +129,25 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
|||||||
|
|
||||||
React.useEffect(() => { setLocal(note); }, [note]);
|
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');
|
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
|
||||||
|
|
||||||
async function saveTitle(next: string) {
|
async function saveTitle(next: string) {
|
||||||
@@ -426,7 +445,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
|||||||
>
|
>
|
||||||
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
|
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
|
||||||
현재 status 와 다른 3개 목적지만 표시. */}
|
현재 status 와 다른 3개 목적지만 표시. */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
aria-label="이동"
|
aria-label="이동"
|
||||||
@@ -461,9 +480,16 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
|||||||
{possibleTargets.map((t) => (
|
{possibleTargets.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setMoveTarget(t);
|
|
||||||
setMenuOpen(false);
|
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={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@@ -509,22 +535,6 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{moveTarget !== null && (
|
|
||||||
<MoveStatusModal
|
|
||||||
noteId={local.id}
|
|
||||||
rawText={local.rawText}
|
|
||||||
summary={local.aiSummary ?? ''}
|
|
||||||
onClose={() => setMoveTarget(null)}
|
|
||||||
onMoved={(newStatus, reason) => {
|
|
||||||
const updated = { ...local, status: newStatus, moveReason: reason };
|
|
||||||
setLocal(updated);
|
|
||||||
onUpdated(updated);
|
|
||||||
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
|
|
||||||
if (newStatus !== local.status) onDeleted?.();
|
|
||||||
setMoveTarget(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showRevisions && (
|
{showRevisions && (
|
||||||
<RevisionHistoryModal
|
<RevisionHistoryModal
|
||||||
noteId={local.id}
|
noteId={local.id}
|
||||||
|
|||||||
@@ -14,13 +14,37 @@ const periodLabel: Record<Props['period'], string> = {
|
|||||||
|
|
||||||
export function ReviewView({ period }: Props): React.ReactElement {
|
export function ReviewView({ period }: Props): React.ReactElement {
|
||||||
const reviewData = useInbox((s) => s.reviewData);
|
const reviewData = useInbox((s) => s.reviewData);
|
||||||
|
const setView = useInbox((s) => s.setView);
|
||||||
|
const backButton = (
|
||||||
|
<button
|
||||||
|
onClick={() => setView('inbox')}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#0a4b80',
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← 돌아가기
|
||||||
|
</button>
|
||||||
|
);
|
||||||
if (!reviewData) {
|
if (!reviewData) {
|
||||||
return <div style={{ padding: 16, fontSize: 13, color: '#666' }}>불러오는 중…</div>;
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>{backButton}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#666' }}>불러오는 중…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const max = reviewData.tagCounts[0]?.count ?? 1;
|
const max = reviewData.tagCounts[0]?.count ?? 1;
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ padding: 16 }}>
|
||||||
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} 회고</h2>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
{backButton}
|
||||||
|
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} 회고</h2>
|
||||||
|
</div>
|
||||||
<div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
|
<div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
|
||||||
총 {reviewData.totalCount}건
|
총 {reviewData.totalCount}건
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
26
src/renderer/inbox/components/statusLabel.ts
Normal file
26
src/renderer/inbox/components/statusLabel.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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}으로`;
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ interface InboxState {
|
|||||||
setTagFilter: (tag: string | null) => void;
|
setTagFilter: (tag: string | null) => void;
|
||||||
setShowSettings: (open: boolean) => void;
|
setShowSettings: (open: boolean) => void;
|
||||||
setView: (view: InboxView) => void;
|
setView: (view: InboxView) => void;
|
||||||
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
|
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
|
||||||
toggleShowTrash: () => Promise<void>;
|
toggleShowTrash: () => Promise<void>;
|
||||||
loadTrash: () => Promise<void>;
|
loadTrash: () => Promise<void>;
|
||||||
restoreNote: (id: string) => Promise<void>;
|
restoreNote: (id: string) => Promise<void>;
|
||||||
@@ -104,7 +104,9 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
async loadInitial() {
|
async loadInitial() {
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||||
inboxApi.listNotes({ limit: 50 }),
|
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
|
||||||
|
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
|
||||||
|
inboxApi.listByStatus('active', { limit: 50 }),
|
||||||
inboxApi.getContinuity(),
|
inboxApi.getContinuity(),
|
||||||
inboxApi.getPendingCount(),
|
inboxApi.getPendingCount(),
|
||||||
inboxApi.getOllamaStatus(),
|
inboxApi.getOllamaStatus(),
|
||||||
@@ -182,13 +184,18 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
else get().setView('inbox');
|
else get().setView('inbox');
|
||||||
},
|
},
|
||||||
setView(view) {
|
setView(view) {
|
||||||
|
// view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
|
||||||
set({
|
set({
|
||||||
view,
|
view,
|
||||||
showTrash: view === 'trash',
|
showTrash: view === 'trash',
|
||||||
showSettings: view === 'settings'
|
showSettings: view === 'settings',
|
||||||
|
searchResults: null,
|
||||||
|
searchQuery: '',
|
||||||
|
tagFilter: null
|
||||||
});
|
});
|
||||||
// settings/inbox 외 status view 면 해당 status fetch.
|
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
|
||||||
if (view === 'completed' || view === 'archived' || view === 'trash') {
|
// 이전 status 로 stale 한 상태이므로 재로드 필요.
|
||||||
|
if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
|
||||||
void get().loadByView(view);
|
void get().loadByView(view);
|
||||||
}
|
}
|
||||||
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
|
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
|
||||||
@@ -197,7 +204,8 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
if (view === 'review-monthly') void get().loadReview('monthly');
|
if (view === 'review-monthly') void get().loadReview('monthly');
|
||||||
},
|
},
|
||||||
async loadByView(view) {
|
async loadByView(view) {
|
||||||
const status = view === 'trash' ? 'trashed' : view;
|
const status =
|
||||||
|
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
|
||||||
const notes = await inboxApi.listByStatus(status, { limit: 200 });
|
const notes = await inboxApi.listByStatus(status, { limit: 200 });
|
||||||
if (view === 'trash') {
|
if (view === 'trash') {
|
||||||
set({ trashNotes: notes, trashCount: notes.length });
|
set({ trashNotes: notes, trashCount: notes.length });
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
// @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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,10 +32,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockRefreshMeta = vi.fn();
|
||||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||||
useInbox: Object.assign(
|
useInbox: Object.assign(
|
||||||
() => ({}),
|
() => ({}),
|
||||||
{ getState: () => ({ setTagFilter: vi.fn() }) }
|
{ getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -143,19 +144,45 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
|
|||||||
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
|
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
|
it('메뉴 항목 클릭 → 즉시 setStatus 호출 (modal 없음)', async () => {
|
||||||
const onUpdated = vi.fn();
|
const onUpdated = vi.fn();
|
||||||
render(<NoteCard note={baseNote} onUpdated={onUpdated} mode="inbox" />);
|
const onDeleted = vi.fn();
|
||||||
|
render(
|
||||||
|
<NoteCard
|
||||||
|
note={baseNote}
|
||||||
|
onUpdated={onUpdated}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
mode="inbox"
|
||||||
|
/>
|
||||||
|
);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
|
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
|
||||||
// Modal 의 dialog role 등장
|
|
||||||
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
|
|
||||||
// Modal 내부의 "완료" 버튼 클릭 → setStatus
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '완료' }));
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
|
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
|
||||||
expect(onUpdated).toHaveBeenCalled();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
|||||||
// 빈 객체 대신 필요한 메서드를 stub 한다.
|
// 빈 객체 대신 필요한 메서드를 stub 한다.
|
||||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||||
inboxApi: {
|
inboxApi: {
|
||||||
|
// setShowSettings(false) → setView('inbox') → loadByView('inbox') 가 listByStatus 호출.
|
||||||
|
listByStatus: vi.fn(async () => []),
|
||||||
loadOllamaSettings: vi.fn(async () => null),
|
loadOllamaSettings: vi.fn(async () => null),
|
||||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Note } from '@shared/types';
|
|||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
listNotes: vi.fn(async () => [] as Note[]),
|
listNotes: vi.fn(async () => [] as Note[]),
|
||||||
|
listByStatus: vi.fn(async () => [] as Note[]),
|
||||||
listTrash: vi.fn(async () => [] as Note[]),
|
listTrash: vi.fn(async () => [] as Note[]),
|
||||||
getTrashCount: vi.fn(async () => 0),
|
getTrashCount: vi.fn(async () => 0),
|
||||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||||
|
|||||||
Reference in New Issue
Block a user