diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc8fe8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + checks: + name: Lint, format, test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Test + run: npm test diff --git a/README.md b/README.md index 6e86132..8117844 100644 --- a/README.md +++ b/README.md @@ -4,46 +4,46 @@ + +CI -**A clean, focused operating dashboard for your GitHub activity.** +**Build turns a GitHub account into an engineering dashboard: 30-day contribution signal, PR and issue flow health, repository momentum and risk, and release cadence.** -[Live Demo](https://github-commit-dashboard.vercel.app) · [Report Bug](https://github.com/brandonperfetti/github-commit-dashboard/issues) +[Live Demo](https://github.brandonperfetti.com) · [Report Bug](https://github.com/brandonperfetti/github-commit-dashboard/issues) ---- +![Build dashboard overview: 30-day contribution trend, language share, and featured repositories](public/og-image.png) -## Overview +## What it shows -GitHub Commit Dashboard transforms raw GitHub API signals into a polished, scannable UI. It surfaces your recent contribution history, top repositories, and language breakdown — without the noise of the standard GitHub profile UI. +| Route | Charts and panels | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/` | 30-day contribution trend · language share · pinned and recently shipped repositories | +| `/activity` | 30-day contribution heatmap with weekly totals and streaks · PR cycle time (median open-to-merge hours) · PR flow health (merge and reopen rates) · issue flow health (opened vs closed, net backlog) · commit timing heatmap (weekday × hour, timezone-aware) · PR throughput · daily breakdown | +| `/repos` | pinned vs non-pinned momentum · weekly commit cadence · repository risk panel (hot / active / stale / dormant) · browsable repository inventory | +| `/featured` | featured relevance score breakdown (pinned boost + stars + recency + 30-day commits) · monthly release cadence · featured project cards | -Built with **Next.js App Router** (server components for real-time data), **Tailwind CSS**, and a dark/light theme system, it's a practical tool for developers who want a cleaner lens into their own productivity. - ---- - -## Features - -- 📊 **Contribution Activity** — Visual heatmap-style contribution feed over the past 30 days -- 🏆 **Featured Repositories** — Top repos surfaced by star count and recent push activity -- 🗂️ **Repository Browser** — Full list of public repos with language, stars, and last-updated indicators -- 🌐 **Language Breakdown** — At-a-glance view of your most-used languages across repositories -- 🌙 **Dark / Light Mode** — System-aware theme toggle via `next-themes` -- ⚡ **Live GitHub Signal** — Server-side data fetching ensures fresh data on every page load +Every chart is a [Recharts](https://recharts.org/) component fed by server-side data shaping in [`lib/github.ts`](lib/github.ts). Data comes from the GitHub REST and GraphQL APIs, fetched in server components and cached with a five-minute revalidation window plus an on-demand revalidation endpoint. It is not real-time: there are no WebSockets or streaming updates. --- ## Tech Stack -| Technology | Purpose | -| --------------------------------------------------------- | -------------------------------------- | -| [Next.js](https://nextjs.org/) (App Router) | React framework with server components | -| [TypeScript](https://www.typescriptlang.org/) | Type safety throughout | -| [Tailwind CSS](https://tailwindcss.com/) | Utility-first styling | -| [next-themes](https://github.com/pacocoursey/next-themes) | Dark/light mode | -| [GitHub REST API](https://docs.github.com/en/rest) | Repository and activity data | -| [Lucide React](https://lucide.dev/) | Icon library | +| Technology | Purpose | +| ------------------------------------------------------------ | ----------------------------------------------------------- | +| [Next.js](https://nextjs.org/) 16 (App Router) | React 19 server components, `"use cache"` route caching | +| [TypeScript](https://www.typescriptlang.org/) | Type safety throughout | +| [Recharts](https://recharts.org/) | Charts, with a theme-aware palette from CSS variables | +| [Tailwind CSS](https://tailwindcss.com/) v4 | Utility-first styling | +| [GSAP](https://gsap.com/) | Headline and scroll-reveal motion (respects reduced motion) | +| [GitHub REST + GraphQL API](https://docs.github.com/en/rest) | Repositories, pinned items, commits, PRs, issues, releases | +| [Lucide React](https://lucide.dev/) | Icon library | +| [Vitest](https://vitest.dev/) | Unit and smoke tests | + +Dark and light mode use a small custom theme provider (cookie plus `localStorage`, system-aware) rather than a library. --- @@ -51,9 +51,9 @@ Built with **Next.js App Router** (server components for real-time data), **Tail ### Prerequisites -- [Node.js](https://nodejs.org/) v18+ +- [Node.js](https://nodejs.org/) v20.9+ (CI runs on 24) - [npm](https://www.npmjs.com/) or compatible package manager -- A [GitHub Personal Access Token](https://github.com/settings/tokens) (classic, with `public_repo` scope) +- A [GitHub token](https://github.com/settings/tokens) (fine-grained with repository read access is preferred; classic with `public_repo` also works) ### Installation @@ -69,7 +69,6 @@ Create a `.env.local` file in the project root: ```env GITHUB_TOKEN=your_github_personal_access_token -GITHUB_USERNAME=your_github_username NEXT_PUBLIC_SITE_URL=http://localhost:3000 REVALIDATE_SECRET=replace_with_a_long_random_secret ``` @@ -77,6 +76,7 @@ REVALIDATE_SECRET=replace_with_a_long_random_secret > **Note:** The `GITHUB_TOKEN` is used server-side only and is never exposed to the client. > **Note:** `NEXT_PUBLIC_SITE_URL` is required for production builds and metadata URLs. > **Note:** `REVALIDATE_SECRET` secures on-demand cache invalidation (`POST /api/revalidate`). +> **Note:** The dashboard's GitHub username is the `USERNAME` constant in `lib/github.ts`; change it there to point the dashboard at another account. ### Development @@ -102,8 +102,16 @@ Notes: - Keep it server-side only (do not prefix with `NEXT_PUBLIC_`). - `ALLOWED_DEV_ORIGINS` accepts a comma-separated list for local device testing (for example, `192.168.1.156,192.168.1.200`). - Fine-grained token with read access to repositories is preferred. -- With a token present, the app uses authenticated `/user/repos` calls and can include private repositories you can access. -- Without a token, it falls back to public `/users/{username}/repos`. +- With a token present, the app uses authenticated `/user/repos` calls and can include private repositories you can access, and it reads your pinned repositories over GraphQL. +- Without a token, it falls back to public `/users/{username}/repos` and skips pinned repositories. + +### Testing + +```bash +npm test +``` + +The suite runs on [Vitest](https://vitest.dev/) with no network access: every GitHub request goes through a fetch router in `tests/helpers`, and any unrouted request fails the test. It covers the data shaping behind the charts (streaks, calendar weeks, weekly and monthly windows, cycle-time medians, merge rates, timezone bucketing, risk buckets), the GitHub client's pagination and fallbacks, the URL and timezone helpers, and a smoke render of the home route from stubbed API responses, including the case where every upstream call fails. `npm run test:watch` keeps it running while you work. ### Production Build @@ -172,6 +180,11 @@ You can also run it manually from Actions and override: npm run lint ``` +### Continuous Integration + +- `CI` (`.github/workflows/ci.yml`) runs lint, the Prettier check, and the test suite on every pull request and on pushes to `main` and `develop`. +- `Env Guard` (`.github/workflows/env-guard.yml`) validates the production environment variables. + --- ## Project Structure @@ -179,14 +192,24 @@ npm run lint ``` github-commit-dashboard/ ├── app/ -│ ├── activity/ # Contribution activity page -│ ├── components/ # Shared UI components +│ ├── activity/ # Contribution, PR/issue flow, and commit timing page +│ ├── api/ +│ │ ├── activity/ # Commit timing heatmap endpoint (timezone query) +│ │ └── revalidate/ # Secret-guarded on-demand cache invalidation +│ ├── components/ +│ │ ├── charts/ # Recharts components + theme-aware color hook +│ │ ├── motion/ # GSAP headline and scroll-reveal wrappers +│ │ └── ui/ # Badge, button, card primitives │ ├── featured/ # Featured repositories page │ ├── repos/ # Repository browser page │ ├── layout.tsx # Root layout with theme provider │ └── page.tsx # Dashboard home ├── lib/ -│ └── github.ts # GitHub API utilities +│ ├── github.ts # GitHub API client and all chart data shaping +│ ├── site-config.ts # Public site URL resolution +│ ├── timezone.ts # IANA timezone validation +│ └── url.ts # Safe http(s) URL normalisation +├── tests/ # Vitest suite (fetch-stubbed, no network) └── public/ # Static assets ``` @@ -194,7 +217,7 @@ github-commit-dashboard/ ## Deployment -This project is optimized for deployment on [Vercel](https://vercel.com/). Add your `GITHUB_TOKEN` and `GITHUB_USERNAME` as environment variables in the Vercel project settings. +This project is optimized for deployment on [Vercel](https://vercel.com/). Add `GITHUB_TOKEN`, `NEXT_PUBLIC_SITE_URL`, and `REVALIDATE_SECRET` as environment variables in the Vercel project settings. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/brandonperfetti/github-commit-dashboard) diff --git a/app/featured/page.tsx b/app/featured/page.tsx index 4cba7d7..762c859 100644 --- a/app/featured/page.tsx +++ b/app/featured/page.tsx @@ -50,7 +50,7 @@ export const metadata: Metadata = { type: "website", images: [ { - url: `${siteUrl}/globe.svg`, + url: `${siteUrl}/og-image.png`, alt: "Build featured project overview", }, ], @@ -59,7 +59,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "Featured · Build", description: featuredDescription, - images: [`${siteUrl}/globe.svg`], + images: [`${siteUrl}/og-image.png`], }, }; diff --git a/app/page.tsx b/app/page.tsx index 687086d..9400588 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -45,7 +45,7 @@ export const metadata: Metadata = { type: "website", images: [ { - url: `${siteUrl}/globe.svg`, + url: `${siteUrl}/og-image.png`, alt: "Build GitHub activity dashboard overview", }, ], @@ -54,7 +54,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "Build · GitHub Activity Dashboard", description: homeDescription, - images: [`${siteUrl}/globe.svg`], + images: [`${siteUrl}/og-image.png`], creator: "@brandonperfetti", }, }; diff --git a/app/repos/page.tsx b/app/repos/page.tsx index 28e485b..2f274b6 100644 --- a/app/repos/page.tsx +++ b/app/repos/page.tsx @@ -37,7 +37,7 @@ export const metadata: Metadata = { type: "website", images: [ { - url: `${siteUrl}/globe.svg`, + url: `${siteUrl}/og-image.png`, alt: "Build repository surface area overview", }, ], @@ -46,7 +46,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "Repos · Build", description: reposDescription, - images: [`${siteUrl}/globe.svg`], + images: [`${siteUrl}/og-image.png`], }, }; diff --git a/package-lock.json b/package-lock.json index 97245d7..9e652b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.11" } }, "node_modules/@alloc/quick-lru": { @@ -1232,6 +1233,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -1268,6 +1279,286 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1578,6 +1869,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1641,6 +1943,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2262,72 +2571,185 @@ "win32" ] }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://opencollective.com/vitest" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", @@ -2495,6 +2917,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2705,6 +3137,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3226,6 +3668,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3755,6 +4204,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3771,6 +4230,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3912,6 +4381,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5269,9 +5753,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -5539,6 +6023,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5647,6 +6145,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5677,9 +6182,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -5697,7 +6202,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6050,6 +6555,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6351,6 +6890,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6367,6 +6913,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6593,15 +7153,32 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6629,9 +7206,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6641,6 +7218,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6961,6 +7548,473 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7066,6 +8120,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 662833b..c1b70d6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "prestart": "npm run validate:env -- --mode=production", "start": "next start", "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", "validate:env": "node scripts/validate-env.mjs", "format": "prettier --write .", "format:check": "prettier --check ." @@ -32,6 +34,7 @@ "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.11" } } diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..d522f03 Binary files /dev/null and b/public/og-image.png differ diff --git a/tests/app/home-page.test.tsx b/tests/app/home-page.test.tsx new file mode 100644 index 0000000..2eb59ac --- /dev/null +++ b/tests/app/home-page.test.tsx @@ -0,0 +1,134 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Providers } from "@/app/components/providers"; +import { USERNAME } from "@/lib/github"; +import { + contributionsHtml, + FROZEN_NOW, + installFetchRouter, + jsonResponse, + makeRepo, + route, +} from "../helpers/github-fixtures"; + +// The home route is an async server component. Awaiting it yields the element +// tree it would hand to React, and rendering that tree to static markup is the +// same path the production server takes, minus streaming. The theme provider +// wraps it because the charts read the theme context. +async function renderHome() { + const { default: Home } = await import("@/app/page"); + const tree = await Home(); + return renderToStaticMarkup({tree}); +} + +beforeEach(() => { + vi.useFakeTimers({ now: FROZEN_NOW, toFake: ["Date"] }); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("home page", () => { + it("renders the dashboard from stubbed GitHub responses", async () => { + const alpha = makeRepo({ + name: "alpha", + owner: { login: USERNAME }, + description: "Pinned project", + language: "TypeScript", + stargazers_count: 10, + pushed_at: "2026-09-24T10:00:00Z", + }); + const beta = makeRepo({ + name: "beta", + owner: { login: USERNAME }, + language: "TypeScript", + stargazers_count: 2, + pushed_at: "2026-09-10T10:00:00Z", + }); + const gamma = makeRepo({ + name: "gamma", + owner: { login: USERNAME }, + language: "Go", + pushed_at: "2026-08-30T10:00:00Z", + }); + const { calls } = installFetchRouter([ + route.contributions( + USERNAME, + contributionsHtml({ + "2026-09-20": 3, + "2026-09-24": 4, + "2026-09-25": 5, + }), + ), + route.authedRepos([ + alpha, + beta, + gamma, + makeRepo({ name: "old", owner: { login: USERNAME }, archived: true }), + ]), + route.pinned([ + { + databaseId: alpha.id, + name: alpha.name, + nameWithOwner: alpha.full_name, + description: alpha.description, + url: alpha.html_url, + homepageUrl: null, + primaryLanguage: { name: "TypeScript" }, + stargazerCount: alpha.stargazers_count, + forkCount: 0, + isArchived: false, + isPrivate: false, + pushedAt: alpha.pushed_at, + updatedAt: alpha.updated_at, + owner: { login: USERNAME }, + }, + ]), + route.commits({ + [alpha.full_name]: ["2026-09-24T09:00:00Z", "2026-09-20T09:00:00Z"], + }), + ]); + + const html = await renderHome(); + + // Quick snapshot card + expect(html).toContain("12 contributions in 30 days"); + expect(html).toContain("Repos tracked"); + expect(html).toMatch(/Repos tracked<\/div>]*>3]*>2alpha<")).toBeLessThan(html.indexOf(">beta<")); + expect(html.indexOf(">beta<")).toBeLessThan(html.indexOf(">gamma<")); + expect(html).not.toContain(">old<"); + // Recent shipping: 30-day commit totals per repo + expect(html).toContain("2 commits"); + expect(html).toContain("Updated Sep 24, 2026"); + // Commit totals were requested only for the repos on the page + expect( + calls.filter((url) => url.pathname.endsWith("/commits")).length, + ).toBe(3); + }); + + it("still renders when every GitHub request fails", async () => { + installFetchRouter([ + { + match: () => true, + respond: () => jsonResponse({ message: "boom" }, { status: 500 }), + }, + ]); + + const html = await renderHome(); + + expect(html).toContain("0 contributions in 30 days"); + expect(html).toContain("Strongest day: No contributions yet"); + expect(html).toContain("No contribution data available."); + expect(html).toMatch(/Repos tracked<\/div>]*>0 = {}): Repo { + const id = overrides.id ?? nextRepoId++; + const name = overrides.name ?? `repo-${id}`; + const owner = overrides.owner?.login ?? "octo"; + return { + id, + name, + full_name: `${owner}/${name}`, + description: null, + html_url: `https://github.com/${owner}/${name}`, + homepage: null, + language: "TypeScript", + stargazers_count: 0, + forks_count: 0, + archived: false, + private: false, + owner: { login: owner }, + pushed_at: "2026-09-20T10:00:00Z", + updated_at: "2026-09-20T10:00:00Z", + topics: [], + ...overrides, + }; +} + +export function makeDays( + counts: number[], + startDate = "2026-09-01", +): ContributionDay[] { + const start = new Date(`${startDate}T00:00:00.000Z`); + return counts.map((count, index) => { + const date = new Date(start); + date.setUTCDate(start.getUTCDate() + index); + return { + date: date.toISOString().slice(0, 10), + count, + level: + count === 0 ? 0 : count < 2 ? 1 : count < 4 ? 2 : count < 7 ? 3 : 4, + }; + }); +} + +/** + * Builds the fragment of GitHub's contributions HTML that getContributionDays + * scrapes: a `` cell followed by a `` label. + */ +export function contributionsHtml(cells: Record) { + const rows = Object.entries(cells).map(([date, count]) => { + const label = + count === 0 + ? `No contributions on ${date}` + : `${count} contribution${count === 1 ? "" : "s"} on ${date}`; + return `\n${label}`; + }); + return `${rows.join("\n")}
`; +} + +export function jsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +) { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "content-type": "application/json", ...init.headers }, + }); +} + +export function textResponse(body: string, status = 200) { + return new Response(body, { + status, + headers: { "content-type": "text/html" }, + }); +} + +export type FetchRoute = { + match: (url: URL) => boolean; + respond: (url: URL, init?: RequestInit) => Response | Promise; +}; + +/** + * Replaces global fetch with a URL router. Any request that no route matches + * throws, so a test cannot quietly reach the real GitHub API or fall through to + * a fallback branch it did not mean to exercise. + */ +export function installFetchRouter(routes: FetchRoute[]) { + const calls: URL[] = []; + const fetchMock = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + ); + calls.push(url); + const route = routes.find((candidate) => candidate.match(url)); + if (!route) { + throw new Error(`Unrouted fetch in test: ${url.href}`); + } + return route.respond(url, init); + }, + ); + vi.stubGlobal("fetch", fetchMock); + return { calls, fetchMock }; +} + +export const route = { + contributions: (username: string, html: string): FetchRoute => ({ + match: (url) => + url.hostname === "github.com" && + url.pathname === `/users/${username}/contributions`, + respond: () => textResponse(html), + }), + authedRepos: (repos: Repo[]): FetchRoute => ({ + match: (url) => + url.hostname === "api.github.com" && url.pathname === "/user/repos", + respond: () => jsonResponse(repos), + }), + publicRepos: (username: string, repos: Repo[]): FetchRoute => ({ + match: (url) => + url.hostname === "api.github.com" && + url.pathname === `/users/${username}/repos`, + respond: () => jsonResponse(repos), + }), + pinned: (nodes: unknown[]): FetchRoute => ({ + match: (url) => url.pathname === "/graphql", + respond: () => jsonResponse({ data: { user: { pinnedItems: { nodes } } } }), + }), + commits: ( + byFullName: Record, + options: { requireAuthor?: string } = {}, + ): FetchRoute => ({ + match: (url) => /^\/repos\/[^/]+\/[^/]+\/commits$/.test(url.pathname), + respond: (url) => { + if ( + options.requireAuthor && + url.searchParams.get("author") !== options.requireAuthor + ) { + return jsonResponse([]); + } + const fullName = url.pathname + .replace(/^\/repos\//, "") + .replace(/\/commits$/, ""); + // The real endpoint only returns commits at or after `since`. + const since = url.searchParams.get("since"); + const dates = (byFullName[fullName] ?? []).filter( + (date) => !since || date >= since, + ); + return jsonResponse( + dates.map((date) => ({ commit: { author: { date } } })), + ); + }, + }), + searchCount: (countFor: (query: string) => number): FetchRoute => ({ + match: (url) => url.pathname === "/search/issues", + respond: (url) => + jsonResponse({ total_count: countFor(url.searchParams.get("q") ?? "") }), + }), +}; + +export const extraRoutes = { + pulls: ( + byFullName: Record< + string, + Array<{ + created_at: string; + updated_at: string; + merged_at: string | null; + user: { login: string }; + }> + >, + ): FetchRoute => ({ + match: (url) => /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(url.pathname), + respond: (url) => { + const fullName = url.pathname + .replace(/^\/repos\//, "") + .replace(/\/pulls$/, ""); + return jsonResponse(byFullName[fullName] ?? []); + }, + }), + releases: ( + byFullName: Record>, + ): FetchRoute => ({ + match: (url) => /^\/repos\/[^/]+\/[^/]+\/releases$/.test(url.pathname), + respond: (url) => { + const fullName = url.pathname + .replace(/^\/repos\//, "") + .replace(/\/releases$/, ""); + return jsonResponse(byFullName[fullName] ?? []); + }, + }), +}; + +/** + * Drives a promise to completion under fake timers by advancing the clock in + * steps. lib/github.ts spaces GitHub Search calls out with setTimeout, so a + * test that awaited the promise directly would hang. + */ +export async function settle( + promise: Promise, + stepMs = 500, + maxSteps = 400, +): Promise { + type Outcome = { ok: true; value: T } | { ok: false; error: unknown }; + // Held in an object because the assignment happens inside a callback, + // which TypeScript's control-flow narrowing cannot see. + const box: { outcome: Outcome | null } = { outcome: null }; + const tracked = promise.then( + (value) => { + box.outcome = { ok: true, value }; + }, + (error: unknown) => { + box.outcome = { ok: false, error }; + }, + ); + for (let step = 0; step < maxSteps && box.outcome === null; step += 1) { + await vi.advanceTimersByTimeAsync(stepMs); + } + await tracked; + if (box.outcome === null) { + throw new Error(`Promise did not settle within ${maxSteps * stepMs}ms`); + } + if (box.outcome.ok) { + return box.outcome.value; + } + throw box.outcome.error; +} diff --git a/tests/lib/contribution-shaping.test.ts b/tests/lib/contribution-shaping.test.ts new file mode 100644 index 0000000..b0cb1eb --- /dev/null +++ b/tests/lib/contribution-shaping.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildCalendarCells, + buildLast30Days, + buildRepoPushCadence, + buildSparklinePoints, + buildWeeklyTotals, + chunkWeeks, + currentStreak, + daysSince, + formatRepoDate, + levelForCount, + longestStreak, + prettyDay, + prettyLongDay, +} from "@/lib/github"; +import { FROZEN_NOW, makeDays, makeRepo } from "../helpers/github-fixtures"; + +// Every function here derives its window from "today", so the clock is frozen +// at 2026-09-25 (a Friday) and the 30-day window runs 2026-08-27 .. 2026-09-25. +beforeEach(() => { + vi.useFakeTimers({ now: FROZEN_NOW, toFake: ["Date"] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("levelForCount", () => { + it.each([ + [0, 0], + [1, 1], + [2, 2], + [3, 2], + [4, 3], + [6, 3], + [7, 4], + [40, 4], + ])("maps %i contributions to heatmap level %i", (count, level) => { + expect(levelForCount(count)).toBe(level); + }); +}); + +describe("streaks", () => { + it("longestStreak finds the longest run of active days", () => { + expect(longestStreak(makeDays([1, 2, 0, 3, 4, 5, 0]))).toBe(3); + }); + + it("longestStreak is 0 for no days or no activity", () => { + expect(longestStreak([])).toBe(0); + expect(longestStreak(makeDays([0, 0, 0]))).toBe(0); + }); + + it("currentStreak counts back from the most recent day", () => { + expect(currentStreak(makeDays([1, 1, 0, 2, 3]))).toBe(2); + }); + + it("currentStreak is 0 when the most recent day is quiet", () => { + expect(currentStreak(makeDays([4, 4, 4, 0]))).toBe(0); + }); +}); + +describe("buildLast30Days", () => { + it("returns 30 consecutive ISO dates ending today", () => { + const dates = buildLast30Days(); + expect(dates).toHaveLength(30); + expect(dates[0]).toBe("2026-08-27"); + expect(dates.at(-1)).toBe("2026-09-25"); + for (let index = 1; index < dates.length; index += 1) { + const previous = new Date(`${dates[index - 1]}T00:00:00Z`).getTime(); + const current = new Date(`${dates[index]}T00:00:00Z`).getTime(); + expect(current - previous).toBe(86_400_000); + } + }); +}); + +describe("calendar layout", () => { + it("buildCalendarCells pads to full Sunday-to-Saturday weeks", () => { + // 2026-09-23 is a Wednesday, 2026-09-25 a Friday. + const cells = buildCalendarCells(makeDays([1, 1, 1], "2026-09-23")); + expect(cells).toHaveLength(7); + expect(cells.slice(0, 3)).toEqual([null, null, null]); + expect(cells[3]?.date).toBe("2026-09-23"); + expect(cells[6]).toBeNull(); + }); + + it("buildCalendarCells returns nothing for no days", () => { + expect(buildCalendarCells([])).toEqual([]); + }); + + it("chunkWeeks splits cells into rows of seven", () => { + const cells = buildCalendarCells(makeDays(Array(30).fill(1), "2026-08-27")); + const weeks = chunkWeeks(cells); + expect(weeks).toHaveLength(5); + expect(weeks.every((week) => week.length === 7)).toBe(true); + expect(chunkWeeks(cells.slice(0, 8)).map((week) => week.length)).toEqual([ + 7, 1, + ]); + }); + + it("buildWeeklyTotals sums each calendar week and labels its range", () => { + const counts = Array.from({ length: 30 }, (_, index) => index + 1); + const weeks = buildWeeklyTotals(makeDays(counts, "2026-08-27")); + + expect(weeks.map((week) => week.label)).toEqual([ + "Week 1", + "Week 2", + "Week 3", + "Week 4", + "Week 5", + ]); + // Thu 27 .. Sat 29 Aug -> 1 + 2 + 3 + expect(weeks[0]).toMatchObject({ total: 6, range: "Aug 27 – Aug 29" }); + // Sun 20 .. Fri 25 Sep -> 25 + ... + 30 + expect(weeks[4]).toMatchObject({ total: 165, range: "Sep 20 – Sep 25" }); + expect(weeks.reduce((sum, week) => sum + week.total, 0)).toBe(465); + }); + + it("buildWeeklyTotals returns nothing for no days", () => { + expect(buildWeeklyTotals([])).toEqual([]); + }); +}); + +describe("buildSparklinePoints", () => { + it("scales counts into the 260x72 viewBox with 6px padding", () => { + expect(buildSparklinePoints(makeDays([0, 5, 10]))).toBe( + "6,66 130,36 254,6", + ); + }); +}); + +describe("buildRepoPushCadence", () => { + it("counts repos pushed inside each weekly window of the 30-day range", () => { + const repos = [ + makeRepo({ pushed_at: "2026-08-27T00:00:00Z" }), // first window, first ms + makeRepo({ pushed_at: "2026-09-04T10:00:00Z" }), // second window + makeRepo({ pushed_at: "2026-09-25T23:59:59Z" }), // last window, last second + makeRepo({ pushed_at: "2026-08-01T00:00:00Z" }), // before the window + ]; + + const cadence = buildRepoPushCadence(repos); + + expect(cadence.map((point) => point.value)).toEqual([1, 1, 0, 0, 1]); + expect(cadence.map((point) => point.label)).toEqual([ + "Aug 27", + "Sep 3", + "Sep 10", + "Sep 17", + "Sep 24", + ]); + expect(cadence.map((point) => point.index)).toEqual([1, 2, 3, 4, 5]); + expect(cadence.at(-1)?.range).toBe("Sep 24 – Sep 25"); + }); +}); + +describe("date helpers", () => { + it("daysSince floors to whole days and never goes negative", () => { + expect(daysSince("2026-09-20T12:00:00Z")).toBe(5); + expect(daysSince("2026-09-25T13:00:00Z")).toBe(0); + expect(daysSince("not a date")).toBe(0); + }); + + it("formats dates the way the cards show them", () => { + expect(formatRepoDate("2026-09-20T10:00:00Z")).toBe("Sep 20, 2026"); + expect(prettyDay("2026-09-05")).toBe("Sep 5"); + expect(prettyLongDay("2026-09-05")).toBe("September 5, 2026"); + }); +}); diff --git a/tests/lib/github-api.test.ts b/tests/lib/github-api.test.ts new file mode 100644 index 0000000..68efcbe --- /dev/null +++ b/tests/lib/github-api.test.ts @@ -0,0 +1,552 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildRepoCommitActivitySummary, + getCommitTimingHeatmap, + getContributionDays, + getIssueFlowHealth, + getPinnedRepos, + getPullRequestHealth, + getReleaseCadence, + getRepoRiskSnapshot, + getRepos, +} from "@/lib/github"; +import { + contributionsHtml, + extraRoutes, + FROZEN_NOW, + installFetchRouter, + jsonResponse, + makeRepo, + route, + settle, + textResponse, +} from "../helpers/github-fixtures"; + +// The clock is frozen at 2026-09-25 so the weekly windows are +// Aug 27-Sep 2, Sep 3-9, Sep 10-16, Sep 17-23 and Sep 24-25. +// Only Date and setTimeout are faked: the search rate limiter sleeps with +// setTimeout, and nothing else in the module depends on timers. +beforeEach(() => { + vi.useFakeTimers({ + now: FROZEN_NOW, + toFake: ["Date", "setTimeout", "clearTimeout"], + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("getContributionDays", () => { + it("scrapes the contribution calendar into a zero-filled 30-day series", async () => { + const { calls } = installFetchRouter([ + route.contributions( + "octo", + contributionsHtml({ + "2026-08-27": 8, + "2026-09-01": 1, + "2026-09-24": 0, + "2026-09-25": 3, + }), + ), + ]); + + const days = await getContributionDays("octo"); + + expect(calls[0].searchParams.get("from")).toBe("2026-08-27"); + expect(calls[0].searchParams.get("to")).toBe("2026-09-25"); + expect(days).toHaveLength(30); + expect(days[0]).toEqual({ date: "2026-08-27", count: 8, level: 4 }); + expect(days.at(-1)).toEqual({ date: "2026-09-25", count: 3, level: 2 }); + expect(days.find((day) => day.date === "2026-09-01")).toEqual({ + date: "2026-09-01", + count: 1, + level: 1, + }); + // Days GitHub did not list, and days labelled "No contributions", are 0. + expect(days.find((day) => day.date === "2026-09-10")?.count).toBe(0); + expect(days.find((day) => day.date === "2026-09-24")?.count).toBe(0); + }); + + it("degrades to an all-zero series when GitHub rate-limits the page", async () => { + installFetchRouter([ + { + match: (url) => url.pathname.endsWith("/contributions"), + respond: () => textResponse("", 403), + }, + ]); + + const days = await getContributionDays("octo"); + + expect(days).toHaveLength(30); + expect(days.every((day) => day.count === 0 && day.level === 0)).toBe(true); + }); + + it("throws on any other upstream failure", async () => { + installFetchRouter([ + { + match: (url) => url.pathname.endsWith("/contributions"), + respond: () => textResponse("", 500), + }, + ]); + + await expect(getContributionDays("octo")).rejects.toThrow(/500/); + }); +}); + +describe("getRepos", () => { + it("uses the authenticated endpoint and keeps only the user's active repos", async () => { + const { calls } = installFetchRouter([ + route.authedRepos([ + makeRepo({ name: "mine" }), + makeRepo({ name: "mine-archived", archived: true }), + makeRepo({ name: "theirs", owner: { login: "someone-else" } }), + { + ...makeRepo({ name: "legacy-shape" }), + owner: undefined, + full_name: "octo/legacy-shape", + }, + ]), + ]); + + const repos = await getRepos("octo"); + + expect(calls[0].pathname).toBe("/user/repos"); + expect(calls[0].searchParams.get("visibility")).toBe("all"); + expect(repos.map((repo) => repo.name)).toEqual(["mine", "legacy-shape"]); + }); + + it("follows Link rel=next pagination", async () => { + installFetchRouter([ + { + match: (url) => url.pathname === "/user/repos", + respond: (url) => + url.searchParams.get("page") === "2" + ? jsonResponse([makeRepo({ name: "page-two" })]) + : jsonResponse([makeRepo({ name: "page-one" })], { + headers: { + link: '; rel="next", ; rel="last"', + }, + }), + }, + ]); + + const repos = await getRepos("octo"); + + expect(repos.map((repo) => repo.name)).toEqual(["page-one", "page-two"]); + }); + + it("falls back to the public listing when the token is rejected", async () => { + const { calls } = installFetchRouter([ + { + match: (url) => url.pathname === "/user/repos", + respond: () => + jsonResponse({ message: "Bad credentials" }, { status: 401 }), + }, + route.publicRepos("octo", [ + makeRepo({ name: "public" }), + makeRepo({ name: "public-archived", archived: true }), + ]), + ]); + + const repos = await getRepos("octo"); + + expect(calls.map((url) => url.pathname)).toEqual([ + "/user/repos", + "/users/octo/repos", + ]); + expect(repos.map((repo) => repo.name)).toEqual(["public"]); + }); + + it("returns an empty list when both endpoints are rate-limited", async () => { + installFetchRouter([ + { + match: (url) => url.pathname.endsWith("/repos"), + respond: () => + jsonResponse({ message: "rate limited" }, { status: 403 }), + }, + ]); + + await expect(getRepos("octo")).resolves.toEqual([]); + }); + + it("goes straight to the public listing without a token", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + const { calls } = installFetchRouter([ + route.publicRepos("octo", [makeRepo({ name: "public" })]), + ]); + + const repos = await getRepos("octo"); + + expect(calls.map((url) => url.pathname)).toEqual(["/users/octo/repos"]); + expect(repos.map((repo) => repo.name)).toEqual(["public"]); + }); +}); + +describe("getPinnedRepos", () => { + const pinnedNode = { + id: "R_1", + databaseId: 11, + name: "alpha", + nameWithOwner: "octo/alpha", + description: "First project", + url: "https://github.com/octo/alpha", + homepageUrl: "https://alpha.example.com", + primaryLanguage: { name: "TypeScript" }, + stargazerCount: 5, + forkCount: 1, + isArchived: false, + isPrivate: false, + pushedAt: "2026-09-20T10:00:00Z", + updatedAt: "2026-09-21T10:00:00Z", + repositoryTopics: { nodes: [{ topic: { name: "nextjs" } }, { topic: {} }] }, + owner: { login: "octo" }, + }; + + it("maps GraphQL pinned items onto the REST repo shape", async () => { + installFetchRouter([ + route.pinned([ + pinnedNode, + { ...pinnedNode, databaseId: 12, name: "old", isArchived: true }, + { ...pinnedNode, databaseId: null, name: "no-id" }, + ]), + ]); + + const repos = await getPinnedRepos("octo"); + + expect(repos).toHaveLength(1); + expect(repos[0]).toMatchObject({ + id: 11, + name: "alpha", + full_name: "octo/alpha", + html_url: "https://github.com/octo/alpha", + homepage: "https://alpha.example.com", + language: "TypeScript", + stargazers_count: 5, + forks_count: 1, + archived: false, + private: false, + pushed_at: "2026-09-20T10:00:00Z", + updated_at: "2026-09-21T10:00:00Z", + topics: ["nextjs"], + }); + }); + + it("throws when GraphQL reports errors", async () => { + installFetchRouter([ + { + match: (url) => url.pathname === "/graphql", + respond: () => jsonResponse({ errors: [{ message: "nope" }] }), + }, + ]); + + await expect(getPinnedRepos("octo")).rejects.toThrow(/GraphQL/); + }); + + it("refuses to run without a token instead of returning nothing", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + installFetchRouter([]); + + await expect(getPinnedRepos("octo")).rejects.toThrow(/authentication/i); + }); +}); + +describe("buildRepoCommitActivitySummary", () => { + it("fetches commits only for the top repos, buckets them by week and ranks repos", async () => { + const hot = makeRepo({ + name: "a-very-long-repository-name", + pushed_at: "2026-09-24T10:00:00Z", + }); + const warm = makeRepo({ name: "warm", pushed_at: "2026-09-10T10:00:00Z" }); + const cold = makeRepo({ name: "cold", pushed_at: "2026-08-01T10:00:00Z" }); + const pinnedOld = makeRepo({ + name: "pinned-old", + pushed_at: "2026-07-01T10:00:00Z", + }); + const { calls } = installFetchRouter([ + route.commits({ + [hot.full_name]: [ + "2026-09-24T09:00:00Z", + "2026-09-04T09:00:00Z", + "2026-08-01T00:00:00Z", + ], + [pinnedOld.full_name]: ["2026-09-25T01:00:00Z"], + }), + ]); + + const summary = await buildRepoCommitActivitySummary( + [cold, warm, hot, pinnedOld], + 2, + { forceIncludeFullNames: [pinnedOld.full_name] }, + ); + + expect( + calls.map((url) => url.pathname.replace(/^\/repos\//, "")).sort(), + ).toEqual([`${hot.full_name}/commits`, `${pinnedOld.full_name}/commits`]); + expect(calls[0].searchParams.get("since")).toBe("2026-08-27T00:00:00Z"); + expect(summary.weekly.map((week) => week.value)).toEqual([0, 1, 0, 0, 2]); + expect(summary.weekly.map((week) => week.index)).toEqual([1, 2, 3, 4, 5]); + expect(summary.perRepo).toEqual([ + { + name: "a-very-long-re…", + fullName: hot.full_name, + commits: 2, + pushedAt: hot.pushed_at, + }, + { + name: "pinned-old", + fullName: pinnedOld.full_name, + commits: 1, + pushedAt: pinnedOld.pushed_at, + }, + ]); + }); + + it("returns empty weekly buckets without calling GitHub when there are no repos", async () => { + const { fetchMock } = installFetchRouter([]); + + const summary = await buildRepoCommitActivitySummary([], 8); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(summary.weekly.map((week) => week.value)).toEqual([0, 0, 0, 0, 0]); + expect(summary.perRepo).toEqual([]); + }); +}); + +describe("getCommitTimingHeatmap", () => { + it("buckets commits by weekday and hour in the requested timezone", async () => { + installFetchRouter([ + route.authedRepos([ + makeRepo({ name: "heat", owner: { login: "octo-heat" } }), + ]), + route.commits( + { + "octo-heat/heat": [ + "2026-09-21T03:30:00Z", // Mon 03:30 UTC = Sun 20:30 in Los Angeles + "2026-09-21T03:45:00Z", + "2026-09-22T15:00:00Z", // Tue 15:00 UTC = Tue 08:00 in Los Angeles + "not-a-date", + ], + }, + { requireAuthor: "octo-heat" }, + ), + ]); + + const heatmap = await getCommitTimingHeatmap( + "America/Los_Angeles", + "octo-heat", + ); + + expect(heatmap.timezone).toBe("America/Los_Angeles"); + expect(heatmap.totalCommits).toBe(3); + expect(heatmap.maxCellCount).toBe(2); + expect(heatmap.cells).toHaveLength(7 * 24); + const sundayEvening = heatmap.cells.find( + (cell) => cell.dayIndex === 0 && cell.hour === 20, + ); + expect(sundayEvening).toMatchObject({ + dayLabel: "Sun", + hourLabel: "20:00", + count: 2, + intensity: 4, + }); + expect( + heatmap.cells.find((cell) => cell.dayIndex === 2 && cell.hour === 8), + ).toMatchObject({ dayLabel: "Tue", count: 1, intensity: 2 }); + expect(heatmap.cells.filter((cell) => cell.count > 0)).toHaveLength(2); + }); + + it("falls back to UTC for an unknown timezone", async () => { + installFetchRouter([ + route.authedRepos([ + makeRepo({ name: "heat", owner: { login: "octo-heat-2" } }), + ]), + route.commits( + { "octo-heat-2/heat": ["2026-09-21T03:30:00Z"] }, + { requireAuthor: "octo-heat-2" }, + ), + ]); + + const heatmap = await getCommitTimingHeatmap("Not/AZone", "octo-heat-2"); + + expect(heatmap.timezone).toBe("UTC"); + expect( + heatmap.cells.find((cell) => cell.dayIndex === 1 && cell.hour === 3), + ).toMatchObject({ dayLabel: "Mon", count: 1, intensity: 4 }); + }); +}); + +describe("getRepoRiskSnapshot", () => { + it("assigns active repos to freshness buckets and excludes archived ones", async () => { + const owner = { login: "octo-risk" }; + installFetchRouter([ + route.authedRepos([ + makeRepo({ name: "hot", owner, pushed_at: "2026-09-22T10:00:00Z" }), + makeRepo({ + name: "hot-private", + owner, + private: true, + pushed_at: "2026-09-25T10:00:00Z", + }), + makeRepo({ name: "active", owner, pushed_at: "2026-09-10T10:00:00Z" }), + makeRepo({ name: "stale", owner, pushed_at: "2026-08-01T10:00:00Z" }), + makeRepo({ name: "dormant", owner, pushed_at: "2026-01-01T10:00:00Z" }), + makeRepo({ + name: "archived", + owner, + archived: true, + pushed_at: "2026-09-24T10:00:00Z", + }), + ]), + ]); + + const snapshot = await getRepoRiskSnapshot("octo-risk"); + + expect(snapshot).toEqual({ + totalRepos: 5, + archivedRepos: 1, + privateRepos: 1, + atRiskRepos: 2, + buckets: [ + { label: "Hot (0-7d)", count: 2 }, + { label: "Active (8-30d)", count: 1 }, + { label: "Stale (31-89d)", count: 1 }, + { label: "Dormant (90d+)", count: 1 }, + ], + }); + }); +}); + +describe("getReleaseCadence", () => { + it("counts published releases per calendar month across the window", async () => { + installFetchRouter([ + route.authedRepos([ + makeRepo({ name: "lib", owner: { login: "octo-rel" } }), + ]), + extraRoutes.releases({ + "octo-rel/lib": [ + { published_at: "2026-09-20T00:00:00Z" }, + { published_at: "2026-09-02T00:00:00Z" }, + { published_at: "2026-07-15T00:00:00Z" }, + { published_at: null }, // draft + { published_at: "2025-12-01T00:00:00Z" }, // before the window + ], + }), + ]); + + const cadence = await getReleaseCadence("octo-rel", 6); + + expect(cadence.map((point) => point.label)).toEqual([ + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + ]); + expect(cadence.map((point) => point.monthKey)).toEqual([ + "2026-04", + "2026-05", + "2026-06", + "2026-07", + "2026-08", + "2026-09", + ]); + expect(cadence.map((point) => point.releases)).toEqual([0, 0, 0, 1, 0, 2]); + }); +}); + +describe("getPullRequestHealth", () => { + it("derives weekly merge rate and median cycle time from the user's merged PRs", async () => { + installFetchRouter([ + route.searchCount((query) => { + if (query.includes("merged:")) return 3; + return 4; // created and closed + }), + route.authedRepos([ + makeRepo({ name: "repo-x", owner: { login: "octo-pr" } }), + ]), + extraRoutes.pulls({ + "octo-pr/repo-x": [ + { + created_at: "2026-09-10T00:00:00Z", + updated_at: "2026-09-11T00:00:00Z", + merged_at: "2026-09-11T00:00:00Z", // 24h, third week + user: { login: "Octo-PR" }, // login case must not matter + }, + { + created_at: "2026-09-12T00:00:00Z", + updated_at: "2026-09-12T06:00:00Z", + merged_at: "2026-09-12T06:00:00Z", // 6h, third week + user: { login: "octo-pr" }, + }, + { + created_at: "2026-09-12T00:00:00Z", + updated_at: "2026-09-12T01:00:00Z", + merged_at: "2026-09-12T01:00:00Z", + user: { login: "someone-else" }, // not the author we measure + }, + { + created_at: "2026-09-13T00:00:00Z", + updated_at: "2026-09-13T00:00:00Z", + merged_at: null, // closed without merging + user: { login: "octo-pr" }, + }, + { + created_at: "2026-06-30T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + merged_at: "2026-07-01T00:00:00Z", // before the window + user: { login: "octo-pr" }, + }, + ], + }), + ]); + + const weeks = await settle(getPullRequestHealth("octo-pr")); + + expect(weeks).toHaveLength(5); + expect(weeks[2]).toEqual({ + label: "Sep 10", + range: "Sep 10 – Sep 16", + opened: 4, + merged: 3, + closed: 4, + reopened: 0, + mergeRate: 75, + reopenRate: 0, + medianCycleHours: 15, + cycleSampleSize: 2, + }); + for (const index of [0, 1, 3, 4]) { + expect(weeks[index]).toMatchObject({ + medianCycleHours: 0, + cycleSampleSize: 0, + mergeRate: 75, + }); + } + }); +}); + +describe("getIssueFlowHealth", () => { + it("accumulates the net backlog delta week over week", async () => { + installFetchRouter([ + route.searchCount((query) => (query.includes("closed:") ? 1 : 3)), + ]); + + const weeks = await settle(getIssueFlowHealth("octo-issues")); + + expect(weeks.map((week) => week.label)).toEqual([ + "Aug 27", + "Sep 3", + "Sep 10", + "Sep 17", + "Sep 24", + ]); + expect(weeks.map((week) => [week.opened, week.closed])).toEqual( + Array(5).fill([3, 1]), + ); + expect(weeks.map((week) => week.backlogDelta)).toEqual([2, 4, 6, 8, 10]); + }); +}); diff --git a/tests/lib/site-helpers.test.ts b/tests/lib/site-helpers.test.ts new file mode 100644 index 0000000..402a916 --- /dev/null +++ b/tests/lib/site-helpers.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { + getConfiguredActivityTimezone, + sanitizeTimezone, +} from "@/lib/timezone"; +import { normalizeHttpUrl } from "@/lib/url"; + +describe("sanitizeTimezone", () => { + it("accepts IANA names and trims whitespace", () => { + expect(sanitizeTimezone("America/New_York")).toBe("America/New_York"); + expect(sanitizeTimezone(" UTC ")).toBe("UTC"); + }); + + it("rejects unknown or empty values", () => { + expect(sanitizeTimezone("Mars/Olympus")).toBeNull(); + expect(sanitizeTimezone("")).toBeNull(); + expect(sanitizeTimezone(undefined)).toBeNull(); + }); +}); + +describe("getConfiguredActivityTimezone", () => { + it("reads the server-side variable first", () => { + vi.stubEnv("ACTIVITY_HEATMAP_TIMEZONE", "Europe/Berlin"); + vi.stubEnv("NEXT_PUBLIC_ACTIVITY_HEATMAP_TIMEZONE", "Asia/Tokyo"); + expect(getConfiguredActivityTimezone()).toBe("Europe/Berlin"); + }); + + it("falls back to the public variable, then to UTC", () => { + vi.stubEnv("ACTIVITY_HEATMAP_TIMEZONE", undefined); + vi.stubEnv("NEXT_PUBLIC_ACTIVITY_HEATMAP_TIMEZONE", "Asia/Tokyo"); + expect(getConfiguredActivityTimezone()).toBe("Asia/Tokyo"); + + vi.stubEnv("NEXT_PUBLIC_ACTIVITY_HEATMAP_TIMEZONE", "Nowhere/Real"); + expect(getConfiguredActivityTimezone()).toBe("UTC"); + }); +}); + +describe("normalizeHttpUrl", () => { + it("adds https to bare hosts and keeps http(s) URLs", () => { + expect(normalizeHttpUrl("example.com")).toBe("https://example.com/"); + expect(normalizeHttpUrl("https://alpha.example.com/docs")).toBe( + "https://alpha.example.com/docs", + ); + expect(normalizeHttpUrl("http://localhost:3000")).toBe( + "http://localhost:3000/", + ); + }); + + it("refuses anything that is not a web URL, so repo homepages cannot inject other schemes", () => { + expect(normalizeHttpUrl("javascript:alert(1)")).toBeNull(); + expect(normalizeHttpUrl("ftp://files.example.com")).toBeNull(); + expect(normalizeHttpUrl("not a url")).toBeNull(); + expect(normalizeHttpUrl("")).toBeNull(); + expect(normalizeHttpUrl(null)).toBeNull(); + }); +}); + +describe("getPublicSiteUrl", () => { + async function loadFresh() { + vi.resetModules(); + return (await import("@/lib/site-config")).getPublicSiteUrl; + } + + it("strips trailing slashes from the configured URL", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://github.brandonperfetti.com/"); + const getPublicSiteUrl = await loadFresh(); + expect(getPublicSiteUrl()).toBe("https://github.brandonperfetti.com"); + }); + + it("falls back to localhost only in development", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", undefined); + vi.stubEnv("NODE_ENV", "development"); + const getPublicSiteUrl = await loadFresh(); + expect(getPublicSiteUrl()).toBe("http://localhost:3000"); + }); + + it("throws outside development when the URL is missing", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", undefined); + vi.stubEnv("NODE_ENV", "production"); + const getPublicSiteUrl = await loadFresh(); + expect(() => getPublicSiteUrl()).toThrow(/NEXT_PUBLIC_SITE_URL/); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..c3237ee --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,20 @@ +import { beforeEach, vi } from "vitest"; + +// Date formatting in lib/github.ts uses the process timezone; pin it so the +// expected strings in the tests are the same on every machine and in CI. +process.env.TZ = "UTC"; + +// `cacheLife` / `cacheTag` only work inside the Next.js runtime. The page and +// data modules call them at the top of "use cache" functions, so stub them out. +vi.mock("next/cache", () => ({ + cacheLife: () => undefined, + cacheTag: () => undefined, +})); + +beforeEach(() => { + // Tests never talk to GitHub. A token is stubbed so the authenticated code + // paths run; the fetch router in tests/helpers decides what comes back. + vi.stubEnv("GITHUB_TOKEN", "test-token"); + vi.stubEnv("GH_TOKEN", ""); + vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://github.brandonperfetti.com"); +}); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..74bb08b --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const projectRoot = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@": projectRoot, + }, + }, + test: { + environment: "node", + include: ["tests/**/*.test.{ts,tsx}"], + setupFiles: ["tests/setup.ts"], + restoreMocks: true, + unstubEnvs: true, + unstubGlobals: true, + }, +});