diff --git a/apps/web/src/pages/AnalyticsOverviewPage.tsx b/apps/web/src/pages/AnalyticsOverviewPage.tsx index 243a678..07193b4 100644 --- a/apps/web/src/pages/AnalyticsOverviewPage.tsx +++ b/apps/web/src/pages/AnalyticsOverviewPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; import { @@ -12,70 +12,106 @@ import { import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; import { issueService } from '../services/issueService'; +import { stateService } from '../services/stateService'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; import type { WorkspaceApiResponse, ProjectApiResponse, IssueApiResponse, + StateApiResponse, WorkspaceMemberApiResponse, } from '../api/types'; +const ISSUE_PAGE_SIZE = 100; + +async function fetchAllProjectIssues( + workspaceSlug: string, + projectId: string, +): Promise { + const issues: IssueApiResponse[] = []; + let offset = 0; + + while (true) { + const page = await issueService.list(workspaceSlug, projectId, { + limit: ISSUE_PAGE_SIZE, + offset, + }); + issues.push(...page); + + if (page.length < ISSUE_PAGE_SIZE) return issues; + offset += page.length; + } +} + export function AnalyticsOverviewPage() { const { t } = useTranslation(); const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const [workspace, setWorkspace] = useState(null); const [projects, setProjects] = useState([]); const [issues, setIssues] = useState([]); + const [states, setStates] = useState([]); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); useDocumentTitle(t('analytics.documentTitle', 'Analytics')); useEffect(() => { if (!workspaceSlug) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug (kept for future use) setLoading(false); return; } let cancelled = false; setLoading(true); - workspaceService - .getBySlug(workspaceSlug) - .then((w) => { + setLoadError(false); + + const loadAnalytics = async () => { + try { + let loadedWorkspace: WorkspaceApiResponse; + try { + loadedWorkspace = await workspaceService.getBySlug(workspaceSlug); + } catch { + if (cancelled) return; + setWorkspace(null); + setProjects([]); + setIssues([]); + setStates([]); + setMembers([]); + return; + } + if (cancelled) return; - setWorkspace(w); - return Promise.all([ + setWorkspace(loadedWorkspace); + + const [projs, mem] = await Promise.all([ projectService.list(workspaceSlug), workspaceService.listMembers(workspaceSlug), ]); - }) - .then((res) => { - const [projs, mem] = res ?? [[], []]; - if (!cancelled) { - setProjects(projs ?? []); - setMembers(mem ?? []); - } - if (!cancelled && projs?.length) { - return Promise.all( - projs.map((p) => issueService.list(workspaceSlug!, p.id, { limit: 200 })), - ); - } - return []; - }) - .then((issueArrays) => { - if (cancelled || !Array.isArray(issueArrays)) return; - if (issueArrays.length > 0 && Array.isArray(issueArrays[0])) - setIssues((issueArrays as IssueApiResponse[][]).flat()); - }) - .catch(() => { - if (!cancelled) setWorkspace(null); + const [issueArrays, stateArrays] = projs.length + ? await Promise.all([ + Promise.all(projs.map((p) => fetchAllProjectIssues(workspaceSlug, p.id))), + Promise.all(projs.map((p) => stateService.list(workspaceSlug, p.id))), + ]) + : [[], []]; + + if (cancelled) return; + setProjects(projs); + setMembers(mem); + setIssues(issueArrays.flat()); + setStates(stateArrays.flat()); + } catch { + if (cancelled) return; setProjects([]); setIssues([]); + setStates([]); setMembers([]); - }) - .finally(() => { + setLoadError(true); + } finally { if (!cancelled) setLoading(false); - }); + } + }; + + void loadAnalytics(); return () => { cancelled = true; }; @@ -90,6 +126,24 @@ export function AnalyticsOverviewPage() { const modules: unknown[] = []; const pages: unknown[] = []; + const completionByProjectId = useMemo(() => { + const stateGroupById = new Map(states.map((s) => [s.id, s.group])); + const stats = new Map(); + for (const issue of issues) { + const group = issue.state_id ? stateGroupById.get(issue.state_id) : undefined; + if (group === 'cancelled' || group === 'canceled') continue; + const entry = stats.get(issue.project_id) ?? { total: 0, completed: 0 }; + entry.total += 1; + if (group === 'completed') entry.completed += 1; + stats.set(issue.project_id, entry); + } + const pctById = new Map(); + for (const [projectId, { total, completed }] of stats) { + pctById.set(projectId, total > 0 ? Math.round((completed / total) * 100) : 0); + } + return pctById; + }, [issues, states]); + if (loading) { return (
@@ -104,6 +158,13 @@ export function AnalyticsOverviewPage() {
); } + if (loadError) { + return ( +
+ {t('analytics.loadError', 'Could not load analytics data.')} +
+ ); + } const baseUrl = `/${workspace.slug}/analytics`; @@ -250,22 +311,25 @@ export function AnalyticsOverviewPage() { {t('analytics.activeProjects', 'Active Projects')}
    - {projects.map((p) => ( -
  • - - {p.name.charAt(0)} - - - {p.name} - - - 0% - -
  • - ))} + {projects.map((p) => { + const pct = completionByProjectId.get(p.id) ?? 0; + return ( +
  • + + {p.name.charAt(0)} + + + {p.name} + + + {pct}% + +
  • + ); + })}