diff --git a/components/molecules/ContributionsTreemap/contributions-treemap.tsx b/components/molecules/ContributionsTreemap/contributions-treemap.tsx new file mode 100644 index 0000000000..7090405b0a --- /dev/null +++ b/components/molecules/ContributionsTreemap/contributions-treemap.tsx @@ -0,0 +1,79 @@ +import { useSpring, animated } from "@react-spring/web"; +import dynamic from "next/dynamic"; +import Card from "components/atoms/Card/card"; +import { SpecialNode } from "stories/molecules/treemap-prototype/special-node"; +import { ContributorNode } from "stories/molecules/treemap-prototype/contributor-node"; +import ClientOnly from "components/atoms/ClientOnly/client-only"; +import type { NodeMouseEventHandler, NodeProps } from "@nivo/treemap"; + +interface ContributionsTreemapProps { + data: any; + color: string; + onClick: NodeMouseEventHandler; + repoId: number | null; + setRepoId: (repoId: number | null) => void; +} + +function BreadCrumb({ isActive, ...rest }: any) { + const separatorStyle = useSpring(isActive ? { opacity: 1 } : { opacity: 0 }); + const textStyle = useSpring(isActive ? { opacity: 1, translateX: 0 } : { opacity: 0, translateX: 100 }); + + return ( + <> + + {"/"} + + + + ); +} + +const ResponsiveTreeMapHtml = dynamic(() => import("@nivo/treemap").then((module) => module.ResponsiveTreeMapHtml)); + +export const ContributionsTreemap = ({ setRepoId, repoId, data, color, onClick }: ContributionsTreemapProps) => { + return ( + +
+ {/* Label: Text */} +
+ +
+ Contributors +
+
+
+ + ({ + node, + animatedProps, + borderWidth, + enableLabel, + labelSkipSize, + }: NodeProps) => JSX.Element) + } + colors={color} + nodeOpacity={1} + borderWidth={0} + onClick={onClick} + motionConfig={"default"} + /> + +
+
+
+
+ ); +}; diff --git a/helpers/fetchApiData.ts b/helpers/fetchApiData.ts index a36d94339b..c7832918d1 100644 --- a/helpers/fetchApiData.ts +++ b/helpers/fetchApiData.ts @@ -1,7 +1,7 @@ export function validateListPath(path: string) { // () const regex = - /^lists\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\/(contributors|stats\/(most-active-contributors|contributions-evolution-by-type))))\/??/; + /^lists\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\/(contributors|stats\/(most-active-contributors|contributions-evolution-by-type|contributions-by-project))))\/??/; return regex.test(path); } diff --git a/lib/hooks/api/useContributionsByProject.ts b/lib/hooks/api/useContributionsByProject.ts new file mode 100644 index 0000000000..bc44b9f62d --- /dev/null +++ b/lib/hooks/api/useContributionsByProject.ts @@ -0,0 +1,27 @@ +import useSWR, { Fetcher } from "swr"; +import publicApiFetcher from "lib/utils/public-api-fetcher"; + +export const useContributionsByProject = ({ + listId, + range, + initialData, +}: { + listId: string; + range: number; + initialData?: DbProjectContributions[]; +}) => { + const { data, error } = useSWR( + `lists/${listId}/stats/contributions-by-project?range=${range}`, + publicApiFetcher as Fetcher + // { + // fallbackData: { + // `lists/${listId}/stats/contributions-by-project?range=${range}`: initialData + // }, + // } + ); + + return { + data, + error, + }; +}; diff --git a/lib/hooks/api/useContributorsByProject.ts b/lib/hooks/api/useContributorsByProject.ts new file mode 100644 index 0000000000..34a2bdbc8d --- /dev/null +++ b/lib/hooks/api/useContributorsByProject.ts @@ -0,0 +1,18 @@ +import useSWR, { Fetcher } from "swr"; +import { useState } from "react"; +import publicApiFetcher from "lib/utils/public-api-fetcher"; + +export const useContributorsByProject = (listId: string, range: number) => { + const [repoId, setRepoId] = useState(null); + const { data, error } = useSWR( + `lists/${listId}/stats/top-project-contributions-by-contributor?repoId=${repoId}&range=${range}`, + publicApiFetcher as Fetcher + ); + + return { + data, + error, + setRepoId, + repoId, + }; +}; diff --git a/lib/hooks/api/useMostActiveContributors.ts b/lib/hooks/api/useMostActiveContributors.ts index 38efc5a2cb..d3f998e407 100644 --- a/lib/hooks/api/useMostActiveContributors.ts +++ b/lib/hooks/api/useMostActiveContributors.ts @@ -20,16 +20,15 @@ const useMostActiveContributors = ({ listId, initData, intialLimit = 20, - initialRange = 30, + range = 30, defaultContributorType = "all", }: { listId: string; initData?: ContributorStat[]; intialLimit?: number; - initialRange?: number; + range?: number; defaultContributorType?: ContributorType; }) => { - const [range, setRange] = useState(initialRange); const [limit, setLimit] = useState(intialLimit); const [contributorType, setContributorType] = useState(defaultContributorType); @@ -52,7 +51,6 @@ const useMostActiveContributors = ({ data, isLoading: !error && !data, isError: !!error, - setRange, contributorType, setContributorType, }; diff --git a/lib/utils/color-utils.ts b/lib/utils/color-utils.ts new file mode 100644 index 0000000000..083e0bb1d7 --- /dev/null +++ b/lib/utils/color-utils.ts @@ -0,0 +1,26 @@ +export function stringToHSLAColor({ + id, + saturation = 90, + lightness = 48, + alpha = 1, +}: { + id: string; + saturation?: number; + lightness?: number; + alpha?: number; +}) { + // Ensure valid values for saturation and lightness (0-100) and alpha (0-1) + saturation = Math.min(Math.max(saturation, 0), 100); + lightness = Math.min(Math.max(lightness, 0), 100); + alpha = Math.min(Math.max(alpha, 0), 1); + + // Use a simple hashing algorithm to generate H, S, and L values + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = id.charCodeAt(i) + ((hash << 5) - hash); + } + + const h = ((hash % 360) + 360) % 360; // Ensure H value is between 0 and 360 + + return `hsla(${h}, ${saturation}%, ${lightness}%, ${alpha})`; +} diff --git a/lib/utils/nivo-utils.ts b/lib/utils/nivo-utils.ts new file mode 100644 index 0000000000..a0029df93a --- /dev/null +++ b/lib/utils/nivo-utils.ts @@ -0,0 +1,18 @@ +import { SpringValue, to } from "@react-spring/web"; + +/** + There are several utility functions in the @nico/* packages that are ESM only. If you are + unable to dynamically import them for usage in your project, you can copy the source code +here and use this instead. + +You'll know if you need to do this if you see an error like this: + + Error: require() of ES Module /Users/nicktaylor/dev/work/app/node_modules/@nivo/treemap/node_modules/d3-color/src/index.js from /Users/nicktaylor/dev/work/app/node_modules/@nivo/treemap/node_modules/@nivo/colors/dist/nivo-colors.cjs.js not supported. +Instead change the require of index.js in /Users/nicktaylor/dev/work/app/node_modules/@nivo/treemap/node_modules/@nivo/colors/dist/nivo-colors.cjs.js to a dynamic import() which is available in all CommonJS modules. + +**/ + +// See https://github.com/plouc/nivo/blob/master/packages/treemap/src/transitions.ts#L6-L7 +export function htmlNodeTransform(x: SpringValue, y: SpringValue) { + return to([x, y], (x, y) => `translate(${x}px, ${y}px)`); +} diff --git a/next-types.d.ts b/next-types.d.ts index 2d2db97377..0399ae57dc 100644 --- a/next-types.d.ts +++ b/next-types.d.ts @@ -369,3 +369,18 @@ interface DbListContributorStat { commits: number; prsCreated: number; } +interface DbProjectContributions { + org_id: string; + project_id: string; + repo_id: number; + contributions: number; +} + +interface DBProjectContributor { + login: string; + commits: number; + prs_created: number; + prs_reviewed: number; + issues_created: number; + comments: number; +} diff --git a/pages/lists/[listId]/activity.tsx b/pages/lists/[listId]/activity.tsx index f1a1d97602..c76106b34d 100644 --- a/pages/lists/[listId]/activity.tsx +++ b/pages/lists/[listId]/activity.tsx @@ -1,14 +1,18 @@ import { createPagesServerClient } from "@supabase/auth-helpers-nextjs"; import { GetServerSidePropsContext } from "next"; +import { useState } from "react"; +import { NodeMouseEventHandler } from "@nivo/treemap"; import Error from "components/atoms/Error/Error"; import { fetchApiData, validateListPath } from "helpers/fetchApiData"; import ListPageLayout from "layouts/lists"; import MostActiveContributorsCard, { ContributorStat, } from "components/molecules/MostActiveContributorsCard/most-active-contributors-card"; - import useMostActiveContributors from "lib/hooks/api/useMostActiveContributors"; import ClientOnly from "components/atoms/ClientOnly/client-only"; +import { ContributionsTreemap } from "components/molecules/ContributionsTreemap/contributions-treemap"; +import { useContributorsByProject } from "lib/hooks/api/useContributorsByProject"; +import { useContributionsByProject } from "lib/hooks/api/useContributionsByProject"; interface ContributorListPageProps { list?: DBList; @@ -17,6 +21,7 @@ interface ContributorListPageProps { activityData: { contributorStats: { data: ContributorStat[]; meta: Meta }; topContributor: ContributorStat; + projectData: DbProjectContributions[]; }; } @@ -33,6 +38,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { { data, error: contributorListError }, { data: list, error }, { data: mostActiveData, error: mostActiveError }, + { data: projectData, error: projectError }, ] = await Promise.all([ fetchApiData>({ path: `lists/${listId}/contributors?limit=1`, @@ -45,6 +51,11 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { bearerToken, pathValidator: validateListPath, }), + fetchApiData({ + path: `lists/${listId}/stats/contributions-by-project?range=${range}`, + bearerToken, + pathValidator: validateListPath, + }), ]); if (error?.status === 404 || error?.status === 401) { @@ -61,21 +72,53 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { activityData: { contributorStats: mostActiveData, topContributor: mostActiveData?.data?.length ? mostActiveData.data[0] : null, + projectData: projectData ?? [], }, }, }; }; const ListActivityPage = ({ list, numberOfContributors, isError, activityData }: ContributorListPageProps) => { + const [range, setRange] = useState(30); const isOwner = false; const { data: contributorStats, isLoading, isError: isMostActiveError, - setRange, setContributorType, contributorType, - } = useMostActiveContributors({ listId: list!.id, initData: activityData.contributorStats.data }); + } = useMostActiveContributors({ listId: list!.id, initData: activityData.contributorStats.data, range }); + + const { setRepoId, error, data: projectContributionsByUser, repoId } = useContributorsByProject(list!.id, range); + + const { data: projectData, error: projectDataError } = useContributionsByProject({ + listId: list!.id, + range, + initialData: activityData.projectData, + }); + + const onHandleClick: NodeMouseEventHandler = (node) => { + // @ts-ignore TODO: fix this + setRepoId(Number(node.data.repoId)); + }; + const treemapData = { + id: "root", + children: + repoId === null + ? (projectData ?? []).map(({ org_id, project_id, repo_id, contributions }) => { + return { + id: `${org_id}/${project_id}`, + value: contributions, + repoId: `${repo_id}`, + }; + }) + : projectContributionsByUser?.map(({ login, commits, prs_created, prs_reviewed, issues_created, comments }) => { + return { + id: login, + value: commits + prs_created, // Coming soon + prs_reviewed + issues_created + comments, + }; + }), + }; return ( @@ -94,6 +137,13 @@ const ListActivityPage = ({ list, numberOfContributors, isError, activityData }: isLoading={isLoading} /> + )} diff --git a/stories/molecules/treemap-prototype/contributor-node.tsx b/stories/molecules/treemap-prototype/contributor-node.tsx index ca5ffc1098..4cad726a36 100644 --- a/stories/molecules/treemap-prototype/contributor-node.tsx +++ b/stories/molecules/treemap-prototype/contributor-node.tsx @@ -1,7 +1,9 @@ import { memo } from "react"; import { animated } from "@react-spring/web"; -import { NodeProps, htmlNodeTransform } from "@nivo/treemap"; import { getAvatarByUsername } from "lib/utils/github"; +import { htmlNodeTransform } from "lib/utils/nivo-utils"; +import { stringToHSLAColor } from "lib/utils/color-utils"; +import type { NodeProps } from "@nivo/treemap"; const NonMemoizedContributorNode = ({ node, @@ -14,44 +16,35 @@ const NonMemoizedContributorNode = labelSkipSize); const avatarURL = getAvatarByUsername(node.id); + const color = stringToHSLAColor({ id: node.id }); return ( {showLabel && ( ({ node, @@ -11,34 +13,28 @@ const NonMemoizedSpecialNode = ({ }: NodeProps) => { const showLabel = enableLabel && node.isLeaf && (labelSkipSize === 0 || Math.min(node.width, node.height) > labelSkipSize); + const [fullRepoName] = node.id.split(":"); + node.color = stringToHSLAColor({ id: node.id }); return ( ({ /> {showLabel && ( ({ }} >
-
{node.id}
+
{fullRepoName}
{node.label}