From 22c07a9f62ac540ba9a39edae7d06d14c03d3ef0 Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Thu, 4 Jan 2024 19:02:02 -0800 Subject: [PATCH] feat: Working Nivo chart (#22) * use v2 pr/search * fix build * wip data * data is fetched and structured * remove async functions * add nivo/line and prep data * o.filter is not a function * dirty fix for rendering the chart * fix type error * adds meta data * npm run format * swap Datum * npm run format * chore: add required packages * chore: disable comma dangle rule * chore: update chart styles and data * feat: fix all type errors and refactor implementation * chore: show stats data * chore: more updates to charts data * chore: update formating and color of chart lines * misc: minor refactor * fix: build now goes through * Update components/CIResponsiveLine.tsx * type errors * lock files * build fix? * build fix --------- Co-authored-by: Sunday Ogbonna Co-authored-by: OGBONNA SUNDAY <62995161+OgDev-01@users.noreply.github.com> --- .eslintrc.json | 2 +- components/CIResponsiveLine.tsx | 50 +++++++++ hooks/useContributorData.ts | 62 +++++------ lib/prCounts.ts | 89 +++++++++++++++ next-types.d.ts | 31 ++++-- npm-shrinkwrap.json | 29 ++++- package.json | 2 + pages/[owner]/[repo]/CIResponsiveLine.tsx | 74 ------------- pages/[owner]/[repo]/index.tsx | 128 ++++++++++++++++++---- pages/[owner]/[repo]/prCounts.ts | 65 ----------- 10 files changed, 324 insertions(+), 208 deletions(-) create mode 100644 components/CIResponsiveLine.tsx create mode 100644 lib/prCounts.ts delete mode 100644 pages/[owner]/[repo]/CIResponsiveLine.tsx delete mode 100644 pages/[owner]/[repo]/prCounts.ts diff --git a/.eslintrc.json b/.eslintrc.json index 1d91cd0..322976d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,7 @@ { "extends": "next/core-web-vitals", "rules": { - "comma-dangle": "error", + // "comma-dangle": "error", "quotes": "error", "camelcase": "error", "jsx-quotes": "error", diff --git a/components/CIResponsiveLine.tsx b/components/CIResponsiveLine.tsx new file mode 100644 index 0000000..4d6b94b --- /dev/null +++ b/components/CIResponsiveLine.tsx @@ -0,0 +1,50 @@ +import { ResponsiveLine, Datum } from "@nivo/line"; +import { format, parse } from "date-fns"; + +const CIResponsiveLine = ({ data }: Datum) => { + return ( + <> +
+ { + return format(parse(value, "MM/dd/yyyy", new Date()), "MMM d"); + }, + }} + pointSize={0} + pointColor={{ theme: "background" }} + enableGridX={false} + enableGridY={false} + useMesh={true} + enableArea={false} + enableCrosshair={true} + enablePointLabel={false} + colors={(d) => d.color} + /> +
+ + ); +}; + +export default CIResponsiveLine; diff --git a/hooks/useContributorData.ts b/hooks/useContributorData.ts index a702594..bf6fa9a 100644 --- a/hooks/useContributorData.ts +++ b/hooks/useContributorData.ts @@ -1,46 +1,38 @@ import useSWR from "swr"; import { useCallback } from "react"; interface PaginatedDataResponse { - readonly data: DBContributors[]; + readonly data: DBContributorsPR[]; readonly meta: Meta; } -type Response = - | { type: "loading" } - | { type: "error"; error: Error } - // Todo: figure out meta - | { type: "result"; data: DBContributors[]; meta: { itemCount: number } }; - // We're not currently using this, we're just using useSWR directly inside ChildWithSWR // this needs useCallback wrap if we want to use it in the other component -const useContributorData = () => { - useCallback(async (owner: string, repo: string): Promise => { - // useSWR is probably going to be a sticking point - // eslint-disable-next-line react-hooks/rules-of-hooks - const { data, error, mutate } = useSWR( - `repos/${owner}/${repo}/contributions` - ); - - if (!error && !data) { - return { type: "loading" }; - } - - if (error) { - return { - type: "error", - error: error - }; - } - - return { - type: "result", - data: data?.data ?? [], - meta: data?.meta ?? { itemCount: 0 } - // commenting for now to appease build - // mutate - }; - }, []); +const useContributorData = (repo: string, startDate?: number, status?: "closed" | "open") => { + const query = new URLSearchParams(); + + if (startDate) { + query.set("prev_days_start_date", `${startDate}`); + } + if (status) { + query.set("status", `${status}`); + } + query.set("repo", `${repo}`); + + query.set("limit", "100"); + + const baseEndpoint = "prs/search"; + + const endpointString = `${baseEndpoint}?${query.toString()}`; + + const { data, error, mutate } = useSWR(repo ? endpointString : null); + + return { + data: data?.data ?? [], + isLoading: !data && !error, + isError: Object.keys(error ?? {}).length > 0, + meta: data?.meta ?? { itemCount: 0 }, + mutate, + }; }; -// good catch [] export { useContributorData }; diff --git a/lib/prCounts.ts b/lib/prCounts.ts new file mode 100644 index 0000000..dd170cd --- /dev/null +++ b/lib/prCounts.ts @@ -0,0 +1,89 @@ +const count = (prData: DBContributorsPR[]): { mergedCount: number; closedCount: number; totalCount: number } => { + const mergedCount = prData.filter((item) => item.pr_is_merged).length; // Merged PRs + const closedCount = prData.filter((item) => item.pr_state === "closed").length; // Closed PRs + + const totalCount = prData.length; // Total number of PRs + + return { + mergedCount, + closedCount, + totalCount, + }; +}; + +const prPerDay = (open: DBContributorsPR[], closed: DBContributorsPR[]) => { + const sortedMergedPRs = closed.sort((a, b) => { + const aDate = new Date(a.pr_created_at); + const bDate = new Date(b.pr_created_at); + + return aDate.getTime() - bDate.getTime(); + }); + + const sortedOpenedPRs = open.sort((a, b) => { + const aDate = new Date(a.pr_created_at); + const bDate = new Date(b.pr_created_at); + + return aDate.getTime() - bDate.getTime(); + }); + const mergedPRsPerDay = sortedMergedPRs.reduce>((acc, item) => { + const mergedDate = new Date(item.pr_merged_at).toLocaleDateString(); + + if (item.pr_is_merged) { + if (!acc[mergedDate]) { + acc[mergedDate] = 0; + } + acc[mergedDate]++; + } + + return acc; + }, {}); + + const closedPRsPerDay = sortedMergedPRs.reduce>((acc, item) => { + const closedDate = new Date(item.pr_updated_at).toLocaleDateString(); + + if (item.pr_is_merged === false) { + if (!acc[closedDate]) { + acc[closedDate] = 0; + } + acc[closedDate]++; + } + + return acc; + }, {}); + + const openedPRsPerDay = sortedOpenedPRs.reduce>((acc, item) => { + const openedDate = new Date(item.pr_created_at).toLocaleDateString(); + + if (!acc[openedDate]) { + acc[openedDate] = 0; + } + acc[openedDate]++; + + return acc; + }, {}); + + const openedPRs = Object.entries(openedPRsPerDay).map(([x, y]) => ({ x, y })); + const closedPRs = Object.entries(closedPRsPerDay).map(([x, y]) => ({ x, y })); + + const mergedPrs = Object.entries(mergedPRsPerDay).map(([x, y]) => ({ x, y })); + + return [ + { + id: "Opened PRs", + color: "#10b981", + data: openedPRs, + }, + { + id: "Merged PRs", + color: "#3b38f1", + data: mergedPrs, + }, + { + id: "Closed PRs", + color: "#ef4444", + data: closedPRs, + }, + ]; +}; + +export default prPerDay; diff --git a/next-types.d.ts b/next-types.d.ts index e4fcb2c..a6e0b9a 100644 --- a/next-types.d.ts +++ b/next-types.d.ts @@ -1,12 +1,27 @@ // User defined type definitions. Please add type definitions for global types here -interface DBContributors { - readonly id: string - readonly commits: number, - readonly first_commit_time: string - readonly last_commit_time: string +interface Meta { + readonly itemCount: number; } -interface Meta { - readonly itemCount: number, -} \ No newline at end of file +interface DBContributorsPR { + readonly event_id: string; + readonly pr_additions: number; + readonly pr_author_login: string; + readonly pr_base_label: string; + readonly pr_changed_files: number; + readonly pr_comments: number; + readonly pr_commits: number; + readonly pr_created_at: string; + readonly pr_deletions: number; + readonly pr_head_label: string; + readonly pr_is_draft: boolean; + readonly pr_is_merged: boolean; + readonly pr_head_ref: string; + readonly pr_merged_at: string; + readonly pr_number: number; + readonly pr_state: string; + readonly pr_title: string; + readonly pr_updated_at: string; + readonly repo_name: string; +} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9501883..8ce3872 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -12,9 +12,11 @@ "dependencies": { "@nivo/core": "^0.84.0", "@nivo/line": "^0.84.0", + "date-fns": "^3.0.6", "next": "12.2.5", "react": "18.2.0", "react-dom": "18.2.0", + "react-icons": "^4.12.0", "rooks": "^7.2.1", "swr": "^1.3.0", "tailwindcss": "^3.1.8", @@ -3342,9 +3344,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001402", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001402.tgz", - "integrity": "sha512-Mx4MlhXO5NwuvXGgVb+hg65HZ+bhUYsz8QtDGDo2QmaJS2GBX47Xfi2koL86lc8K+l+htXeTEB/Aeqvezoo6Ew==", + "version": "1.0.30001574", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz", + "integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==", "funding": [ { "type": "opencollective", @@ -3353,6 +3355,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -3982,6 +3988,15 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/date-fns": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.0.6.tgz", + "integrity": "sha512-W+G99rycpKMMF2/YD064b2lE7jJGUe+EjOES7Q8BIGY8sbNdbgcs9XFTZwvzc9Jx1f3k7LB7gZaZa7f8Agzljg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -8170,6 +8185,14 @@ "react": "^18.2.0" } }, + "node_modules/react-icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", + "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 710e476..69e6e9a 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,11 @@ "dependencies": { "@nivo/core": "^0.84.0", "@nivo/line": "^0.84.0", + "date-fns": "^3.0.6", "next": "12.2.5", "react": "18.2.0", "react-dom": "18.2.0", + "react-icons": "^4.12.0", "rooks": "^7.2.1", "swr": "^1.3.0", "tailwindcss": "^3.1.8", diff --git a/pages/[owner]/[repo]/CIResponsiveLine.tsx b/pages/[owner]/[repo]/CIResponsiveLine.tsx deleted file mode 100644 index 55feacd..0000000 --- a/pages/[owner]/[repo]/CIResponsiveLine.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { ResponsiveLine, Datum } from "@nivo/line"; - -const CIResponsiveLine = ({ data }: Datum) => { - return ( - <> -
- -
- - ); -}; - -export default CIResponsiveLine; diff --git a/pages/[owner]/[repo]/index.tsx b/pages/[owner]/[repo]/index.tsx index 4f10368..6d739ca 100644 --- a/pages/[owner]/[repo]/index.tsx +++ b/pages/[owner]/[repo]/index.tsx @@ -1,42 +1,126 @@ -import React, { useEffect } from "react"; +import React from "react"; import { useRouter } from "next/router"; -import useSWR from "swr"; import dynamic from "next/dynamic"; -const CIResponsiveLine = dynamic(() => import("./CIResponsiveLine"), { ssr: false }); +import { AiOutlinePullRequest } from "react-icons/ai"; +import { GoGitMerge, GoGitPullRequestClosed, GoIssueOpened, GoIssueClosed } from "react-icons/go"; +import { FaArrowTrendUp, FaArrowTrendDown } from "react-icons/fa6"; -import prCounts from "./prCounts"; +const CIResponsiveLine = dynamic(() => import("components/CIResponsiveLine"), { ssr: false }); -type Response = - | { type: "loading" } - | { type: "error"; error: Error } - | { type: "result"; data: DBContributors[]; meta: { itemCount: number } }; +import prPerDay from "lib/prCounts"; +import { useContributorData } from "hooks/useContributorData"; -interface PaginatedDataResponse { - readonly data: DBContributors[]; +export interface PaginatedDataResponse { + readonly data: DBContributorsPR[]; readonly meta: Meta; } +interface StatsCardProps { + type: "issue" | "pr"; + count: number; + status: "open" | "closed" | "merged"; + prevMonthCount?: number; +} const ChildWithSWR = (props: { owner: string; repo: string }) => { const { owner, repo } = props; - const { data, error, mutate } = useSWR(`prs/search?repo=${owner}%2F${repo}`); - if (!data) { + const { data: openedPrs } = useContributorData(`${owner}/${repo}`, 0, "open"); + const { data: prevMonthOpenedPrs, meta: prevMonthOpenedPrsMeta } = useContributorData(`${owner}/${repo}`, 30, "open"); + + const { data: closedPrs } = useContributorData(`${owner}/${repo}`, 0, "closed"); + const { data: prevMonthClosedPrs, meta: prevMonthClosedPrsMeta } = useContributorData( + `${owner}/${repo}`, + 30, + "closed" + ); + + const { data: currentData, isError, isLoading, meta } = useContributorData(`${owner}/${repo}`); + + // fetch previous months data seperately to compare + + if (isLoading) { return <>Loading...; } - const chartData = prCounts(data); + const chartData = prPerDay(openedPrs, closedPrs); + + const getPercentageChange = (prevCount: number, currentCount: number) => { + const percentageChange = Math.abs((currentCount - prevCount) / prevCount) * 100; + return percentageChange; + }; + + const StatsCard = ({ type, status, count, prevMonthCount }: StatsCardProps) => { + const IconMap = { + issue: { + open: , + closed: , + merged: , + }, + pr: { + open: , + closed: , + merged: , + }, + }; + + return ( +
+
+ {IconMap[type][status]} +
+
+ Pull Requests {status === "merged" || status === "closed" ? status : null} +
+
+ {count} +
+ {getPercentageChange(prevMonthCount!, count) >= 0 ? ( + + ) : ( + + )} + = 0 ? "text-green-700" : "text-red-700"}`}> + {getPercentageChange(prevMonthCount!, count).toFixed(2)}% + {" "} + vs last month +
+
+
+ ); + }; return ( - <> -
    -
  • Merged: {chartData.meta.mergedCount}
  • -
  • Closed: {chartData.meta.closedCount}
  • -
  • Total: {chartData.meta.totalCount}
  • -
- - - +
+
+ + pr.pr_is_merged === true).length} + prevMonthCount={prevMonthClosedPrs ? prevMonthClosedPrsMeta.itemCount : undefined} + /> + item.pr_state === "close" && !item.pr_is_merged).length} + prevMonthCount={prevMonthClosedPrsMeta ? prevMonthClosedPrsMeta.itemCount : undefined} + /> +
+ + + +
+
); }; diff --git a/pages/[owner]/[repo]/prCounts.ts b/pages/[owner]/[repo]/prCounts.ts deleted file mode 100644 index 2747b51..0000000 --- a/pages/[owner]/[repo]/prCounts.ts +++ /dev/null @@ -1,65 +0,0 @@ - -const prCounts = (prData) => { - const meta = count(prData); - const prsPerDay = prPerDay(prData); - - return { - meta, - prsPerDay - }; -}; - -const count = (prData): { mergedCount: number; closedCount: number; totalCount: number } => { - let mergedCount = 0; - let closedCount = 0; - - prData.data.forEach((item) => { - if (item.pr_is_merged === true) { - mergedCount++; // Increment merged PR count - } - - if (item.pr_state === "closed") { - closedCount++; // Increment closed PR count - } - }); - - const totalCount = prData.data.length; // Total number of PRs - - console.log("Merged PRs:", mergedCount); - console.log("Closed PRs:", closedCount); - console.log("Total PRs:", totalCount); - - return { - mergedCount, - closedCount, - totalCount - }; -}; - -const prPerDay = (prData) => { - const mergedPRsPerDay = {}; - - prData.data.forEach((item) => { - const mergedDate = new Date(item.pr_merged_at).toLocaleDateString(); - - if (item.pr_is_merged === true && item.pr_merged_at !== "0001-01-01T00:00:00.000Z") { - if (!mergedPRsPerDay[mergedDate]) { - mergedPRsPerDay[mergedDate] = 0; - } - mergedPRsPerDay[mergedDate]++; - } - }); - - const transformedData = Object.entries(mergedPRsPerDay).map(([x, y]) => ({ x, y })); - - console.log("Merged PRs per day:", transformedData); - - return [{ - id: "Pull Requests", - color: "#f59e0b", - data: transformedData - }]; - -}; - -export default prCounts;