Repository: open-sauced/insights
Pull request #1216 (2023-05-23 17:55:45)

v1.48.0-beta.7 -> production


Patch:
diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml
index fdb792df..63a7b153 100644
--- a/.github/workflows/development.yml
+++ b/.github/workflows/development.yml
@@ -10,4 +10,27 @@ on:
 
 jobs:
   test:
+    name: Unit Tests and Lint Checks
     uses: open-sauced/open-sauced/.github/workflows/development.yml@main
+
+  e2e:
+    name: E2E Tests
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    steps:
+      - name: "☁️ checkout repository"
+        uses: actions/checkout@v2
+
+      - name: "🔧 setup node"
+        uses: actions/setup-node@v2.1.5
+        with:
+          node-version: 16
+
+      - name: "🔧 install npm@latest"
+        run: npm i -g npm@latest
+
+      - name: "📦 install dependencies"
+        uses: bahmutov/npm-install@v1
+
+      - name: "🔍 run tests"
+        run: npm run test:e2e --if-present
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 27a46a8a..87fd51d9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,11 +22,32 @@ jobs:
           echo "::set-output name=DEPLOY_ENVIRONMENT::$([[ ${{ github.ref_name }} == 'main' ]] && echo 'production' || echo ${{ github.ref_name }})"
           echo "::set-output name=DEPLOY_SUBDOMAIN::$([[ ${{ github.ref_name }} == 'main' ]] && echo '' || echo '${{ github.ref_name }}.')"
 
-
   test:
-    name: Test and lint
+    name: Unit Tests and Lint Checks
     uses: open-sauced/open-sauced/.github/workflows/development.yml@main
 
+  e2e:
+    name: E2E Tests
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    steps:
+      - name: "☁️ checkout repository"
+        uses: actions/checkout@v2
+
+      - name: "🔧 setup node"
+        uses: actions/setup-node@v2.1.5
+        with:
+          node-version: 16
+
+      - name: "🔧 install npm@latest"
+        run: npm i -g npm@latest
+
+      - name: "📦 install dependencies"
+        uses: bahmutov/npm-install@v1
+
+      - name: "🔍 run tests"
+        run: npm run test:e2e --if-present
+
   build:
     name: Build application
     runs-on: ubuntu-latest
@@ -63,6 +84,7 @@ jobs:
     needs:
       - setup
       - test
+      - e2e
       - build
     runs-on: ubuntu-latest
     timeout-minutes: 10
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 067cf939..d7ea3a6b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,65 @@
 
 > All notable changes to this project will be documented in this file
 
