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( + ( +