diff --git a/docs/doctoring/dashboard_customer_copy.md b/docs/doctoring/dashboard_customer_copy.md new file mode 100644 index 000000000..2dbd160dc --- /dev/null +++ b/docs/doctoring/dashboard_customer_copy.md @@ -0,0 +1,27 @@ +# Dashboard customer-facing status copy + +## Evidence and scope + +The scoped visual inspection of Naruon #1570 at +`c298e4d93dfa5dcdefeac3a9312f92804b234584` found two implementation terms +on the Today dashboard: `source-linked` and `충돌 토큰 있음`. +See [the independent visual receipt](https://github.com/ContextualWisdomLab/naruon/pull/1570#issuecomment-5560458888) +and Gap baseline 1.52. This follow-up changes existing copy, not the parent +response guards, request lifecycle, API fields, or translation infrastructure. + +The pending-task card counts tasks whose status is not done. Its caption is +therefore `미완료`, without implying that the user must understand source linkage. +An ETag provides a comparison baseline, not proof of freshness, write permission, +or absence of a conflict. The existing truthy branch becomes `원본 변경 비교 가능`; +the absent branch becomes `변경 전 원본 확인 필요`. Do not say changes were checked +or writes completed merely because an ETag is present. The calendar action +remains available and provider enforcement is unchanged. + +The existing dashboard source-evidence test covers both ETag states, unchanged +counts, source scope and request headers. Its first new expectation failed on +the parent text before the copy repair. Existing browser recovery cases also +assert the customer-facing labels and absence of the two internal terms. +Record fresh browser screenshots before claiming visual acceptance. This is +not delivery of all eight locales or a versioned database translation owner; +those remain separate product requirements. No static catalog or new dependency +is introduced. diff --git a/frontend/src/components/WorkspaceHome.dashboard.test.tsx b/frontend/src/components/WorkspaceHome.dashboard.test.tsx index e5397cea5..492609ac5 100644 --- a/frontend/src/components/WorkspaceHome.dashboard.test.tsx +++ b/frontend/src/components/WorkspaceHome.dashboard.test.tsx @@ -664,7 +664,10 @@ describe("WorkspaceHome Today dashboard", () => { )); }); - it("backs Today dashboard operating metrics with source evidence instead of fixed fixture numbers", async () => { + it.each([ + ["etag-home-1", "원본 변경 비교 가능"], + [null, "변경 전 원본 확인 필요"], + ])("backs Today dashboard metrics with source evidence (comparison: %s)", async (sourceEtag, comparisonLabel) => { vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ matches: false, media: query, @@ -739,7 +742,7 @@ describe("WorkspaceHome Today dashboard", () => { protocol: "caldav", capabilities: ["read", "write"], writeback_enabled: true, - etag: "etag-home-1", + etag: sourceEtag, }, { source_id: "calendar-readonly", @@ -794,7 +797,10 @@ describe("WorkspaceHome Today dashboard", () => { expect(container.textContent).toContain("2"); expect(container.textContent).toContain("1개 일정 반영 가능"); expect(container.textContent).toContain("일정 원본 1"); - expect(container.textContent).toContain("충돌 토큰 있음"); + expect(container.textContent).toContain(comparisonLabel); + expect(container.textContent).not.toContain("충돌 토큰"); + expect(container.textContent).not.toContain("source-linked"); + expect(container.querySelector('article[aria-label="대기 작업"]')?.textContent).toContain("미완료"); expect(container.textContent).toContain("프로젝트 원본"); expect(container.textContent).toContain("1개 WebDAV 폴더"); expect(container.textContent).toContain("작업 완료율"); diff --git a/frontend/src/components/WorkspaceHome.tsx b/frontend/src/components/WorkspaceHome.tsx index fe458d773..e0c97d49e 100644 --- a/frontend/src/components/WorkspaceHome.tsx +++ b/frontend/src/components/WorkspaceHome.tsx @@ -180,7 +180,7 @@ function getCalendarCapabilityLabel(capability: string) { } function getCalendarConflictLabel(source: CalendarWritebackSource) { - return source.etag ? '충돌 토큰 있음' : '충돌 토큰 대기'; + return source.etag ? '원본 변경 비교 가능' : '변경 전 원본 확인 필요'; } function buildCompletionRate(tasks: TaskItem[]) { @@ -406,7 +406,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV { title: '받은 메일', value: emailUnavailable ? '오류' : emailLoading ? '-' : emails.length.toString(), diff: emailUnavailable ? '확인 필요' : unreadCount > 0 ? `+${unreadCount}` : '-', diffText: '안 읽음', icon: Inbox, color: emailUnavailable ? 'text-red-500' : 'text-primary' }, { title: '답변 대기', value: pendingReplyUnavailable ? '오류' : pendingReplyLoading ? '-' : pendingReplyCount.toString(), diff: pendingReplyUnavailable ? '확인 필요' : pendingReplyCount > 0 ? `${pendingReplyCount}건` : '-', diffText: '보낸 메일', icon: Send, color: pendingReplyUnavailable ? 'text-red-500' : 'text-rose-500' }, { title: '일정 원본', value: calendarSourceError ? '오류' : calendarSourceLoading ? '-' : calendarSources.length.toString(), diff: calendarSourceError ? '확인 필요' : calendarSourceLoading ? '-' : `${writableCalendarSourceCount}개`, diffText: calendarSourceError ? '원본 확인' : '반영 가능', icon: CalendarDays, color: calendarSourceError ? 'text-red-500' : 'text-blue-500' }, - { title: '대기 작업', value: taskUnavailable ? '오류' : taskLoading ? '-' : pendingTasks.length.toString(), diff: taskUnavailable ? '확인 필요' : '-', diffText: 'source-linked', icon: CheckCircle2, color: taskUnavailable ? 'text-red-500' : 'text-green-500' }, + { title: '대기 작업', value: taskUnavailable ? '오류' : taskLoading ? '-' : pendingTasks.length.toString(), diff: taskUnavailable ? '확인 필요' : '-', diffText: '미완료', icon: CheckCircle2, color: taskUnavailable ? 'text-red-500' : 'text-green-500' }, { title: '프로젝트 원본', value: projectFolderError ? '오류' : projectFolderLoading ? '-' : projectFolders.length.toString(), diff: projectFolderError ? '확인 필요' : projectFolderLoading ? '-' : `${projectFolders.length}개`, diffText: 'WebDAV 폴더', icon: Network, color: projectFolderError ? 'text-red-500' : 'text-purple-500' }, { title: '작업 완료율', value: taskUnavailable ? '오류' : taskLoading ? '-' : `${taskCompletionRate}%`, diff: taskUnavailable ? '확인 필요' : taskLoading ? '-' : `${completedTaskCount}/${tasks.length}`, diffText: '완료', icon: CheckCircle2, color: taskUnavailable ? 'text-red-500' : 'text-emerald-500' }, ]), [ diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts index cb4418740..8128f6ea0 100644 --- a/frontend/tests/e2e/dashboard-branding.spec.ts +++ b/frontend/tests/e2e/dashboard-branding.spec.ts @@ -164,6 +164,35 @@ test('renders Today dashboard pending reply lane with signed API headers', async await page.screenshot({ path: testInfo.outputPath('today-pending-replies-mobile-scroll.png'), fullPage: false }); }); +for (const comparisonState of [ + { stateName: 'present', sourceEtag: 'calendar-comparison-baseline', label: '원본 변경 비교 가능' }, + { stateName: 'absent', sourceEtag: null, label: '변경 전 원본 확인 필요' }, +]) { + test(`shows customer calendar comparison copy with ${comparisonState.stateName} baseline`, async ({ page }, testInfo) => { + const pageErrors: string[] = []; + page.on('pageerror', (pageError) => pageErrors.push(pageError.message)); + await mockDashboardApi(page); + await page.route('**/api/calendar/writeback-sources', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([{ + source_id: 'calendar-copy-source', provider: 'Calendar', protocol: 'caldav', + capabilities: ['read', 'write'], writeback_enabled: true, etag: comparisonState.sourceEtag, + }]), + })); + await page.goto('/'); + const dashboard = page.locator('section[aria-label="홈 개요"]:visible').first(); + const comparisonLabel = dashboard.getByText(`일정 원본 1 · ${comparisonState.label}`, { exact: true }); + await expect(comparisonLabel).toBeVisible(); + await comparisonLabel.scrollIntoViewIfNeeded(); + await expect(comparisonLabel).toBeInViewport(); + await expect(dashboard).not.toContainText('충돌 토큰'); + await expect(dashboard).not.toContainText('source-linked'); + await page.screenshot({ path: testInfo.outputPath('calendar-comparison-focused.png') }); + expect(pageErrors).toEqual([]); + }); +} + for (const failureResponse of [ { name: 'source request fails', status: 503, body: '{"error_code":"source_unavailable"}' }, { name: 'source returns malformed members', status: 200, body: '{"emails":[null]}' }, @@ -196,6 +225,10 @@ test(`recovers the Today dashboard after a ${failureResponse.name}`, async ({ pa const recoveryAlert = dashboard.getByRole('alert'); await expect(recoveryAlert).toContainText('업무 현황을 모두 불러오지 못했습니다.'); await expect(dashboard.getByRole('article', { name: '받은 메일' })).toContainText('오류'); + await expect(dashboard.getByRole('article', { name: '대기 작업' })).toContainText('미완료'); + await expect(dashboard).toContainText('원본 변경 비교 가능'); + await expect(dashboard).not.toContainText('source-linked'); + await expect(dashboard).not.toContainText('충돌 토큰'); await page.screenshot({ path: testInfo.outputPath('dashboard-source-unavailable.png') }); inboxAvailable = true;