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:
parent
ba9ef9741e
commit
f064c93197
96 changed files with 12886 additions and 8924 deletions
121
apps/desktop/tests/unit/meeting-page-models-wave2.test.ts
Normal file
121
apps/desktop/tests/unit/meeting-page-models-wave2.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { MeetingDocument, MeetingSessionSummary } from '@d3ro/core/types'
|
||||
import { appendMeetingPage, filterMeetingsByTitle, hasMoreMeetings } from '../../src/renderer/pages/meeting/meeting-list'
|
||||
import { formatElapsed, shouldShowStatusBadge } from '../../src/renderer/components/meeting/meeting-format'
|
||||
import {
|
||||
clearNotepad,
|
||||
loadNotepad,
|
||||
notepadStorageKey,
|
||||
saveNotepad,
|
||||
type NotepadStorage,
|
||||
} from '../../src/renderer/components/meeting/meeting-notepad-storage'
|
||||
import {
|
||||
formatActionItemsForCopy,
|
||||
selectSummarySource,
|
||||
summaryCopyText,
|
||||
} from '../../src/renderer/components/meeting/meeting-summary-model'
|
||||
|
||||
function session(id: string, title: string | null): MeetingSessionSummary {
|
||||
return { id, title, status: 'completed', startedAt: 0, endedAt: null, durationMs: null, memoCount: 0 }
|
||||
}
|
||||
|
||||
function memoryStorage(): NotepadStorage & { data: Map<string, string> } {
|
||||
const data = new Map<string, string>()
|
||||
return {
|
||||
data,
|
||||
getItem: (k) => data.get(k) ?? null,
|
||||
setItem: (k, v) => void data.set(k, v),
|
||||
removeItem: (k) => void data.delete(k),
|
||||
}
|
||||
}
|
||||
|
||||
describe('회의 목록 모델', () => {
|
||||
it('다음 페이지를 붙일 때 밀려 온 중복은 버린다', () => {
|
||||
const merged = appendMeetingPage([session('a', 'A'), session('b', 'B')], [session('b', 'B'), session('c', 'C')])
|
||||
expect(merged.map((s) => s.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(hasMoreMeetings(3, 3)).toBe(false)
|
||||
expect(hasMoreMeetings(2, 3)).toBe(true)
|
||||
})
|
||||
|
||||
it('제목 검색은 대소문자·공백을 무시하고, 제목 없는 회의는 대체 제목으로 찾는다', () => {
|
||||
const list = [session('a', '주간 Sync 회의'), session('b', null)]
|
||||
expect(filterMeetingsByTitle(list, 'sync 회의', '무제 회의').map((s) => s.id)).toEqual(['a'])
|
||||
expect(filterMeetingsByTitle(list, '무제', '무제 회의').map((s) => s.id)).toEqual(['b'])
|
||||
expect(filterMeetingsByTitle(list, ' ', '무제 회의')).toBe(list)
|
||||
})
|
||||
|
||||
it('완료는 배지를 달지 않고 예외 상태만 드러낸다', () => {
|
||||
expect(shouldShowStatusBadge('completed')).toBe(false)
|
||||
expect(shouldShowStatusBadge('error')).toBe(true)
|
||||
expect(formatElapsed(65_000)).toBe('01:05')
|
||||
expect(formatElapsed(3_725_000)).toBe('1:02:05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('내 메모 보관 — 닫아도 사라지지 않는다', () => {
|
||||
it('회의 id 별로 저장·복원하고, 비우거나 회의를 지우면 키를 지운다', () => {
|
||||
const storage = memoryStorage()
|
||||
expect(saveNotepad(storage, 's1', '- 예산 재검토')).toBe(true)
|
||||
expect(loadNotepad(storage, 's1')).toBe('- 예산 재검토')
|
||||
expect(loadNotepad(storage, 's2')).toBe('')
|
||||
saveNotepad(storage, 's1', ' ')
|
||||
expect(storage.data.has(notepadStorageKey('s1'))).toBe(false)
|
||||
saveNotepad(storage, 's1', 'x')
|
||||
clearNotepad(storage, 's1')
|
||||
expect(loadNotepad(storage, 's1')).toBe('')
|
||||
})
|
||||
|
||||
it('저장소가 막히면 실패를 알리고 던지지 않는다', () => {
|
||||
const broken: NotepadStorage = {
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('quota')
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
}
|
||||
expect(saveNotepad(broken, 's1', 'x')).toBe(false)
|
||||
expect(loadNotepad(broken, 's1')).toBe('')
|
||||
expect(() => clearNotepad(broken, 's1')).not.toThrow()
|
||||
expect(saveNotepad(null, 's1', 'x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('요약 출처 — 1순위 문서 → 동기화된 회의록 → 없음', () => {
|
||||
const doc = (content: string): MeetingDocument => ({
|
||||
id: 'd1',
|
||||
sessionId: 's1',
|
||||
templateType: 'minutes',
|
||||
title: '회의록',
|
||||
content,
|
||||
promptUsed: null,
|
||||
llmModel: null,
|
||||
llmLatencyMs: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
})
|
||||
const labels = { decisions: '결정 사항', actionItems: '할 일' }
|
||||
|
||||
it('문서가 있으면 문서가 요약이다', () => {
|
||||
const source = selectSummarySource([doc('# 요약\n본문')], '다른 회의록', null)
|
||||
expect(source.kind).toBe('document')
|
||||
expect(summaryCopyText(source, labels)).toBe('# 요약\n본문')
|
||||
})
|
||||
|
||||
it('문서가 없으면 minutesMarkdown, 그다음 구조화된 minutes, 없으면 none', () => {
|
||||
expect(selectSummarySource([], ' md ', null).kind).toBe('markdown')
|
||||
const minutes = { summary: '요약', decisions: ['출시 연기'], actionItems: [{ task: '공지', assignee: '민수' }], timeline: [] }
|
||||
const source = selectSummarySource([], null, minutes)
|
||||
expect(source.kind).toBe('minutes')
|
||||
expect(summaryCopyText(source, labels)).toBe('요약\n\n## 결정 사항\n- 출시 연기\n\n## 할 일\n- [ ] 공지 (민수)')
|
||||
expect(selectSummarySource([], '', null).kind).toBe('none')
|
||||
expect(summaryCopyText({ kind: 'none' }, labels)).toBe('')
|
||||
})
|
||||
|
||||
it('할 일 복사는 체크리스트 형식', () => {
|
||||
expect(formatActionItemsForCopy([{ text: 'a', done: true }, { text: 'b', done: false }])).toBe('- [x] a\n- [ ] b')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue