diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec84c36f..37a93810c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- Today 대시보드의 메일·답변 대기·작업 핵심 API가 실패할 때 응답을 빈 배열로 위장하지 않고 명시적인 unavailable 상태와 재시도 행동을 표시합니다. KPI와 각 목록도 확인 필요 상태를 유지해 백엔드 불가를 정상적인 0건으로 오인하지 않도록 했습니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/docs/operations/source-of-truth-and-writeback-sovereignty.md b/docs/operations/source-of-truth-and-writeback-sovereignty.md index 1396bbf0e..a931d0feb 100644 --- a/docs/operations/source-of-truth-and-writeback-sovereignty.md +++ b/docs/operations/source-of-truth-and-writeback-sovereignty.md @@ -55,6 +55,9 @@ now expose opaque `project_folders.folder_uid` values, scope listing by the signed-session `user_id` and `organization_id`, and keep sequential folder primary keys internal. `/dav` mutation methods fail closed until provider execution can enforce source, capability, credential, and ETag/If-Match checks. +Today dashboard mail, pending-reply, and task reads fail closed as a group: a +failed core request is rendered as unavailable with a retry action, while a +successful empty response remains the only evidence for a zero-count state. The Data workspace can create scoped workspace document rows through signed `POST /api/data/documents` and can request reparse, embedding regeneration intent, and HWP conversion intent for the selected opaque `document_id`; these diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 98bc17d2a..c90bca866 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -587,7 +587,7 @@ button, form, navigation, chart, or asynchronous data surface. | Gap | Buyer problem | Protected/current evidence | Existing work | Completion evidence | |---|---|---|---|---| -| Responsive shell hydration and unavailable state | a buyer can see a polished navigation shell but no actionable content when a data request is unavailable, and hydration drift can produce inconsistent controls | local fixed-origin capture showed tablet/mobile loading feedback, desktop blank content without the backend, and a development-server caret-style hydration mismatch; this is not a hosted release result | follow-up required; keep separate from #1470's bounded lookup optimization | deterministic server/client markup, explicit desktop unavailable/error state, backend-backed responsive Playwright evidence, and no hydration warnings | +| Responsive shell hydration and unavailable state | a buyer can see a polished navigation shell but no actionable content when a data request is unavailable, and hydration drift can produce inconsistent controls | local fixed-origin capture showed tablet/mobile loading feedback, desktop blank content without the backend, and a development-server caret-style hydration mismatch; this is not a hosted release result | local follow-up branch `feat/dashboard-unavailable-state` adds an explicit fail-closed dashboard state and retry action; protected `develop` remains unchanged and the work stays separate from #1470's bounded lookup optimization | deterministic server/client markup, explicit desktop unavailable/error state, backend-backed responsive Playwright evidence, and no hydration warnings | | Stacked PR current-head review dispatch | a dependent PR can show only metadata while the central OpenCode/required checks are still being materialized on a non-default base branch | #1448 exact head `068aefdf…` received a targeted scheduler/ OpenCode dispatch and then merged normally; its merge-result checks on `62a0d645…` remain queued | #1443, #1448, ContextualWisdomLab/.github scheduler | every supported stack base receives a bounded exact-head OpenCode/Noema/required-check run, with queued/provider states observable and no false merge readiness | | Typed Person/Event/Commitment graph | generic string graph cannot safely drive high-stakes action | planning spec marks types as new/planned | #977, #978, #1000 | normalized temporal/multi-membership identities, evidence/confidence/correction on every inferred edge | | Status-weighted scheduling | calendar CRUD does not prevent harmful double booking | CalDAV source/writeback/retry foundation exists | #978, #988, #989, #990, #1416 | confirmed/tentative/desired + organizer/attendee + recurrence/free-busy/resource end-to-end | diff --git a/frontend/src/app/page.test.tsx b/frontend/src/app/page.test.tsx index dc44feb06..7858f6c78 100644 --- a/frontend/src/app/page.test.tsx +++ b/frontend/src/app/page.test.tsx @@ -473,7 +473,7 @@ describe("Home workspace action bridge", () => { expect(fetch).toHaveBeenCalledWith("/api/search", expect.objectContaining({ method: "POST" })); }); - it("shows startup dashboard empty states and ignores malformed API payloads", async () => { + it("shows an unavailable dashboard state for malformed API payloads", async () => { localStorage.setItem("naruon_startup_view", "dashboard"); vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: true, @@ -486,11 +486,12 @@ describe("Home workspace action bridge", () => { await act(async () => { root?.render(); }); - await waitForCondition(() => container?.textContent?.includes("수신된 메일이 없습니다.") ?? false); + await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]'))); - expect(container.textContent).toContain("수신된 메일이 없습니다."); - expect(container.textContent).toContain("답변 대기 중인 보낸 메일이 없습니다."); - expect(container.textContent).toContain("대기 작업이 없습니다."); + expect(container.textContent).toContain("대시보드 데이터를 불러올 수 없습니다."); + expect(container.textContent).not.toContain("수신된 메일이 없습니다."); + expect(container.textContent).not.toContain("답변 대기 중인 보낸 메일이 없습니다."); + expect(container.textContent).not.toContain("대기 작업이 없습니다."); }); it("shows desktop calendar empty and error states from the search API", async () => { diff --git a/frontend/src/components/WorkspaceHome.dashboard.test.tsx b/frontend/src/components/WorkspaceHome.dashboard.test.tsx index fa8213adc..a1249076a 100644 --- a/frontend/src/components/WorkspaceHome.dashboard.test.tsx +++ b/frontend/src/components/WorkspaceHome.dashboard.test.tsx @@ -787,4 +787,169 @@ describe("WorkspaceHome Today dashboard", () => { expect(container.textContent).toContain("일정 원본 목록 응답을 확인할 수 없습니다."); expect(container.textContent).not.toContain("연결된 일정 원본이 없습니다."); }); + + it("shows an actionable unavailable state instead of false empty dashboard data", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("backend unavailable")))); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]'))); + + const alert = container.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]'); + expect(alert?.textContent).toContain("대시보드 데이터를 불러올 수 없습니다."); + expect(alert?.textContent).toContain("데이터 연결에 일시적인 문제가 있습니다. 잠시 후 다시 시도하세요."); + expect(container.querySelector('button[aria-label="대시보드 데이터 다시 시도"]')).not.toBeNull(); + expect(container.textContent).not.toContain("수신된 메일이 없습니다."); + expect(container.textContent).not.toContain("대기 작업이 없습니다."); + }); + + it.each([401, 403])("shows login recovery instead of retry for a %i core response", async (status) => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status }))); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("로그인이 필요합니다.") ?? false); + + expect(container.textContent).toContain("세션이 만료됐거나 이 작업공간에 접근할 수 없습니다."); + expect(container.querySelector('a[href="/settings"]')?.textContent).toContain("로그인 설정 열기"); + expect(container.querySelector('button[aria-label="대시보드 데이터 다시 시도"]')).toBeNull(); + }); + + it("leaves loading state when a core read never returns", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + const timeoutController = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/emails") || url.endsWith("/api/emails/pending-replies?limit=3") || url.endsWith("/api/tasks")) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("timed out", "AbortError")), { once: true }); + }); + } + const sourceEvidenceResponse = emptySourceEvidenceResponse(url); + if (sourceEvidenceResponse) return sourceEvidenceResponse; + const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url); + if (calendarCandidateResponse) return calendarCandidateResponse; + throw new Error(`Unexpected fetch: ${url}`); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await act(async () => timeoutController.abort()); + await waitForCondition(() => container?.textContent?.includes("대시보드 데이터를 불러올 수 없습니다.") ?? false); + + expect(container.textContent).not.toContain("메일을 불러오는 중..."); + timeoutSpy.mockRestore(); + }); + + it.each([ + ["mail missing emails", "/api/emails", {}, "수신된 메일이 없습니다."], + ["mail", "/api/emails", { emails: "not-an-array" }, "수신된 메일이 없습니다."], + ["pending-reply missing emails", "/api/emails/pending-replies?limit=3", {}, "답변 대기 중인 보낸 메일이 없습니다."], + ["pending-reply", "/api/emails/pending-replies?limit=3", { emails: null }, "답변 대기 중인 보낸 메일이 없습니다."], + ["task", "/api/tasks", { tasks: "not-an-array" }, "대기 작업이 없습니다."], + ])("fails closed for a malformed %s response", async (_name, malformedUrl, malformedResponse, falseEmptyState) => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(malformedUrl)) { + return Promise.resolve({ ok: true, json: async () => malformedResponse }); + } + if (url.endsWith("/api/emails")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + const sourceEvidenceResponse = emptySourceEvidenceResponse(url); + if (sourceEvidenceResponse) return sourceEvidenceResponse; + const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url); + if (calendarCandidateResponse) return calendarCandidateResponse; + throw new Error(`Unexpected fetch: ${url}`); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]'))); + + expect(container.textContent).toContain("대시보드 데이터를 불러올 수 없습니다."); + expect(container.textContent).not.toContain(falseEmptyState); + }); + + it("renders core data as unavailable while source evidence remains pending", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/emails")) return Promise.reject(new Error("mail backend unavailable")); + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) return Promise.resolve({ ok: true, json: async () => [] }); + if (url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) { + return new Promise(() => undefined); + } + const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url); + if (calendarCandidateResponse) return calendarCandidateResponse; + throw new Error(`Unexpected fetch: ${url}`); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]'))); + + expect(container.textContent).toContain("메일 데이터를 확인할 수 없습니다."); + expect(container.textContent).not.toContain("메일을 불러오는 중..."); + expect(container.textContent).not.toContain("답변 대기 메일을 불러오는 중..."); + expect(container.textContent).not.toContain("작업을 불러오는 중..."); + }); }); diff --git a/frontend/src/components/WorkspaceHome.tsx b/frontend/src/components/WorkspaceHome.tsx index ad0c58140..283575be4 100644 --- a/frontend/src/components/WorkspaceHome.tsx +++ b/frontend/src/components/WorkspaceHome.tsx @@ -146,35 +146,49 @@ function buildCompletionRate(tasks: TaskItem[]) { return Math.round((tasks.filter((task) => task.status === 'done').length / tasks.length) * 100); } +type DashboardDataStatus = 'loading' | 'ready' | 'auth' | 'unavailable'; + +function getApiErrorStatus(error: unknown) { + const shapedError = error as { status?: unknown; response?: { status?: unknown } } | null; + if (typeof shapedError?.status === 'number') return shapedError.status; + if (typeof shapedError?.response?.status === 'number') return shapedError.response.status; + return null; +} + function useDashboardData() { const [emails, setEmails] = useState([]); const [pendingReplies, setPendingReplies] = useState([]); const [tasks, setTasks] = useState([]); const [calendarSources, setCalendarSources] = useState([]); const [projectFolders, setProjectFolders] = useState([]); - const [loading, setLoading] = useState(true); + const [dashboardDataStatus, setDashboardDataStatus] = useState('loading'); const [sourceEvidenceStatus, setSourceEvidenceStatus] = useState<'loading' | 'ready' | 'error'>('loading'); useEffect(() => { let cancelled = false; - let pendingRequests = 2; - const finishRequest = () => { - pendingRequests -= 1; - if (pendingRequests === 0 && !cancelled) { - setLoading(false); - } - }; + const coreReadSignal = AbortSignal.timeout(15_000); Promise.all([ - apiClient.get<{ emails: EmailItem[] }>('/api/emails').catch(() => ({ emails: [] })), - apiClient.get<{ emails: EmailItem[] }>('/api/emails/pending-replies?limit=3').catch(() => ({ emails: [] })), - apiClient.get('/api/tasks').catch(() => []), + apiClient.get<{ emails: EmailItem[] }>('/api/emails', { signal: coreReadSignal }), + apiClient.get<{ emails: EmailItem[] }>('/api/emails/pending-replies?limit=3', { signal: coreReadSignal }), + apiClient.get('/api/tasks', { signal: coreReadSignal }), ]).then(([emailRes, pendingReplyRes, tasksRes]) => { if (cancelled) return; - setEmails(Array.isArray(emailRes.emails) ? emailRes.emails : []); - setPendingReplies(Array.isArray(pendingReplyRes.emails) ? pendingReplyRes.emails : []); - setTasks(Array.isArray(tasksRes) ? tasksRes : []); - }).finally(finishRequest); + if (!Array.isArray(emailRes.emails) || !Array.isArray(pendingReplyRes.emails) || !Array.isArray(tasksRes)) { + throw new Error('Invalid dashboard data response'); + } + setEmails(emailRes.emails); + setPendingReplies(pendingReplyRes.emails); + setTasks(tasksRes); + setDashboardDataStatus('ready'); + }).catch((error: unknown) => { + if (cancelled) return; + setEmails([]); + setPendingReplies([]); + setTasks([]); + const status = getApiErrorStatus(error); + setDashboardDataStatus(status === 401 || status === 403 ? 'auth' : 'unavailable'); + }); Promise.all([ apiClient.get('/api/calendar/writeback-sources'), @@ -189,14 +203,14 @@ function useDashboardData() { setCalendarSources([]); setProjectFolders([]); setSourceEvidenceStatus('error'); - }).finally(finishRequest); + }); return () => { cancelled = true; }; }, []); - return { emails, pendingReplies, tasks, setTasks, calendarSources, projectFolders, loading, sourceEvidenceStatus }; + return { emails, pendingReplies, tasks, setTasks, calendarSources, projectFolders, loading: dashboardDataStatus === 'loading', dashboardDataStatus, sourceEvidenceStatus }; } function formatStartupDate(value: string) { @@ -239,7 +253,7 @@ function StartupResultList({ results }: { results: StartupSearchResult[] }) { } function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupView) => void }) { - const { emails, pendingReplies, tasks, setTasks: setDashboardTasks, calendarSources, projectFolders, loading, sourceEvidenceStatus } = useDashboardData(); + const { emails, pendingReplies, tasks, setTasks: setDashboardTasks, calendarSources, projectFolders, loading, dashboardDataStatus, sourceEvidenceStatus } = useDashboardData(); const calendarCandidateEvidence = useStartupSearch('일정 충돌 일정 조율 회의 후보', 3); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [currentTimestamp, setCurrentTimestamp] = useState(''); @@ -250,18 +264,21 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV const completedTaskCount = tasks.filter((task) => task.status === 'done').length; const writableCalendarSourceCount = calendarSources.filter(isWritableCalendarSource).length; const taskCompletionRate = buildCompletionRate(tasks); + const dashboardDataUnavailable = dashboardDataStatus === 'unavailable' || dashboardDataStatus === 'auth'; + const dashboardAuthenticationRequired = dashboardDataStatus === 'auth'; const sourceEvidenceLoading = sourceEvidenceStatus === 'loading'; const sourceEvidenceError = sourceEvidenceStatus === 'error'; const dashboardStats = useMemo(() => ([ - { title: '받은 메일', value: loading ? '-' : emails.length.toString(), diff: unreadCount > 0 ? `+${unreadCount}` : '-', diffText: '안 읽음', icon: Inbox, color: 'text-primary' }, - { title: '답변 대기', value: loading ? '-' : pendingReplyCount.toString(), diff: pendingReplyCount > 0 ? `${pendingReplyCount}건` : '-', diffText: '보낸 메일', icon: Send, color: 'text-rose-500' }, + { title: '받은 메일', value: dashboardDataUnavailable ? '오류' : loading ? '-' : emails.length.toString(), diff: dashboardDataUnavailable ? '확인 필요' : unreadCount > 0 ? `+${unreadCount}` : '-', diffText: '안 읽음', icon: Inbox, color: 'text-primary' }, + { title: '답변 대기', value: dashboardDataUnavailable ? '오류' : loading ? '-' : pendingReplyCount.toString(), diff: dashboardDataUnavailable ? '확인 필요' : pendingReplyCount > 0 ? `${pendingReplyCount}건` : '-', diffText: '보낸 메일', icon: Send, color: 'text-rose-500' }, { title: '일정 원본', value: sourceEvidenceError ? '오류' : sourceEvidenceLoading ? '-' : calendarSources.length.toString(), diff: sourceEvidenceError ? '확인 필요' : sourceEvidenceLoading ? '-' : `${writableCalendarSourceCount}개`, diffText: sourceEvidenceError ? '원본 확인' : '반영 가능', icon: CalendarDays, color: sourceEvidenceError ? 'text-red-500' : 'text-blue-500' }, - { title: '대기 작업', value: loading ? '-' : pendingTasks.length.toString(), diff: '-', diffText: 'source-linked', icon: CheckCircle2, color: 'text-green-500' }, + { title: '대기 작업', value: dashboardDataUnavailable ? '오류' : loading ? '-' : pendingTasks.length.toString(), diff: dashboardDataUnavailable ? '확인 필요' : '-', diffText: 'source-linked', icon: CheckCircle2, color: 'text-green-500' }, { title: '프로젝트 원본', value: sourceEvidenceError ? '오류' : sourceEvidenceLoading ? '-' : projectFolders.length.toString(), diff: sourceEvidenceError ? '확인 필요' : sourceEvidenceLoading ? '-' : `${projectFolders.length}개`, diffText: 'WebDAV 폴더', icon: Network, color: sourceEvidenceError ? 'text-red-500' : 'text-purple-500' }, - { title: '작업 완료율', value: loading ? '-' : `${taskCompletionRate}%`, diff: loading ? '-' : `${completedTaskCount}/${tasks.length}`, diffText: '완료', icon: CheckCircle2, color: 'text-emerald-500' }, + { title: '작업 완료율', value: dashboardDataUnavailable ? '오류' : loading ? '-' : `${taskCompletionRate}%`, diff: dashboardDataUnavailable ? '확인 필요' : loading ? '-' : `${completedTaskCount}/${tasks.length}`, diffText: '완료', icon: CheckCircle2, color: 'text-emerald-500' }, ]), [ calendarSources.length, completedTaskCount, + dashboardDataUnavailable, emails.length, loading, pendingReplyCount, @@ -339,6 +356,27 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV + {dashboardDataUnavailable ? ( +
+

{dashboardAuthenticationRequired ? '로그인이 필요합니다.' : '대시보드 데이터를 불러올 수 없습니다.'}

+

{dashboardAuthenticationRequired ? '세션이 만료됐거나 이 작업공간에 접근할 수 없습니다.' : '데이터 연결에 일시적인 문제가 있습니다. 잠시 후 다시 시도하세요.'}

+ {dashboardAuthenticationRequired ? ( + + 로그인 설정 열기 + + ) : ( + + )} +
+ ) : null} + {/* KPI Cards */}
{dashboardStats.map((stat) => ( @@ -362,8 +400,8 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
-

답변 대기 {loading ? '-' : pendingReplyCount}건

-

보낸 메일 중 회신 확인이 필요한 항목입니다.

+

답변 대기 {dashboardDataUnavailable ? '확인 필요' : loading ? '-' : `${pendingReplyCount}건`}

+

{dashboardDataUnavailable ? '대시보드 데이터 연결을 확인할 수 없습니다.' : '보낸 메일 중 회신 확인이 필요한 항목입니다.'}

보낸 메일 보기
@@ -419,7 +457,9 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
답변 대기 메일
- {loading ? ( + {dashboardDataUnavailable ? ( +
답변 대기 메일을 확인할 수 없습니다.
+ ) : loading ? (
답변 대기 메일을 불러오는 중...
) : pendingReplies.length === 0 ? (
답변 대기 중인 보낸 메일이 없습니다.
@@ -448,7 +488,9 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV

대기 작업 {pendingTasks.length > 0 && {pendingTasks.length}건}

- {loading ? ( + {dashboardDataUnavailable ? ( +
작업 데이터를 확인할 수 없습니다.
+ ) : loading ? (
작업을 불러오는 중...
) : pendingTasks.length === 0 ? (
대기 작업이 없습니다.
@@ -549,7 +591,9 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV

최근 메일 {unreadCount > 0 && 새 메일 {unreadCount}}

- {loading ? ( + {dashboardDataUnavailable ? ( +
메일 데이터를 확인할 수 없습니다.
+ ) : loading ? (
메일을 불러오는 중...
) : emails.length === 0 ? (
수신된 메일이 없습니다.
diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts index f89f5149e..63b90795d 100644 --- a/frontend/tests/e2e/dashboard-branding.spec.ts +++ b/frontend/tests/e2e/dashboard-branding.spec.ts @@ -164,6 +164,29 @@ 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 }); }); +test('shows an actionable dashboard state when the backend is unavailable', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 1024 }); + let emailRequestCount = 0; + await page.route('**/api/**', (route) => { + if (new URL(route.request().url()).pathname === '/api/emails') emailRequestCount += 1; + return route.abort(); + }); + + await page.goto('/'); + + const alert = page.getByRole('alert', { name: '대시보드 데이터 상태' }); + await expect(alert).toBeVisible(); + await expect(alert).toContainText('대시보드 데이터를 불러올 수 없습니다.'); + await expect(alert).toContainText('데이터 연결에 일시적인 문제가 있습니다. 잠시 후 다시 시도하세요.'); + const retryButton = alert.getByRole('button', { name: '대시보드 데이터 다시 시도' }); + await expect(retryButton).toBeVisible(); + + const requestsBeforeRetry = emailRequestCount; + await retryButton.click(); + await expect.poll(() => emailRequestCount).toBeGreaterThan(requestsBeforeRetry); + await expect(page.getByRole('alert', { name: '대시보드 데이터 상태' })).toBeVisible(); +}); + test('keeps the short mobile AI quick action menu inside the viewport with scrollable actions', async ({ page }) => { await page.setViewportSize({ width: 390, height: 640 }); await mockDashboardApi(page);