From 4095e813a4d13dc26dc22e6c0e9ecae3b420183e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fukan=20G=C3=B6ker?= Date: Tue, 4 Aug 2026 14:33:38 +0300 Subject: [PATCH 1/2] fix(analytics): calculate project completion percentages --- apps/web/src/pages/AnalyticsOverviewPage.tsx | 103 ++++++++++++++----- 1 file changed, 77 insertions(+), 26 deletions(-) diff --git a/apps/web/src/pages/AnalyticsOverviewPage.tsx b/apps/web/src/pages/AnalyticsOverviewPage.tsx index 243a6785..21b84b3a 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,20 +12,44 @@ 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); @@ -55,22 +79,28 @@ export function AnalyticsOverviewPage() { setProjects(projs ?? []); setMembers(mem ?? []); } - if (!cancelled && projs?.length) { - return Promise.all( - projs.map((p) => issueService.list(workspaceSlug!, p.id, { limit: 200 })), - ); + if (cancelled) return null; + if (projs?.length) { + return Promise.all([ + Promise.all(projs.map((p) => fetchAllProjectIssues(workspaceSlug, p.id))), + Promise.all(projs.map((p) => stateService.list(workspaceSlug, p.id))), + ]); } - return []; + setIssues([]); + setStates([]); + return null; }) - .then((issueArrays) => { - if (cancelled || !Array.isArray(issueArrays)) return; - if (issueArrays.length > 0 && Array.isArray(issueArrays[0])) - setIssues((issueArrays as IssueApiResponse[][]).flat()); + .then((result) => { + if (cancelled || !result) return; + const [issueArrays, stateArrays] = result; + setIssues(issueArrays.flat()); + setStates(stateArrays.flat()); }) .catch(() => { if (!cancelled) setWorkspace(null); setProjects([]); setIssues([]); + setStates([]); setMembers([]); }) .finally(() => { @@ -90,6 +120,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 (
@@ -250,22 +298,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}% + +
  • + ); + })}
From a6f5c397f681a064605b2e883e78fcbc9e15ff18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fukan=20G=C3=B6ker?= Date: Wed, 5 Aug 2026 00:54:11 +0300 Subject: [PATCH 2/2] fix(analytics): handle data load failures safely --- apps/web/src/pages/AnalyticsOverviewPage.tsx | 79 ++++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/apps/web/src/pages/AnalyticsOverviewPage.tsx b/apps/web/src/pages/AnalyticsOverviewPage.tsx index 21b84b3a..07193b48 100644 --- a/apps/web/src/pages/AnalyticsOverviewPage.tsx +++ b/apps/web/src/pages/AnalyticsOverviewPage.tsx @@ -52,60 +52,66 @@ export function AnalyticsOverviewPage() { 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) return null; - if (projs?.length) { - return Promise.all([ - Promise.all(projs.map((p) => fetchAllProjectIssues(workspaceSlug, p.id))), - Promise.all(projs.map((p) => stateService.list(workspaceSlug, p.id))), - ]); - } - setIssues([]); - setStates([]); - return null; - }) - .then((result) => { - if (cancelled || !result) return; - const [issueArrays, stateArrays] = result; + 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) setWorkspace(null); + } catch { + if (cancelled) return; setProjects([]); setIssues([]); setStates([]); setMembers([]); - }) - .finally(() => { + setLoadError(true); + } finally { if (!cancelled) setLoading(false); - }); + } + }; + + void loadAnalytics(); return () => { cancelled = true; }; @@ -152,6 +158,13 @@ export function AnalyticsOverviewPage() { ); } + if (loadError) { + return ( +
+ {t('analytics.loadError', 'Could not load analytics data.')} +
+ ); + } const baseUrl = `/${workspace.slug}/analytics`;