diff --git a/frontend/package.json b/frontend/package.json index 590cf681d7a7..e3de68e65b5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -105,6 +105,7 @@ "react-dom": "18.3.1", "react-dropzone": "14.2.10", "react-error-boundary": "3.1.4", + "react-github-calendar": "^4.5.1", "react-hooks-global-state": "2.1.0", "react-joyride": "^2.5.3", "react-markdown": "^8.0.4", diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx new file mode 100644 index 000000000000..5bbb6d5f476c --- /dev/null +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx @@ -0,0 +1,90 @@ +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus'; +import ActivityCalendar, { type ThemeInput } from 'react-activity-calendar'; +import type { ProjectActivitySchema } from '../../../../openapi'; +import { styled, Tooltip } from '@mui/material'; + +const StyledContainer = styled('div')(({ theme }) => ({ + gap: theme.spacing(1), +})); + +type Output = { date: string; count: number; level: number }; + +export function transformData(inputData: ProjectActivitySchema): Output[] { + const resultMap: Record = {}; + + // Step 1: Count the occurrences of each date + inputData.forEach((item) => { + const formattedDate = new Date(item.date).toISOString().split('T')[0]; + resultMap[formattedDate] = (resultMap[formattedDate] || 0) + 1; + }); + + // Step 2: Get all counts, sort them, and find the cut-off values for percentiles + const counts = Object.values(resultMap).sort((a, b) => a - b); + + const percentile = (percent: number) => { + const index = Math.floor((percent / 100) * counts.length); + return counts[index] || counts[counts.length - 1]; + }; + + const thresholds = [ + percentile(25), // 25th percentile + percentile(50), // 50th percentile + percentile(75), // 75th percentile + percentile(100), // 100th percentile + ]; + + // Step 3: Assign a level based on the percentile thresholds + const calculateLevel = (count: number): number => { + if (count <= thresholds[0]) return 1; // 1-25% + if (count <= thresholds[1]) return 2; // 26-50% + if (count <= thresholds[2]) return 3; // 51-75% + return 4; // 76-100% + }; + + // Step 4: Convert the map back to an array and assign levels + return Object.entries(resultMap) + .map(([date, count]) => ({ + date, + count, + level: calculateLevel(count), + })) + .reverse(); // Optional: reverse the order if needed +} + +export const ProjectActivity = () => { + const projectId = useRequiredPathParam('projectId'); + const { data } = useProjectStatus(projectId); + + const explicitTheme: ThemeInput = { + light: ['#f1f0fc', '#ceccfd', '#8982ff', '#6c65e5', '#615bc2'], + dark: ['#f1f0fc', '#ceccfd', '#8982ff', '#6c65e5', '#615bc2'], + }; + + const levelledData = transformData(data.activityCountByDate); + + return ( + + {data.activityCountByDate.length > 0 ? ( + <> + Activity in project + ( + + {block} + + )} + /> + + ) : ( + No activity + )} + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx index 45f48e64c45e..5732824907ae 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx @@ -1,6 +1,7 @@ import { styled } from '@mui/material'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { ProjectResources } from './ProjectResources'; +import { ProjectActivity } from './ProjectActivity'; const ModalContentContainer = styled('div')(({ theme }) => ({ minHeight: '100vh', @@ -17,6 +18,7 @@ export const ProjectStatusModal = ({ open, close }: Props) => { + ); diff --git a/frontend/src/hooks/api/getters/useProjectInsights/useProjectInsights.ts b/frontend/src/hooks/api/getters/useProjectInsights/useProjectInsights.ts index 8e4ae4faeb3e..67378dc06ffa 100644 --- a/frontend/src/hooks/api/getters/useProjectInsights/useProjectInsights.ts +++ b/frontend/src/hooks/api/getters/useProjectInsights/useProjectInsights.ts @@ -49,7 +49,7 @@ export const useProjectInsights = (projectId: string) => { const projectPath = formatApiPath(path(projectId)); const { data, refetch, loading, error } = useApiGetter(projectPath, () => - fetcher(projectPath, 'Outdated SDKs'), + fetcher(projectPath, 'Project Insights'), ); return { data: data || placeholderData, refetch, loading, error }; diff --git a/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts b/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts new file mode 100644 index 000000000000..967914ce1829 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts @@ -0,0 +1,19 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter'; +import type { ProjectStatusSchema } from '../../../../openapi'; +import { formatApiPath } from 'utils/formatPath'; + +const path = (projectId: string) => `api/admin/projects/${projectId}/status`; + +const placeholderData: ProjectStatusSchema = { + activityCountByDate: [], +}; + +export const useProjectStatus = (projectId: string) => { + const projectPath = formatApiPath(path(projectId)); + const { data, refetch, loading, error } = useApiGetter( + projectPath, + () => fetcher(projectPath, 'Project Status'), + ); + + return { data: data || placeholderData, refetch, loading, error }; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index cff30a0a8160..f503406ffc91 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4578,6 +4578,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^4.1.0": + version: 4.1.0 + resolution: "date-fns@npm:4.1.0" + checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8 + languageName: node + linkType: hard + "dayjs@npm:^1.10.4": version: 1.11.11 resolution: "dayjs@npm:1.11.11" @@ -8366,6 +8373,18 @@ __metadata: languageName: node linkType: hard +"react-activity-calendar@npm:^2.7.1": + version: 2.7.1 + resolution: "react-activity-calendar@npm:2.7.1" + dependencies: + date-fns: "npm:^4.1.0" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 10c0/2d4c9dd688c1187b75b8878094365ee7ef8cec361ac6a2a8088cf83296025c7849fe909487c415ce3e739642821800a6452f7ce4873b52270a3b47834ca2498e + languageName: node + linkType: hard + "react-archer@npm:4.4.0": version: 4.4.0 resolution: "react-archer@npm:4.4.0" @@ -8437,6 +8456,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^4.1.2": + version: 4.1.2 + resolution: "react-error-boundary@npm:4.1.2" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + react: ">=16.13.1" + checksum: 10c0/0737e5259bed40ce14eb0823b3c7b152171921f2179e604f48f3913490cdc594d6c22d43d7abb4ffb1512c832850228db07aa69d3b941db324953a5e393cb399 + languageName: node + linkType: hard + "react-fast-compare@npm:^2.0.4": version: 2.0.4 resolution: "react-fast-compare@npm:2.0.4" @@ -8460,6 +8490,19 @@ __metadata: languageName: node linkType: hard +"react-github-calendar@npm:^4.5.1": + version: 4.5.1 + resolution: "react-github-calendar@npm:4.5.1" + dependencies: + react-activity-calendar: "npm:^2.7.1" + react-error-boundary: "npm:^4.1.2" + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + checksum: 10c0/51688983b28da92718cf2276e6c947b9639b529a93212d77a6c65040af2768b74153b32dee44eed4f706cf0cb4c025864327c51c9c666d1f5b88080f523ef672 + languageName: node + linkType: hard + "react-hooks-global-state@npm:2.1.0": version: 2.1.0 resolution: "react-hooks-global-state@npm:2.1.0" @@ -10152,6 +10195,7 @@ __metadata: react-dom: "npm:18.3.1" react-dropzone: "npm:14.2.10" react-error-boundary: "npm:3.1.4" + react-github-calendar: "npm:^4.5.1" react-hooks-global-state: "npm:2.1.0" react-joyride: "npm:^2.5.3" react-markdown: "npm:^8.0.4"