+## [1.48.0-beta.7](https://github.com/open-sauced/insights/compare/v1.48.0-beta.6...v1.48.0-beta.7) (2023-05-23)
+
+
+### 🍕 Features
+
+* add pull request velocity to user profile page ([#1215](https://github.com/open-sauced/insights/issues/1215)) ([e458956](https://github.com/open-sauced/insights/commit/e458956b148d2d4046bdda69a750b1fb9798550f))
+
+## [1.48.0-beta.6](https://github.com/open-sauced/insights/compare/v1.48.0-beta.5...v1.48.0-beta.6) (2023-05-23)
+
+
+### 🐛 Bug Fixes
+
+* set default value to repo filter state ([#1214](https://github.com/open-sauced/insights/issues/1214)) ([2f8b8d2](https://github.com/open-sauced/insights/commit/2f8b8d2163317c4226348f9b0738848c333df812))
+
+## [1.48.0-beta.5](https://github.com/open-sauced/insights/compare/v1.48.0-beta.4...v1.48.0-beta.5) (2023-05-23)
+
+
+### 🐛 Bug Fixes
+
+* check user follow after opening highlight menu ([#1197](https://github.com/open-sauced/insights/issues/1197)) ([7ded9aa](https://github.com/open-sauced/insights/commit/7ded9aa684e01dccaf915eba398694aaedd8c145))
+
+## [1.48.0-beta.4](https://github.com/open-sauced/insights/compare/v1.48.0-beta.3...v1.48.0-beta.4) (2023-05-23)
+
+
+### 🍕 Features
+
+* add Posthog as Next middleware ([#1210](https://github.com/open-sauced/insights/issues/1210)) ([1f19b43](https://github.com/open-sauced/insights/commit/1f19b43bb9f5e752791a790c159c96092bdec3c2))
+
+
+### 🔁 Continuous Integration
+
+* move unit and e2e tests into separate jobs ([#1213](https://github.com/open-sauced/insights/issues/1213)) ([d1ac6a0](https://github.com/open-sauced/insights/commit/d1ac6a0a27797bbe1780b0958f67fab606b3e1d7))
+
+## [1.48.0-beta.3](https://github.com/open-sauced/insights/compare/v1.48.0-beta.2...v1.48.0-beta.3) (2023-05-22)
+
+
+### 🍕 Features
+
+* Replace zendesk with SiteGPT ([#1209](https://github.com/open-sauced/insights/issues/1209)) ([cb85a45](https://github.com/open-sauced/insights/commit/cb85a457ecef9c1be5851d68849f822ef9f85fda))
+
+## [1.48.0-beta.2](https://github.com/open-sauced/insights/compare/v1.48.0-beta.1...v1.48.0-beta.2) (2023-05-22)
+
+
+### 🐛 Bug Fixes
+
+* fix repo overlap issue in Insight page ([#1204](https://github.com/open-sauced/insights/issues/1204)) ([26f07b9](https://github.com/open-sauced/insights/commit/26f07b9dabdd330956d4cb8d662f7cbc33bfbfda))
+
+## [1.48.0-beta.1](https://github.com/open-sauced/insights/compare/v1.47.0...v1.48.0-beta.1) (2023-05-19)
+
+
+### 🍕 Features
+
+* add OpenGraph image for individual highlights ([#1205](https://github.com/open-sauced/insights/issues/1205)) ([0e2b49f](https://github.com/open-sauced/insights/commit/0e2b49f26cac2443b1f0c6d4dfd31d660061b6bd))
+
+
+### 🐛 Bug Fixes
+
+* fix package.json merge ([287d999](https://github.com/open-sauced/insights/commit/287d999a3abf77a5cc8980ae62e62946abeff508))
+
 ## [1.47.0](https://github.com/open-sauced/insights/compare/v1.46.0...v1.47.0) (2023-05-18)
 
 
diff --git a/components/molecules/ContributorHighlight/contributor-highlight-card.tsx b/components/molecules/ContributorHighlight/contributor-highlight-card.tsx
index 50b5de0a..d7216a85 100644
--- a/components/molecules/ContributorHighlight/contributor-highlight-card.tsx
+++ b/components/molecules/ContributorHighlight/contributor-highlight-card.tsx
@@ -90,8 +90,6 @@ const ContributorHighlightCard = ({
 
   const [date, setDate] = useState<Date | undefined>(shipped_date ? new Date(shipped_date) : undefined);
 
-  const { follow, unFollow, isError } = useFollowUser(user);
-
   const { data: reactions, mutate } = useHighlightReactions(id);
   const { data: userReaction, deleteReaction, addReaction } = useUserHighlightReactions(id);
 
@@ -204,6 +202,34 @@ const ContributorHighlightCard = ({
     }
   }, [highlight]);
 
+  function FollowUser(){
+    
+    const { follow, unFollow, isError } = useFollowUser(user);
+                
+    return (loggedInUser ? (
+      <DropdownMenuItem
+        className={`rounded-md ${loggedInUser.user_metadata.user_name === user && "hidden"}`}
+      >
+        <div onClick={isError ? follow : unFollow} className="flex gap-2.5 py-1 items-center pl-3 pr-7 cursor-pointer">
+          <FaUserPlus size={22} />
+          <span>
+            {!isError ? "Unfollow" : "Follow"} {user}
+          </span>
+        </div>
+      </DropdownMenuItem>
+    ) : (
+      <DropdownMenuItem className="rounded-md">
+        <div
+          onClick={async () => await signIn({ provider: "github" })}
+          className="flex gap-2.5 py-1  items-center pl-3 pr-7"
+        >
+          <FaUserPlus size={22} />
+          <span>Follow {user}</span>
+        </div>
+      </DropdownMenuItem>
+    ));
+  }
+
   return (
     <article className="flex flex-col  md:max-w-[40rem] flex-1 gap-3 lg:gap-6">
       <div>
@@ -252,28 +278,7 @@ const ContributorHighlightCard = ({
                     <span>Copy link</span>
                   </div>
                 </DropdownMenuItem>
-                {loggedInUser ? (
-                  <DropdownMenuItem
-                    className={`rounded-md ${loggedInUser.user_metadata.user_name === user && "hidden"}`}
-                  >
-                    <div onClick={isError ? follow : unFollow} className="flex gap-2.5 py-1 items-center pl-3 pr-7 cursor-pointer">
-                      <FaUserPlus size={22} />
-                      <span>
-                        {!isError ? "Unfollow" : "Follow"} {user}
-                      </span>
-                    </div>
-                  </DropdownMenuItem>
-                ) : (
-                  <DropdownMenuItem className="rounded-md">
-                    <div
-                      onClick={async () => await signIn({ provider: "github" })}
-                      className="flex gap-2.5 py-1  items-center pl-3 pr-7"
-                    >
-                      <FaUserPlus size={22} />
-                      <span>Follow {user}</span>
-                    </div>
-                  </DropdownMenuItem>
-                )}
+                <FollowUser />
                 {loggedInUser && (
                   <DropdownMenuItem
                     className={`rounded-md ${
diff --git a/components/organisms/ContributorProfileTab/contributor-profile-tab.tsx b/components/organisms/ContributorProfileTab/contributor-profile-tab.tsx
index e6ecff27..5647656e 100644
--- a/components/organisms/ContributorProfileTab/contributor-profile-tab.tsx
+++ b/components/organisms/ContributorProfileTab/contributor-profile-tab.tsx
@@ -169,24 +169,27 @@ const ContributorProfileTab = ({
           {/* <HightlightEmptyState /> */}
 
           {isError && <>An error occured</>}
-          {isLoading && <>
-            {Array.from({ length: 2 }).map((_, index) => (
-              <div className="flex flex-col gap-2 lg:flex-row lg:gap-6" key={index}>
-                <SkeletonWrapper width={100} height={20} />
-                <div className="md:max-w-[40rem]">
-                  <SkeletonWrapper height={20} width={500} classNames="mb-2" />
-                  <SkeletonWrapper height={300}  />
+          {isLoading && (
+            <>
+              {Array.from({ length: 2 }).map((_, index) => (
+                <div className="flex flex-col gap-2 lg:flex-row lg:gap-6" key={index}>
+                  <SkeletonWrapper width={100} height={20} />
+                  <div className="md:max-w-[40rem]">
+                    <SkeletonWrapper height={20} width={500} classNames="mb-2" />
+                    <SkeletonWrapper height={300} />
+                  </div>
                 </div>
-              </div>
-            ))}
-          </>}
+              ))}
+            </>
+          )}
           <>
             {!isError && highlights && highlights.length > 0 ? (
               <div>
                 {highlights.map(({ id, title, highlight, url, shipped_at, created_at }) => (
                   <div className="flex flex-col gap-2 mb-6 lg:flex-row lg:gap-7" key={id}>
                     <Link href={`/feed/${id}`}>
-                      <p className="text-sm text-light-slate-10">{formatDistanceToNowStrict(new Date(created_at), { addSuffix: true })}
+                      <p className="text-sm text-light-slate-10">
+                        {formatDistanceToNowStrict(new Date(created_at), { addSuffix: true })}
                       </p>
                     </Link>
                     <ContributorHighlightCard
@@ -264,8 +267,8 @@ const ContributorProfileTab = ({
                   <div className="flex items-end justify-center mt-1"> - </div>
                 )}
               </div>
-              <div className="hidden">
-                <span className="text-xs text-light-slate-11">Avg PRs velocity</span>
+              <div>
+                <span className="text-xs text-light-slate-11">Avg PR velocity</span>
                 {prVelocity ? (
                   <div className="flex items-center gap-2 lg:justify-center">
                     <Text className="!text-lg md:!text-xl lg:!text-2xl !text-black !leading-none">
diff --git a/components/organisms/InsightPage/InsightPage.tsx b/components/organisms/InsightPage/InsightPage.tsx
index 66a11748..53ffe9fd 100644
--- a/components/organisms/InsightPage/InsightPage.tsx
+++ b/components/organisms/InsightPage/InsightPage.tsx
@@ -28,7 +28,7 @@ enum RepoLookupError {
   Initial = 0,
   NotIndexed = 1,
   Invalid = 3,
-  Error = 4
+  Error = 4,
 }
 
 interface InsightPageProps {
@@ -53,7 +53,7 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
     data.map((member) => ({
       ...member,
       email: member.invitation_email,
-      avatarUrl: !!member.user_id ? getAvatarById(String(member.user_id)) : ""
+      avatarUrl: !!member.user_id ? getAvatarById(String(member.user_id)) : "",
     }));
 
   const insightOwner: TeamMemberData = {
@@ -62,7 +62,7 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
     id: String(insight?.user.id),
     name: String(insight?.user.name || insight?.user.login),
     avatarUrl: getAvatarByUsername(String(insight?.user.login)),
-    access: "owner"
+    access: "owner",
   };
 
   // Loading States
@@ -104,7 +104,7 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
       repoName: repoName,
       totalPrs,
       avatar: getAvatarByUsername(repoOwner, 60),
-      handleRemoveItem: () => {}
+      handleRemoveItem: () => {},
     };
   });
 
@@ -134,14 +134,14 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
       method: "POST",
       headers: {
         "Content-type": "application/json",
-        Authorization: `Bearer ${sessionToken}`
+        Authorization: `Bearer ${sessionToken}`,
       },
       body: JSON.stringify({
         name,
         repos: repos.map((repo) => ({ id: repo.id, fullName: repo.full_name })),
         // eslint-disable-next-line
-        is_public: isPublic
-      })
+        is_public: isPublic,
+      }),
     });
     setCreateLoading(false);
     if (response.ok) {
@@ -161,14 +161,14 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
       method: "PATCH",
       headers: {
         "Content-type": "application/json",
-        Authorization: `Bearer ${sessionToken}`
+        Authorization: `Bearer ${sessionToken}`,
       },
       body: JSON.stringify({
         name,
         repos: repos.map((repo) => ({ id: repo.id, fullName: repo.full_name })),
         // eslint-disable-next-line
-        is_public: isPublic
-      })
+        is_public: isPublic,
+      }),
     });
     setCreateLoading(false);
     if (response && response.ok) {
@@ -210,7 +210,7 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
           // create a stub repo to send to API
           const addedRepo = {
             id: publicRepo.id,
-            full_name: publicRepo.full_name
+            full_name: publicRepo.full_name,
           } as DbRepo;
 
           setRepos((repos) => {
@@ -274,8 +274,8 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
       method: "DELETE",
       headers: {
         "Content-type": "application/json",
-        Authorization: `Bearer ${sessionToken}`
-      }
+        Authorization: `Bearer ${sessionToken}`,
+      },
     });
 
     setDeleteLoading(false);
@@ -303,10 +303,10 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
         ...(providerToken
           ? {
             headers: {
-              Authorization: `Bearer ${providerToken}`
-            }
+              Authorization: `Bearer ${providerToken}`,
+            },
           }
-          : {})
+          : {}),
       }
     );
 
@@ -331,26 +331,26 @@ const InsightPage = ({ edit, insight, pageRepos }: InsightPageProps) => {
       prCount: 8,
       repoName: "insights",
       issueCount: 87,
-      orgName: "open-sauced"
+      orgName: "open-sauced",
     },
     {
       avatar: "https://avatars.githubusercontent.com/u/59704711?s=200&v=4",
       prCount: 26,
       repoName: "cli",
       issueCount: 398,
-      orgName: "cli"
+      orgName: "cli",
     },
     {
       avatar: "https://avatars.githubusercontent.com/u/42048915?s=200&v=4",
       prCount: 100,
       repoName: "deno",
       issueCount: 1200,
-      orgName: "denoland"
-    }
+      orgName: "denoland",
+    },
   ];
 
   return (
-    <section className="flex flex-col justify-center w-full py-4 lg:flex-row lg:gap-20 lg:pl-28 ">
+    <section className="flex flex-col justify-center w-full py-4 xl:flex-row xl:gap-20 xl:pl-28 ">
       <div className="flex flex-col gap-8">
         <div className="pb-6 border-b border-light-slate-8">
           <Title className="!text-2xl !leading-none mb-4" level={1}>
diff --git a/lib/utils/get-contributor-pr-velocity.ts b/lib/utils/get-contributor-pr-velocity.ts
new file mode 100644
index 00000000..7a3c198f
--- /dev/null
+++ b/lib/utils/get-contributor-pr-velocity.ts
@@ -0,0 +1,20 @@
+import differenceInDays from "date-fns/differenceInDays";
+
+const getContributorPullRequestVelocity = (repositoryPullRequests: DbRepoPR[]) => {
+  const mergedPRs = repositoryPullRequests.filter((prState) => prState.state.toLowerCase() === "merged");
+
+  const totalDays = mergedPRs.reduce((total, pr) => {
+    const daysBetween = differenceInDays(new Date(pr.closed_at), new Date(pr.created_at));
+    return (total += daysBetween);
+  }, 0);
+
+  const averageVelocity = mergedPRs.length > 0 ? totalDays / mergedPRs.length : undefined;
+
+  if (averageVelocity && averageVelocity < 1) {
+    return 1;
+  }
+
+  return averageVelocity;
+};
+
+export default getContributorPullRequestVelocity;
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
index 2b0cbf37..4383acc6 100644
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -1,12 +1,12 @@
 {
   "name": "@open-sauced/insights",
-  "version": "1.47.0",
+  "version": "1.48.0-beta.7",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@open-sauced/insights",
-      "version": "1.47.0",
+      "version": "1.48.0-beta.7",
       "hasInstallScript": true,
       "license": "MIT",
       "dependencies": {
@@ -44,7 +44,7 @@
         "echarts-for-react": "^3.0.2",
         "next": "^13.1.6",
         "octokit": "^2.0.14",
-        "posthog-js": "^1.42.3",
+        "posthog-js": "^1.57.2",
         "react": "^18.2.0",
         "react-csv": "^2.2.2",
         "react-day-picker": "^8.7.1",
@@ -7704,14 +7704,6 @@
       "integrity": "sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==",
       "dev": true
     },
-    "node_modules/@sentry/types": {
-      "version": "7.22.0",
-      "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.22.0.tgz",
-      "integrity": "sha512-LhCL+wb1Jch+OesB2CIt6xpfO1Ab6CRvoNYRRzVumWPLns1T3ZJkarYfhbLaOEIb38EIbPgREdxn2AJT560U4Q==",
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/@sinclair/typebox": {
       "version": "0.24.20",
       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.20.tgz",
@@ -29387,11 +29379,10 @@
       "dev": true
     },
     "node_modules/posthog-js": {
-      "version": "1.42.3",
-      "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.42.3.tgz",
-      "integrity": "sha512-+HdPY1PRsENcqjKyjw8CGbcGzBXcElPikSUSFffjKVJKmkMqEgMFpEKhIY5UBbrU6xtgcvnQmidn+8gRhCBa5g==",
+      "version": "1.57.2",
+      "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.57.2.tgz",
+      "integrity": "sha512-ER4gkYZasrd2Zwmt/yLeZ5G/nZJ6tpaYBCpx3CvocDx+3F16WdawJlYMT0IyLKHXDniC5+AsjzFd6fi8uyYlJA==",
       "dependencies": {
-        "@sentry/types": "7.22.0",
         "fflate": "^0.4.1",
         "rrweb-snapshot": "^1.1.14"
       }
diff --git a/package.json b/package.json
index 7f9d94b8..e0c05717 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
   "name": "@open-sauced/insights",
   "description": "🍕The dashboard for open source discovery.",
   "keywords": [],
-  "version": "1.47.0",
+  "version": "1.48.0-beta.7",
   "author": "TED Vortex <ted.vortex@gmail.com>",
   "private": true,
   "license": "MIT",
@@ -31,7 +31,7 @@
     "prebuild": "node generate-netlify-env.js",
     "build": "next build",
     "start": "next start",
-    "test": "npm run test:jest && npm run test:e2e",
+    "test": "npm run test:jest",
     "test:jest": "jest",
     "test:playwright": "npx playwright",
     "pretest:e2e": "npm run test:e2e-install",
@@ -78,7 +78,7 @@
     "echarts-for-react": "^3.0.2",
     "next": "^13.1.6",
     "octokit": "^2.0.14",
-    "posthog-js": "^1.42.3",
+    "posthog-js": "^1.57.2",
     "react": "^18.2.0",
     "react-csv": "^2.2.2",
     "react-day-picker": "^8.7.1",
diff --git a/pages/_app.tsx b/pages/_app.tsx
index 333c656d..05d3eb72 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -9,7 +9,6 @@ import type { AppProps } from "next/app";
 import { useRouter } from "next/router";
 import { SessionContextProvider } from "@supabase/auth-helpers-react";
 import { SWRConfig } from "swr";
-import posthog from "posthog-js";
 
 import { TipProvider } from "components/atoms/Tooltip/tooltip";
 
@@ -22,6 +21,20 @@ import { Toaster } from "components/molecules/Toaster/toaster";
 import Script from "next/script";
 import useSession from "lib/hooks/useSession";
 
+import posthog from "posthog-js";
+import { PostHogProvider } from "posthog-js/react";
+
+// Check that PostHog is client-side (used to handle Next.js SSR)
+if (typeof window !== "undefined") {
+  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_ID || "", {
+    api_host: "https://app.posthog.com",
+    // Enable debug mode in development
+    loaded: (posthog) => {
+      if (process.env.NODE_ENV === "development") posthog.debug();
+    }
+  });
+}
+
 type ComponentWithPageLayout = AppProps & {
   Component: AppProps["Component"] & {
     PageLayout?: React.ComponentType<any>;
@@ -52,6 +65,16 @@ function MyApp({ Component, pageProps }: ComponentWithPageLayout) {
     };
   }, [router.events]);
 
+  useEffect(() => {
+    // Track page views
+    const handleRouteChange = () => posthog?.capture("$pageview");
+    router.events.on("routeChangeComplete", handleRouteChange);
+
+    return () => {
+      router.events.off("routeChangeComplete", handleRouteChange);
+    };
+  }, []);
+
   const { filterName, toolName } = router.query;
 
   function localStorageProvider() {
@@ -103,20 +126,20 @@ function MyApp({ Component, pageProps }: ComponentWithPageLayout) {
         {/* <Toaster position="top-center" /> */}
         <Toaster />
         <SessionContextProvider supabaseClient={supabase} initialSession={pageProps.initialSession}>
-          <TipProvider>
-            {Component.PageLayout ? (
-              <Component.PageLayout>
+          <PostHogProvider client={posthog}>
+            <TipProvider>
+              {Component.PageLayout ? (
+                <Component.PageLayout>
+                  <Component {...pageProps} />
+                </Component.PageLayout>
+              ) : (
                 <Component {...pageProps} />
-              </Component.PageLayout>
-            ) : (
-              <Component {...pageProps} />
-            )}
-          </TipProvider>
-          <Script
-            id="ze-snippet"
-            src="https://static.zdassets.com/ekr/snippet.js?key=765edcc9-b888-4651-8b22-79e4365e06f1"
-            strategy="afterInteractive"
-          />
+              )}
+            </TipProvider>
+            <Script id="siteGPT" type="text/javascript">
+              {"d=document;s=d.createElement(\"script\");s.src=\"https://sitegpt.ai/widget/365440930125185604.js\";s.async=1;d.getElementsByTagName(\"head\")[0].appendChild(s);"}
+            </Script>
+          </PostHogProvider>
         </SessionContextProvider>
       </SWRConfig>
     </>
diff --git a/pages/feed/[id].tsx b/pages/feed/[id].tsx
index 8bcd34e5..70078f85 100644
--- a/pages/feed/[id].tsx
+++ b/pages/feed/[id].tsx
@@ -1,2 +1,48 @@
+import { GetServerSidePropsContext } from "next";
+
 import Feed from "../feed";
+
 export default Feed;
+
+export const getServerSideProps = async (context: HighlightSSRPropsContext) => {
+  return await handleHighlightSSR(context);
+};
+
+export type HighlightSSRPropsContext = GetServerSidePropsContext<{ id: string }>;
+
+export async function handleHighlightSSR({ params }: GetServerSidePropsContext<{ id: string }>) {
+  const { id } = params!;
+  let highlight: DbHighlight | null = null;
+  let ogImage;
+
+  async function fetchUserData() {
+    const req = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user/highlights/${id}`, {
+      headers: {
+        accept: "application/json",
+      },
+    });
+
+    if (req.ok) {
+      highlight = (await req.json()) as DbHighlight;
+    }
+  }
+
+  async function fetchSocialCardURL() {
+    const socialCardUrl = `${String(process.env.NEXT_PUBLIC_OPENGRAPH_URL ?? "")}/highlights/${id}`;
+    const ogReq = await fetch(`${socialCardUrl}/metadata`); //status returned: 204 or 304 or 404
+    if (ogReq.status !== 204) {
+      fetch(socialCardUrl, {
+        method: "HEAD",
+      }); // trigger the generation of the social card
+    }
+
+    ogImage = ogReq.headers.get("x-amz-meta-location");
+  }
+
+  // Runs the data fetching in parallel. Decreases the loading time by 50%.
+  await Promise.allSettled([fetchUserData(), fetchSocialCardURL()]);
+
+  return {
+    props: { highlight, ogImage },
+  };
+}
diff --git a/pages/feed/index.tsx b/pages/feed/index.tsx
index 03cb685e..da39d4b0 100644
--- a/pages/feed/index.tsx
+++ b/pages/feed/index.tsx
@@ -1,49 +1,55 @@
-import { useEffect, useState, useRef } from "react";
+import { useEffect, useState } from "react";
 import { useRouter } from "next/router";
-import Link from "next/link";
 import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
+import clsx from "clsx";
 
 import useSupabaseAuth from "lib/hooks/useSupabaseAuth";
 import { useFetchAllHighlights } from "lib/hooks/useFetchAllHighlights";
 import { useFetchHighlightRepos } from "lib/hooks/useFetchHiglightRepos";
-import { useFetchSingleHighlight } from "lib/hooks/useFetchSingleHighlight";
+import useFetchAllEmojis from "lib/hooks/useFetchAllEmojis";
+import { useFetchFollowersHighlightRepos } from "lib/hooks/useFetchFollowingHighlightRepos";
+
+import { WithPageLayout } from "interfaces/with-page-layout";
+import ProfileLayout from "layouts/profile";
+import SEO from "layouts/SEO/SEO";
 
-import SkeletonWrapper from "components/atoms/SkeletonLoader/skeleton-wrapper";
 import { Dialog, DialogCloseButton, DialogContent } from "components/molecules/Dialog/dialog";
 import Avatar from "components/atoms/Avatar/avatar";
 import { Tabs, TabsContent, TabsList, TabsTrigger } from "components/atoms/Tabs/tabs";
 import ContributorHighlightCard from "components/molecules/ContributorHighlight/contributor-highlight-card";
 import HighlightInputForm from "components/molecules/HighlightInput/highlight-input-form";
 import HighlightsFilterCard from "components/molecules/HighlightsFeedCard/highlights-filter-card";
-import ProfileLayout from "layouts/profile";
-import { WithPageLayout } from "interfaces/with-page-layout";
 import Pagination from "components/molecules/Pagination/pagination";
 import PaginationResults from "components/molecules/PaginationResults/pagination-result";
-import useFetchAllEmojis from "lib/hooks/useFetchAllEmojis";
-import clsx from "clsx";
 import FollowingHighlightWrapper from "components/organisms/FollowersHighlightWrapper/following-highlight-wrapper";
-import { useFetchFollowersHighlightRepos } from "lib/hooks/useFetchFollowingHighlightRepos";
 import HomeHighlightsWrapper from "components/organisms/HomeHighlightsWrapper/home-highlights-wrapper";
 
 type activeTabType = "home" | "following";
 type highlightReposType = { repoName: string; repoIcon: string; full_name: string };
-const Feeds: WithPageLayout = () => {
+
+interface HighlightSSRProps {
+  highlight: DbHighlight | null;
+  ogImage?: string;
+}
+
+const Feeds: WithPageLayout<HighlightSSRProps> = (props: HighlightSSRProps) => {
   const { user } = useSupabaseAuth();
+  const { data: repos } = useFetchHighlightRepos();
+
   const router = useRouter();
   const [hydrated, setHydrated] = useState(false);
   const [openSingleHighlight, setOpenSingleHighlight] = useState(false);
   const [selectedRepo, setSelectedRepo] = useState("");
   const [activeTab, setActiveTab] = useState<activeTabType>("home");
-  const [repoList, setRepoList] = useState<highlightReposType[]>();
+  const [repoList, setRepoList] = useState<highlightReposType[]>(repos as highlightReposType[]);
 
   const { id } = router.query;
-  const highlightId = id as string;
+  const singleHighlight = props.highlight;
+  const highlightId = props.highlight?.id as string;
 
-  const { data: repos } = useFetchHighlightRepos();
   const { data: followersRepo } = useFetchFollowersHighlightRepos();
 
   const { data, mutate, setPage, isError, isLoading, meta } = useFetchAllHighlights(selectedRepo);
-  const { data: singleHighlight } = useFetchSingleHighlight(id as unknown as number);
   const { data: emojis } = useFetchAllEmojis();
 
   const repoTofilterList = (repos: { full_name: string }[]): highlightReposType[] => {
@@ -57,12 +63,12 @@ const Feeds: WithPageLayout = () => {
 
   useEffect(() => {
     setSelectedRepo("");
-    if (activeTab === "home" && repos) {
+    if (activeTab === "home") {
       setRepoList(repoTofilterList(repos));
-    } else if (activeTab === "following" && followersRepo) {
+    } else if (activeTab === "following") {
       setRepoList(repoTofilterList(followersRepo));
     }
-  }, [activeTab]);
+  }, [activeTab, followersRepo, repoList, repos]);
 
   useEffect(() => {
     if (selectedRepo) {
@@ -84,123 +90,141 @@ const Feeds: WithPageLayout = () => {
     setHydrated(true);
   }, []);
 
-  if (!hydrated) return <></>;
+  if (!hydrated)
+    return (
+      <>
+        <SEO
+          title={`${props?.highlight ? "Highlight | OpenSauced" : "Highlights | OpenSauced"}`}
+          description={`${props?.highlight?.highlight || "OpenSauced Highlight"}`}
+          image={props?.ogImage}
+          twitterCard="summary_large_image"
+        />
+      </>
+    );
 
   return (
-    <div className="container flex flex-col gap-12 px-2 pt-12 mx-auto md:px-16 lg:justify-end md:flex-row">
-      {singleHighlight && (
-        <Dialog
-          open={openSingleHighlight}
-          onOpenChange={(open) => {
-            if (!open) {
-              router.push("/feed");
-            }
-          }}
-        >
-          <DialogContent className=" sm:max-w-[80%] w-full  sm:max-h-screen ">
-            <div className="flex flex-col gap-8 mx-auto mt-10">
-              <div className="flex flex-col gap-6 px-3 ">
-                <div className="flex items-center gap-3 ">
-                  <Avatar
-                    alt="user profile avatar"
-                    isCircle
-                    size="sm"
-                    avatarURL={`https://www.github.com/${singleHighlight.login}.png?size=300`}
-                  />
-                  <strong>{singleHighlight.login}</strong>
-                  <span className="text-xs font-normal text-light-slate-11">
-                    {formatDistanceToNowStrict(new Date(singleHighlight.created_at), {
-                      addSuffix: true,
-                    })}
-                  </span>
-                  <DialogCloseButton onClick={() => router.push("/feed")} />
-                </div>
-
-                <div className="w-full px-2 py-6 border bg-light-slate-1 md:px-6 lg:px-12 rounded-xl">
-                  <ContributorHighlightCard
-                    emojis={emojis}
-                    title={singleHighlight.title}
-                    desc={singleHighlight.highlight}
-                    prLink={singleHighlight.url}
-                    user={singleHighlight.login}
-                    id={singleHighlight.id}
-                    shipped_date={singleHighlight.shipped_at}
-                    refreshCallBack={mutate}
-                  />
+    <>
+      <SEO
+        title={`${props?.highlight ? "Highlight | OpenSauced" : "Highlights | OpenSauced"}`}
+        description={`${props?.highlight?.highlight || "OpenSauced Highlight"}`}
+        image={props?.ogImage}
+        twitterCard="summary_large_image"
+      />
+      <div className="container flex flex-col gap-12 px-2 pt-12 mx-auto md:px-16 lg:justify-end md:flex-row">
+        {singleHighlight && (
+          <Dialog
+            open={openSingleHighlight}
+            onOpenChange={(open) => {
+              if (!open) {
+                router.push("/feed");
+              }
+            }}
+          >
+            <DialogContent className=" sm:max-w-[80%] w-full  sm:max-h-screen ">
+              <div className="flex flex-col gap-8 mx-auto mt-10">
+                <div className="flex flex-col gap-6 px-3 ">
+                  <div className="flex items-center gap-3 ">
+                    <Avatar
+                      alt="user profile avatar"
+                      isCircle
+                      size="sm"
+                      avatarURL={`https://www.github.com/${singleHighlight.login}.png?size=300`}
+                    />
+                    <strong>{singleHighlight.login}</strong>
+                    <span className="text-xs font-normal text-light-slate-11">
+                      {formatDistanceToNowStrict(new Date(singleHighlight.created_at), {
+                        addSuffix: true,
+                      })}
+                    </span>
+                    <DialogCloseButton onClick={() => router.push("/feed")} />
+                  </div>
+
+                  <div className="w-full px-2 py-6 border bg-light-slate-1 md:px-6 lg:px-12 rounded-xl">
+                    <ContributorHighlightCard
+                      emojis={emojis}
+                      title={singleHighlight.title}
+                      desc={singleHighlight.highlight}
+                      prLink={singleHighlight.url}
+                      user={singleHighlight.login}
+                      id={singleHighlight.id}
+                      shipped_date={singleHighlight.shipped_at}
+                      refreshCallBack={mutate}
+                    />
+                  </div>
                 </div>
               </div>
-            </div>
-          </DialogContent>
-        </Dialog>
-      )}
-      <Tabs
-        onValueChange={(value) => {
-          setActiveTab(value as activeTabType);
-        }}
-        defaultValue="home"
-        className="flex-1 lg:pl-[21.875rem]"
-      >
-        <TabsList className={clsx("justify-start  w-full border-b", !user && "hidden")}>
-          <TabsTrigger
-            className="data-[state=active]:border-sauced-orange data-[state=active]:border-b-2 text-2xl"
-            value="home"
-          >
-            Home
-          </TabsTrigger>
-          <TabsTrigger
-            className="data-[state=active]:border-sauced-orange  data-[state=active]:border-b-2 text-2xl"
-            value="following"
-          >
-            Following
-          </TabsTrigger>
-        </TabsList>
-
-        {user && (
-          <div className="lg:gap-x-3 px-3 pt-4 flex max-w-[48rem]">
-            <div className="hidden lg:inline-flex pt-[0.4rem]">
-              <Avatar
-                alt="user profile avatar"
-                isCircle
-                size="sm"
-                avatarURL={`https://www.github.com/${user?.user_metadata.user_name}.png?size=300`}
-              />
-            </div>
-
-            <HighlightInputForm refreshCallback={mutate} />
-          </div>
+            </DialogContent>
+          </Dialog>
         )}
-        <TabsContent value="home">
-          <HomeHighlightsWrapper emojis={emojis} mutate={mutate} highlights={data} loading={isLoading} />
-          {meta.pageCount > 1 && (
-            <div className="mt-10 max-w-[48rem] flex px-2 items-center justify-between">
-              <div>
-                <PaginationResults metaInfo={meta} total={meta.itemCount} entity={"highlights"} />
+        <Tabs
+          onValueChange={(value) => {
+            setActiveTab(value as activeTabType);
+          }}
+          defaultValue="home"
+          className="flex-1 lg:pl-[21.875rem]"
+        >
+          <TabsList className={clsx("justify-start  w-full border-b", !user && "hidden")}>
+            <TabsTrigger
+              className="data-[state=active]:border-sauced-orange data-[state=active]:border-b-2 text-2xl"
+              value="home"
+            >
+              Home
+            </TabsTrigger>
+            <TabsTrigger
+              className="data-[state=active]:border-sauced-orange  data-[state=active]:border-b-2 text-2xl"
+              value="following"
+            >
+              Following
+            </TabsTrigger>
+          </TabsList>
+
+          {user && (
+            <div className="lg:gap-x-3 px-3 pt-4 flex max-w-[48rem]">
+              <div className="hidden lg:inline-flex pt-[0.4rem]">
+                <Avatar
+                  alt="user profile avatar"
+                  isCircle
+                  size="sm"
+                  avatarURL={`https://www.github.com/${user?.user_metadata.user_name}.png?size=300`}
+                />
               </div>
-              <Pagination
-                pages={[]}
-                totalPage={meta.pageCount}
-                page={meta.page}
-                pageSize={meta.itemCount}
-                goToPage
-                hasNextPage={meta.hasNextPage}
-                hasPreviousPage={meta.hasPreviousPage}
-                onPageChange={function (page: number): void {
-                  setPage(page);
-                }}
-              />
+
+              <HighlightInputForm refreshCallback={mutate} />
             </div>
           )}
-        </TabsContent>
-        <TabsContent value="following">
-          <FollowingHighlightWrapper selectedFilter={selectedRepo} emojis={emojis} />
-        </TabsContent>
-      </Tabs>
-      <div className="hidden mt-10 md:block">
-        {repoList && repoList.length > 0 && (
-          <HighlightsFilterCard selectedFilter={selectedRepo} setSelected={setSelectedRepo} repos={repoList} />
-        )}
+          <TabsContent value="home">
+            <HomeHighlightsWrapper emojis={emojis} mutate={mutate} highlights={data} loading={isLoading} />
+            {meta.pageCount > 1 && (
+              <div className="mt-10 max-w-[48rem] flex px-2 items-center justify-between">
+                <div>
+                  <PaginationResults metaInfo={meta} total={meta.itemCount} entity={"highlights"} />
+                </div>
+                <Pagination
+                  pages={[]}
+                  totalPage={meta.pageCount}
+                  page={meta.page}
+                  pageSize={meta.itemCount}
+                  goToPage
+                  hasNextPage={meta.hasNextPage}
+                  hasPreviousPage={meta.hasPreviousPage}
+                  onPageChange={function (page: number): void {
+                    setPage(page);
+                  }}
+                />
+              </div>
+            )}
+          </TabsContent>
+          <TabsContent value="following">
+            <FollowingHighlightWrapper selectedFilter={selectedRepo} emojis={emojis} />
+          </TabsContent>
+        </Tabs>
+        <div className="hidden mt-10 md:block">
+          {repoList && repoList.length > 0 && (
+            <HighlightsFilterCard selectedFilter={selectedRepo} setSelected={setSelectedRepo} repos={repoList} />
+          )}
+        </div>
       </div>
-    </div>
+    </>
   );
 };
 
diff --git a/pages/user/[username]/index.tsx b/pages/user/[username]/index.tsx
index d5049f3b..93b17e6e 100644
--- a/pages/user/[username]/index.tsx
+++ b/pages/user/[username]/index.tsx
@@ -14,6 +14,7 @@ import { useFetchUser } from "lib/hooks/useFetchUser";
 import useRepoList from "lib/hooks/useRepoList";
 import { getAvatarByUsername } from "lib/utils/github";
 import useContributorLanguages from "lib/hooks/api/useContributorLanguages";
+import getContributorPullRequestVelocity from "lib/utils/get-contributor-pr-velocity";
 
 // A quick fix to the hydration issue. Should be replaced with a real solution.
 // Slows down the page's initial client rendering as the component won't be loaded on the server.
@@ -37,6 +38,7 @@ const Contributor: WithPageLayout<ContributorSSRProps> = ({ username, user, ogIm
   const mergedPrs = contributorPRData.filter((prData) => prData.merged);
   const contributorLanguageList = useContributorLanguages(username);
   const githubAvatar = getAvatarByUsername(username, 300);
+  const prVelocity = getContributorPullRequestVelocity(contributorPRData);
 
   return (
     <>
@@ -78,6 +80,7 @@ const Contributor: WithPageLayout<ContributorSSRProps> = ({ username, user, ogIm
           openPrs={contributorPRData.length}
           recentContributionCount={repoList.length}
           prFirstOpenedDate={contributor?.first_opened_pr_at}
+          prVelocity={prVelocity}
         />
       </div>
     </>


Test patch:
diff --git a/tests/lib/utils/get-contributor-pr-velocity.test.ts b/tests/lib/utils/get-contributor-pr-velocity.test.ts
new file mode 100644
index 00000000..607ba4bd
--- /dev/null
+++ b/tests/lib/utils/get-contributor-pr-velocity.test.ts
@@ -0,0 +1,308 @@
+import getContributorPullRequestVelocity from "lib/utils/get-contributor-pr-velocity";
+
+const data = [
+  {
+    id: 1361496755,
+    repo_id: 501028599,
+    number: 1213,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "ci: move unit and e2e tests into separate jobs",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:ci-e2e-tests",
+    target_branch: "open-sauced:beta",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-23T14:08:34.000Z",
+    closed_at: "2023-05-23T14:16:07.000Z",
+    merged_at: "2023-05-23T14:16:07.000Z",
+    updated_at: "2023-05-23T14:21:44.000Z",
+    last_updated_at: "2023-05-23T16:02:14.554Z",
+    comments: 3,
+    additions: 48,
+    deletions: 3,
+    changed_files: 3,
+    full_name: "open-sauced/insights",
+  },
+  {
+    id: 1357648118,
+    repo_id: 499330806,
+    number: 170,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "feat: add endpoints for managing user collaborations",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:feat-user-collaborations",
+    target_branch: "open-sauced:beta",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-19T20:40:52.000Z",
+    closed_at: "2023-05-22T14:03:26.000Z",
+    merged_at: "2023-05-22T14:03:26.000Z",
+    updated_at: "2023-05-23T13:18:53.000Z",
+    last_updated_at: "2023-05-23T14:16:03.974Z",
+    comments: 2,
+    additions: 409,
+    deletions: 2,
+    changed_files: 9,
+    full_name: "open-sauced/api.opensauced.pizza",
+  },
+  {
+    id: 1361394082,
+    repo_id: 499330806,
+    number: 172,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "v1.36.0-beta.2 -> production",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:beta",
+    target_branch: "open-sauced:main",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-23T13:06:52.000Z",
+    closed_at: "2023-05-23T13:12:15.000Z",
+    merged_at: "2023-05-23T13:12:15.000Z",
+    updated_at: "2023-05-23T13:18:47.000Z",
+    last_updated_at: "2023-05-23T14:16:03.974Z",
+    comments: 1,
+    additions: 578,
+    deletions: 8,
+    changed_files: 17,
+    full_name: "open-sauced/api.opensauced.pizza",
+  },
+  {
+    id: 1360542042,
+    repo_id: 511199000,
+    number: 415,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "fix(content): add markdown route component for content routes",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:fix-render-markdown",
+    target_branch: "analogjs:main",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-23T03:43:52.000Z",
+    closed_at: "2023-05-23T03:56:46.000Z",
+    merged_at: "2023-05-23T03:56:46.000Z",
+    updated_at: "2023-05-23T03:56:47.000Z",
+    last_updated_at: "2023-05-23T16:01:48.550Z",
+    comments: 3,
+    additions: 56,
+    deletions: 3,
+    changed_files: 3,
+    full_name: "analogjs/analog",
+  },
+  {
+    id: 1360501488,
+    repo_id: 511199000,
+    number: 414,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "refactor: remove Angular libs from forced optimization",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:refactor-optimize-ng-deps",
+    target_branch: "analogjs:main",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-23T02:57:53.000Z",
+    closed_at: "2023-05-23T03:35:48.000Z",
+    merged_at: "2023-05-23T03:35:48.000Z",
+    updated_at: "2023-05-23T03:35:49.000Z",
+    last_updated_at: "2023-05-23T16:01:48.550Z",
+    comments: 3,
+    additions: 5,
+    deletions: 16,
+    changed_files: 2,
+    full_name: "analogjs/analog",
+  },
+  {
+    id: 1357276864,
+    repo_id: 501028599,
+    number: 1205,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "feat: add OpenGraph image for individual highlights",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:feat-highlight-social-card",
+    target_branch: "open-sauced:beta",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-19T14:56:59.000Z",
+    closed_at: "2023-05-19T15:57:48.000Z",
+    merged_at: "2023-05-19T15:57:48.000Z",
+    updated_at: "2023-05-19T16:02:39.000Z",
+    last_updated_at: "2023-05-23T16:02:14.554Z",
+    comments: 3,
+    additions: 189,
+    deletions: 120,
+    changed_files: 2,
+    full_name: "open-sauced/insights",
+  },
+  {
+    id: 1181031673,
+    repo_id: 511199000,
+    number: 192,
+    state: "OPEN",
+    draft: false,
+    merged: false,
+    mergeable: false,
+    rebaseable: false,
+    title: "build: migrate to pnpm",
+    source_label: null,
+    target_label: null,
+    source_branch: "brandonroberts:vite-3-ci",
+    target_branch: "analogjs:main",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2022-12-30T16:38:13.000Z",
+    closed_at: null,
+    merged_at: null,
+    updated_at: "2023-05-19T14:51:57.000Z",
+    last_updated_at: "2023-05-23T16:01:48.550Z",
+    comments: 5,
+    additions: 22780,
+    deletions: 20001,
+    changed_files: 6,
+    full_name: "analogjs/analog",
+  },
+  {
+    id: 1356454399,
+    repo_id: 511199000,
+    number: 402,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "feat(vite-plugin-nitro): add BUILD_PRESET as a server preset alias",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:feat-build-preset",
+    target_branch: "analogjs:main",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-19T01:05:53.000Z",
+    closed_at: "2023-05-19T01:44:51.000Z",
+    merged_at: "2023-05-19T01:44:51.000Z",
+    updated_at: "2023-05-19T01:44:53.000Z",
+    last_updated_at: "2023-05-23T16:01:48.550Z",
+    comments: 3,
+    additions: 6,
+    deletions: 5,
+    changed_files: 3,
+    full_name: "analogjs/analog",
+  },
+  {
+    id: 1315260200,
+    repo_id: 37953959,
+    number: 3093,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "fix(@nguniversal/express-engine): export default bootstrap in server template for standalone apps",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:fix-standalone-prerender",
+    target_branch: "angular:main",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-04-15T21:55:37.000Z",
+    closed_at: "2023-04-18T12:22:18.000Z",
+    merged_at: "2023-04-18T12:22:18.000Z",
+    updated_at: "2023-05-19T00:09:53.000Z",
+    last_updated_at: "2023-05-23T16:04:14.864Z",
+    comments: 1,
+    additions: 1,
+    deletions: 1,
+    changed_files: 1,
+    full_name: "angular/universal",
+  },
+  {
+    id: 1356191185,
+    repo_id: 499330806,
+    number: 168,
+    state: "MERGED",
+    draft: false,
+    merged: true,
+    mergeable: false,
+    rebaseable: false,
+    title: "feat: add endpoint to generate pull request description",
+    source_label: "",
+    target_label: "",
+    source_branch: "brandonroberts:feat-generate-pr-description",
+    target_branch: "open-sauced:beta",
+    author_login: "brandonroberts",
+    author_avatar: "https://avatars.githubusercontent.com/u/42211?u=32763fc85100fa45c5aa5d4bdddc4344e53d0173&v=4",
+    assignee_login: "",
+    assignee_avatar: "",
+    created_at: "2023-05-18T19:40:00.000Z",
+    closed_at: "2023-05-18T20:01:40.000Z",
+    merged_at: "2023-05-18T20:01:40.000Z",
+    updated_at: "2023-05-18T20:17:52.000Z",
+    last_updated_at: "2023-05-23T14:16:03.974Z",
+    comments: 3,
+    additions: 184,
+    deletions: 3,
+    changed_files: 7,
+    full_name: "open-sauced/api.opensauced.pizza",
+  },
+] as DbRepoPR[];
+
+describe("[lib] getContributorPullRequestVelocity()", () => {
+  it("should return the average velocity", () => {
+    const result = getContributorPullRequestVelocity(data);
+
+    expect(result).toEqual(1);
+  });
+
+  it("should return undefined if no records are provided", () => {
+    const result = getContributorPullRequestVelocity([]);
+
+    expect(result).toBeUndefined();
+  });
+});