diff --git a/e2e/user-profile.spec.ts b/e2e/user-profile.spec.ts index ec554545c..2154050d2 100644 --- a/e2e/user-profile.spec.ts +++ b/e2e/user-profile.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; -import playwrightConfig from "../playwright.config"; +import config from "../playwright.config"; -const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? playwrightConfig.use?.baseURL ?? "http://localhost:3000"; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? config.use?.baseURL ?? "http://localhost:3000"; test("Loads user profile page", async ({ page }) => { await page.goto("/u/bdougie"); @@ -10,6 +10,13 @@ test("Loads user profile page", async ({ page }) => { await expect(page.getByRole("heading", { name: "bdougie", exact: true })).toBeVisible(); + // Check for the OG image + const expectedUrl = `${config.use?.baseURL}/og-images/dev-card?username=bdougie`; + + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute("content", expectedUrl); + await expect(page.locator('meta[name="twitter:image"]')).toHaveAttribute("content", expectedUrl); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute("content", "summary_large_image"); + // Check for login button for viewing OSCR await page .getByRole("button", { name: "Log in to view Open Source Contributor Rating (OSCR)", exact: true }) @@ -18,6 +25,19 @@ test("Loads user profile page", async ({ page }) => { await expect(page.url()).toContain("https://github.com/login"); }); +test("Loads user dev card page", async ({ page }) => { + await page.goto("/u/bdougie/card"); + + expect(await page.title()).toBe("bdougie | OpenSauced"); + + // Check for the OG image + const expectedUrl = `${config.use?.baseURL}/og-images/dev-card?username=bdougie`; + + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute("content", expectedUrl); + await expect(page.locator('meta[name="twitter:image"]')).toHaveAttribute("content", expectedUrl); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute("content", "summary_large_image"); +}); + test("Redirects to user profile page", async ({ page }) => { await page.goto("/user/bdougie"); diff --git a/lib/utils/urls.ts b/lib/utils/urls.ts index 4ef840899..4234f2d34 100644 --- a/lib/utils/urls.ts +++ b/lib/utils/urls.ts @@ -29,9 +29,6 @@ export const isValidUrl = (url: string) => { */ export const cardPageUrl = (username: string) => siteUrl(`user/${username}/card`); -export const cardImageUrl = (username: string, opts: { size?: string } = {}) => - siteUrl(`api/card`, { username, ...opts }); - export const twitterCardShareUrl = (username: string) => { const url = new URL("https://twitter.com/intent/tweet"); url.searchParams.append("text", "Check out my open-source contributions card!"); diff --git a/netlify/edge-functions/dev-card.tsx b/netlify/edge-functions/dev-card.tsx new file mode 100644 index 000000000..e8aec9b6b --- /dev/null +++ b/netlify/edge-functions/dev-card.tsx @@ -0,0 +1,381 @@ +import React from "react"; +import { ImageResponse } from "og_edge"; +import type { Config } from "https://edge.netlify.com"; +import { getLocalAsset } from "../og-image-utils.ts"; + +const MAX_ABOUT_LENGTH = 77; +const baseApiUrl = Deno.env.get("NEXT_PUBLIC_API_URL"); + +function getTopPercent(oscr: number) { + switch (true) { + case oscr >= 250: + return 1; + case oscr >= 235: + return 2; + case oscr >= 225: + return 3; + case oscr >= 215: + return 4; + case oscr >= 200: + return 5; + default: + return ""; + } +} +function differenceInDays(dateLeft, dateRight) { + // Convert both dates to milliseconds + const dateLeftMs = dateLeft.getTime(); + const dateRightMs = dateRight.getTime(); + + // Calculate the difference in milliseconds + const differenceMs = Math.abs(dateLeftMs - dateRightMs); + + // Convert the difference to days + const days = Math.floor(differenceMs / (1000 * 60 * 60 * 24)); + + return days; +} + +const getContributorPullRequestVelocity = (repositoryPullRequests: any[]) => { + const mergedPRs = repositoryPullRequests.filter((prState) => prState.pr_is_merged); + + const totalDays = mergedPRs.reduce((total, event) => { + const daysBetween = differenceInDays(new Date(event.pr_closed_at), new Date(event.pr_created_at)); + return (total += daysBetween); + }, 0); + + const averageVelocity = mergedPRs.length > 0 ? totalDays / mergedPRs.length : undefined; + + if (averageVelocity && averageVelocity < 1) { + return 1; + } + + return averageVelocity ? Math.floor(averageVelocity) : averageVelocity; +}; + +const getPullRequestsHistogramToDays = (pull_requests: any[], range = 30) => { + const graphDays = pull_requests.reduce((days: { [name: string]: number }, curr: any) => { + const day = differenceInDays(new Date(), new Date(curr.bucket)); + + if (days[day]) { + days[day] += curr.prs_count; + } else { + days[day] = curr.prs_count; + } + + return days; + }, {}); + + const days: any[] = []; + + for (let d = range; d >= 0; d--) { + days.push({ x: d, y: graphDays[d] || 0 }); + } + + return days; +}; + +const CrownIcon = () => { + return ( + + + + ); +}; + +const GlobeIcon = () => { + return ( + + + + ); +}; + +const ArrowTrendingUpIcon = ({ color }: { color: string }) => { + return ( + + ); +}; + +const MinusSmallIcon = ({ color }: { color: string }) => { + return ( + + + + ); +}; + +const ArrowTrendingDownIcon = ({ color }: { color: string }) => { + return ( + + + + ); +}; + +export default async function handler(req: Request) { + const { searchParams } = new URL(req.url); + const username = searchParams.get("username"); + + const [interSemiBoldFontData, userResponse, prHistorgramRequest, contributorPrDataRequest] = await Promise.all([ + getLocalAsset(new URL("/assets/card/Inter-SemiBold.ttf", req.url)), + fetch(`${baseApiUrl}/users/${username}`, { + headers: { + accept: "application/json", + }, + }), + fetch(`${baseApiUrl}/histogram/pull-requests?contributor=${username}&orderDirection=ASC&range=30&width=1`, { + headers: { + accept: "application/json", + }, + }), + fetch(`${baseApiUrl}/users/${username}/prs?limit=50&range=30`, { + headers: { + accept: "application/json", + }, + }), + ]); + + const prHistogramData = await prHistorgramRequest.json(); + const { data: contributorPRData } = await contributorPrDataRequest.json(); + const chartData: any[] = getPullRequestsHistogramToDays(prHistogramData, 30); + const openedPrs = chartData.reduce((total, curr) => total + curr.y, 0); + + const userData = await userResponse.json(); + const { oscr: rawOscr, devstats_updated_at, bio } = userData; + const about: string = bio ?? ""; + const oscr = devstats_updated_at !== "1970-01-01 00:00:00+00Z" ? Math.ceil(rawOscr) : "-"; + const prVelocity = getContributorPullRequestVelocity(contributorPRData); // e.g. 13d + const activityText = openedPrs > 4 ? "high" : "mid"; + const activityBgColor = activityText === "high" ? "#dff3df" : activityText === "mid" ? "#fde68a" : "#f1f3f5"; + const activityTextColor = activityText === "high" ? "#297c3b" : activityText === "mid" ? "#b45309" : "#687076"; + + const topPercent = oscr === "-" ? "" : getTopPercent(oscr); + + return new ImageResponse( + ( +
+ + + {username} + + {about.length > MAX_ABOUT_LENGTH ? about.slice(0, MAX_ABOUT_LENGTH).trim() + "..." : bio} + + + {oscr} + + {topPercent === "" ? null : ( + + {topPercent < 4 ? : } + In the top {topPercent}% + + )} + + {openedPrs} + + + {prVelocity}d + +
+
+ {activityText === "high" ? : null} + {activityText === "mid" ? : null} + {activityText === "low" ? : null} + {activityText} +
+
+
+ ), + { + width: "1200px", + height: "630px", + headers: { + // cache for 2 hours + "Cache-Control": "public, max-age=0, stale-while-revalidate", + "Netlify-CDN-Cache-Control": "public, max-age=0, stale-while-revalidate=7200", + "Netlify-Vary": "query=username", + "content-type": "image/png", + }, + fonts: [ + { + name: "Inter", + data: interSemiBoldFontData, + weight: 700, + style: "normal", + }, + ], + } + ); +} + +export const config: Config = { + path: "/og-images/dev-card", + cache: "manual", +}; diff --git a/netlify/edge-functions/workspaces-card.tsx b/netlify/edge-functions/workspaces-card.tsx index f9ae4cef2..e08501af8 100644 --- a/netlify/edge-functions/workspaces-card.tsx +++ b/netlify/edge-functions/workspaces-card.tsx @@ -1,26 +1,10 @@ import React from "react"; import { ImageResponse } from "og_edge"; import type { Config } from "https://edge.netlify.com"; -import { getLocalAsset, getOrgUsernameAvatar, humanizeNumber } from "../og-image-utils.ts"; +import { getLocalAsset, getOrgUsernameAvatar, humanizeNumber, getActivityRatio } from "../og-image-utils.ts"; const baseApiUrl = Deno.env.get("NEXT_PUBLIC_API_URL"); -const getActivityRatio = (total?: number) => { - if (total === undefined) { - return "-"; - } - - if (total > 7) { - return "high"; - } - - if (total >= 4 && total <= 7) { - return "mid"; - } - - return "low"; -}; - export default async function handler(req: Request) { const { searchParams, pathname } = new URL(req.url); const workspaceName = searchParams.get("wname"); diff --git a/netlify/og-image-utils.ts b/netlify/og-image-utils.ts index d23b531b6..c34ac939a 100644 --- a/netlify/og-image-utils.ts +++ b/netlify/og-image-utils.ts @@ -15,3 +15,19 @@ export function getLocalAsset(url: URL): Promise { export function getOrgUsernameAvatar(username: string, size = 25.2) { return `https://www.github.com/${username}.png?size=${size}`; } + +export const getActivityRatio = (total?: number) => { + if (total === undefined) { + return "-"; + } + + if (total > 7) { + return "high"; + } + + if (total >= 4 && total <= 7) { + return "mid"; + } + + return "low"; +}; diff --git a/pages/u/[username]/card.tsx b/pages/u/[username]/card.tsx index e01703110..c16ede84a 100644 --- a/pages/u/[username]/card.tsx +++ b/pages/u/[username]/card.tsx @@ -16,7 +16,7 @@ import { getRepoList } from "lib/hooks/useRepoList"; import { DevCardProps } from "components/molecules/DevCard/dev-card"; import SEO from "layouts/SEO/SEO"; import useSupabaseAuth from "lib/hooks/useSupabaseAuth"; -import { cardImageUrl, linkedinCardShareUrl, twitterCardShareUrl } from "lib/utils/urls"; +import { linkedinCardShareUrl, siteUrl, twitterCardShareUrl } from "lib/utils/urls"; import FullHeightContainer from "components/atoms/FullHeightContainer/full-height-container"; import { isValidUrlSlug } from "lib/utils/url-validators"; import TwitterIcon from "../../../public/twitter-x-logo.svg"; @@ -129,8 +129,6 @@ const Card: NextPage = ({ username, cards }) => { const socialSummary = `${firstCard?.bio || `${username} has connected their GitHub but has not added a bio.`}`; - const ogImage = cardImageUrl(username, { size: "490" }); - /** * for each of the cards we need to load additional data async because it's slow to block page load * to fetch all of them @@ -159,7 +157,7 @@ const Card: NextPage = ({ username, cards }) => {
{ } const userData = (await req.json()) as DbUser; - const ogImage = `${process.env.NEXT_PUBLIC_OPENGRAPH_URL}/users/${username}`; + const ogImage = siteUrl(`og-images/dev-card`, { username }); // Cache page for 60 seconds context.res.setHeader("Cache-Control", "public, max-age=0, must-revalidate"); diff --git a/public/assets/og-images/dev-card-background.png b/public/assets/og-images/dev-card-background.png new file mode 100644 index 000000000..e404b22fc Binary files /dev/null and b/public/assets/og-images/dev-card-background.png differ