feat(desktop): information architecture overhaul — home, records, meetings first

Wave 2 of the 2026-09-28 design overhaul ("records are the main
character, a quiet instrument"). Decisions: design.md.

- Shell: sidebar with primary Home / Records / Meetings (Ctrl+1..3) and a
  secondary "Tools" group; every item is a button with aria-current.
  The status bar is removed — its "100% LOCAL PRIVACY · ZERO CLOUD SYNC"
  claim contradicted device sync; the sidebar shows only queried state.
- Navigation context (routes, params, back, useRouteRequest) so Home can
  open a specific meeting or record.
- Home replaces the dashboard: dictation key, start actions, only real
  problems, one-line summary, recent records and meetings, file
  transcription, free-tier limits.
- Settings become a full-screen route with vertical tabs (tabs no longer
  clip) and a shared row grammar; long-standing bugs fixed: team and
  enterprise tiers crashed the license tab (currentTier undefined),
  first-run onboarding could not be left, Pro+ saw "upgrade to Pro+".
- Records: date-grouped single list with in-place expansion, search and
  tags together, mode/favorite filters, load more past 50, undoable
  delete. Meetings: list with search/rename/delete, summary-first detail
  (summary | transcript | documents | my notes), notes persisted locally
  instead of being lost. Tools pages cleaned up; stale-closure example
  chips and a 1550x mouse-distance bug fixed.
- Shared pieces: PageHeader, EmptyStateCard, SearchInput ("/" works),
  ConfirmDialog, notify with undo; display-labels maps domain values to
  typed i18n keys so missing keys fail to compile.
- Mono font stack falls back to Pretendard for Hangul (Korean labels no
  longer render spaced out).
- i18n: new ko/en keys via scripts/i18n-add-keys.mjs (one read-merge-
  write so parallel editors cannot clobber locale files).

Some renderer files also carried pre-existing in-flight changes (error
recovery deep links, meeting document hooks); they are included as-is.
This commit is contained in:
Yun Chan 2026-09-28 20:46:08 +09:00
parent ba9ef9741e
commit f064c93197
96 changed files with 12886 additions and 8924 deletions

View file

@ -0,0 +1,335 @@
import fs from 'fs'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MeetingDocument } from '@d3ro/core/types'
import {
MEETING_DETAIL_TABS,
MeetingDocumentsStore,
extractActionItems,
meetingDocumentsPortFromApi,
resolveSelectedDocumentId,
selectPrimaryActionItems,
toggleActionItem,
withoutChecklistLines,
type DocumentWriteOutcome,
type MeetingDocumentsPort,
} from '../../src/renderer/components/meeting/meeting-documents-model'
function doc(id: string, content: string): MeetingDocument {
return {
id,
sessionId: 's1',
templateType: 'summary' as MeetingDocument['templateType'],
title: id,
content,
promptUsed: null,
llmModel: null,
llmLatencyMs: null,
createdAt: 0,
updatedAt: 0,
}
}
function fakePort(overrides: Partial<MeetingDocumentsPort> = {}): {
port: MeetingDocumentsPort
saveContent: ReturnType<typeof vi.fn<MeetingDocumentsPort['saveContent']>>
remove: ReturnType<typeof vi.fn<MeetingDocumentsPort['remove']>>
} {
const saveContent = vi.fn<MeetingDocumentsPort['saveContent']>(
overrides.saveContent ?? (async (): Promise<DocumentWriteOutcome> => ({ ok: true })),
)
const remove = vi.fn<MeetingDocumentsPort['remove']>(
overrides.remove ?? (async (): Promise<DocumentWriteOutcome> => ({ ok: true })),
)
return { port: { saveContent, remove }, saveContent, remove }
}
const CHECKLIST = [
'# 회의록',
'',
'- [ ] 예산 재검토',
'본문 문단',
' * [X] 들여쓴 별표 항목',
'-[ ] 붙여 쓴 항목',
'- [x] 완료 항목',
].join('\n')
describe('extractActionItems — 소스 줄 번호를 보존한다', () => {
it('들여쓰기·* 불릿·[X]·붙여 쓴 괄호를 모두 인식하고 줄 번호를 단다', () => {
expect(extractActionItems(CHECKLIST)).toEqual([
{ id: 'todo-2', lineIndex: 2, text: '예산 재검토', done: false },
{ id: 'todo-4', lineIndex: 4, text: '들여쓴 별표 항목', done: true },
{ id: 'todo-5', lineIndex: 5, text: '붙여 쓴 항목', done: false },
{ id: 'todo-6', lineIndex: 6, text: '완료 항목', done: true },
])
})
it('CRLF 본문도 인식한다', () => {
expect(extractActionItems('- [ ] a\r\n- [x] b\r\n')).toEqual([
{ id: 'todo-0', lineIndex: 0, text: 'a', done: false },
{ id: 'todo-1', lineIndex: 1, text: 'b', done: true },
])
})
})
describe('toggleActionItem — 목록 위치가 아니라 소스 줄을 고친다', () => {
it('해당 줄의 체크 표시 한 글자만 바꾼다', () => {
const next = toggleActionItem(CHECKLIST, 2, true)
expect(next).toBe(CHECKLIST.replace('- [ ] 예산 재검토', '- [x] 예산 재검토'))
})
it('들여쓴 * [X] 를 해제하면 들여쓰기와 불릿은 그대로다', () => {
const next = toggleActionItem(CHECKLIST, 4, false)
expect(next?.split('\n')[4]).toBe(' * [ ] 들여쓴 별표 항목')
expect(next?.split('\n').filter((_, i) => i !== 4)).toEqual(CHECKLIST.split('\n').filter((_, i) => i !== 4))
})
it('CRLF 를 보존한다', () => {
expect(toggleActionItem('- [ ] a\r\n- [ ] b', 1, true)).toBe('- [ ] a\r\n- [x] b')
})
it('체크리스트가 아닌 줄·범위 밖·같은 상태면 null', () => {
expect(toggleActionItem(CHECKLIST, 3, true)).toBeNull()
expect(toggleActionItem(CHECKLIST, 99, true)).toBeNull()
expect(toggleActionItem(CHECKLIST, 6, true)).toBeNull()
})
})
describe('selectPrimaryActionItems — 이전 목록을 남기지 않는다', () => {
it('1순위 문서에 체크리스트가 없거나 문서가 없으면 빈 목록', () => {
expect(selectPrimaryActionItems([doc('a', '본문만'), doc('b', '- [ ] x')])).toEqual([])
expect(selectPrimaryActionItems([])).toEqual([])
})
})
describe('상세 탭 · 문서 선택 정합', () => {
it('요약이 첫 탭이다(요약 먼저, 전사는 근거)', () => {
expect(MEETING_DETAIL_TABS[0]).toBe('summary')
expect(MEETING_DETAIL_TABS).toEqual(['summary', 'transcript', 'documents', 'notes'])
})
it('사라진 문서를 고르고 있으면 첫 문서, 문서가 없으면 null', () => {
const docs = [doc('a', ''), doc('b', '')]
expect(resolveSelectedDocumentId('b', docs)).toBe('b')
expect(resolveSelectedDocumentId('gone', docs)).toBe('a')
expect(resolveSelectedDocumentId(null, docs)).toBe('a')
expect(resolveSelectedDocumentId('gone', [])).toBeNull()
})
})
describe('withoutChecklistLines — 요약 본문에서 할 일 줄만 뺀다(표시 전용)', () => {
it('체크리스트 줄만 빠지고 나머지 줄은 그대로', () => {
expect(withoutChecklistLines('# 요약\n- [ ] 할 일\n본문\n- [x] 끝남\n- 일반 목록')).toBe('# 요약\n본문\n- 일반 목록')
})
})
describe('MeetingDocumentsStore', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('편집은 즉시 반영하고 저장은 문서별로 디바운스한다(A 의 대기 저장이 B 입력에 취소되지 않는다)', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500)
store.editContent('a', 'A1')
expect(store.getState().documents[0].content).toBe('A1')
await vi.advanceTimersByTimeAsync(200)
store.editContent('b', 'B1')
await vi.advanceTimersByTimeAsync(600)
expect(saveContent.mock.calls).toEqual([
['a', 'A1'],
['b', 'B1'],
])
})
it('연속 입력은 마지막 본문 한 번만 저장한다', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await vi.advanceTimersByTimeAsync(300)
store.editContent('a', 'A12')
await vi.advanceTimersByTimeAsync(600)
expect(saveContent.mock.calls).toEqual([['a', 'A12']])
})
it('flush 는 대기 중인 저장을 바로 실행한다(탭을 떠날 때)', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await store.flush('a')
expect(saveContent.mock.calls).toEqual([['a', 'A1']])
await vi.advanceTimersByTimeAsync(1000)
expect(saveContent).toHaveBeenCalledTimes(1)
})
it('flushAll 은 모든 문서의 대기 저장을 실행한다(화면을 떠날 때)', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500)
store.editContent('a', 'A1')
store.editContent('b', 'B1')
await store.flushAll()
expect(saveContent.mock.calls).toEqual([
['a', 'A1'],
['b', 'B1'],
])
})
it('자동저장 실패는 error 로 드러나고 입력은 지우지 않는다', async () => {
const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'db locked' }) })
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await vi.advanceTimersByTimeAsync(600)
expect(store.getState().error).toBe('db locked')
expect(store.getState().documents[0].content).toBe('A1')
store.clearError()
expect(store.getState().error).toBeNull()
})
it('[회귀] 액션 아이템 체크는 문서 markdown 에 [x] 로 저장되고, 이후 다른 문서의 자동저장에도 유지된다', async () => {
const { port, saveContent } = fakePort()
const primary = '# 할 일\n- [ ] 예산 재검토\n- [ ] 일정 공유'
const store = new MeetingDocumentsStore([doc('a', primary), doc('b', 'B0')], port, 500)
const item = selectPrimaryActionItems(store.getState().documents)[1]
const result = await store.toggleActionItem('a', item.lineIndex, true)
expect(result).toEqual({ status: 'saved' })
expect(saveContent).toHaveBeenLastCalledWith('a', '# 할 일\n- [ ] 예산 재검토\n- [x] 일정 공유')
store.editContent('b', 'B1')
await vi.advanceTimersByTimeAsync(600)
expect(selectPrimaryActionItems(store.getState().documents).map((i) => i.done)).toEqual([false, true])
})
it('체크는 대기 중인 편집과 함께 최신 본문으로 저장되고, 대기 타이머는 다시 쓰지 않는다', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500)
store.editContent('a', '- [ ] a\n- [ ] b')
await store.toggleActionItem('a', 1, true)
expect(saveContent.mock.calls).toEqual([['a', '- [ ] a\n- [x] b']])
await vi.advanceTimersByTimeAsync(1000)
expect(saveContent).toHaveBeenCalledTimes(1)
})
it('체크 저장이 실패하면 체크를 되돌리고 error 를 남긴다', async () => {
const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'nope' }) })
const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500)
const result = await store.toggleActionItem('a', 0, true)
expect(result).toEqual({ status: 'failed', message: 'nope' })
expect(store.getState().documents[0].content).toBe('- [ ] a')
expect(store.getState().error).toBe('nope')
})
it('포트가 던져도 실패로 처리한다', async () => {
const { port } = fakePort({
saveContent: async () => {
throw new Error('ipc down')
},
})
const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500)
expect(await store.toggleActionItem('a', 0, true)).toEqual({ status: 'failed', message: 'ipc down' })
})
it('같은 문서의 저장은 순서대로 하나씩 실행된다', async () => {
const resolvers: Array<() => void> = []
const { port, saveContent } = fakePort({
saveContent: () => new Promise<DocumentWriteOutcome>((resolve) => resolvers.push(() => resolve({ ok: true }))),
})
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await vi.advanceTimersByTimeAsync(600)
store.editContent('a', 'A2')
await vi.advanceTimersByTimeAsync(600)
expect(saveContent).toHaveBeenCalledTimes(1)
resolvers[0]()
await vi.advanceTimersByTimeAsync(0)
expect(saveContent.mock.calls).toEqual([
['a', 'A1'],
['a', 'A2'],
])
resolvers[1]()
})
it('삭제 실패면 문서를 남기고 error 를 드러낸다', async () => {
const { port } = fakePort({ remove: async () => ({ ok: false, message: 'fk' }) })
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
expect(await store.remove('a')).toEqual({ status: 'failed', message: 'fk' })
expect(store.getState().documents.map((d) => d.id)).toEqual(['a'])
expect(store.getState().error).toBe('fk')
})
it('삭제가 확정되면 목록에서 빼고 대기 중인 자동저장은 쓰지 않는다', async () => {
const { port, saveContent, remove } = fakePort()
const store = new MeetingDocumentsStore([doc('a', '- [ ] a'), doc('b', 'B0')], port, 500)
store.editContent('a', '- [ ] a!')
expect(await store.remove('a')).toEqual({ status: 'saved' })
await vi.advanceTimersByTimeAsync(1000)
expect(remove).toHaveBeenCalledWith('a')
expect(saveContent).not.toHaveBeenCalled()
expect(store.getState().documents.map((d) => d.id)).toEqual(['b'])
// 1순위 문서가 바뀌면 액션 아이템도 새 1순위에서 파생된다(삭제된 문서 목록이 남지 않는다).
expect(selectPrimaryActionItems(store.getState().documents)).toEqual([])
})
it('add 는 생성된 문서를 끝에 붙이고, 같은 id 는 두 번 붙이지 않는다', () => {
const { port } = fakePort()
const store = new MeetingDocumentsStore([doc('a', '')], port, 500)
store.add(doc('b', 'B'))
store.add(doc('b', 'B'))
expect(store.getState().documents.map((d) => d.id)).toEqual(['a', 'b'])
})
it('구독자에게 변경을 알리고, 바뀌지 않으면 스냅샷을 유지한다', () => {
const { port } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
const listener = vi.fn()
const unsubscribe = store.subscribe(listener)
const before = store.getState()
store.editContent('a', 'A0')
expect(store.getState()).toBe(before)
store.editContent('a', 'A1')
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
})
describe('meetingDocumentsPortFromApi', () => {
it('IPCResult 를 저장 결과로 옮긴다', async () => {
const port = meetingDocumentsPortFromApi({
updateDocument: async () => ({ success: true, data: undefined }),
deleteDocument: async () => ({ success: false, error: { code: 1 as never, message: 'gone' } }),
})
expect(await port.saveContent('a', 'x')).toEqual({ ok: true })
expect(await port.remove('a')).toEqual({ ok: false, message: 'gone' })
})
})
describe('회의 상세 문서 구조 회귀', () => {
const dir = path.join(__dirname, '../../src/renderer/components/meeting')
const detailTabs = fs.readFileSync(path.join(dir, 'MeetingDetailTabs.tsx'), 'utf-8')
const documentTab = fs.readFileSync(path.join(dir, 'DocumentTab.tsx'), 'utf-8')
const editor = fs.readFileSync(path.join(dir, 'MarkdownEditor.tsx'), 'utf-8')
it('액션 아이템은 별도 상태 사본이 아니라 문서에서 파생한다', () => {
expect(detailTabs).not.toMatch(/setActionItems/)
expect(detailTabs).toMatch(/selectPrimaryActionItems/)
})
it('DocumentTab 은 문서마다 key 로 렌더링되고, 자체 디바운스 타이머를 두지 않는다', () => {
expect(detailTabs).toMatch(/<DocumentTab\s+key=\{currentDoc\.id\}/)
expect(documentTab).not.toMatch(/setTimeout/)
})
it('MarkdownEditor 는 content prop 을 내부 draft 로 복사하지 않는다', () => {
expect(editor).not.toMatch(/setDraft/)
})
it('문서 저장·삭제 IPC 는 뷰에서 직접 부르지 않는다', () => {
expect(detailTabs).not.toMatch(/meetingMode\.(updateDocument|deleteDocument)/)
})
})