From b64249b86afa9ffcc39cc570d59011fdc664ed34 Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:23:17 +0530 Subject: [PATCH 01/10] docs: plan Build Run 5 around people, Cards, and comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recenter the Site on Profiles and fair, hideable comparisons - Define React-rendered Cards and per-person Surviving Lines - Add ADRs 0020–0022 and update project context and state --- CONTEXT.md | 72 ++- IDEA.md | 419 +++++++++--------- STATE.md | 2 +- docs/adr/0020-the-site-is-about-people.md | 33 ++ ...eact-components-rendered-to-svg-and-png.md | 25 ++ ...s-come-from-blame-per-person-on-request.md | 24 + 6 files changed, 357 insertions(+), 218 deletions(-) create mode 100644 docs/adr/0020-the-site-is-about-people.md create mode 100644 docs/adr/0021-cards-are-react-components-rendered-to-svg-and-png.md create mode 100644 docs/adr/0022-surviving-lines-come-from-blame-per-person-on-request.md diff --git a/CONTEXT.md b/CONTEXT.md index 5f2cdc9..88e91a7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # commitscape -A tool that reads a git repository, locally or on the Site, and reports the things about it that change what you do next. Every number it shows must either alter a decision or be interesting enough to screenshot, and every number must be enterable. +A tool that shows a developer what they have built, how they stand next to the people they work with, and gives them something to share about it (ADR-0020). It reads git history, locally or on the Site, and GitHub's API. Every number says what it counts, and none is a guess. ## Language @@ -44,7 +44,7 @@ _Avoid_: age, freshness, last modified. **Code Age**: The distribution of surviving code by the quarter in which it was introduced. Until line-level history exists it is measured per file: each file's lines count toward the quarter the file first appeared. Distinct from Staleness, which is per-file and backward-looking from now. -_Avoid_: code lifetime, survival. +_Avoid_: code lifetime, Surviving Lines (a different, line-level measure). ### What we measure it over @@ -127,8 +127,12 @@ The code at HEAD drawn as nested rectangles, each as large as its lines of code, _Avoid_: treemap, tree view, file browser. **Card**: -A composited image summarizing a repository, rendered for sharing rather than for reading in a terminal: the Overview's story of all of history, unless a shorter Window is asked for. The tool's growth mechanism, treated as a product feature. -_Avoid_: summary image, badge (and report, which is the Report). +One of a set of images made for sharing, about a person, a person in a repository, a Versus, a Race, a Season or a repository: one React component, shown on the Site, embedded in a README as animated SVG, and used as a PNG link preview (ADR-0021). The tool's growth mechanism, treated as a product feature. +_Avoid_: summary image, badge, widget (and report, which is the Report). + +**Embed**: +A Card placed in a README or elsewhere by its address, served from a stored copy that never waits on GitHub. +_Avoid_: badge, widget, stats image. **Report**: Every answer the browser interface needs, for every Window, written ahead of time: it needs no server and answers only what it was written with. Kept as one file, stored on the Site for a repository, or uploaded as a Shared Report. @@ -143,7 +147,7 @@ One short row per commit (who, when, its subject line, its Commit Kind, how much _Avoid_: log, history, search index. **Wrapped**: -One person's year across every repository in a folder: only their own commits, under every address they commit with, told as a page and a Card. +One person's year, told as a page and a set of Cards: across GitHub on the Site, or across every repository in a folder in the CLI, under every address they commit with. _Avoid_: year in review, stats, recap. **Moment**: @@ -169,5 +173,59 @@ A repository someone let the Site read by choosing it in commitscape's GitHub Ap _Avoid_: linked repo, imported repo, private repo. **Leaderboard**: -A ranking of repositories, never of people, by something commitscape measures, such as Bus Factor or how quickly issues get a first answer. It says when it was built and from how many repositories. -_Avoid_: ranking of developers, top contributors, hall of fame. +A ranking of repositories, or of people who haven't hidden, by one view at a time, such as Bus Factor or merged pull requests this Season. It says when it was built and from how many repositories. +_Avoid_: score, top developers. + +### People + +**Profile**: +Everything commitscape shows about one GitHub login: their totals, their Archetype and Achievements, and the per-project breakdown. GitHub's facts show at once; the engine's numbers stream in when their Builds finish. Private work appears only as totals unless its person opts in. +_Avoid_: account, user page, dashboard. + +**Hidden**: +Said of a Profile whose person chose to stay out of comparisons: it appears in no Versus, Leaderboard, Race or other person's Standings. +_Avoid_: private profile, opted out. + +**Surviving Lines**: +The lines at a repository's head that blame attributes to a person, passing through Bulk Commits and ignored revisions, leaving out Generated Files and Prose Files (ADR-0022). Counted per person on request; "not counted" when over its time budget. +_Avoid_: lines owned, code alive, impact. + +**Survival**: +A person's Surviving Lines as a share of the lines they added in that repository. Shown only when both are known. +_Avoid_: retention, survival rate, code quality. + +**Standing**: +Where a person stands among a repository's people in one view, counted from the top, as Rank is for files. **Standings** are every person's Standing side by side, view by view, never combined into a score. A private repository's Standings are shown only to people who can see it. +_Avoid_: score, rating, leaderboard position. + +**Proof of Work**: +What a person shipped in a chosen period, grouped by repository and month, with links: merged pull requests and commits. Shared as a link or downloaded as Markdown or PDF. +_Avoid_: report (that is the Report), timesheet, invoice. + +**Versus**: +Two Profiles side by side, with a winner for each view and no overall winner. +_Avoid_: battle, duel, comparison score. + +**Rival**: +A person someone chose to measure themselves against, whose gap shows on their own Profile. +_Avoid_: friend, follow. + +**Archetype**: +A label for how a person works, from a written rule over their numbers, such as Reviewer (more reviews than pull requests) or Janitor (removes more lines than they add). Its rule is shown with it. +_Avoid_: personality, type, persona. + +**Achievement**: +A milestone a person reached, from a written rule, such as a first merged pull request in a repository with 10,000 stars. Each has a Card. +_Avoid_: badge, trophy. + +**Season**: +One calendar month, after which Crew Standings and people Leaderboards start again. +_Avoid_: period, sprint. + +**Race**: +A fixed window in which two or more people who accepted compare their Standings live, ending with a finish Card. +_Avoid_: challenge, competition, contest. + +**Crew**: +A group of people who accepted an invitation to compare themselves every Season. +_Avoid_: team, group, organisation. diff --git a/IDEA.md b/IDEA.md index 363d1ff..9c8feb3 100644 --- a/IDEA.md +++ b/IDEA.md @@ -1,258 +1,257 @@ # commitscape: the idea -Written 2026-09-25, after Build Run 3 shipped and the repository went on -GitHub (`pixelactstudio/commitscape`). This is the product brief for Build Run -4. It says what commitscape becomes, what changes, and in what order. -`STATE.md` tracks progress against it; `CONTEXT.md` is the glossary; -`docs/adr/` holds the decisions that are hard to reverse. Build Run 3's brief -is in git history (`git show ed40d28:IDEA.md`). +Written 2026-10-04, after the owner used the live Site +(`commitscape.damnlabs.com`) on their own repositories and on facebook/react +and reviewed it. This is the product brief for Build Run 5. It says what +commitscape becomes, what changes, and in what order. `STATE.md` tracks +progress against it; `CONTEXT.md` is the glossary; `docs/adr/` holds the +decisions that are hard to reverse. Build Run 4's brief is in git history +(`git show e54e961:IDEA.md`). --- ## In one sentence -Run one command on any git repository, or paste any GitHub link into the -Site, and see its story: who built it, who knows which part, what is fragile, -what changes together, and what you are about to forget. Every page is good -enough to screenshot. +commitscape shows a developer what they have built, how they stand next to +the people they work with, and gives them something good to post about it. + +## Why it changes + +commitscape started from one question: how much work have I put into this +project? The answer grew into a Report about a repository, and the Report +answers questions an auditor asks (what is fragile, who holds which folder, +what changes together), not the ones a developer arrives with. The owner's +review of 2026-10-04 found: + +- **Nothing on a repository page is about the person looking at it.** They + look, think "neat", and leave, with nothing to come back for and nothing + of theirs to share. +- **Git alone misses where the work is.** Most work is pull requests and + reviews, and most repositories squash-merge, so commit counts say little. + Kinds of work is empty wherever commits follow no convention, and the + People table is mostly dashes. +- **Several panels mean nothing to a visitor:** the story so far (a list of + first and last commits and releases), Did you know, Worth a look, Risk. +- **Repository analytics is already free elsewhere** (GitHub Insights, OSS + Insight, Repobeats, star-history), often better presented. +- **The interface feels slow and unfinished:** pages load grey and then turn + blue, skeletons replace whole pages instead of the missing part, the main + chart draws twice, release lines bury the chart on large repositories, + people have no avatars, and the logo is not the favicon or used anywhere + on the Site. + +Developer tools that reach tens of thousands of people share three things: +they are about *me*, they make something I post or embed (every embedded +README card is an advert), and they need nothing but a link. Build Run 5 +turns commitscape around to the person. ## Who it is for -- **A developer joining a codebase** who needs to know who to ask and where the - risk is. -- **A developer about to push** who wants to know what they forgot to change. -- **A maintainer or lead** who wants to see who holds which part of the code - before someone leaves. -- **Anyone choosing a dependency** who wants to know whether it is alive and - whether it depends on one person. -- **Anyone curious about their own work**, at the end of the year or before a - review. -- **Anyone on a headless machine** who wants to see all of this in the - laptop's browser without setting up Tailscale or port forwarding. +- **A developer proving their work:** a freelancer showing a client what + they shipped this month, anyone writing a self-review, anyone posting on + LinkedIn or X about what they built. +- **A developer on a team who likes to stay ahead:** they want to see how + they stand among the 23 people on their project, fairly. +- **Friends who compete:** versus pages, races and crews. +- **Anyone with a GitHub profile README** who wants a better card on it. +- **A maintainer** who wants to thank contributors with a hall of fame. +- Still: **anyone curious about a repository**, through the trimmed + repository page, and anyone using the CLI locally. ## What it must stay -- **Small.** One person maintains it. Every feature is useful, shareable, or - both. -- **Local first.** The CLI and its local browser interface stay complete - without the Site. Nothing about a repository leaves the machine unless the - user runs `commitscape share` (encrypted, ADR-0016) or asks the Site about - a repository on GitHub. +- **Small.** One person maintains it. Every feature is about a person and + makes something worth sharing, or it goes. - **Honest.** No number the data cannot support. "Unknown" is never 0. A - limitation is stated next to the number it affects ("lines not counted"). -- **Plain.** Every number in words a newcomer understands; `?` explains how - it was measured. -- **Fast.** Terminal UI first screen under 100 ms warm (ADR-0002). Local - browser interface usable within a second warm. On the Site, a stored Report - shows within a second, and commit search updates within a frame. -- **Free to run.** The Site fits Cloudflare's free Workers plan (ADR-0014). The - only other cost is the owner's existing VPS (ADR-0015). -- **Open source, all of it.** The CLI, the Site and the Builder live in this - repository under MIT OR Apache-2.0. -- **Built openly with AI.** That is how the project is made, not a feature of - the product. The product shows nothing AI-related. -- **No scores for people.** No productivity score and no ranking of - developers. Leaderboards rank repositories. + limitation is stated next to the number it affects. +- **Fair.** Standings rank by what is hard to inflate (Surviving Lines, + merged pull requests, reviews) before what is easy (commits, raw lines). + Bulk Commits, Generated Files and `.git-blame-ignore-revs` never count. +- **Private by default where it matters** (ADR-0020): private work is never + named to someone who can't see it, and anyone can hide from comparisons. +- **Server-rendered and streamed.** Every page is drawn on the server with + the data it has. Each slow part sits in its own Suspense boundary with a + skeleton of its final size, so nothing moves when data arrives. GitHub's + facts show at once; the engine's numbers stream in after. +- **Plain.** Every number in words a newcomer understands, with what it + counts ("60 commits", never a bare "60"). +- **Open source, all of it,** under MIT OR Apache-2.0. +- **Built openly with AI.** That is how the project is made, not a feature. + The product shows nothing AI-related. --- -## What changed since Build Run 3 +## What changed since Build Run 4 -The owner reviewed the finished Build Run 3 on 2026-09-25 and decided to grow -commitscape into something with a future, while keeping the local tool as the -heart of it. The following Build Run 3 decisions are **reversed**: - -| Build Run 3 said | Build Run 4 says | +| Build Run 4 said | Build Run 5 says | |---|---| -| No hosted service, no accounts, no uploading | A hosted Site with GitHub sign-in; uploading only as an encrypted Shared Report or when the user asks the Site about a GitHub repository | -| No Next.js, TanStack Start or Node server | The Site is TanStack Start on Cloudflare Workers. The local interface is still served by the binary (ADR-0010), and Rust never runs as a web server (ADR-0014) | -| The web UI is hand-rolled CSS | The web UI is built on Astryx (ADR-0018) | -| `web/` is the web app | A monorepo: `apps/local`, `apps/site`, `apps/builder`, `packages/ui`, `packages/data` (ADR-0013) | -| No HTTP client or TLS in the binary (ADR-0009) | `share` uploads over HTTPS (ADR-0016); GitHub is still asked through `gh` | - -Kept: the terminal UI stays **frozen** (bug fixes only). All interface work -goes to the browser interface, which the local page and the Site share. - ---- +| A repository is the centre of everything | A person is the centre; the repository page is secondary (ADR-0020) | +| No scores or rankings of people; Leaderboards rank repositories | People are compared, within a repository and with each other, by several views shown side by side, never one combined score. Anyone can hide from comparisons (ADR-0020) | +| The Card is one composited image of a repository | Cards are a set of React components, rendered as animated SVG for READMEs and PNG for previews (ADR-0021) | +| Code Age is per file; blame-based surviving lines are "later" | Surviving Lines per person, from blame, are a headline number (ADR-0022) | +| Hosted Reports have no pull-request history | The Site reads pull requests and reviews from GitHub's API for profiles, and the Builder keeps each repository's pull requests (ADR-0020) | -## Three ways to use it - -1. **Locally.** `commitscape` in a repository opens the browser interface - served by the binary, or the terminal UI where no browser can open. As - today, plus the Astryx interface, a Commits screen and a Share button. -2. **Share from a terminal.** `commitscape share` builds the Report locally, - encrypts it, uploads it, prints a link and **exits**. The terminal is free - at once. The link works on any browser for 4 hours (1 to 12 with - `--expires`). The page has a Delete button. The Site can't read what it - stores (ADR-0016). -3. **On the Site.** - - **Any public GitHub repository:** paste a link (or go to - `/gh//`). GitHub's facts show at once; the full Report - follows when the Builder has read the history (seconds for most, about - 15 s for a React-sized one the first time), and is instant for everyone - after. - - **Your repositories:** sign in with GitHub, pick repositories through - the GitHub App (read-only, ADR-0017), and open any of them. - - **Leaderboards:** repositories ranked by what commitscape measures. - -### How the pieces fit - -``` - Browser ──────────────► Site: TanStack Start on Cloudflare Workers (free plan) - │ pages: static assets - │ decrypts Shared /api/*: auth, Reports, Shares, Builds, webhooks - │ Reports; searches D1: users, sessions, repositories, Builds, Shares - │ commits R2: Reports (gzip), encrypted Shared Reports - │ │ a Build (HTTPS + HMAC) - │ ▼ - │ Builder on the owner's VPS: TypeScript job runner - │ git clone → `commitscape report --data` → the Site → R2 - │ - A user's machine: `commitscape` (local page, TUI, `share`, `check`, …) -``` - -Every screen, in all three ways, is the same code (`packages/ui`), reading -through a Data Source (ADR-0013): the local server, an inlined Report, or a -fetched Report. +Kept: the Rust engine, identity merging (ADR-0011), the Builder and its +queue, R2, Postgres, GitHub sign-in through the App (ADR-0017), Shared +Reports, the CLI, `npx commitscape`, and the MCP server. The terminal UI +stays frozen. --- -## The browser interface, on Astryx - -- **Astryx for every component** (ADR-0018): app shell and top navigation, - tabs, tables for people, files and hotspots, tooltips and hover cards, - dialogs, toasts, avatars, a ⌘K command palette ("Jump to": a screen, a - person, a folder, a file, a commit). One commitscape theme in light and - dark, plus "system". -- **Charts stay ours,** restyled with Astryx's tokens. Astryx's canary - charts replace bar or line charts only if a side-by-side screenshot shows - they're better; Recharts is the fallback. -- **Keyboard first,** as npmx.dev does it: `/` focuses search, `1`–`6` pick a - screen, `?` shows every shortcut and highlights the keys on screen, - shortcuts off while typing. -- **Avatars.** People linked to a GitHub login show their GitHub avatar. - Locally the browser fetches it from GitHub unless `--offline`. README's - "What leaves your machine" says so. -- **Six screens:** Overview, Activity, People, Map, Risk and **Commits** - (ADR-0019): search every commit by message, person, date and kind, - instantly, with a link to GitHub. The terminal UI doesn't get it. -- **Share button** on the local page: the same as `commitscape share`, with - the link to copy. - ## The Site -Few pages, each doing one thing well. npmx.dev is the reference for feel: -a big search box, instant response, keyboard shortcuts, calm layout. - | Page | What it is | |---|---| -| `/` | Landing page: one line on what commitscape is; a box to paste a GitHub link (with suggestions); the three ways to use it, each with its command or button; a few leaderboard highlights; install commands | -| `/gh//` | A repository's Report: the six screens, with GitHub's instant facts first and "updating" while a newer Build runs. Private ones only for signed-in users who can see them on GitHub | -| `/s/#` | A Shared Report, decrypted in the browser, with its expiry and a Delete button | -| `/me` | Signed in: your repositories through the GitHub App, "Add repositories", "Delete my data" | -| `/leaderboards` | Repository rankings, rebuilt daily as static pages | -| `/privacy` | Exactly what is stored, where, for how long, and who can read it | - -The top navigation has the command palette and a **Connect** menu: -"Share from your terminal" (shows the `share` command) and "Sign in with -GitHub". - -**Social previews.** Every repository page has an Open Graph image: its -Card, written as a PNG or SVG when its Report is built and served from R2. - -## Leaderboards - -Repositories only, never people. Built by the Builder from a seed list of -popular repositories per language (from GitHub search: most stars), a -budgeted number per night, so the VPS isn't overrun. First boards: - -- **Resting on one person:** popular projects with a Bus Factor of 1. -- **Most maintainers active** in the last 90 days. -- **Most active this month** by commits and by people. -- **Fastest to answer issues** (from `health`'s numbers). -- **Oldest code still running:** the largest share of lines untouched for five - years. - -Each row links to the repository's page. Boards say when they were built and -from how many repositories. - -## Security, in one place - -- **Shared Reports:** AES-256-GCM, key only in the link's fragment, removed - from the address bar on load; Delete Token derived from the key and stored - hashed; IDs of 128 random bits; 4 hours by default, 12 at most; 25 MB cap; - per-IP limits (ADR-0016). -- **GitHub:** one GitHub App, read-only permissions; installation tokens live - an hour and are never stored; access re-checked on every view; webhooks - verified; uninstall and "Delete my data" delete everything (ADR-0017). -- **Builder:** HMAC-authenticated Builds, one at a time, size and time caps, - clones deleted after private Builds (ADR-0015). -- **Hosted Commit Lists carry no email addresses** (ADR-0019). -- **The local server** keeps ADR-0010's token and `Host` checks. - -## The name - -`commitscape` may be renamed before the first npm release. Nothing is -published and no domain is bought during this build run. The product name -and the Site's origin each live in one constant in `packages/data` (ADR-0014); -the CLI's Site origin is overridable with `COMMITSCAPE_SITE`. Code and docs -use `commitscape` until the owner decides. +| `/` | One line on what commitscape is, a box that takes a GitHub username or a repository link, "See your own" with sign-in, example profiles and cards, install commands | +| `/u/` | **The Profile.** GitHub identity and avatar; totals (pull requests opened, merged and closed, reviews given, median time to merge, commits, lines added and removed, Surviving Lines, active days, longest streak); the Archetype and Achievements; the per-project breakdown; activity over time; languages over the years; the people they work with most | +| `/u///` | **You in this repository.** Your numbers first, then your Standing, then the Standings of everyone in it, each with avatar, pull requests, reviews, lines and Surviving Lines, and what each person works on | +| `/u//cards` | **The card gallery.** Every Card for this person, in light and dark, with the README Markdown to copy, a PNG to download, and share to LinkedIn and X | +| `/u//work` | **Proof of Work.** Everything shipped in a chosen period, grouped by repository and month, with links; a link to share and Markdown and PDF to download | +| `/vs//` | **Versus.** Two Profiles side by side, a winner per view, with its own Card | +| `/races/` | **A Race.** A fixed window, two or more people who agreed to it, live Standings, a finish Card | +| `/crews/` | **A Crew.** A group that compares itself every Season | +| `/gh//` | **The repository page,** trimmed (below), with contributors shown as people with avatars | +| `/leaderboards` | Repositories as before, plus people: top contributors this month, per repository, with a time filter. Only people who haven't hidden | +| `/me` | Settings: Connected Repositories, Rivals, Crews, what is shown and hidden, "Delete my data" | +| `/s/#` | A Shared Report, unchanged | +| `/privacy` | Rewritten for Profiles, comparisons and Cards | + +### Two speeds + +A Profile has two sources (ADR-0020): +1. **GitHub's API, at once:** identity, avatar, repositories, pull requests + with their additions and deletions, reviews, issues, contribution + calendar. Read with the signed-in viewer's own token; anonymous views + use the Site's token and a stored copy. +2. **The engine, streamed in:** for each repository the person works on that + the Site has built, their Lines Changed and Surviving Lines, under every + address they commit with. Each section shows "counting…" in its own + skeleton until its Build finishes, then fills in. + +### The repository page, trimmed + +- **Kept:** the stat tiles; one chart of commits over time with releases + drawn only where they fit (the story so far merges into it); contributors + with avatars, pull requests and +/− lines; Languages; Commits with + avatars; the Map, as something fun to explore. +- **Removed from the Site:** the story so far as a list, Did you know, Worth + a look, Risk. Risk stays in the CLI and the MCP server, where an agent or + a lead asks for it. +- **Hidden when empty:** Kinds of work in a repository with no commit + convention, and any column that would be all dashes. +- **Search** across the page: people, files, commits. + +## Cards + +A Card is one React component rendered three ways (ADR-0021): on the Site +as itself, as **SVG** for README embeds (animated: numbers count up, bars +grow, the calendar fills), and as **PNG** for link previews and downloads. +Every page has a dynamic preview image, like GitHub's, from a Card. + +The set, each in light and dark: +- **Totals:** pull requests, reviews, lines, Surviving Lines. +- **Top repositories:** where the person's work is, with their Standing in + each. +- **Survival:** "41k of the 120k lines I wrote still run." +- **Calendar:** the contribution calendar, animated. +- **Languages over the years.** +- **Archetype** and **Achievements.** +- **Person in a repository:** "Top 3% of facebook/react contributors." +- **Versus**, **Race finish**, **Season recap**. +- **Hall of fame** for maintainers: a repository's contributors with avatars + and their numbers. +- **Proof of Work:** a period's summary. + +Embeds are served from a stored copy, refreshed in the background at most +every six hours, so a popular README never reaches GitHub's API. + +## The fun side + +- **Versus:** any two public Profiles, a winner per view, no overall score. +- **Rivals:** pick a person; your Profile always shows the gap ("312 + Surviving Lines behind this month"). +- **Archetypes:** a label from plain rules over the numbers, no AI: Builder, + Reviewer, Janitor (removes more than adds), Night Owl, Firefighter (mostly + fixes), Polyglot, and a few more, each with its rule shown. +- **Achievements:** "First pull request merged into a 10k-star repository", + "100 reviews", "A line that has survived five years", "Removed 10k lines in + one pull request". Each has a Card. +- **Seasons:** Standings in a Crew and on the people boards reset monthly so + a newcomer can win; each Season ends with a recap Card. +- **Races:** a fixed window, two or more people who each accepted, live + Standings, a finish Card. +- **Crews:** a group of friends or a team with shared Standings each Season. + Joining is by invitation, accepted. + +## Proof of Work + +The original problem, answered directly: pick a period, optionally a +repository or a client's organisation, and get every merged pull request and +commit in it, grouped by repository and month, with sizes and links. Share it +as a link, or download Markdown or a PDF for a client or a self-review. +Private work appears only to the signed-in person, and in what they choose +to share. + +## What to watch + +- **Privacy** (ADR-0020). A private repository's Standings are shown only to + people who can see it on GitHub. A Profile shows private work as totals, + never by name, unless the person opts in. A signed-in person can hide + their Profile from Versus, Leaderboards and other people's Standings. + Races and Crews need each person's acceptance. +- **Cost of Surviving Lines** (ADR-0022). Blame is expensive on huge + repositories, so it runs per person, only over the files they changed, + when someone asks, and is kept per repository head. Over a time budget it + says "not counted", never a guess. +- **GitHub's rate limits** (ADR-0020). The viewer's own token where there is + one; stored copies everywhere else; embeds never call GitHub. +- **Gaming.** Standings lead with Surviving Lines and merged pull requests. + Raw commits and raw lines are shown, never ranked first. +- **The name.** commitscape still fits. It is not changed in this run. --- -## Build Run 4: the plan +## Build Run 5: the plan -Phases continue from Build Run 3. Each phase ends with every check passing -(Rust on Linux, macOS and Windows; the TypeScript workspace; Playwright) and -`STATE.md` updated with its measured numbers and findings. **The agent +Phases continue from Build Run 4. Each phase ends with every check passing +(Rust on Linux, macOS and Windows; the TypeScript workspace; Playwright) +and `STATE.md` updated with its measured numbers and findings. **The agent doing the work never commits, pushes, publishes or deploys; the owner commits.** | Phase | What | Gate | |---|---|---| -| 23 | Monorepo (ADR-0013): pnpm + Turborepo; `web/` → `apps/local`; `packages/ui` and `packages/data`; the Data Source seam; every path that named `web/` (build.rs, assets.rs, flake, CI, release, scripts, README) | Every existing test passes from the new layout (Rust, vitest, Playwright); `cargo build` embeds the app; `nix build` builds; actionlint passes on both workflows | -| 24 | Astryx (ADR-0018): theme, app shell, every screen's components, command palette, keyboard shortcuts, avatars; charts restyled; canary charts tried side by side | Playwright tests pass; screenshots of every screen in both themes on ripgrep in `target/preview/web/`; size of the embedded app before and after; a keyboard-only walk through every screen | -| 25 | Commit search (ADR-0019): subjects in the index (cache version bump), `/api/commits`, the Commit List in Reports, the Commits screen | Hand-worked fixture values; goldens and the generated types updated; search time and list size measured on facebook/react and rust-lang/rust | -| 26 | The Site's foundation (ADR-0014): `apps/site`, landing page, repository page reading a stored Report, the fetched-Report Data Source, D1 schema and migrations, rate limiting, `/privacy` | Under `wrangler dev`, Playwright passes on the landing page and on a repository page from a fixture's Report; CPU time of every API handler recorded (under 10 ms) | -| 27 | Builder and public lookup (ADR-0015): `commitscape report --data`, clone policy and threshold, `apps/builder`, instant GitHub facts, refresh after 24 hours, Open Graph Cards | End to end on this machine (`wrangler dev` + the Builder): ripgrep and facebook/react built and shown, times recorded; not-found, private, too-big and timed-out repositories each show a plain message | -| 28 | Sharing (ADR-0016): `commitscape share` (with `--expires`, `--delete`, `--list`, `--yes`), the Share button, the `/s/` page, expiry via Cron Trigger | End to end on this machine, CLI to browser; the key never appears in any request (checked in Playwright); a tampered ciphertext fails; expired answers 410; CI green on all three operating systems with the new crates | -| 29 | GitHub sign-in (ADR-0017): the App's user sign-in, `/me`, installations, access checks, webhooks, retention, "Delete my data" | Tests against GitHub responses recorded by hand (as `commitscape-forge` does) and, if the owner has created a test App, against GitHub itself; webhook signatures and access checks covered | -| 30 | Leaderboards: seed list, nightly budget, the five boards as static pages | 50 or more seed repositories built on this machine; boards rendered and screenshotted | -| 31 | Ready to launch: README for all three ways, "What leaves your machine" rewritten, `DEPLOY.md` (Cloudflare, the VPS, the GitHub App, secrets), a security pass over every endpoint | Every CI job green; `DEPLOY.md` followed from scratch against local stand-ins; nothing is left for the owner but the steps listed under "Needs the owner" | +| 32 | Foundation: server rendering with a Suspense boundary and sized skeleton per section; the theme applied on the server (no grey flash); the logo as favicon and in the header; the chart drawn once; releases thinned on dense charts; the repository page trimmed as above, with avatars and search | Playwright on every page; Cumulative Layout Shift under 0.05 on each, measured; screenshots in both themes; no request after hydration repeats one the server made | +| 33 | The Profile, first speed (ADR-0020): `/u/` from GitHub's API, stored copies in Postgres, the viewer's token, the per-project breakdown, the new landing page | Tests against GitHub responses written by hand; the owner's Profile and two well-known ones rendered and screenshotted; GitHub API calls per view counted and recorded | +| 34 | The Profile, second speed (ADR-0022): Surviving Lines in the engine, per person and per repository head; the Builder computes them on request; Lines Changed per person; sections stream in | Hand-worked fixture values for Surviving Lines, Bulk Commits and ignored revisions; time measured on ripgrep, facebook/react and rust-lang/rust; over budget shows "not counted" | +| 35 | You in this repository: `/u///`, Standings, the Builder keeping each repository's pull requests and reviews, privacy rules and hiding, `/privacy` rewritten, MCP tools for a Profile and Standings | Access tests: a private repository's Standings refused to someone without access; a hidden person absent everywhere; pull-request fetch time on facebook/react recorded | +| 36 | Cards (ADR-0021): the rendering pipeline, the set above, animation, light and dark, the gallery, embeds with stored copies, a preview image on every page | Every Card snapshotted as SVG and PNG; an embed shown in a real README on GitHub (owner's test repository); render time per Card under 200 ms, measured | +| 37 | Proof of Work: `/u//work`, filters, share link, Markdown and PDF | A month of the owner's work checked against GitHub by hand; private items absent from a shared link unless chosen | +| 38 | Versus and Rivals | Fixture Profiles give the winners worked out by hand; a hidden Profile refused | +| 39 | Archetypes and Achievements, each rule written in `CONTEXT.md` and on the page | Every rule tested at its edges on fixtures | +| 40 | Seasons, Races and Crews: invitations, acceptance, live Standings, recap and finish Cards | End to end with three test accounts; leaving a Crew removes the person from it at once | +| 41 | People on the Leaderboards: top contributors this month, per repository, with a time filter and Seasons | Boards built from the seed repositories and screenshotted; hidden people absent | +| 42 | Wrapped 2026: a person's year across GitHub, as a page and a set of Cards | The owner's Wrapped checked against GitHub by hand | ### Needs the owner -These can't be done by an agent and aren't part of any gate: -- choosing the name, buying the domain, publishing to npm and Homebrew -- creating the Cloudflare account, D1 database, R2 bucket and secrets, and - deploying -- setting up the VPS from `DEPLOY.md` -- creating the GitHub App (a test one earlier helps Phase 29) -- GitHub Sponsors (`.github/FUNDING.yml` once the account exists) +- Adding the "Read user profile" permission to the GitHub App if Phase 33 + finds it needed (it is read-only). +- A test repository on GitHub to show an embed in a real README (Phase 36). +- Two more GitHub accounts, or friends, to test Races and Crews (Phase 40). +- Deploying each phase when they choose. ## Later, not in this run -- A live mode for Shared Reports, if people ask for it. -- A paid team plan: private-repository dashboards, weekly emails ("the only - person who knows `payments/` hasn't committed in 60 days"), `check` as a - bot on private pull requests. Public repositories stay free. -- Sponsor slots on the landing page and leaderboards, once there is traffic. -- `commitscape ssh host:path`, the replay video, GitLab, blame-based - surviving lines, replacing `bincode`. - -## Launch - -Unchanged in spirit: aim for early December 2026, Wrapped season. The post -shows the owner's Wrapped, a repository card, and the Site's "paste any -GitHub link", and says plainly that the project was built with AI agents. +- Notifications for Rivals, Races and Crews (email, then maybe push). +- A paid plan for agencies: Proof of Work across a team, for clients. +- GitLab. ## Not doing -- A live relay or tunnel between a user's machine and the Site (ADR-0016). -- A Rust web server anywhere (ADR-0014). -- Anything that needs Cloudflare's paid plan. -- Scores or rankings of people. -- AI attribution of any kind. +- One combined score for a person. +- Anything AI in the product. - New features in the terminal UI. - Email addresses on the Site. +- Renaming the project in this run. - Publishing, deploying or buying anything during the build run. diff --git a/STATE.md b/STATE.md index e21bc95..9c35afc 100644 --- a/STATE.md +++ b/STATE.md @@ -3,7 +3,7 @@ Running log for Build Run 1 (Phases 0 to 7). Written so a fresh session with no context can read this plus `docs/adr/` and continue without asking anything. -**Current position:** Build Run 4 (Phases 23 to 31) is done, all of it uncommitted in the working tree: its brief is `IDEA.md`, with ADR-0013 to ADR-0019. Every phase's findings and the files it created, changed or deleted are below. Build Run 3 is done (Phases 13 to 22). The repository is on GitHub (`pixelactstudio/commitscape`) and CI is green on Linux, macOS and Windows for what is committed. Nothing is published or deployed: the name may still change. +**Current position:** Build Run 5 (Phases 32 to 42) is planned and not started: its brief is `IDEA.md`, with ADR-0020 to ADR-0022, written 2026-10-04 after the owner's review of the live Site. Build Run 4 (Phases 23 to 31) is done, all of it uncommitted in the working tree: its brief is `IDEA.md`, with ADR-0013 to ADR-0019. Every phase's findings and the files it created, changed or deleted are below. Build Run 3 is done (Phases 13 to 22). The repository is on GitHub (`pixelactstudio/commitscape`) and CI is green on Linux, macOS and Windows for what is committed. Nothing is published or deployed: the name may still change. Build Run 1 (Phases 0 to 7) built a correct, fast tool. Build Run 2 (Phases 8 to 12) made it fun and visual. On 2026-09-24 the owner used it on their own repositories and reviewed it. The review and the decisions that followed are diff --git a/docs/adr/0020-the-site-is-about-people.md b/docs/adr/0020-the-site-is-about-people.md new file mode 100644 index 0000000..768b5f3 --- /dev/null +++ b/docs/adr/0020-the-site-is-about-people.md @@ -0,0 +1,33 @@ +# The Site is about people; people are compared, never scored, and can hide + +The Site's centre moves from a repository's Report to a person's Profile. People are compared within a repository and with each other, view by view, with no combined score. Private work is never named to someone who can't see it, and anyone can hide from comparisons. A Profile reads GitHub's API at once and the engine's numbers stream in after. + +## Status + +accepted (2026-10-04, Build Run 5 plan). Reverses Build Run 4's "No scores for people. Leaderboards rank repositories." Extends ADR-0017. + +## Context + +The owner reviewed the live Site on 2026-10-04. A repository's Report answers an auditor's questions, and nothing on it is about the visitor, so nobody returns or shares it. The owner's original question was "how much have I put into this project", and they compare themselves with the people on their team to stay ahead. Developer tools that spread are about the person and make something they post. + +Ranking people carries risks: shaming, gaming, and naming private work. GitHub's API also has rate limits: 5,000 requests an hour per token. + +## Decision + +- **A Profile per GitHub login** (`/u/`), built from public data for anyone, and from private data only for the signed-in person themselves. +- **Two speeds.** GitHub's GraphQL API gives identity, repositories, pull requests with additions and deletions, reviews, issues and the contribution calendar at once. The engine gives Lines Changed and Surviving Lines (ADR-0022) for repositories the Site has built, streamed into their sections. +- **Tokens.** A signed-in viewer's requests use their own token. Anonymous views use the Site's token and a stored copy in Postgres, refreshed after a day. Embeds and preview images never call GitHub (ADR-0021). +- **Comparisons, not scores.** Standings, Versus, Races, Crews and the people Leaderboards show several views side by side: merged pull requests, reviews, Surviving Lines, Lines Changed, commits. They lead with the views that are hard to inflate. There is never one number for a person. +- **Privacy rules:** + - A private repository's Standings are shown only to people who can see the repository on GitHub, checked as ADR-0017 checks access. + - A Profile counts private work only in totals and never names a private repository, unless its person opts in. + - A signed-in person can hide their Profile. A hidden person appears in no Versus, Leaderboard or other person's Standings, and their Profile says only that it is hidden. + - Races and Crews include a person only after they accept. +- **The Builder keeps each built repository's pull requests and reviews,** fetched once and then only what changed, so Standings have more than commits. + +## Consequences + +- The App may need the read-only "Read user profile" permission. The owner adds it if Phase 33 shows it is needed. +- Postgres gains Profiles, stored GitHub copies, hiding, Rivals, Races, Crews and Seasons. +- `/privacy` is rewritten to say what a Profile shows and how to hide. +- The repository page stays, trimmed, as a secondary page. diff --git a/docs/adr/0021-cards-are-react-components-rendered-to-svg-and-png.md b/docs/adr/0021-cards-are-react-components-rendered-to-svg-and-png.md new file mode 100644 index 0000000..fc2e1c0 --- /dev/null +++ b/docs/adr/0021-cards-are-react-components-rendered-to-svg-and-png.md @@ -0,0 +1,25 @@ +# Cards are React components, rendered to SVG for READMEs and PNG for previews + +Each Card is written once as a React component with inline styles. The Site shows it as a component; Satori turns it into SVG for README embeds, with CSS animation added; resvg turns that SVG into PNG for link previews and downloads. + +## Status + +accepted (2026-10-04, Build Run 5 plan). Replaces the single composited repository Card. + +## Context + +The owner wants a set of Cards a person picks from, animated README embeds, and dynamic preview images like GitHub's. GitHub strips HTML, styles and scripts from READMEs and serves images through its own caching proxy, so only an image survives there. An SVG with CSS keyframes animates inside a README; a GIF is larger, blurry when scaled and has no dark mode. Designing in HTML and CSS is faster and better than writing SVG by hand. + +## Decision + +- **One component per Card,** in `packages/ui`, using only the CSS Satori supports (flexbox, no grid). +- **SVG:** Satori renders the component; a small post-processing step adds keyframe animation (counting numbers, growing bars, a filling calendar) to marked elements. Light and dark come as two URLs, for GitHub's `` with `prefers-color-scheme`. +- **PNG:** resvg renders the same SVG, unanimated, for Open Graph images and downloads. +- **Fonts** are bundled files, so output is the same on every machine. +- **Stored copies.** A Card is rendered when its data changes, kept in R2, and served with `cache-control` of six hours. A request never waits on GitHub or the Builder; a stale Card is served and refreshed in the background. + +## Consequences + +- Satori's CSS subset limits Card layouts, and some fine typography won't come out the same as on the Site. +- Every Card has SVG and PNG snapshot tests. +- The Builder's existing resvg dependency moves to the Site, or Card rendering moves to the Builder. Phase 36 decides by measured render time. diff --git a/docs/adr/0022-surviving-lines-come-from-blame-per-person-on-request.md b/docs/adr/0022-surviving-lines-come-from-blame-per-person-on-request.md new file mode 100644 index 0000000..0a3b29e --- /dev/null +++ b/docs/adr/0022-surviving-lines-come-from-blame-per-person-on-request.md @@ -0,0 +1,24 @@ +# Surviving Lines come from blame, per person, on request, and are kept per head + +A person's Surviving Lines are the lines at a repository's head that blame attributes to them. They are computed only over the files that person changed, when someone asks, and kept for that head. Over a time budget the number is "not counted", never an estimate. + +## Status + +accepted (2026-10-04, Build Run 5 plan). Builds on ADR-0011 (identities) and ADR-0012 (line counts in a background pass); goes further than Code Age's per-file measure. + +## Context + +"41k of the 120k lines I wrote still run" is the number nobody else shows, since it needs the history itself, and it is the hardest one to inflate. Blaming every file of a large repository takes minutes (rust-lang/rust has over 100,000 files), and a reformat would take the credit for every line it touched. + +## Decision + +- **Blame at the head**, attributing each line to an Author Identity. Bulk Commits and commits in `.git-blame-ignore-revs` are passed through, so their lines go to whoever wrote them before. Generated Files and Prose Files are not counted. +- **Per person, on request:** only files at the head that the person ever changed are blamed. The result is kept per repository, head and person. A new head means a new count, starting from the files changed since. +- **A time budget per request,** measured in Phase 34. Beyond it the Profile says "not counted for this repository" next to the number. +- **Survival** is Surviving Lines divided by the lines the person added in that repository (Lines Changed). It is shown only when both are known. + +## Consequences + +- The engine gains a blame pass, through gix, behind the existing `RepoSource` seam with a scripted fake for tests. +- The Builder keeps clones of repositories that people have asked about, within a disk budget, so later requests don't clone again. +- Code Age keeps its per-file meaning; Surviving Lines is a separate term. From 351af3502ba81d30a1915fbe9000d68248ef3f9c Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:48:45 +0530 Subject: [PATCH 02/10] feat(phase-32): build Profiles, comparisons, Cards, and Wrapped - Add Profiles, Standings, Surviving Lines, and repository insights - Add Cards, Proof of Work, Versus, Rivals, Races, Crews, and Wrapped - Add pull-request data, privacy controls, and leaderboard views --- CONTEXT.md | 23 +- DEPLOY.md | 6 +- README.md | 23 +- STATE.md | 234 +- apps/builder/Dockerfile | 2 +- apps/builder/package.json | 5 +- apps/builder/src/builder.test.ts | 9 +- apps/builder/src/config.ts | 2 + apps/builder/src/github.ts | 23 + apps/builder/src/job.test.ts | 33 +- apps/builder/src/job.ts | 52 +- apps/builder/src/main.ts | 41 +- apps/builder/src/people.test.ts | 54 + apps/builder/src/people.ts | 76 + apps/builder/src/pulls.test.ts | 80 + apps/builder/src/pulls.ts | 117 + apps/builder/src/run.ts | 38 +- apps/builder/src/survival.test.ts | 66 + apps/builder/src/survival.ts | 67 + apps/site/e2e/accounts.ts | 30 + apps/site/e2e/boards.spec.ts | 23 + apps/site/e2e/cards.spec.ts | 50 + apps/site/e2e/foundation.spec.ts | 95 + apps/site/e2e/github.ts | 11 + apps/site/e2e/graphql.ts | 160 ++ apps/site/e2e/profile.spec.ts | 117 + apps/site/e2e/races.spec.ts | 89 + apps/site/e2e/site.spec.ts | 19 +- apps/site/e2e/start.ts | 2 + apps/site/e2e/versus.spec.ts | 42 + apps/site/e2e/work.spec.ts | 31 + apps/site/e2e/wrapped.spec.ts | 19 + apps/site/package.json | 4 + apps/site/playwright.config.ts | 1 + apps/site/public/favicon.svg | 2 +- apps/site/src/components/Boundary.tsx | 22 + apps/site/src/components/CardBox.tsx | 37 + apps/site/src/components/Connect.tsx | 32 - apps/site/src/components/Facts.tsx | 78 +- apps/site/src/components/Frame.tsx | 124 +- apps/site/src/components/Membership.tsx | 65 + apps/site/src/components/Rivals.tsx | 60 + apps/site/src/components/Start.tsx | 116 + apps/site/src/components/States.tsx | 26 +- apps/site/src/components/WindowTable.tsx | 41 + apps/site/src/functions/account.ts | 18 +- apps/site/src/functions/cards.ts | 25 + apps/site/src/functions/profiles.ts | 43 + apps/site/src/functions/races.ts | 71 + apps/site/src/functions/repos.ts | 2 +- apps/site/src/functions/standings.ts | 39 + apps/site/src/functions/versus.ts | 46 + apps/site/src/functions/work.ts | 45 + apps/site/src/lib/hydrated.ts | 12 + apps/site/src/lib/markdown.ts | 11 + apps/site/src/lib/queries.ts | 62 +- apps/site/src/lib/target.ts | 11 + apps/site/src/routeTree.gen.ts | 477 +++- apps/site/src/routes/__root.tsx | 55 +- .../site/src/routes/api/cards/$owner/$repo.ts | 17 - .../src/routes/api/cards/crews/$id/$file.ts | 6 + .../routes/api/cards/gh/$owner/$repo/$file.ts | 6 + .../src/routes/api/cards/races/$id/$file.ts | 6 + apps/site/src/routes/api/cards/site/$file.ts | 19 + .../src/routes/api/cards/u/$login/$file.ts | 6 + .../api/cards/u/$login/$owner/$repo/$file.ts | 6 + .../api/cards/u/$login/wrapped/$year/$file.ts | 6 + .../src/routes/api/cards/vs/$a/$b/$file.ts | 6 + apps/site/src/routes/api/work/s/$id/$file.ts | 9 + .../src/routes/api/work/u/$login/$file.ts | 18 + apps/site/src/routes/crews.$id.tsx | 54 + apps/site/src/routes/crews.index.tsx | 15 + apps/site/src/routes/gh.$owner.$repo.tsx | 187 +- apps/site/src/routes/index.tsx | 148 +- apps/site/src/routes/leaderboards.tsx | 188 +- apps/site/src/routes/me.tsx | 214 +- apps/site/src/routes/privacy.tsx | 76 +- apps/site/src/routes/races.$id.tsx | 60 + apps/site/src/routes/races.index.tsx | 15 + apps/site/src/routes/s.$id.tsx | 16 +- apps/site/src/routes/u.$login.tsx | 237 ++ .../src/routes/u.$login_.$owner.$repo.tsx | 88 + apps/site/src/routes/u.$login_.cards.tsx | 88 + apps/site/src/routes/u.$login_.work.tsx | 146 ++ apps/site/src/routes/u.$login_.work_.$id.tsx | 55 + .../src/routes/u.$login_.wrapped.$year.tsx | 111 + apps/site/src/routes/vs.$a.$b.tsx | 129 ++ apps/site/src/routes/you.tsx | 7 + .../__snapshots__/cards/achievement-dark.png | Bin 0 -> 42632 bytes .../cards/achievement-dark.png.sha256 | 1 + .../__snapshots__/cards/achievement-light.png | Bin 0 -> 43584 bytes .../cards/achievement-light.png.sha256 | 1 + .../__snapshots__/cards/archetype-dark.png | Bin 0 -> 47771 bytes .../cards/archetype-dark.png.sha256 | 1 + .../__snapshots__/cards/archetype-light.png | Bin 0 -> 48344 bytes .../cards/archetype-light.png.sha256 | 1 + .../__snapshots__/cards/calendar-dark.png | Bin 0 -> 49537 bytes .../cards/calendar-dark.png.sha256 | 1 + .../__snapshots__/cards/calendar-light.png | Bin 0 -> 49806 bytes .../cards/calendar-light.png.sha256 | 1 + .../__snapshots__/cards/hall-of-fame-dark.png | Bin 0 -> 58660 bytes .../cards/hall-of-fame-dark.png.sha256 | 1 + .../cards/hall-of-fame-light.png | Bin 0 -> 60967 bytes .../cards/hall-of-fame-light.png.sha256 | 1 + .../__snapshots__/cards/languages-dark.png | Bin 0 -> 33855 bytes .../cards/languages-dark.png.sha256 | 1 + .../__snapshots__/cards/languages-light.png | Bin 0 -> 34247 bytes .../cards/languages-light.png.sha256 | 1 + .../__snapshots__/cards/preview-dark.png | Bin 0 -> 96501 bytes .../cards/preview-dark.png.sha256 | 1 + .../__snapshots__/cards/preview-light.png | Bin 0 -> 98487 bytes .../cards/preview-light.png.sha256 | 1 + .../server/__snapshots__/cards/race-dark.png | Bin 0 -> 51237 bytes .../__snapshots__/cards/race-dark.png.sha256 | 1 + .../server/__snapshots__/cards/race-light.png | Bin 0 -> 52360 bytes .../__snapshots__/cards/race-light.png.sha256 | 1 + .../__snapshots__/cards/repositories-dark.png | Bin 0 -> 50769 bytes .../cards/repositories-dark.png.sha256 | 1 + .../cards/repositories-light.png | Bin 0 -> 52134 bytes .../cards/repositories-light.png.sha256 | 1 + .../__snapshots__/cards/season-dark.png | Bin 0 -> 51237 bytes .../cards/season-dark.png.sha256 | 1 + .../__snapshots__/cards/season-light.png | Bin 0 -> 52360 bytes .../cards/season-light.png.sha256 | 1 + .../__snapshots__/cards/standing-dark.png | Bin 0 -> 59547 bytes .../cards/standing-dark.png.sha256 | 1 + .../__snapshots__/cards/standing-light.png | Bin 0 -> 61381 bytes .../cards/standing-light.png.sha256 | 1 + .../__snapshots__/cards/survival-dark.png | Bin 0 -> 41280 bytes .../cards/survival-dark.png.sha256 | 1 + .../__snapshots__/cards/survival-light.png | Bin 0 -> 42368 bytes .../cards/survival-light.png.sha256 | 1 + .../__snapshots__/cards/totals-dark.png | Bin 0 -> 41739 bytes .../cards/totals-dark.png.sha256 | 1 + .../__snapshots__/cards/totals-light.png | Bin 0 -> 42655 bytes .../cards/totals-light.png.sha256 | 1 + .../__snapshots__/cards/versus-dark.png | Bin 0 -> 71627 bytes .../cards/versus-dark.png.sha256 | 1 + .../__snapshots__/cards/versus-light.png | Bin 0 -> 73317 bytes .../cards/versus-light.png.sha256 | 1 + .../cards/wrapped-calendar-dark.png | Bin 0 -> 44920 bytes .../cards/wrapped-calendar-dark.png.sha256 | 1 + .../cards/wrapped-calendar-light.png | Bin 0 -> 45083 bytes .../cards/wrapped-calendar-light.png.sha256 | 1 + .../__snapshots__/cards/wrapped-dark.png | Bin 0 -> 133788 bytes .../cards/wrapped-dark.png.sha256 | 1 + .../__snapshots__/cards/wrapped-light.png | Bin 0 -> 135502 bytes .../cards/wrapped-light.png.sha256 | 1 + apps/site/src/server/cards.test.ts | 98 + apps/site/src/server/cards.ts | 210 ++ apps/site/src/server/engine.test.ts | 60 + apps/site/src/server/engine.ts | 104 + apps/site/src/server/graphql.ts | 22 + apps/site/src/server/mcp.ts | 32 +- apps/site/src/server/me.ts | 7 +- apps/site/src/server/people-boards.test.ts | 41 + apps/site/src/server/people-boards.ts | 88 + apps/site/src/server/people.ts | 39 + apps/site/src/server/profiles.test.ts | 217 ++ apps/site/src/server/profiles.ts | 539 +++++ apps/site/src/server/races.test.ts | 97 + apps/site/src/server/races.ts | 207 ++ apps/site/src/server/repos.ts | 26 +- apps/site/src/server/serve-card.ts | 28 + apps/site/src/server/serve-work.ts | 15 + apps/site/src/server/standings.test.ts | 102 + apps/site/src/server/standings.ts | 137 ++ apps/site/src/server/traits.test.ts | 8 + apps/site/src/server/traits.ts | 14 + apps/site/src/server/versus.test.ts | 92 + apps/site/src/server/versus.ts | 73 + apps/site/src/server/viewer.ts | 24 + apps/site/src/server/work-pdf.ts | 39 + apps/site/src/server/work.test.ts | 84 + apps/site/src/server/work.ts | 134 ++ apps/site/src/server/wrapped.ts | 11 + apps/site/src/site.css | 94 +- apps/site/src/start.ts | 6 +- apps/site/vite.config.ts | 4 +- crates/commitscape-core/src/index.rs | 6 + crates/commitscape-index/src/blame.rs | 521 +++++ .../src/cache/blame_store.rs | 215 ++ .../commitscape-index/src/cache/line_store.rs | 4 +- crates/commitscape-index/src/cache/mod.rs | 2 + .../commitscape-index/src/gix_source/blame.rs | 223 ++ .../commitscape-index/src/gix_source/mod.rs | 93 +- crates/commitscape-index/src/lib.rs | 14 +- crates/commitscape-index/src/lines.rs | 10 +- crates/commitscape-index/src/scripted.rs | 116 +- crates/commitscape-index/src/similarity.rs | 203 ++ crates/commitscape-index/src/source.rs | 42 + crates/commitscape-index/src/surviving.rs | 594 +++++ crates/commitscape-report/src/api.rs | 32 + crates/commitscape/src/main.rs | 29 +- crates/commitscape/src/surviving.rs | 238 ++ crates/commitscape/tests/surviving.rs | 248 ++ ...eact-components-rendered-to-svg-and-png.md | 8 + ...s-come-from-blame-per-person-on-request.md | 9 + ...ad-in-two-stages-and-kept-as-two-copies.md | 27 + docs/fixtures.md | 79 + packages/data/src/cards.ts | 45 + packages/data/src/github.test.ts | 14 +- packages/data/src/github.ts | 19 + packages/data/src/index.ts | 8 + packages/data/src/profile.ts | 111 + packages/data/src/races.test.ts | 24 + packages/data/src/races.ts | 54 + packages/data/src/standings.test.ts | 21 + packages/data/src/standings.ts | 55 + packages/data/src/traits.test.ts | 160 ++ packages/data/src/traits.ts | 138 ++ packages/data/src/types.ts | 6 + packages/data/src/versus.test.ts | 28 + packages/data/src/versus.ts | 50 + packages/data/src/work.test.ts | 33 + packages/data/src/work.ts | 129 ++ packages/data/src/wrapped.test.ts | 33 + packages/data/src/wrapped.ts | 70 + packages/server/drizzle/0001_repo_people.sql | 16 + packages/server/drizzle/0002_profiles.sql | 13 + packages/server/drizzle/0003_surviving.sql | 17 + packages/server/drizzle/0004_standings.sql | 39 + packages/server/drizzle/0005_cards.sql | 1 + packages/server/drizzle/0006_proofs.sql | 10 + packages/server/drizzle/0007_rivals.sql | 8 + packages/server/drizzle/0008_oldest_line.sql | 1 + .../server/drizzle/0009_races_and_crews.sql | 57 + .../server/drizzle/meta/0001_snapshot.json | 1018 ++++++++ .../server/drizzle/meta/0002_snapshot.json | 1103 +++++++++ .../server/drizzle/meta/0003_snapshot.json | 1227 ++++++++++ .../server/drizzle/meta/0004_snapshot.json | 1519 ++++++++++++ .../server/drizzle/meta/0005_snapshot.json | 1513 ++++++++++++ .../server/drizzle/meta/0006_snapshot.json | 1586 +++++++++++++ .../server/drizzle/meta/0007_snapshot.json | 1639 +++++++++++++ .../server/drizzle/meta/0008_snapshot.json | 1645 +++++++++++++ .../server/drizzle/meta/0009_snapshot.json | 2042 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 63 + packages/server/src/db/schema.ts | 195 +- packages/server/src/queue.ts | 13 +- packages/server/src/repos.ts | 2 +- packages/ui/package.json | 8 +- packages/ui/src/App.tsx | 189 +- packages/ui/src/Palette.tsx | 21 +- packages/ui/src/cards/Cards.tsx | 527 +++++ .../cards/__snapshots__/achievement-dark.svg | 1 + .../cards/__snapshots__/achievement-light.svg | 1 + .../cards/__snapshots__/archetype-dark.svg | 1 + .../cards/__snapshots__/archetype-light.svg | 1 + .../src/cards/__snapshots__/calendar-dark.svg | 1 + .../cards/__snapshots__/calendar-light.svg | 1 + .../cards/__snapshots__/hall-of-fame-dark.svg | 1 + .../__snapshots__/hall-of-fame-light.svg | 1 + .../cards/__snapshots__/languages-dark.svg | 1 + .../cards/__snapshots__/languages-light.svg | 1 + .../src/cards/__snapshots__/preview-dark.svg | 1 + .../src/cards/__snapshots__/preview-light.svg | 1 + .../ui/src/cards/__snapshots__/race-dark.svg | 1 + .../ui/src/cards/__snapshots__/race-light.svg | 1 + .../cards/__snapshots__/repositories-dark.svg | 1 + .../__snapshots__/repositories-light.svg | 1 + .../src/cards/__snapshots__/season-dark.svg | 1 + .../src/cards/__snapshots__/season-light.svg | 1 + .../src/cards/__snapshots__/standing-dark.svg | 1 + .../cards/__snapshots__/standing-light.svg | 1 + .../src/cards/__snapshots__/survival-dark.svg | 1 + .../cards/__snapshots__/survival-light.svg | 1 + .../src/cards/__snapshots__/totals-dark.svg | 1 + .../src/cards/__snapshots__/totals-light.svg | 1 + .../src/cards/__snapshots__/versus-dark.svg | 1 + .../src/cards/__snapshots__/versus-light.svg | 1 + .../__snapshots__/wrapped-calendar-dark.svg | 1 + .../__snapshots__/wrapped-calendar-light.svg | 1 + .../src/cards/__snapshots__/wrapped-dark.svg | 1 + .../src/cards/__snapshots__/wrapped-light.svg | 1 + packages/ui/src/cards/cards.test.tsx | 22 + packages/ui/src/cards/fixture.ts | 103 + packages/ui/src/cards/fonts.ts | 5 + packages/ui/src/cards/fonts/LICENSE-Inter.txt | 93 + packages/ui/src/cards/fonts/inter-400.woff | Bin 0 -> 30696 bytes packages/ui/src/cards/fonts/inter-600.woff | Bin 0 -> 31260 bytes packages/ui/src/cards/fonts/inter-700.woff | Bin 0 -> 31320 bytes packages/ui/src/cards/kinds.ts | 39 + packages/ui/src/cards/paint.test.ts | 20 + packages/ui/src/cards/paint.ts | 52 + packages/ui/src/cards/render.tsx | 44 + packages/ui/src/cards/sizes.ts | 45 + packages/ui/src/cards/tokens.ts | 54 + packages/ui/src/cards/values.ts | 10 + packages/ui/src/charts/Columns.tsx | 166 +- packages/ui/src/charts/Grid.tsx | 36 +- packages/ui/src/charts/Hours.tsx | 10 +- packages/ui/src/charts/Lines.tsx | 16 +- packages/ui/src/charts/columns.test.ts | 23 + packages/ui/src/charts/common.tsx | 2 +- packages/ui/src/charts/marks.ts | 40 + packages/ui/src/charts/scale.ts | 19 +- packages/ui/src/components/Face.tsx | 9 + packages/ui/src/components/Loading.tsx | 97 +- packages/ui/src/components/Logo.tsx | 9 +- packages/ui/src/components/Name.tsx | 22 +- packages/ui/src/components/login.ts | 9 + packages/ui/src/components/skeletons.ts | 11 + packages/ui/src/data.ts | 28 +- packages/ui/src/format.ts | 3 +- packages/ui/src/help.ts | 2 +- packages/ui/src/index.css | 143 +- packages/ui/src/index.ts | 21 +- packages/ui/src/jump.test.ts | 4 +- packages/ui/src/jump.ts | 3 +- packages/ui/src/keys.ts | 4 +- packages/ui/src/mode.ts | 12 + packages/ui/src/profile/engine.tsx | 71 + packages/ui/src/profile/sections.tsx | 239 ++ packages/ui/src/profile/survival.test.ts | 12 + packages/ui/src/profile/survival.ts | 6 + packages/ui/src/profile/traits.tsx | 57 + packages/ui/src/route.ts | 5 +- packages/ui/src/screens/Activity.tsx | 32 +- packages/ui/src/screens/Commits.tsx | 25 +- packages/ui/src/screens/Map.tsx | 14 +- packages/ui/src/screens/Overview.tsx | 370 +-- packages/ui/src/screens/People.tsx | 43 +- packages/ui/src/screens/Risk.tsx | 195 -- packages/ui/src/standings/Standings.tsx | 113 + packages/ui/src/standings/places.ts | 20 + packages/ui/src/theme.ts | 60 +- packages/ui/src/work/WorkView.tsx | 60 + packages/ui/src/worker.d.ts | 5 + packages/ui/vitest.config.ts | 2 +- pnpm-lock.yaml | 261 ++- xtask/src/fixtures.rs | 142 ++ 331 files changed, 27280 insertions(+), 1626 deletions(-) create mode 100644 apps/builder/src/github.ts create mode 100644 apps/builder/src/people.test.ts create mode 100644 apps/builder/src/people.ts create mode 100644 apps/builder/src/pulls.test.ts create mode 100644 apps/builder/src/pulls.ts create mode 100644 apps/builder/src/survival.test.ts create mode 100644 apps/builder/src/survival.ts create mode 100644 apps/site/e2e/accounts.ts create mode 100644 apps/site/e2e/boards.spec.ts create mode 100644 apps/site/e2e/cards.spec.ts create mode 100644 apps/site/e2e/foundation.spec.ts create mode 100644 apps/site/e2e/graphql.ts create mode 100644 apps/site/e2e/profile.spec.ts create mode 100644 apps/site/e2e/races.spec.ts create mode 100644 apps/site/e2e/versus.spec.ts create mode 100644 apps/site/e2e/work.spec.ts create mode 100644 apps/site/e2e/wrapped.spec.ts create mode 100644 apps/site/src/components/Boundary.tsx create mode 100644 apps/site/src/components/CardBox.tsx delete mode 100644 apps/site/src/components/Connect.tsx create mode 100644 apps/site/src/components/Membership.tsx create mode 100644 apps/site/src/components/Rivals.tsx create mode 100644 apps/site/src/components/Start.tsx create mode 100644 apps/site/src/components/WindowTable.tsx create mode 100644 apps/site/src/functions/cards.ts create mode 100644 apps/site/src/functions/profiles.ts create mode 100644 apps/site/src/functions/races.ts create mode 100644 apps/site/src/functions/standings.ts create mode 100644 apps/site/src/functions/versus.ts create mode 100644 apps/site/src/functions/work.ts create mode 100644 apps/site/src/lib/hydrated.ts create mode 100644 apps/site/src/lib/markdown.ts create mode 100644 apps/site/src/lib/target.ts delete mode 100644 apps/site/src/routes/api/cards/$owner/$repo.ts create mode 100644 apps/site/src/routes/api/cards/crews/$id/$file.ts create mode 100644 apps/site/src/routes/api/cards/gh/$owner/$repo/$file.ts create mode 100644 apps/site/src/routes/api/cards/races/$id/$file.ts create mode 100644 apps/site/src/routes/api/cards/site/$file.ts create mode 100644 apps/site/src/routes/api/cards/u/$login/$file.ts create mode 100644 apps/site/src/routes/api/cards/u/$login/$owner/$repo/$file.ts create mode 100644 apps/site/src/routes/api/cards/u/$login/wrapped/$year/$file.ts create mode 100644 apps/site/src/routes/api/cards/vs/$a/$b/$file.ts create mode 100644 apps/site/src/routes/api/work/s/$id/$file.ts create mode 100644 apps/site/src/routes/api/work/u/$login/$file.ts create mode 100644 apps/site/src/routes/crews.$id.tsx create mode 100644 apps/site/src/routes/crews.index.tsx create mode 100644 apps/site/src/routes/races.$id.tsx create mode 100644 apps/site/src/routes/races.index.tsx create mode 100644 apps/site/src/routes/u.$login.tsx create mode 100644 apps/site/src/routes/u.$login_.$owner.$repo.tsx create mode 100644 apps/site/src/routes/u.$login_.cards.tsx create mode 100644 apps/site/src/routes/u.$login_.work.tsx create mode 100644 apps/site/src/routes/u.$login_.work_.$id.tsx create mode 100644 apps/site/src/routes/u.$login_.wrapped.$year.tsx create mode 100644 apps/site/src/routes/vs.$a.$b.tsx create mode 100644 apps/site/src/routes/you.tsx create mode 100644 apps/site/src/server/__snapshots__/cards/achievement-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/achievement-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/achievement-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/achievement-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/archetype-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/archetype-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/archetype-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/archetype-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/calendar-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/calendar-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/calendar-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/calendar-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/hall-of-fame-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/hall-of-fame-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/hall-of-fame-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/hall-of-fame-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/languages-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/languages-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/languages-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/languages-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/preview-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/preview-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/preview-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/preview-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/race-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/race-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/race-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/race-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/repositories-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/repositories-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/repositories-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/repositories-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/season-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/season-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/season-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/season-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/standing-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/standing-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/standing-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/standing-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/survival-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/survival-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/survival-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/survival-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/totals-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/totals-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/totals-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/totals-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/versus-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/versus-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/versus-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/versus-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-calendar-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-calendar-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-calendar-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-calendar-light.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-dark.png create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-dark.png.sha256 create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-light.png create mode 100644 apps/site/src/server/__snapshots__/cards/wrapped-light.png.sha256 create mode 100644 apps/site/src/server/cards.test.ts create mode 100644 apps/site/src/server/cards.ts create mode 100644 apps/site/src/server/engine.test.ts create mode 100644 apps/site/src/server/engine.ts create mode 100644 apps/site/src/server/graphql.ts create mode 100644 apps/site/src/server/people-boards.test.ts create mode 100644 apps/site/src/server/people-boards.ts create mode 100644 apps/site/src/server/people.ts create mode 100644 apps/site/src/server/profiles.test.ts create mode 100644 apps/site/src/server/profiles.ts create mode 100644 apps/site/src/server/races.test.ts create mode 100644 apps/site/src/server/races.ts create mode 100644 apps/site/src/server/serve-card.ts create mode 100644 apps/site/src/server/serve-work.ts create mode 100644 apps/site/src/server/standings.test.ts create mode 100644 apps/site/src/server/standings.ts create mode 100644 apps/site/src/server/traits.test.ts create mode 100644 apps/site/src/server/traits.ts create mode 100644 apps/site/src/server/versus.test.ts create mode 100644 apps/site/src/server/versus.ts create mode 100644 apps/site/src/server/work-pdf.ts create mode 100644 apps/site/src/server/work.test.ts create mode 100644 apps/site/src/server/work.ts create mode 100644 apps/site/src/server/wrapped.ts create mode 100644 crates/commitscape-index/src/blame.rs create mode 100644 crates/commitscape-index/src/cache/blame_store.rs create mode 100644 crates/commitscape-index/src/gix_source/blame.rs create mode 100644 crates/commitscape-index/src/similarity.rs create mode 100644 crates/commitscape-index/src/surviving.rs create mode 100644 crates/commitscape/src/surviving.rs create mode 100644 crates/commitscape/tests/surviving.rs create mode 100644 docs/adr/0023-a-profile-is-read-in-two-stages-and-kept-as-two-copies.md create mode 100644 packages/data/src/cards.ts create mode 100644 packages/data/src/profile.ts create mode 100644 packages/data/src/races.test.ts create mode 100644 packages/data/src/races.ts create mode 100644 packages/data/src/standings.test.ts create mode 100644 packages/data/src/standings.ts create mode 100644 packages/data/src/traits.test.ts create mode 100644 packages/data/src/traits.ts create mode 100644 packages/data/src/versus.test.ts create mode 100644 packages/data/src/versus.ts create mode 100644 packages/data/src/work.test.ts create mode 100644 packages/data/src/work.ts create mode 100644 packages/data/src/wrapped.test.ts create mode 100644 packages/data/src/wrapped.ts create mode 100644 packages/server/drizzle/0001_repo_people.sql create mode 100644 packages/server/drizzle/0002_profiles.sql create mode 100644 packages/server/drizzle/0003_surviving.sql create mode 100644 packages/server/drizzle/0004_standings.sql create mode 100644 packages/server/drizzle/0005_cards.sql create mode 100644 packages/server/drizzle/0006_proofs.sql create mode 100644 packages/server/drizzle/0007_rivals.sql create mode 100644 packages/server/drizzle/0008_oldest_line.sql create mode 100644 packages/server/drizzle/0009_races_and_crews.sql create mode 100644 packages/server/drizzle/meta/0001_snapshot.json create mode 100644 packages/server/drizzle/meta/0002_snapshot.json create mode 100644 packages/server/drizzle/meta/0003_snapshot.json create mode 100644 packages/server/drizzle/meta/0004_snapshot.json create mode 100644 packages/server/drizzle/meta/0005_snapshot.json create mode 100644 packages/server/drizzle/meta/0006_snapshot.json create mode 100644 packages/server/drizzle/meta/0007_snapshot.json create mode 100644 packages/server/drizzle/meta/0008_snapshot.json create mode 100644 packages/server/drizzle/meta/0009_snapshot.json create mode 100644 packages/ui/src/cards/Cards.tsx create mode 100644 packages/ui/src/cards/__snapshots__/achievement-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/achievement-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/archetype-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/archetype-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/calendar-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/calendar-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/languages-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/languages-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/preview-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/preview-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/race-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/race-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/repositories-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/repositories-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/season-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/season-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/standing-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/standing-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/survival-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/survival-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/totals-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/totals-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/versus-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/versus-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg create mode 100644 packages/ui/src/cards/__snapshots__/wrapped-dark.svg create mode 100644 packages/ui/src/cards/__snapshots__/wrapped-light.svg create mode 100644 packages/ui/src/cards/cards.test.tsx create mode 100644 packages/ui/src/cards/fixture.ts create mode 100644 packages/ui/src/cards/fonts.ts create mode 100644 packages/ui/src/cards/fonts/LICENSE-Inter.txt create mode 100644 packages/ui/src/cards/fonts/inter-400.woff create mode 100644 packages/ui/src/cards/fonts/inter-600.woff create mode 100644 packages/ui/src/cards/fonts/inter-700.woff create mode 100644 packages/ui/src/cards/kinds.ts create mode 100644 packages/ui/src/cards/paint.test.ts create mode 100644 packages/ui/src/cards/paint.ts create mode 100644 packages/ui/src/cards/render.tsx create mode 100644 packages/ui/src/cards/sizes.ts create mode 100644 packages/ui/src/cards/tokens.ts create mode 100644 packages/ui/src/cards/values.ts create mode 100644 packages/ui/src/charts/columns.test.ts create mode 100644 packages/ui/src/charts/marks.ts create mode 100644 packages/ui/src/components/Face.tsx create mode 100644 packages/ui/src/components/login.ts create mode 100644 packages/ui/src/components/skeletons.ts create mode 100644 packages/ui/src/mode.ts create mode 100644 packages/ui/src/profile/engine.tsx create mode 100644 packages/ui/src/profile/sections.tsx create mode 100644 packages/ui/src/profile/survival.test.ts create mode 100644 packages/ui/src/profile/survival.ts create mode 100644 packages/ui/src/profile/traits.tsx delete mode 100644 packages/ui/src/screens/Risk.tsx create mode 100644 packages/ui/src/standings/Standings.tsx create mode 100644 packages/ui/src/standings/places.ts create mode 100644 packages/ui/src/work/WorkView.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 88e91a7..9f17239 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -187,11 +187,11 @@ Said of a Profile whose person chose to stay out of comparisons: it appears in n _Avoid_: private profile, opted out. **Surviving Lines**: -The lines at a repository's head that blame attributes to a person, passing through Bulk Commits and ignored revisions, leaving out Generated Files and Prose Files (ADR-0022). Counted per person on request; "not counted" when over its time budget. +The lines at a repository's head that blame attributes to a person, passing through Bulk Commits and ignored revisions, leaving out Generated Files, Prose Files and files over a megabyte (ADR-0022). Passing through gives a changed line to the commit before only when the two are the same once whitespace is removed; any other line a reformat changed stays with the reformat. Counted per person on request; "not counted" when over its time budget, never an estimate. _Avoid_: lines owned, code alive, impact. **Survival**: -A person's Surviving Lines as a share of the lines they added in that repository. Shown only when both are known. +A person's Surviving Lines as a share of the lines they added in that repository (their Lines Changed). Shown only when both are known and it is at most 100%: an import left out of Lines Changed as a Bulk Commit can keep more lines than were counted as added. _Avoid_: retention, survival rate, code quality. **Standing**: @@ -211,11 +211,26 @@ A person someone chose to measure themselves against, whose gap shows on their o _Avoid_: friend, follow. **Archetype**: -A label for how a person works, from a written rule over their numbers, such as Reviewer (more reviews than pull requests) or Janitor (removes more lines than they add). Its rule is shown with it. +A label for how a person works, from a written rule over their numbers, shown with its rule. Nobody under 50 contributions has one. The rules are tried in this order, and the first that fits is theirs (the others show as "also"): +- **Reviewer**: gave more reviews than they opened pull requests, and at least 20 reviews. +- **Janitor**: removed more lines than they added in their merged pull requests, and at least 1,000 lines. +- **Firefighter**: at least half of their merged pull requests, and 10 or more, are fixes: titled fix, hotfix or bugfix, or a conventional fix:. +- **Night Owl**: at least 40% of their newest 100 commits, 30 or more read, were made between 22:00 and 04:59 on the commit's own clock. +- **Polyglot**: committed in five or more languages, each at least 5% of their commits, by the main language of each repository. +- **Weekend Warrior**: at least 40% of their active days in the last year fell on a Saturday or Sunday, with 30 or more active days. +- **Marathoner**: a streak of 30 days or more in a row with a contribution. +- **Builder**: merged 25 or more pull requests, adding at least twice the lines they removed. _Avoid_: personality, type, persona. **Achievement**: -A milestone a person reached, from a written rule, such as a first merged pull request in a repository with 10,000 stars. Each has a Card. +A milestone a person reached, from a written rule, shown with the day it was reached when that is known. Each has a Card. The set: +- **Merged into a 10k-star repository**: a pull request of theirs merged into a repository with 10,000 stars or more, as it has now. +- **100** and **1,000 pull requests merged**. +- **100** and **1,000 reviews** of others' pull requests. +- **Removed 10k lines in one pull request**: a merged pull request of theirs that removed 10,000 lines or more. +- **A 30-day** and **a 100-day streak** of contributions. +- **10,000 lines still running**: Surviving Lines at the heads of the repositories commitscape has read. +- **A line that has survived five years**: a line of theirs still at a repository's head, from a commit authored five years ago or more. _Avoid_: badge, trophy. **Season**: diff --git a/DEPLOY.md b/DEPLOY.md index 7db9039..c49af65 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -97,7 +97,7 @@ S3_ENDPOINT=https://.r2.cloudflarestorage.com S3_BUCKET=commitscape-reports S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... -GITHUB_TOKEN=... # a token with no scopes: GitHub's search limits for the Leaderboards +GITHUB_TOKEN=... # a token with no scopes: GitHub's search limits for the Leaderboards, people's logins, and each repository's pull requests GITHUB_APP_ID=... # for private repositories GITHUB_APP_PRIVATE_KEY="..." ``` @@ -111,6 +111,8 @@ Optional limits, with their defaults: | `MAX_REPOSITORY_MB` | 3000 | Larger repositories are refused | | `TIME_LIMIT_SECONDS` | 900 | A Build stops after this long | | `DISK_BUDGET_GB` | 20 | Past this, the least recently built clones in `/work` are deleted | +| `SURVIVING_BUDGET_SECONDS` | 60 | How long one count of a person's Surviving Lines may take before it says "not counted" | +| `PULLS_TIME_LIMIT_SECONDS` | 1800 | How long one read of a repository's pull requests may take; facebook/react's first read took 24 minutes, so a busy repository's first read may need longer, once | | `SEED_LANGUAGES`, `SEED_PER_LANGUAGE`, `SEED_BUDGET` | 9 languages, 10, 50 | The nightly Leaderboard Builds | Deploy it. It applies the database migrations, then logs `builder working`. @@ -141,7 +143,7 @@ S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... BETTER_AUTH_URL=https://example.com BETTER_AUTH_SECRET=... # openssl rand -hex 32 -GITHUB_TOKEN=... # the same token: GitHub's API limits for lookups +GITHUB_TOKEN=... # the same token, required: Profiles, Proof of Work, Races and Crews read GitHub's GraphQL API, which needs a token; signed-in people use their own GITHUB_APP_ID=... GITHUB_APP_SLUG=... GITHUB_APP_CLIENT_ID=... diff --git a/README.md b/README.md index 285e04c..b9334de 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
commitscape

commitscape

-

Reads a git repository's history and shows what changes what you do next.

+

What you have built, how you stand next to the people you work with, and cards to share it.

-commitscape reads a git repository and answers the questions its history -can answer: who knows which part of the code, which files are risky to -change, what you probably forgot to change, and how the project got here. -It reads the whole history of a large repository in seconds, on your -machine, from git's own data. +commitscape shows a developer what they have built: pull requests merged, +reviews given, and the lines of theirs that still run, counted from each +repository's own history with blame. It shows how they stand next to the +people they work with, view by view and never by one score, and turns it +into Cards for a README, a self-review or a post. On your machine it reads +the whole history of a large repository in seconds, from git's own data. ![commitscape's own story, drawn by commitscape](.github/commitscape-card.svg) @@ -31,9 +32,12 @@ There are three ways to use it: 2. **Shared from a terminal.** `commitscape share` builds the Report on your machine, encrypts it, uploads it and prints a link. The link opens in any browser for 4 hours, and the Site cannot read what it stores. -3. **On the Site.** Paste any public GitHub link to see its Report, sign in - with GitHub to open your private repositories, or browse the - Leaderboards. +3. **On the Site.** Type any GitHub username for their Profile: totals, + Surviving Lines, Archetype and Achievements, where their work is, Cards + to embed, Proof of Work for a period, Versus, Rivals, Races, Crews and + Wrapped. Paste a repository link for its Report and Standings, sign in + with GitHub to see your own private work, or browse the Leaderboards. + Anyone can stay out of comparisons. The project is in early development. The CLI and the Site work end to end; the first release is next. @@ -79,6 +83,7 @@ once it runs. | `commitscape health ` | Whether a GitHub project is alive and whether it depends on one person | | `commitscape wrapped [folder]` | Your year across every repository under a folder, as a card | | `commitscape card [path]` | The repository's story as an SVG card, like the one above | +| `commitscape surviving [path] --person ` | A person's Surviving Lines: the lines at the head that blame gives them, reformats passed through | | `commitscape share` | An encrypted link to this repository's Report, for any browser | | `commitscape report [path]` | The Report as gzipped JSON, as the Site stores it | | `commitscape github [path]` | Fetch pull requests, issues and releases through the `gh` CLI | diff --git a/STATE.md b/STATE.md index 9c35afc..694c0b5 100644 --- a/STATE.md +++ b/STATE.md @@ -3,7 +3,7 @@ Running log for Build Run 1 (Phases 0 to 7). Written so a fresh session with no context can read this plus `docs/adr/` and continue without asking anything. -**Current position:** Build Run 5 (Phases 32 to 42) is planned and not started: its brief is `IDEA.md`, with ADR-0020 to ADR-0022, written 2026-10-04 after the owner's review of the live Site. Build Run 4 (Phases 23 to 31) is done, all of it uncommitted in the working tree: its brief is `IDEA.md`, with ADR-0013 to ADR-0019. Every phase's findings and the files it created, changed or deleted are below. Build Run 3 is done (Phases 13 to 22). The repository is on GitHub (`pixelactstudio/commitscape`) and CI is green on Linux, macOS and Windows for what is committed. Nothing is published or deployed: the name may still change. +**Current position:** Build Run 5 (Phases 32 to 42) is done, uncommitted in the working tree: its brief is `IDEA.md`, with ADR-0020 to ADR-0022 and ADR-0023, written during the run. The table under "Build Run 5" gives each phase's gate and result, each phase's findings are under "Phase N findings", and "Where to pick up" (at the end) lists what is left for the owner. Phase 36's README embed and Phase 40's three real accounts need the owner; everything else met its gate. Build Run 4 (Phases 23 to 31) is committed. Since Build Run 4 the Site moved from Cloudflare to TanStack Start on Node with Postgres and R2 (`CLAUDE.md`, `DEPLOY.md`); older findings below that mention D1, Workers or `wrangler` describe that earlier Site. Build Run 1 (Phases 0 to 7) built a correct, fast tool. Build Run 2 (Phases 8 to 12) made it fun and visual. On 2026-09-24 the owner used it on their own repositories and reviewed it. The review and the decisions that followed are @@ -288,6 +288,24 @@ gate for each phase. | 21 | `wrapped` and the README card Action | **DONE**: the owner's 2026 across `~/code` in `target/preview/wrapped/`; hand-worked test for the year; the card Action simulated against a local remote | | 22 | Distribution (npm, Homebrew, Nix) and launch material | **DONE, but not published**: `npx commitscape` ran on clean Debian and Alpine containers from the packed tarballs; the flake builds; nothing is on npm yet (no remote, no token) | +### Build Run 5 + +The brief is `IDEA.md` (written 2026-10-04): the Site turns from a repository's Report to a person's Profile, with Standings, Cards and the fun side (ADR-0020 to ADR-0022). The agent doing the work never commits, pushes, publishes or deploys; the owner commits. + +| Phase | What | Status | +|---|---|---| +| 32 | Foundation: server rendering and streaming, no theme flash, the logo, the chart drawn once, releases thinned, the repository page trimmed with avatars and search | **DONE**: 32 Playwright tests; layout shift 0.0000 on all ten pages measured (People 0.0589 before the fix in finding 6); no server function asked again after hydration on any of them; screenshots in both themes in `target/preview/site/phase32-*` | +| 33 | The Profile, first speed: `/u/` from GitHub's API, stored copies, the viewer's token, the per-project breakdown, the new landing page | **DONE**: 7 vitest tests against GitHub answers written by hand, 4 Playwright tests (Profile, preview, failures, landing); the owner's, gaearon's and torvalds' Profiles screenshotted in both themes (`target/preview/site/phase33-*`); GitHub requests per cold view 18 to 104, per warm view 0 (finding 4); ADR-0023 | +| 34 | The Profile, second speed: Surviving Lines in the engine, per person and head; the Builder counts on request; Lines Changed per person; sections stream in | **DONE**: hand-worked `survival` fixture (Surviving Lines, Bulk Commits, ignored revisions) through gix and through the scripted fake; measured on ripgrep, facebook/react and rust-lang/rust (finding 3); over budget is "not counted" in the engine, the Builder and the page (tests); 265 Rust tests, clippy clean; 21 Builder, Site and UI tests added; the pipeline end to end in Playwright | +| 35 | You in this repository: `/u///`, Standings, the Builder keeping pull requests and reviews, privacy and hiding, `/privacy`, MCP tools | **DONE**: access tests (a private repository's Standings refused without access, refused when GitHub's id differs, shown with access; a hidden person absent from Standings, Profile, engine numbers, MCP and Cards, still seeing their own); facebook/react's pull requests read in 1,414.6 s the first time (20,362 pull requests, 408 pages) and 1.5 s after; 48 Playwright tests | +| 36 | Cards: the pipeline, the set, animation, light and dark, the gallery, embeds with stored copies, a preview image on every page | **DONE, but the README embed needs the owner**: 16 SVG and 16 PNG snapshots; each Card drawn as SVG and PNG under 200 ms (39 to 135 ms warm in the dev server; tested); stored copies, stale-while-drawing and "reading" Cards tested; every page's preview image checked in Playwright. The embed in a real README on GitHub is in "Left for the owner" | +| 37 | Proof of Work: `/u//work`, filters, share link, Markdown and PDF | **DONE**: the owner's September 2026 checked against GitHub with the `gh` CLI (finding 3); private items absent from a public page and from a shared link unless chosen (vitest); 4 vitest and 2 Playwright tests, Markdown and PDF downloads among them | +| 38 | Versus and Rivals | **DONE**: fixture Profiles give the winners worked out by hand (pure function and through stored Profiles); a hidden Profile refused for Versus, its Card and its Profile (vitest and Playwright); 6 vitest and 2 Playwright tests; BurntSushi versus gaearon screenshotted in both themes | +| 39 | Archetypes and Achievements, each rule in `CONTEXT.md` and on the page | **DONE**: every rule tested at its edges on fixtures (17 vitest tests, a just-below and an at-the-line case for each threshold); a test keeps CONTEXT.md's wording equal to the code's; the oldest surviving line added to the engine with hand-worked fixture values; 265 Rust tests, clippy clean, 53 Playwright tests | +| 40 | Seasons, Races and Crews: invitations, acceptance, live Standings, recap and finish Cards | **DONE with stand-in accounts; real accounts need the owner**: end to end in Playwright with three signed-in accounts (alice, bob, carol) against the fake GitHub: a Race joined only by those who accept, live Standings and its Card; a Crew where leaving removes the person at once (checked in the page and the database); a hidden person never in it and never invitable; 4 vitest tests of the rules; 55 Playwright tests | +| 41 | People on the Leaderboards: top contributors per Season, per repository, with a time filter | **DONE**: three people boards (pull requests merged, pull requests reviewed, Surviving Lines) over the seed repositories or one of them, for This Season, Last Season, 90 days and all time; hidden people and `[bot]` accounts absent (2 vitest tests, 1 Playwright test); built from a real local seed night of 6 repositories (20,137 pull requests read) and screenshotted in both themes; the page answers in 80 to 100 ms | +| 42 | Wrapped 2026: a person's year across GitHub, as a page and a set of Cards | **DONE**: `wrappedOf` tested on a year worked out by hand; the owner's 2026 checked against GitHub by hand, every number equal; two Cards with snapshots; 1 Playwright test; screenshots in both themes | + ### Build Run 4 The brief is `IDEA.md` (written 2026-09-25), with ADR-0013 to ADR-0019: @@ -2296,6 +2314,162 @@ Files created, changed or deleted in Phase 31: - Deleted: none (a stray `apps/builder/health`, written by a test in Phase 30 and never tracked, was removed). +## Phase 32 findings + +1. **The grey-then-blue flash was Astryx injecting the theme in the browser.** `Theme` writes a theme's CSS in `useInsertionEffect` unless the theme is marked built, so the server's page had the neutral theme's accent until hydration. `themeCss()` (`packages/ui/src/theme.ts`) now generates the same CSS with Astryx's own `generateThemeCSS`, in the same layers, and the root route puts it in a ` \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/achievement-light.svg b/packages/ui/src/cards/__snapshots__/achievement-light.svg new file mode 100644 index 0000000..634a954 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/achievement-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/archetype-dark.svg b/packages/ui/src/cards/__snapshots__/archetype-dark.svg new file mode 100644 index 0000000..3bd44b2 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/archetype-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/archetype-light.svg b/packages/ui/src/cards/__snapshots__/archetype-light.svg new file mode 100644 index 0000000..6a6939a --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/archetype-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/calendar-dark.svg b/packages/ui/src/cards/__snapshots__/calendar-dark.svg new file mode 100644 index 0000000..c36354c --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/calendar-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/calendar-light.svg b/packages/ui/src/cards/__snapshots__/calendar-light.svg new file mode 100644 index 0000000..e5d943c --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/calendar-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg b/packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg new file mode 100644 index 0000000..4d31f38 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg b/packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg new file mode 100644 index 0000000..83ed934 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/languages-dark.svg b/packages/ui/src/cards/__snapshots__/languages-dark.svg new file mode 100644 index 0000000..5f566b6 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/languages-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/languages-light.svg b/packages/ui/src/cards/__snapshots__/languages-light.svg new file mode 100644 index 0000000..6e36474 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/languages-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/preview-dark.svg b/packages/ui/src/cards/__snapshots__/preview-dark.svg new file mode 100644 index 0000000..0b7d6ca --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/preview-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/preview-light.svg b/packages/ui/src/cards/__snapshots__/preview-light.svg new file mode 100644 index 0000000..ed8ce82 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/preview-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/race-dark.svg b/packages/ui/src/cards/__snapshots__/race-dark.svg new file mode 100644 index 0000000..a220c97 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/race-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/race-light.svg b/packages/ui/src/cards/__snapshots__/race-light.svg new file mode 100644 index 0000000..bebc053 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/race-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/repositories-dark.svg b/packages/ui/src/cards/__snapshots__/repositories-dark.svg new file mode 100644 index 0000000..8a9a173 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/repositories-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/repositories-light.svg b/packages/ui/src/cards/__snapshots__/repositories-light.svg new file mode 100644 index 0000000..5facf63 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/repositories-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/season-dark.svg b/packages/ui/src/cards/__snapshots__/season-dark.svg new file mode 100644 index 0000000..a220c97 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/season-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/season-light.svg b/packages/ui/src/cards/__snapshots__/season-light.svg new file mode 100644 index 0000000..bebc053 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/season-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/standing-dark.svg b/packages/ui/src/cards/__snapshots__/standing-dark.svg new file mode 100644 index 0000000..47f1ed3 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/standing-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/standing-light.svg b/packages/ui/src/cards/__snapshots__/standing-light.svg new file mode 100644 index 0000000..a781d59 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/standing-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/survival-dark.svg b/packages/ui/src/cards/__snapshots__/survival-dark.svg new file mode 100644 index 0000000..6b13e22 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/survival-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/survival-light.svg b/packages/ui/src/cards/__snapshots__/survival-light.svg new file mode 100644 index 0000000..d7db9de --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/survival-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/totals-dark.svg b/packages/ui/src/cards/__snapshots__/totals-dark.svg new file mode 100644 index 0000000..b6e795d --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/totals-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/totals-light.svg b/packages/ui/src/cards/__snapshots__/totals-light.svg new file mode 100644 index 0000000..06ac2f3 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/totals-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/versus-dark.svg b/packages/ui/src/cards/__snapshots__/versus-dark.svg new file mode 100644 index 0000000..2964275 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/versus-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/versus-light.svg b/packages/ui/src/cards/__snapshots__/versus-light.svg new file mode 100644 index 0000000..17c36f9 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/versus-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg b/packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg new file mode 100644 index 0000000..8cb7168 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg b/packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg new file mode 100644 index 0000000..97cbd18 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-dark.svg b/packages/ui/src/cards/__snapshots__/wrapped-dark.svg new file mode 100644 index 0000000..42f7db8 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/wrapped-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-light.svg b/packages/ui/src/cards/__snapshots__/wrapped-light.svg new file mode 100644 index 0000000..7670c98 --- /dev/null +++ b/packages/ui/src/cards/__snapshots__/wrapped-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/cards/cards.test.tsx b/packages/ui/src/cards/cards.test.tsx new file mode 100644 index 0000000..87fd1ec --- /dev/null +++ b/packages/ui/src/cards/cards.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { CARDS } from "./kinds"; +import { ACHIEVEMENT, ARCHETYPE, HALL, PROFILE, STANDING, VERSUS, WINDOW, WRAPPED } from "./fixture"; +import { renderCard } from "./render"; + +const DATA = { totals: PROFILE, survival: PROFILE, repositories: PROFILE, calendar: PROFILE, languages: PROFILE, preview: PROFILE, standing: STANDING, "hall-of-fame": HALL, versus: VERSUS, archetype: ARCHETYPE, achievement: ACHIEVEMENT, race: WINDOW, season: WINDOW, wrapped: WRAPPED, "wrapped-calendar": WRAPPED } as const; + +describe("every Card", () => { + for (const kind of Object.keys(CARDS) as (keyof typeof CARDS)[]) { + for (const theme of ["light", "dark"] as const) { + test(`${kind} in ${theme}`, async () => { + const spec = CARDS[kind] as (typeof CARDS)["totals"]; + const out = await renderCard(spec, DATA[kind] as never, theme, { images: {}, site: "commitscape.example" }); + expect(out.animated.startsWith(` ((i * 7) % 11 === 0 ? 0 : (i * 13) % 9)); + +export const PROFILE: ProfileCardData = { + identity: { login: "alice", name: "Alice Example" }, + totals: { prsOpened: 640, prsMerged: 597, prsClosed: 20, prsOpen: 23, reviews: 212, commits: 4883, issues: 41, hidden: 0, linesAdded: 182_000, linesRemoved: 66_300, hoursToMerge: 2.5, activeDays: 407, longestStreak: 33, currentStreak: 3, contributions: 5384 }, + repositories: [ + { owner: "acme", name: "rocket", private: false, stars: 1200, language: "TypeScript", colour: "#3178c6", commits: 271, prsOpened: 30, prsMerged: 28, reviews: 1, linesAdded: 1, linesRemoved: 1, first: null, last: null }, + { owner: "acme", name: "engine", private: false, stars: 3, language: "Rust", colour: "#dea584", commits: 158, prsOpened: 11, prsMerged: 11, reviews: 1, linesAdded: 1, linesRemoved: 1, first: null, last: null }, + { owner: "alice", name: "dots", private: false, stars: 0, language: "Shell", colour: "#89e051", commits: 81, prsOpened: 3, prsMerged: 3, reviews: 0, linesAdded: 1, linesRemoved: 1, first: null, last: null }, + ], + years: [ + { year: 2024, commits: 300, prs: 10, reviews: 2, issues: 0, hidden: 0, languages: [{ name: "TypeScript", colour: null, commits: 300 }] }, + { year: 2025, commits: 900, prs: 80, reviews: 20, issues: 4, hidden: 0, languages: [{ name: "TypeScript", colour: null, commits: 800 }, { name: "Rust", colour: null, commits: 100 }] }, + { year: 2026, commits: 3683, prs: 550, reviews: 190, issues: 37, hidden: 0, languages: [{ name: "TypeScript", colour: null, commits: 2600 }, { name: "Rust", colour: null, commits: 700 }, { name: "Nix", colour: null, commits: 300 }, { name: "Shell", colour: null, commits: 83 }] }, + ], + calendar: { firstDay: 20_000, days }, + engine: { surviving: 41_200, added: 120_400, repositories: 3 }, + at: 1_790_000_000, +}; + +export const STANDING: StandingCardData = { + identity: { login: "alice", name: "Alice Example" }, + repo: { owner: "facebook", name: "react" }, + places: [ + { view: "surviving", place: 4, of: 120, value: 22_213 }, + { view: "prsMerged", place: 12, of: 1_890, value: 340 }, + { view: "reviews", place: 7, of: 640, value: 910 }, + { view: "commits", place: 12, of: 1_950, value: 1_200 }, + ], + people: 1_950, + top: "Top 1% of facebook/react's 1,950 contributors by commits", + numbers: { surviving: 22_213, prsMerged: 340, reviews: 910, commits: 1_200 }, +}; + +export const HALL: HallOfFameData = { + repo: { owner: "BurntSushi", name: "ripgrep", stars: 68_826 }, + people: [ + { login: "BurntSushi", name: "Andrew Gallant", surviving: 61_148, prsMerged: 106, commits: 1_551 }, + { login: "dana", name: "dana", surviving: 893, prsMerged: 33, commits: 56 }, + { login: null, name: "Igor Gnatenko", surviving: null, prsMerged: null, commits: 10 }, + ], + total: 478, +}; + +export const VERSUS: VersusCardData = { + a: { identity: { login: "alice", name: "Alice Example" } }, + b: { identity: { login: "bob", name: "Bob Example" } }, + rows: [ + { view: "surviving", label: "Lines that still run", a: 41_200, b: 12_000, winner: "a" }, + { view: "prsMerged", label: "Pull requests merged", a: 597, b: 811, winner: "b" }, + { view: "reviews", label: "Reviews given", a: 212, b: 212, winner: "tie" }, + { view: "commits", label: "Commits", a: 4_883, b: 2_100, winner: "a" }, + { view: "linesAdded", label: "Lines added, merged", a: 182_000, b: null, winner: null }, + { view: "activeDays", label: "Active days", a: 407, b: 512, winner: "b" }, + { view: "longestStreak", label: "Longest streak", a: 33, b: 21, winner: "a" }, + { view: "hoursToMerge", label: "Time to merge", a: 2.5, b: 0.4, winner: "b" }, + ], +}; + +export const ARCHETYPE: ArchetypeCardData = { + identity: { login: "alice", name: "Alice Example" }, + archetype: { id: "reviewer", title: "Reviewer", rule: "Gave more reviews than they opened pull requests, and at least 20 reviews." }, + also: ["Marathoner", "Builder"], +}; + +export const ACHIEVEMENT: AchievementCardData = { + identity: { login: "alice", name: "Alice Example" }, + achievement: { id: "star-10k", title: "Merged into a 10k-star repository", rule: "A pull request of theirs merged into a repository with 10,000 stars or more, as it has now.", earned: true, at: "2024-03-02", detail: "facebook/react#28411" }, +}; + +export const WINDOW: WindowCardData = { + title: "The October sprint", + subtitle: "A Race from 2026-10-01 to 2026-10-31, finished", + rows: [ + { login: "alice", name: "Alice Example", prsMerged: 12, reviews: 30, commits: 140, contributions: 190 }, + { login: "bob", name: "Bob Example", prsMerged: 12, reviews: 8, commits: 210, contributions: 230 }, + { login: "carol", name: null, prsMerged: 3, reviews: 41, commits: 20, contributions: 70 }, + ], +}; + +export const WRAPPED: WrappedCardData = { + login: "alice", + name: "Alice Example", + year: 2026, + contributions: 4_917, + commits: 3_683, + prsOpened: 600, + prsMerged: 581, + reviews: 190, + issues: 37, + linesAdded: 1_400_000, + linesRemoved: 520_000, + activeDays: 263, + longestStreak: 33, + busiest: { day: "2026-09-19", contributions: 161 }, + languages: [{ name: "TypeScript", commits: 2600 }], + repositories: [{ repo: "acme/rocket", commits: 900, private: false }], + months: [100, 200, 300, 400, 500, 600, 700, 800, 900, 417, 0, 0], + calendar: { firstDay: Date.UTC(2026, 0, 1) / 86_400_000, days: Array.from({ length: 365 }, (_, i) => (i > 276 ? 0 : (i * 7) % 13)) }, + complete: true, +}; diff --git a/packages/ui/src/cards/fonts.ts b/packages/ui/src/cards/fonts.ts new file mode 100644 index 0000000..beb102f --- /dev/null +++ b/packages/ui/src/cards/fonts.ts @@ -0,0 +1,5 @@ +import inter400 from "./fonts/inter-400.woff?inline"; +import inter600 from "./fonts/inter-600.woff?inline"; +import inter700 from "./fonts/inter-700.woff?inline"; + +export const INTER = { 400: inter400, 600: inter600, 700: inter700 } as const; diff --git a/packages/ui/src/cards/fonts/LICENSE-Inter.txt b/packages/ui/src/cards/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/packages/ui/src/cards/fonts/LICENSE-Inter.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/ui/src/cards/fonts/inter-400.woff b/packages/ui/src/cards/fonts/inter-400.woff new file mode 100644 index 0000000000000000000000000000000000000000..2f21ed484182ce5eeaf53879ea2bd2460ee06324 GIT binary patch literal 30696 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/cards/fonts/inter-600.woff b/packages/ui/src/cards/fonts/inter-600.woff new file mode 100644 index 0000000000000000000000000000000000000000..323fa679d99c6d019afca278b5b23fa77a1c29bd GIT binary patch literal 31260 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 Om!xA*I5a%sc#BSn9X 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 R)JSReoA?KePXy`!003TsIQRen literal 0 HcmV?d00001 diff --git a/packages/ui/src/cards/kinds.ts b/packages/ui/src/cards/kinds.ts new file mode 100644 index 0000000..88842f9 --- /dev/null +++ b/packages/ui/src/cards/kinds.ts @@ -0,0 +1,39 @@ +import type { ComponentType } from "react"; +import type { AchievementCardData, ArchetypeCardData, CardKind, HallOfFameData, ProfileCardData, StandingCardData, VersusCardData, WindowCardData, WrappedCardData } from "@commitscape/data"; + +export type { CardKind }; +import { AchievementCard, ArchetypeCard, CalendarCard, WindowCard, WrappedCalendarCard, WrappedCard, HallOfFameCard, LanguagesCard, PreviewCard, RepositoriesCard, StandingCard, SurvivalCard, TotalsCard, VersusCard, type CardProps } from "./Cards"; +import { calendarSize, hallOfFameSize, languagesSize, PREVIEW_SIZE, repositoriesSize, STANDING_SIZE, SURVIVAL_SIZE, TOTALS_SIZE, versusSize, ARCHETYPE_SIZE, ACHIEVEMENT_SIZE, windowSize, WRAPPED_SIZE, WRAPPED_CALENDAR_SIZE } from "./sizes"; + +type Size = { width: number; height: number }; + +export type CardSpec = { kind: CardKind; title: string; about: string; subject: "person" | "standing" | "repository"; size: (data: T) => Size; Card: ComponentType> }; + +export const STANDING_CARD_SIZE = STANDING_SIZE; +export const ARCHETYPE_CARD_SIZE = ARCHETYPE_SIZE; +export const ACHIEVEMENT_CARD_SIZE = ACHIEVEMENT_SIZE; +export const WRAPPED_CARD_SIZE = WRAPPED_SIZE; +export const WRAPPED_CALENDAR_CARD_SIZE = WRAPPED_CALENDAR_SIZE; + +const person = (spec: CardSpec) => spec; + +/** Every Card, with what it is about and how big it is. */ +export const CARDS = { + totals: person({ kind: "totals", title: "Totals", about: "Pull requests merged, reviews, lines still running and commits.", subject: "person", size: () => TOTALS_SIZE, Card: TotalsCard }), + survival: person({ kind: "survival", title: "Survival", about: "How many of the lines you wrote still run.", subject: "person", size: () => SURVIVAL_SIZE, Card: SurvivalCard }), + repositories: person({ kind: "repositories", title: "Top repositories", about: "Where your work is.", subject: "person", size: repositoriesSize, Card: RepositoriesCard }), + calendar: person({ kind: "calendar", title: "Calendar", about: "The last year, a square a day.", subject: "person", size: calendarSize, Card: CalendarCard }), + languages: person({ kind: "languages", title: "Languages over the years", about: "What you wrote in, year by year.", subject: "person", size: languagesSize, Card: LanguagesCard }), + preview: person({ kind: "preview", title: "Link preview", about: "What a link to your Profile shows when posted.", subject: "person", size: () => PREVIEW_SIZE, Card: PreviewCard }), + standing: person({ kind: "standing", title: "You in a repository", about: "Your place among a repository's people.", subject: "standing", size: () => STANDING_SIZE, Card: StandingCard }), + versus: person({ kind: "versus", title: "Versus", about: "Two people side by side, a winner for each view.", subject: "person", size: versusSize, Card: VersusCard }), + archetype: person({ kind: "archetype", title: "Archetype", about: "How you work, by a written rule.", subject: "person", size: () => ARCHETYPE_SIZE, Card: ArchetypeCard }), + achievement: person({ kind: "achievement", title: "Achievement", about: "A milestone you reached.", subject: "person", size: () => ACHIEVEMENT_SIZE, Card: AchievementCard }), + race: person({ kind: "race", title: "Race", about: "A Race's people, view by view.", subject: "person", size: windowSize, Card: WindowCard }), + season: person({ kind: "season", title: "Season recap", about: "A Crew's Season, view by view.", subject: "person", size: windowSize, Card: WindowCard }), + wrapped: person({ kind: "wrapped", title: "Wrapped", about: "Your year on GitHub, for posting.", subject: "person", size: () => WRAPPED_SIZE, Card: WrappedCard }), + "wrapped-calendar": person({ kind: "wrapped-calendar", title: "Wrapped calendar", about: "Your year, a square a day.", subject: "person", size: () => WRAPPED_CALENDAR_SIZE, Card: WrappedCalendarCard }), + "hall-of-fame": person({ kind: "hall-of-fame", title: "Hall of fame", about: "A repository's people, for its README.", subject: "repository", size: hallOfFameSize, Card: HallOfFameCard }), +}; + +export const PERSON_CARDS = ["totals", "survival", "repositories", "calendar", "languages", "preview"] as const; diff --git a/packages/ui/src/cards/paint.test.ts b/packages/ui/src/cards/paint.test.ts new file mode 100644 index 0000000..48f1e07 --- /dev/null +++ b/packages/ui/src/cards/paint.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { animate, animatedPaint, still, STILL } from "./paint"; + +test("a marked element gets its colour back with its animation, and the still version has no animation", () => { + const paint = animatedPaint(); + const bar = paint.mark("grow", "#2a78d6", 120); + const text = paint.mark("rise", "#1a1a19", 0); + expect(bar).toBe("#020000"); + expect(text).toBe("#010001"); + const svg = ``; + const moving = animate(svg, paint.marks); + expect(moving).toContain('fill="#2a78d6" class="g" style="animation-delay:120ms"'); + expect(moving).toContain('fill="#1a1a19" class="r" style="animation-delay:0ms"'); + expect(moving).toMatch(/^
+
-

{unitWords}

- {width > 0 && ( - - - {marks.map((m, i) => { - const x = xOfDay(m.day); - return ( - {m.label}
released {day(m.day)}
)}> - - - {labelled.has(i) && ( - - {m.label} - - )} - - ); - })} - {stacks.map((stack, b) => { - let base = 0; - const x = LEFT + b * bw + gap / 2; - const w = Math.max(0.5, bw - gap); - return ( - + {unitWords} + {hidden > 0 && `; ${drawn.length} of ${grouped(marks.length)} releases drawn, where they fit`} +

+
+
+ {scale.map((v) => ( +
+ {compact(v)} +
+ ))} + {drawn.map(({ mark, at, labelled }) => ( +
{mark.label}
released {day(mark.day)}
)}> + {labelled && {mark.label}} +
+ ))} +
+ {stacks.map((stack, b) => ( +
{span(b)} @@ -121,42 +87,28 @@ export function Columns({ , )} > - - {stack.map((n, i) => { - if (n === 0) return null; - const y0 = y(base); - base += n; - const y1 = y(base); - return ( - 0 && series.length > 1 ? "var(--surface)" : undefined} - strokeWidth={gap > 0 && series.length > 1 ? 1 : undefined} - /> - ); - })} - - ); - })} - - {(years.length > 0 && years.length < 16 ? years : [firstDay, firstDay + days - 1]).map((d, i, all) => ( - 0 && years.length < 16 ? "start" : i === all.length - 1 ? "end" : "start"} - > - {years.length > 0 && years.length < 16 ? new Date(d * 86_400_000).getUTCFullYear() : day(d)} - + {stack.map((n, i) => + n === 0 ? null : , + )} +
))} - - - )} +
+
+
+ {years.length > 0 ? ( + years.map((d) => ( + + {new Date(d * 86_400_000).getUTCFullYear()} + + )) + ) : ( + <> + {day(firstDay)} + {day(firstDay + Math.max(0, days - 1))} + + )} +
+
1 ? series.map((s) => s.label) : []), `All ${unit}`]} rows={stacks diff --git a/packages/ui/src/charts/Grid.tsx b/packages/ui/src/charts/Grid.tsx index e31eb04..47bd906 100644 --- a/packages/ui/src/charts/Grid.tsx +++ b/packages/ui/src/charts/Grid.tsx @@ -1,9 +1,18 @@ import { day, grouped } from "../format"; import { ramp } from "../theme"; import { TableView } from "./common"; -import { ranges, useWidth } from "./scale"; +import { HALF, ranges } from "./scale"; import { useTip } from "./tip"; +function quantiles(values: number[]): number[] { + const sorted = values.filter((v) => v > 0).sort((a, b) => a - b); + if (sorted.length === 0) return [1, 1, 1, 1]; + const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 1; + const out = [at(0.25), at(0.5), at(0.75), sorted.at(-1) ?? 1]; + for (let i = 1; i < 4; i++) out[i] = Math.max(out[i] ?? 0, out[i - 1] ?? 0); + return out; +} + function steps(max: number): number[] { if (max <= 4) return [1, 2, 3, 4].map((s) => Math.min(s, max)); return [1, 2, 3, 4].map((s) => Math.ceil((max * s) / 4)); @@ -41,15 +50,15 @@ const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Satur export function WeekGrid({ week }: { week: number[][] }) { const tip = useTip(); - const [ref, width] = useWidth(); + const width = HALF; const max = Math.max(0, ...week.flat()); const bounds = steps(max); const left = 80; const cell = Math.max(8, Math.min(26, (width - left) / 24)); return ( -
+
- + {week.map((hours, d) => ( @@ -92,19 +101,18 @@ export function WeekGrid({ week }: { week: number[][] }) { ); } -export function Calendar({ firstDay, days }: { firstDay: number; days: number[] }) { +export function Calendar({ firstDay, days, unit = "commits", quantile = false }: { firstDay: number; days: number[]; unit?: string; quantile?: boolean }) { const tip = useTip(); - const [ref, width] = useWidth(); const max = Math.max(0, ...days); - const bounds = steps(max); + const bounds = quantile ? quantiles(days) : steps(max); const weekday = (d: number) => (d + 3) % 7; const offset = weekday(firstDay); const weeks = Math.ceil((days.length + offset) / 7); - const cell = Math.max(4, Math.min(14, width / Math.max(1, weeks))); + const cell = 14; return ( -
- - +
+ + {days.map((n, i) => { const d = firstDay + i; const col = Math.floor((i + offset) / 7); @@ -121,7 +129,9 @@ export function Calendar({ firstDay, days }: { firstDay: number; days: number[] {...tip( <> {day(d)} -
{grouped(n)} commits
+
+ {grouped(n)} {unit} +
, )} /> @@ -129,7 +139,7 @@ export function Calendar({ firstDay, days }: { firstDay: number; days: number[] })} (n > 0 ? [[day(firstDay + i), n]] : []))} />
diff --git a/packages/ui/src/charts/Hours.tsx b/packages/ui/src/charts/Hours.tsx index 67cb24a..f29c61d 100644 --- a/packages/ui/src/charts/Hours.tsx +++ b/packages/ui/src/charts/Hours.tsx @@ -1,6 +1,6 @@ import { grouped } from "../format"; import { TableView, YAxis } from "./common"; -import { ticks, useWidth } from "./scale"; +import { HALF, ticks } from "./scale"; import { useTip } from "./tip"; const HEIGHT = 150; @@ -9,7 +9,7 @@ const TOP = 16; const BOTTOM = 22; export function Hours({ hours }: { hours: number[] }) { - const [ref, width] = useWidth(); + const width = HALF; const tip = useTip(); const scale = ticks(Math.max(1, ...hours)); const top = scale.at(-1) ?? 1; @@ -18,9 +18,9 @@ export function Hours({ hours }: { hours: number[] }) { const x = (h: number) => LEFT + h * bw; const label = (h: number) => `${String(h).padStart(2, "0")}:00`; return ( -
- {width > 0 && ( - +
+ {( + diff --git a/packages/ui/src/charts/Lines.tsx b/packages/ui/src/charts/Lines.tsx index e5731f5..b0a66e7 100644 --- a/packages/ui/src/charts/Lines.tsx +++ b/packages/ui/src/charts/Lines.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { day, grouped } from "../format"; import { Legend, TableView, YAxis } from "./common"; -import { ticks, useWidth } from "./scale"; +import { HALF, ticks } from "./scale"; export type Line = { label: string; colour: string; values: number[]; dashed?: boolean }; @@ -11,7 +11,7 @@ const TOP = 10; const BOTTOM = 22; export function Lines({ firstWeek, lines, unit }: { firstWeek: number; lines: Line[]; unit: string }) { - const [ref, width] = useWidth(); + const width = HALF; const [at, setAt] = useState(null); const weeks = Math.max(0, ...lines.map((l) => l.values.length)); const most = Math.max(1, ...lines.flatMap((l) => l.values)); @@ -21,20 +21,20 @@ export function Lines({ firstWeek, lines, unit }: { firstWeek: number; lines: Li const y = (v: number) => TOP + (HEIGHT - TOP - BOTTOM) * (1 - v / top); const week = (i: number) => day(firstWeek + i * 7); return ( -
+
({ label: l.label, colour: l.colour, mark: l.dashed ? "dash" : "line" }))} /> {lines.every((l) => l.values.every((v) => v === 0)) ? (

None in this window.

- ) : width > 0 && weeks > 0 && ( + ) : weeks > 0 && (
{ const r = e.currentTarget.getBoundingClientRect(); - const i = Math.round(((e.clientX - r.left - LEFT) / Math.max(1, width - LEFT - 4)) * (weeks - 1)); + const i = Math.round((((e.clientX - r.left) * (width / Math.max(1, r.width)) - LEFT) / Math.max(1, width - LEFT - 4)) * (weeks - 1)); setAt(Math.max(0, Math.min(weeks - 1, i))); }} onMouseLeave={() => setAt(null)} @@ -64,7 +64,7 @@ export function Lines({ firstWeek, lines, unit }: { firstWeek: number; lines: Li {at !== null && ( -
+
Week of {week(at)} {lines.map((l) => (
diff --git a/packages/ui/src/charts/columns.test.ts b/packages/ui/src/charts/columns.test.ts new file mode 100644 index 0000000..f8d850a --- /dev/null +++ b/packages/ui/src/charts/columns.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { binDays, thinMarks } from "./marks"; + +test("columns are days, weeks, four weeks or quarters, by how long the window is", () => { + expect(binDays(90)).toBe(1); + expect(binDays(365)).toBe(7); + expect(binDays(3650)).toBe(28); + expect(binDays(365 * 20)).toBe(91); +}); + +test("releases close together are thinned to those that fit, the newest kept", () => { + const marks = Array.from({ length: 79 }, (_, i) => ({ day: 1000 + i * 10, label: `v${i}` })); + const drawn = thinMarks(marks, 1000, 800); + expect(drawn.length).toBeLessThanOrEqual(Math.ceil(100 / 3.5) + 1); + expect(drawn.at(-1)?.mark.label).toBe("v78"); + for (let i = 1; i < drawn.length; i++) expect((drawn[i]?.at ?? 0) - (drawn[i - 1]?.at ?? 0)).toBeGreaterThanOrEqual(3.5); + const labelled = drawn.filter((d) => d.labelled); + for (let i = 1; i < labelled.length; i++) expect((labelled[i]?.at ?? 0) - (labelled[i - 1]?.at ?? 0)).toBeGreaterThanOrEqual(13); +}); + +test("a release outside the window is not drawn", () => { + expect(thinMarks([{ day: 5, label: "old" }], 100, 50)).toEqual([]); +}); diff --git a/packages/ui/src/charts/common.tsx b/packages/ui/src/charts/common.tsx index b28a154..46054b1 100644 --- a/packages/ui/src/charts/common.tsx +++ b/packages/ui/src/charts/common.tsx @@ -112,7 +112,7 @@ export function Figure({ {title} - {note &&

{note}

} + {note &&
{note}
} {children} ); diff --git a/packages/ui/src/charts/marks.ts b/packages/ui/src/charts/marks.ts new file mode 100644 index 0000000..e4ed9fa --- /dev/null +++ b/packages/ui/src/charts/marks.ts @@ -0,0 +1,40 @@ +export type Mark = { day: number; label: string }; + +const MARK_GAP = 3.5; +const LABEL_GAP = 13; + +export function binDays(days: number): number { + if (days <= 120) return 1; + if (days <= 840) return 7; + if (days <= 28 * 160) return 28; + return 91; +} + +/** The releases drawn on a chart: only those at least a few percent apart, newest kept first, and labels only where they fit. */ +export function thinMarks(marks: Mark[], firstDay: number, days: number): { mark: Mark; at: number; labelled: boolean }[] { + const span = Math.max(1, days); + const placed = marks + .map((mark) => ({ mark, at: ((mark.day - firstDay) / span) * 100 })) + .filter((m) => m.at >= 0 && m.at <= 100) + .sort((a, b) => b.at - a.at); + const kept: { mark: Mark; at: number; labelled: boolean }[] = []; + let lastMark = Infinity; + let lastLabel = Infinity; + for (const m of placed) { + if (lastMark - m.at < MARK_GAP) continue; + const labelled = lastLabel - m.at >= LABEL_GAP && m.at <= 100 - 4; + kept.push({ ...m, labelled }); + lastMark = m.at; + if (labelled) lastLabel = m.at; + } + return kept.reverse(); +} + +export function yearsOf(firstDay: number, days: number): number[] { + const out: number[] = []; + const first = new Date(firstDay * 86_400_000).getUTCFullYear(); + const last = new Date((firstDay + days) * 86_400_000).getUTCFullYear(); + for (let y = first + 1; y <= last; y++) out.push(Date.UTC(y, 0, 1) / 86_400_000); + const every = Math.ceil(out.length / 10); + return out.filter((_, i) => i % every === 0); +} diff --git a/packages/ui/src/charts/scale.ts b/packages/ui/src/charts/scale.ts index c5819c2..99831de 100644 --- a/packages/ui/src/charts/scale.ts +++ b/packages/ui/src/charts/scale.ts @@ -1,20 +1,5 @@ -import { useCallback, useRef, useState } from "react"; - -export function useWidth(): [(el: T | null) => void, number] { - const [width, setWidth] = useState(0); - const observer = useRef(null); - const ref = useCallback((el: T | null) => { - observer.current?.disconnect(); - observer.current = null; - if (!el) return; - setWidth(el.getBoundingClientRect().width); - observer.current = new ResizeObserver(([entry]) => { - if (entry) setWidth(entry.contentRect.width); - }); - observer.current.observe(el); - }, []); - return [ref, width]; -} +export const HALF = 560; +export const FULL = 1200; export function ticks(max: number, count = 3): number[] { if (max <= 0) return [0]; diff --git a/packages/ui/src/components/Face.tsx b/packages/ui/src/components/Face.tsx new file mode 100644 index 0000000..7598ffc --- /dev/null +++ b/packages/ui/src/components/Face.tsx @@ -0,0 +1,9 @@ +import { Avatar } from "@astryxdesign/core/Avatar"; +import { avatarUrl } from "./avatar"; + +export type FaceSize = 20 | 24 | 32 | 40 | 48 | 64 | 96 | 128; + +/** A person's GitHub avatar, or their initials when no GitHub account is known. */ +export function Face({ login, name, size = 24 }: { login: string | null | undefined; name: string; size?: FaceSize }) { + return ; +} diff --git a/packages/ui/src/components/Loading.tsx b/packages/ui/src/components/Loading.tsx index 23c23d4..c47617b 100644 --- a/packages/ui/src/components/Loading.tsx +++ b/packages/ui/src/components/Loading.tsx @@ -1,22 +1,99 @@ import { Skeleton } from "@astryxdesign/core/Skeleton"; +import { Figure } from "../charts/common"; -export function ScreenSkeleton() { +function Note() { return ( -
-
- {[0, 1, 2, 3].map((i) => ( - - ))} +
+ +
+ ); +} + +export function TilesSkeleton({ count = 6, className }: { count?: number; className?: string }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( + + ))} +
+ ); +} + +export function OverviewSkeleton() { + return ( +
+ +
}> +
+ +
+
+
+
}> +
+ +
+
+
+ +
-
- - +
+ ); +} + +export function PeopleSkeleton() { + return ( +
+
+ + +
+
+ ); +} + +export function ActivitySkeleton() { + return ( +
+
+ +
+ +
+
+
+ +
-
); } +export function MapSkeleton() { + return ( +
+ + +
+ ); +} + +export function CommitsSkeleton() { + return ( +
+
+ + +
+
+ ); +} + +export function ScreenSkeleton() { + return ; +} + export function LinesSkeleton({ lines = 3 }: { lines?: number }) { return (
diff --git a/packages/ui/src/components/Logo.tsx b/packages/ui/src/components/Logo.tsx index 8fbc277..3a9c95f 100644 --- a/packages/ui/src/components/Logo.tsx +++ b/packages/ui/src/components/Logo.tsx @@ -1,9 +1,8 @@ -export function Logo({ size = 22 }: { size?: number }) { +export function Logo({ size = 28 }: { size?: number }) { return ( - - - - + + + ); } diff --git a/packages/ui/src/components/Name.tsx b/packages/ui/src/components/Name.tsx index 4ecf161..d23c6ca 100644 --- a/packages/ui/src/components/Name.tsx +++ b/packages/ui/src/components/Name.tsx @@ -1,21 +1,15 @@ -import { useContext } from "react"; -import { Avatar } from "@astryxdesign/core/Avatar"; import type { PersonRef } from "@commitscape/data"; -import { AvatarsContext } from "../help"; -import { avatarUrl } from "./avatar"; -import { personColour } from "../theme"; +import { useLogin } from "./login"; +import { Face } from "./Face"; export function Name({ p, onOpen }: { p: PersonRef | null | undefined; onOpen?: (id: number) => void }) { - const avatars = useContext(AvatarsContext); + const login = useLogin(p); if (!p) return someone unknown; - const mark = - avatars && p.login ? ( - - - - ) : ( - - ); + const mark = ( + + + + ); if (!onOpen) { return ( diff --git a/packages/ui/src/components/login.ts b/packages/ui/src/components/login.ts new file mode 100644 index 0000000..cc8ec6f --- /dev/null +++ b/packages/ui/src/components/login.ts @@ -0,0 +1,9 @@ +import { useContext } from "react"; +import type { PersonRef } from "@commitscape/data"; +import { LoginsContext } from "../help"; + +export function useLogin(p: PersonRef | null | undefined): string | null { + const logins = useContext(LoginsContext); + if (!p) return null; + return p.login ?? logins.get(p.id) ?? null; +} diff --git a/packages/ui/src/components/skeletons.ts b/packages/ui/src/components/skeletons.ts new file mode 100644 index 0000000..76afcb1 --- /dev/null +++ b/packages/ui/src/components/skeletons.ts @@ -0,0 +1,11 @@ +import type { ComponentType } from "react"; +import type { Screen } from "../route"; +import { ActivitySkeleton, CommitsSkeleton, MapSkeleton, OverviewSkeleton, PeopleSkeleton } from "./Loading"; + +export const SCREEN_SKELETONS: Record = { + overview: OverviewSkeleton, + people: PeopleSkeleton, + activity: ActivitySkeleton, + map: MapSkeleton, + commits: CommitsSkeleton, +}; diff --git a/packages/ui/src/data.ts b/packages/ui/src/data.ts index f0bbac2..1f0010d 100644 --- a/packages/ui/src/data.ts +++ b/packages/ui/src/data.ts @@ -1,9 +1,11 @@ import { createContext, useContext } from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { key, type DataSource, type Params } from "@commitscape/data"; export const SourceContext = createContext(null); +export const StaleContext = createContext(false); + export function useSource(): DataSource { const source = useContext(SourceContext); if (!source) throw new Error("No Data Source: wrap the app in ."); @@ -16,19 +18,33 @@ export type Loaded = { stale: boolean; }; -/** The TanStack Query options for one answer, shared by loaders and screens. */ +type Answer = { data: T; error: null } | { data: null; error: string }; + +/** The TanStack Query options for one answer, shared by loaders and screens; a missing answer is a value, not a throw. */ export function dataQuery(source: DataSource, path: string, params: Params) { return { queryKey: ["report", source.id, key(path, params)] as const, - queryFn: () => source.get(path, params), + queryFn: (): Promise> => + source.get(path, params).then( + (data) => ({ data, error: null }), + (e: unknown) => ({ data: null, error: e instanceof Error ? e.message : String(e) }), + ), staleTime: Infinity, retry: false, }; } -/** One answer from the Data Source, keeping the last one on screen while the next loads. */ -export function useData(path: string | null, params: Params): Loaded { +/** One answer from the Data Source; suspends until it is there, so the server draws it and streams it in. */ +export function useData(path: string, params: Params): Loaded & { data: T | null } { + const source = useSource(); + const stale = useContext(StaleContext); + const q = useSuspenseQuery(dataQuery(source, path, params)); + return { data: q.data.data, error: q.data.error, stale }; +} + +/** An answer only some views need, asked in the browser without holding up the page. */ +export function useLazyData(path: string | null, params: Params): Loaded { const source = useSource(); const q = useQuery({ ...dataQuery(source, path ?? "", params), enabled: path !== null, placeholderData: keepPreviousData }); - return { data: q.data ?? null, error: q.error ? q.error.message : null, stale: q.isPlaceholderData }; + return { data: q.data?.data ?? null, error: q.data?.error ?? null, stale: q.isPlaceholderData }; } diff --git a/packages/ui/src/format.ts b/packages/ui/src/format.ts index cfb94cd..d74ee40 100644 --- a/packages/ui/src/format.ts +++ b/packages/ui/src/format.ts @@ -19,7 +19,8 @@ export function compact(n: number): string { export function share(part: number, whole: number): string { if (whole === 0) return "—"; - return `${Math.round((part * 100) / whole)}%`; + const p = Math.round((part * 100) / whole); + return p === 0 && part > 0 ? "<1%" : `${p}%`; } export function day(days: number): string { diff --git a/packages/ui/src/help.ts b/packages/ui/src/help.ts index fa3a4d2..89c91ae 100644 --- a/packages/ui/src/help.ts +++ b/packages/ui/src/help.ts @@ -2,4 +2,4 @@ import { createContext } from "react"; export const HelpContext = createContext(false); -export const AvatarsContext = createContext(false); +export const LoginsContext = createContext>(new Map()); diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index edda1cc..c98a4bc 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -43,7 +43,7 @@ button.link { button.link:hover { text-decoration-color: var(--text-2); } button.link:focus-visible, .bar-row:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; } -.app { max-width: 84rem; margin: 0 auto; padding: 0.75rem 1.5rem 3rem; font-size: 0.94rem; line-height: 1.45; } +.app { width: 100%; padding: 0.25rem 0 3rem; font-size: 0.94rem; line-height: 1.45; } .logo { display: block; } .tools { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; } .screen-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 0.5rem 1.5rem; } @@ -69,7 +69,7 @@ footer { margin-top: 3rem; } .figure-title { margin: 0 0 0.1rem; font-size: 1.05rem; } .note { color: var(--muted); } .small { font-size: 0.82rem; } -.figure > .note { margin: 0 0 0.6rem; font-size: 0.88rem; } +.figure > .note, .figure-note { margin: 0 0 0.6rem; font-size: 0.88rem; min-height: 1.3em; } .error { color: var(--error); } .explain { font-size: 0.85rem; color: var(--text-2); border-left: 3px solid var(--color-accent); padding: 0.3rem 0.7rem; @@ -78,7 +78,7 @@ footer { margin-top: 3rem; } .explain ul { margin: 0.2rem 0; padding-left: 1.1rem; } .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.75rem; } -.tile { display: flex; flex-direction: column; gap: 0.1rem; } +.tile { display: flex; flex-direction: column; gap: 0.1rem; height: 104px; box-sizing: border-box; overflow: hidden; } .tile strong { font-size: 1.55rem; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; } .tile span { color: var(--text-2); } .tile .note { color: var(--muted); } @@ -87,10 +87,6 @@ footer { margin-top: 3rem; } .chart svg { display: block; overflow: visible; } .axis line { stroke: var(--line); stroke-width: 1; } .axis text, .axis-label { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; } -.hit { fill: transparent; } -g:hover > .hit { fill: var(--text); fill-opacity: 0.05; } -.mark line { stroke: var(--text-2); stroke-width: 1; stroke-dasharray: 3 2; } -.mark text { fill: var(--text-2); font-size: 10.5px; } .crosshair { stroke: var(--muted); stroke-width: 1; } .empty-cell { fill: var(--empty); } .legend { display: flex; flex-wrap: wrap; gap: 0.25rem 0.9rem; list-style: none; padding: 0; margin: 0 0 0.4rem; font-size: 0.82rem; color: var(--text-2); } @@ -129,25 +125,13 @@ button.bar-row { width: 100%; font: inherit; font-size: 0.88rem; color: inherit; .bar-value { color: var(--text-2); font-variant-numeric: tabular-nums; white-space: nowrap; } .name { display: inline-flex; align-items: center; gap: 0.4rem; vertical-align: middle; } -.swatch { display: inline-block; width: 0.65rem; height: 0.65rem; border-radius: 2px; flex: none; } -.avatar { display: inline-flex; border-radius: 50%; border: 2px solid; flex: none; } .profile-head { display: flex; align-items: center; gap: 0.9rem; } .profile-name { margin: 0; } -.facts, .worth, .suspects, .groups { padding-left: 0; list-style: none; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; } +.facts, .suspects, .groups { padding-left: 0; list-style: none; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; } .groups { padding-left: 1.2rem; list-style: decimal; } -.worth li, .moments li { display: grid; grid-template-columns: 1.2rem 1fr; gap: 0.4rem; } -.glyph { color: var(--text-2); text-align: center; } .actions { display: flex; gap: 0.5rem; align-items: center; margin: 0.75rem 0; flex-wrap: wrap; } .mailmap { background: var(--surface-2); padding: 0.6rem; border-radius: var(--radius-element); overflow: auto; font-size: 0.82rem; font-family: var(--mono); } -.story svg { margin-bottom: 0.5rem; } -.story-line { stroke: var(--line); stroke-width: 2; } -.quiet { fill: var(--empty); } -.moment { fill: var(--text-2); font-size: 14px; } -.moment.release { fill: var(--muted); font-size: 9px; } -.moments { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; } -.moments li { grid-template-columns: 1.2rem 7rem 1fr; } -.moments .when { color: var(--muted); font-variant-numeric: tabular-nums; } .map-bar { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; } .crumbs { font-size: 0.95rem; } @@ -173,13 +157,6 @@ button.bar-row { width: 100%; font: inherit; font-size: 0.88rem; color: inherit; .file-panel dd { margin: 0; } .commits li { font-size: 0.82rem; } -.danger { fill: var(--danger); } -.danger-label { fill: var(--muted); font-size: 11px; } -.dot { cursor: pointer; } -.dot circle:not(.hit) { fill: var(--s1); fill-opacity: 0.55; stroke: var(--surface); stroke-width: 1; } -.dot circle.top { fill-opacity: 1; stroke: var(--surface); stroke-width: 2; } -.dot:hover circle:not(.hit) { fill-opacity: 1; stroke: var(--text); } -.dot-label { fill: var(--text-2); font-size: 11px; paint-order: stroke; stroke: var(--surface); stroke-width: 3px; pointer-events: none; } .night { fill: var(--surface-2); } .night-label { fill: var(--muted); font-size: 11px; } @@ -216,3 +193,115 @@ button.bar-row { width: 100%; font: inherit; font-size: 0.88rem; color: inherit; } .dialog-body { padding: 0 1.25rem 1.25rem; } + +.cols { position: relative; display: flex; flex-direction: column; padding: 16px 0 0 38px; box-sizing: border-box; } +.cols-plot { position: relative; flex: 1; min-height: 0; } +.cols-grid { position: absolute; left: 0; right: 0; height: 0; border-top: 1px solid var(--line); } +.cols-grid span { position: absolute; right: calc(100% + 6px); transform: translateY(-50%); font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } +.cols-bars { position: absolute; inset: 0; display: flex; align-items: stretch; gap: 1px; } +.cols-bin { flex: 1; min-width: 0; display: flex; flex-direction: column-reverse; border-radius: 2px 2px 0 0; } +.cols-bin:hover { background: var(--color-overlay-hover); } +.cols-bin span { display: block; width: 100%; min-height: 1px; } +.cols-bin span:last-child { border-radius: 2px 2px 0 0; } +.cols-mark { position: absolute; top: -12px; bottom: 0; width: 0; border-left: 1px dashed var(--text-2); opacity: 0.55; z-index: 1; } +.cols-mark::after { content: ""; position: absolute; left: -5px; width: 10px; top: 0; bottom: 0; } +.cols-mark:hover { opacity: 1; } +.cols-mark span { position: absolute; left: 3px; top: -4px; font-size: 10.5px; color: var(--text-2); white-space: nowrap; } +.cols-axis { position: relative; height: 20px; } +.cols-axis span { position: absolute; top: 4px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; } +.fluid { display: block; width: 100%; height: auto; overflow: visible; } +.face { display: inline-flex; flex: none; } + +.overview-chart { height: 262px; } +.contributors { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; height: 380px; } +.contributors li { display: grid; grid-template-columns: minmax(0, 1fr) auto 3rem auto; gap: 0.75rem; align-items: center; height: 38px; border-bottom: 1px solid var(--line); font-size: 0.9rem; } +.contributors li:last-child { border-bottom: none; } +.contributors .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.lines-changed { min-width: 6.5rem; } +.added { color: var(--color-success); } +.removed { color: var(--color-error); } +.hit { fill: transparent; } +g:hover > .hit { fill: var(--text); fill-opacity: 0.05; } +.swatch { display: inline-block; width: 0.65rem; height: 0.65rem; border-radius: 2px; flex: none; } +@media (max-width: 760px) { + .screen-bar { grid-template-columns: minmax(0, 1fr); } + .windows { flex-wrap: wrap; } + .cols-mark span { display: none; } + .contributors li { grid-template-columns: minmax(0, 1fr) auto auto; } + .contributors .lines-changed { display: none; } +} + +.profile-hero { display: flex; gap: 1.25rem; align-items: flex-start; padding: 1.5rem 0 1rem; } +.profile-hero > :first-child { flex: none; } +.profile-title { margin: 0; font-size: 2rem; line-height: 1.15; letter-spacing: -0.02em; } +.profile-login { margin: 0.15rem 0 0; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; font-size: 1rem; } +.profile-login a { color: var(--text-2); text-decoration: none; } +.profile-bio { margin: 0.5rem 0 0; max-width: 46rem; } +.profile-facts { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin: 0.5rem 0 0; } +.profile-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; } +.profile-tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); } +@media (max-width: 760px) { .profile-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.repo-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; } +.repo-row { display: grid; grid-template-columns: 32px minmax(0, 1fr) 4.5rem 4.5rem 4rem 6.5rem; gap: 0.6rem; align-items: center; height: 60px; border-bottom: 1px solid var(--line); font-size: 0.9rem; } +.repo-head-row { height: 28px; } +@media (max-width: 760px) { .repo-row { grid-template-columns: 32px minmax(0, 1fr) 4rem 4rem; } .hide-narrow { display: none; } } +.repo-row:last-child { border-bottom: none; } +.repo-row-name { font-weight: 600; text-decoration: none; color: var(--text) !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } +.repo-row-meta { display: flex; gap: 0.75rem; align-items: center; flex-wrap: nowrap; overflow: hidden; white-space: nowrap; } +.repo-row-numbers { display: flex; flex-direction: column; align-items: flex-end; font-size: 0.86rem; font-variant-numeric: tabular-nums; text-align: right; } +.lang { display: inline-flex; align-items: center; gap: 0.3rem; } +.lang-dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; display: inline-block; } +.lang-years { list-style: none; padding: 0; margin: 0.25rem 0 0; display: flex; flex-direction: column; gap: 0.35rem; } +.lang-years li { display: grid; grid-template-columns: 3rem 1fr; gap: 0.6rem; align-items: center; } +.lang-year { font-size: 0.82rem; color: var(--muted); font-variant-numeric: tabular-nums; } +.lang-bar { display: flex; height: 0.85rem; gap: 2px; } +.lang-bar span { display: block; min-width: 2px; } +.lang-bar span:first-child { border-radius: 4px 0 0 4px; } +.lang-bar span:last-child { border-radius: 0 4px 4px 0; } +.partners { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; } +.partners li { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 0.6rem; align-items: center; height: 44px; border-bottom: 1px solid var(--line); } +.partners li:last-child { border-bottom: none; } +.partners .note { text-align: right; } +@media (max-width: 760px) { + .profile-hero { flex-wrap: wrap; } + .profile-title { font-size: 1.5rem; } + .repo-row-numbers .small { display: none; } +} +.engine-row { grid-template-columns: 32px minmax(0, 1fr) 4.5rem 7rem minmax(6rem, auto) 4.5rem; } +@media (max-width: 760px) { .engine-row { grid-template-columns: 32px minmax(0, 1fr) 4rem minmax(5rem, auto); } } +.standing-top { margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--color-text-accent); } +.standings-wrap { overflow-x: auto; } +.standings { border-collapse: collapse; width: 100%; font-size: 0.9rem; } +.standings th, .standings td { padding: 0 0.55rem; height: 40px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; } +.standings th { color: var(--text-2); font-weight: 600; font-size: 0.82rem; } +.standings .place { width: 2.5rem; color: var(--muted); font-variant-numeric: tabular-nums; } +.standings td.on { font-weight: 600; } +.standings .standing-you td { background: var(--color-accent-muted); } +.standings .works-on { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); } +.sorter { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 0.2em; } +.sorter.on { color: var(--text); text-decoration: underline; } +.work-month { display: flex; flex-direction: column; gap: 0.75rem; } +.work-month-title { font-size: 1.15rem; margin: 0.5rem 0 0; } +.work-repo { border: 1px solid var(--line); border-radius: var(--radius-container); padding: 0.75rem 1rem; background: var(--color-background-surface); } +.work-repo-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.4rem; } +.work-repo-name { font-weight: 600; color: var(--text) !important; text-decoration: none; } +.work-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.work-items li { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr) auto 6.5rem; gap: 0.6rem; align-items: baseline; padding: 0.3rem 0; border-top: 1px solid var(--line); font-size: 0.9rem; } +.work-items a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.work-tag { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 0.82rem; } +.work-when { font-variant-numeric: tabular-nums; } +@media (max-width: 760px) { .work-items li { grid-template-columns: 4rem minmax(0, 1fr); } .work-items .num, .work-items .work-when { display: none; } } +.archetype { display: flex; flex-direction: column; gap: 0.3rem; } +.archetype-title { font-size: 2rem; letter-spacing: -0.02em; line-height: 1.1; } +.archetype p { margin: 0; } +.rules { display: flex; flex-direction: column; gap: 0.4rem; margin: 0.5rem 0 0; } +.rules div { display: grid; grid-template-columns: 9rem 1fr; gap: 0.5rem; } +.rules dt { font-weight: 600; } +.rules dd { margin: 0; color: var(--text-2); } +.achievements { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; } +.achievement { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.75rem; align-items: center; min-height: 48px; border-bottom: 1px solid var(--line); color: var(--text-2); } +.achievement:last-child { border-bottom: none; } +.achievement.earned { color: var(--text); } +.achievement > span:nth-child(2) { display: flex; flex-direction: column; } +.medal { width: 1.6rem; height: 1.6rem; border-radius: 50%; border: 0.42rem solid var(--color-background-muted); background: var(--surface); box-sizing: border-box; } +.achievement.earned .medal { border-color: #00b82b; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e1bfb68..9b474dc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -5,4 +5,23 @@ export { Logo } from "./components/Logo"; export { Tile } from "./components/Tile"; export { dataQuery, SourceContext, useData, useSource } from "./data"; export { paramsOf, primaryRequest, SCREENS, toRoute, toSearch, type Go, type Route, type Screen, type Search } from "./route"; -export { commitscapeTheme, MODES, useMode, type Mode } from "./theme"; +export { commitscapeTheme, MODE_COOKIE, MODES, ModeContext, modeOf, themeCss, useMode, type Mode } from "./theme"; +export { Face, type FaceSize } from "./components/Face"; +export { avatarUrl } from "./components/avatar"; +export { TilesSkeleton } from "./components/Loading"; +export { SCREEN_SKELETONS } from "./components/skeletons"; +export { Activity as ProfileActivity, LanguagesByYear, Partners, ProfileHero, ProfileTotals, PullRequestTiles, Repositories, REPOS_SHOWN, YearCalendar } from "./profile/sections"; +export { Figure } from "./charts/common"; +export { Columns } from "./charts/Columns"; +export { Calendar } from "./charts/Grid"; +export { TipLayer } from "./charts/Tip"; +export { compact, grouped, many, share } from "./format"; +export { EngineRepos, SurvivingTile } from "./profile/engine"; +export { survival } from "./profile/survival"; +export { StandingsTable, YourStanding } from "./standings/Standings"; +export { placesFor, topLine } from "./standings/places"; +export { ACHIEVEMENT_CARD_SIZE, ARCHETYPE_CARD_SIZE, WRAPPED_CALENDAR_CARD_SIZE, WRAPPED_CARD_SIZE, CARDS, PERSON_CARDS, STANDING_CARD_SIZE, type CardSpec } from "./cards/kinds"; +export { STILL, type Paint } from "./cards/paint"; +export { CARD_DESIGN, TOKENS, type CardTheme } from "./cards/tokens"; +export { WorkView } from "./work/WorkView"; +export { Traits } from "./profile/traits"; diff --git a/packages/ui/src/jump.test.ts b/packages/ui/src/jump.test.ts index 3fc14ef..a352640 100644 --- a/packages/ui/src/jump.test.ts +++ b/packages/ui/src/jump.test.ts @@ -13,7 +13,7 @@ test("⌘K matches every word typed, in any order, once the list arrives", async jump("person:2", "Alice Example", "People"), jump("file:src/bob.rs", "src/bob.rs", "Files"), ]); - expect((await early).map((j) => j.id)).toEqual(["person:1"]); - expect((await source.search("BOB")).map((j) => j.id)).toEqual(["person:1", "file:src/bob.rs"]); + expect((await early).map((j) => j.id)).toEqual(["person:1", "commits:example bob"]); + expect((await source.search("BOB")).map((j) => j.id)).toEqual(["person:1", "file:src/bob.rs", "commits:BOB"]); expect((await source.bootstrap()).map((j) => j.id)).toEqual(["screen:people"]); }); diff --git a/packages/ui/src/jump.ts b/packages/ui/src/jump.ts index 4659f06..70f85b4 100644 --- a/packages/ui/src/jump.ts +++ b/packages/ui/src/jump.ts @@ -11,7 +11,8 @@ export function jumpSource(items: Promise): SearchSource { const all = await items; const words = query.toLowerCase().split(/\s+/).filter(Boolean); if (words.length === 0) return shown(all); - return shown(all.filter((i) => words.every((w) => i.label.toLowerCase().includes(w)))); + const commits: Jump = { id: `commits:${query.trim()}`, label: `Commits that mention “${query.trim()}”`, auxiliaryData: { group: "Commits", to: { screen: "commits", q: query.trim(), id: undefined, file: undefined } } }; + return [...shown(all.filter((i) => words.every((w) => i.label.toLowerCase().includes(w)))), commits]; }, }; } diff --git a/packages/ui/src/keys.ts b/packages/ui/src/keys.ts index f6e5bed..a148ed3 100644 --- a/packages/ui/src/keys.ts +++ b/packages/ui/src/keys.ts @@ -41,9 +41,9 @@ export function useShortcuts(shortcuts: Shortcuts) { } export const SHORTCUT_WORDS: [string, string][] = [ - ["1–6", "Choose a screen"], + ["1–5", "Choose a screen"], ["mod+k", "Jump to a screen, a person, a folder or a file"], - ["/", "Search: the commits on Commits, otherwise the same as ⌘K"], + ["/", "Search: the commits on Commits, otherwise people, folders and files"], ["w", "The next Window: 30 days, 90 days, a year, all time"], ["shift+w", "The Window before"], ["?", "Show or hide this help, every number's explanation, and the keys"], diff --git a/packages/ui/src/mode.ts b/packages/ui/src/mode.ts new file mode 100644 index 0000000..71ab851 --- /dev/null +++ b/packages/ui/src/mode.ts @@ -0,0 +1,12 @@ +export const MODES = [ + ["system", "Follow the system"], + ["light", "Light"], + ["dark", "Dark"], +] as const; +export type Mode = (typeof MODES)[number][0]; + +export const MODE_COOKIE = "commitscape-theme"; + +export function modeOf(value: string | undefined | null): Mode { + return MODES.some(([name]) => name === value) ? (value as Mode) : "system"; +} diff --git a/packages/ui/src/profile/engine.tsx b/packages/ui/src/profile/engine.tsx new file mode 100644 index 0000000..eb6d4ba --- /dev/null +++ b/packages/ui/src/profile/engine.tsx @@ -0,0 +1,71 @@ +import type { EngineRepo, EngineView } from "@commitscape/data"; +import { Figure } from "../charts/common"; +import { Face } from "../components/Face"; +import { Tile } from "../components/Tile"; +import { compact, grouped, many } from "../format"; +import { survival } from "./survival"; + +const WORDS: Record = { + counting: "counting…", + counted: "", + over_budget: "not counted: too big to count in time", + not_counted: "not counted: its history is read without old files", + failed: "not counted: the count failed", + stale: "counting…", +}; + + +/** The headline Surviving Lines, across the repositories commitscape has read. */ +export function SurvivingTile({ engine }: { engine: EngineView }) { + const counted = engine.repos.filter((r) => r.surviving.status === "counted").length; + if (engine.repos.length === 0) return ; + if (engine.surviving === null) return ; + const share = survival(engine.surviving, engine.added); + return ( + 0 ? `in ${grouped(counted)} of ${grouped(engine.repos.length)} read, counting…` : `${share ? `${share} of what they added, ` : ""}${many(counted, "repository", "repositories")}`} + /> + ); +} + +/** Each repository commitscape has read that the person committed to: their Lines Changed and Surviving Lines from its history. */ +export function EngineRepos({ engine, login }: { engine: EngineView; login: string }) { + if (engine.repos.length === 0) return null; + return ( +
+
    +
  1. + + Repository + commits + lines changed + still run + survival +
  2. + {engine.repos.map((r) => ( +
  3. + + + {r.owner}/ + {r.name} + + {grouped(r.commits)} + + {r.linesAdded === null ? ( + — + ) : ( + <> + +{compact(r.linesAdded)} −{compact(r.linesRemoved ?? 0)} + + )} + + {r.surviving.status === "counted" && r.surviving.lines !== null ? grouped(r.surviving.lines) : {WORDS[r.surviving.status]}} + {survival(r.surviving.lines, r.surviving.added) ?? —} +
  4. + ))} +
+
+ ); +} diff --git a/packages/ui/src/profile/sections.tsx b/packages/ui/src/profile/sections.tsx new file mode 100644 index 0000000..6cfd907 --- /dev/null +++ b/packages/ui/src/profile/sections.tsx @@ -0,0 +1,239 @@ +import { useState, type ReactNode } from "react"; +import { Avatar } from "@astryxdesign/core/Avatar"; +import { Badge } from "@astryxdesign/core/Badge"; +import { Button } from "@astryxdesign/core/Button"; +import { Heading } from "@astryxdesign/core/Heading"; +import type { Identity, Profile, ProfileRepo } from "@commitscape/data"; +import { Columns } from "../charts/Columns"; +import { Figure, Legend, TableView } from "../charts/common"; +import { Calendar } from "../charts/Grid"; +import { useTip } from "../charts/tip"; +import { avatarUrl } from "../components/avatar"; +import { Face } from "../components/Face"; +import { Tile } from "../components/Tile"; +import { compact, grouped, many } from "../format"; + +const DAY = 86_400; + +export const REPOS_SHOWN = 8; + +function hours(h: number): string { + if (h < 1) return `${Math.max(1, Math.round(h * 60))} min`; + if (h < 48) return `${Math.round(h)} h`; + return `${Math.round(h / 24)} days`; +} + +function joined(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { month: "long", year: "numeric", timeZone: "UTC" }); +} + +/** A person's name, face and what GitHub says of them, drawn at once from their identity. */ +export function ProfileHero({ identity, children, badges }: { identity: Identity; children?: ReactNode; badges?: ReactNode }) { + const facts = [identity.company, identity.location, `on GitHub since ${joined(identity.createdAt)}`, identity.followers > 0 ? many(identity.followers, "follower", "followers") : null].filter(Boolean); + return ( +
+ +
+ + {identity.name ?? identity.login} + +

+ @{identity.login} + {badges} +

+ {identity.bio &&

{identity.bio}

} +

+ {facts.map((f) => ( + {f} + ))} +

+
+ {children &&
{children}
} +
+ ); +} + +/** The headline numbers of a Profile, each saying what it counts; `slow` is where the numbers from its pull requests stream in. */ +export function ProfileTotals({ profile, slow, extra }: { profile: Profile; slow?: ReactNode; extra?: ReactNode }) { + const t = profile.totals; + return ( +
+ + {extra} + + + {slow ?? } + + 0 ? `${many(t.currentStreak, "day", "days")} running now` : "of days in a row"} /> +
+ ); +} + +/** The two headline numbers that need a person's pull requests read. */ +export function PullRequestTiles({ profile }: { profile: Profile }) { + const t = profile.totals; + const capped = profile.read.prs < profile.read.prsTotal; + return ( + <> + + + + ); +} + +/** The contribution calendar of the last year. */ +export function YearCalendar({ profile }: { profile: Profile }) { + const { firstDay, days } = profile.calendar; + const from = Math.max(0, days.length - 364 - (((firstDay + days.length - 1 + 4) % 7) + 1)); + const shown = days.slice(from); + const total = shown.reduce((a, b) => a + b, 0); + return ( +
+ +
+ ); +} + +/** Contributions over every year the person has been on GitHub. */ +export function Activity({ profile }: { profile: Profile }) { + return ( +
+
+ +
+
+ ); +} + +function RepoRow({ r, login, onStanding }: { r: ProfileRepo; login: string; onStanding?: (r: ProfileRepo) => ReactNode }) { + const n = (v: number) => (v > 0 ? grouped(v) : —); + return ( +
  • + +
    + + {r.owner}/ + {r.name} + +
    + {r.language && ( + + + {r.language} + + )} + {r.stars > 0 && {many(r.stars, "star", "stars")}} + {r.private && } + {onStanding?.(r)} +
    +
    + {n(r.prsMerged)} + {n(r.commits)} + {n(r.reviews)} + + {r.linesAdded + r.linesRemoved > 0 ? ( + <> + +{compact(r.linesAdded)} −{compact(r.linesRemoved)} + + ) : ( + — + )} + +
  • + ); +} + +/** Where a person's work is: each repository with their merged pull requests, commits and reviews in it. */ +export function Repositories({ profile, onStanding }: { profile: Profile; onStanding?: (r: ProfileRepo) => ReactNode }) { + const [all, setAll] = useState(false); + const list = profile.repositories; + const shown = all ? list : list.slice(0, REPOS_SHOWN); + return ( +
    + {list.length === 0 ? ( +

    No public work on GitHub yet.

    + ) : ( +
      +
    1. + + Repository + merged PRs + commits + reviews + lines merged +
    2. + {shown.map((r) => ( + + ))} +
    + )} + {list.length > REPOS_SHOWN &&
    + ); +} + +const LANGUAGE_COLOURS = 6; + +/** The languages of the repositories a person committed to, year by year, weighted by their commits. */ +export function LanguagesByYear({ profile }: { profile: Profile }) { + const tip = useTip(); + const totals = new Map(); + for (const y of profile.years) for (const l of y.languages) totals.set(l.name, (totals.get(l.name) ?? 0) + l.commits); + const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n); + const named = ranked.slice(0, LANGUAGE_COLOURS); + const colour = (name: string) => (named.includes(name) ? `var(--s${named.indexOf(name) + 1})` : "var(--other)"); + const years = profile.years.filter((y) => y.languages.length > 0); + return ( +
    + {years.length === 0 ? ( +

    No commits on GitHub to tell from.

    + ) : ( + <> + ({ label: n, colour: colour(n) })), ...(ranked.length > named.length ? [{ label: "Other", colour: "var(--other)" }] : [])]} /> +
      + {years.map((y) => { + const sum = y.languages.reduce((n, l) => n + l.commits, 0); + const other = y.languages.filter((l) => !named.includes(l.name)).reduce((n, l) => n + l.commits, 0); + const parts = [...y.languages.filter((l) => named.includes(l.name)).map((l) => ({ name: l.name, commits: l.commits })), ...(other > 0 ? [{ name: "Other", commits: other }] : [])]; + return ( +
    1. + {y.year} + + {parts.map((p) => ( + {y.year}: {p.name}, {many(p.commits, "commit", "commits")})} /> + ))} + +
    2. + ); + })} +
    + y.languages.map((l) => [String(y.year), l.name, l.commits]))} /> + + )} +
    + ); +} + +/** The people who review a person's pull requests, and whose pull requests they review. */ +export function Partners({ profile }: { profile: Profile }) { + return ( +
    + {profile.partners.length === 0 ? ( +

    No reviews either way yet.

    + ) : ( +
      + {profile.partners.slice(0, 10).map((p) => ( +
    • + + {p.login} + + {[p.reviewedYours > 0 && `${many(p.reviewedYours, "review", "reviews")} from them`, p.reviewedTheirs > 0 && `${many(p.reviewedTheirs, "review", "reviews")} to them`].filter(Boolean).join(" · ")} + +
    • + ))} +
    + )} +
    + ); +} diff --git a/packages/ui/src/profile/survival.test.ts b/packages/ui/src/profile/survival.test.ts new file mode 100644 index 0000000..48c47b2 --- /dev/null +++ b/packages/ui/src/profile/survival.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "vitest"; +import { survival } from "./survival"; + +test("Survival is shown only when both numbers are known and it is a share", () => { + expect(survival(61_148, 106_349)).toBe("57%"); + expect(survival(1, 1000)).toBe("<1%"); + expect(survival(0, 6)).toBe("0%"); + expect(survival(null, 10)).toBeNull(); + expect(survival(10, null)).toBeNull(); + expect(survival(10, 0)).toBeNull(); + expect(survival(12, 10)).toBeNull(); +}); diff --git a/packages/ui/src/profile/survival.ts b/packages/ui/src/profile/survival.ts new file mode 100644 index 0000000..4f66265 --- /dev/null +++ b/packages/ui/src/profile/survival.ts @@ -0,0 +1,6 @@ +/** Survival, as a share, only when both numbers are known and it is a share: an import left out of Lines Changed can make it pass 100%, and then it is not shown. */ +export function survival(lines: number | null, added: number | null): string | null { + if (lines === null || added === null || added === 0 || lines > added) return null; + const p = (lines * 100) / added; + return p > 0 && p < 1 ? "<1%" : `${Math.round(p)}%`; +} diff --git a/packages/ui/src/profile/traits.tsx b/packages/ui/src/profile/traits.tsx new file mode 100644 index 0000000..3d6a86e --- /dev/null +++ b/packages/ui/src/profile/traits.tsx @@ -0,0 +1,57 @@ +import { ARCHETYPES, type Achievement, type Archetype } from "@commitscape/data"; +import { Figure } from "../charts/common"; + +/** A person's Archetype with the rule that gave it, every rule on request, and their Achievements, the next ones showing what reaches them. */ +export function Traits({ archetypes, achievements, login, complete }: { archetypes: Archetype[]; achievements: Achievement[]; login: string; complete: boolean }) { + const main = archetypes[0]; + const earned = achievements.filter((a) => a.earned); + const next = achievements.filter((a) => !a.earned); + return ( +
    +
    +
    + {main ? main.title : "Not decided yet"} +

    {main ? main.rule : "None of the rules fits yet. They need 50 contributions or more, and then each its own threshold."}

    + {archetypes.length > 1 &&

    Also: {archetypes.slice(1).map((a) => a.title).join(", ")}

    } + {!complete &&

    Some rules wait for the pull requests to be read.

    } +
    +
    + Every rule, in the order they are tried +
    + {ARCHETYPES.map((a) => ( +
    +
    {a.title}
    +
    {a.rule}
    +
    + ))} +
    +
    +
    +
    +
      + {earned.map((a) => ( +
    • + + + {a.title} + {[a.detail, a.at ? `reached ${a.at}` : null].filter(Boolean).join(" · ") || a.rule} + + + Card + +
    • + ))} + {next.map((a) => ( +
    • + + + {a.title} + {a.rule} + +
    • + ))} +
    +
    +
    + ); +} diff --git a/packages/ui/src/route.ts b/packages/ui/src/route.ts index 3b7b47b..7aefe89 100644 --- a/packages/ui/src/route.ts +++ b/packages/ui/src/route.ts @@ -1,7 +1,7 @@ import type { Params } from "@commitscape/data"; import type { Meta } from "@commitscape/data"; -export const SCREENS = ["overview", "activity", "people", "map", "risk", "commits"] as const; +export const SCREENS = ["overview", "people", "activity", "map", "commits"] as const; export type Screen = (typeof SCREENS)[number]; export const TITLES: Record = { @@ -9,7 +9,6 @@ export const TITLES: Record = { activity: "Activity", people: "People", map: "Map", - risk: "Risk", commits: "Commits", }; @@ -71,8 +70,6 @@ export function primaryRequest(route: Route, params: Params): [string, Params] | return route.id !== undefined ? ["/api/person", { ...params, id: route.id }] : ["/api/people", params]; case "map": return ["/api/map", { ...params, path: route.path ?? "" }]; - case "risk": - return ["/api/risk", params]; default: return null; } diff --git a/packages/ui/src/screens/Activity.tsx b/packages/ui/src/screens/Activity.tsx index 0eed4ac..f587b75 100644 --- a/packages/ui/src/screens/Activity.tsx +++ b/packages/ui/src/screens/Activity.tsx @@ -6,15 +6,13 @@ import { Figure } from "../charts/common"; import { WeekGrid } from "../charts/Grid"; import { Lines } from "../charts/Lines"; import { Explain } from "../explain"; -import { githubWhy, grouped, many, share, WINDOW_WORDS } from "../format"; +import { grouped, many, share, WINDOW_WORDS } from "../format"; import { personColour } from "../theme"; import type { ScreenProps } from "./props"; -import { ScreenSkeleton } from "../components/Loading"; -export function Activity({ meta, params }: ScreenProps) { +export function Activity({ params }: ScreenProps) { const { data: a, error, stale } = useData("/api/activity", params); - if (error) return

    {error}

    ; - if (!a) return ; + if (error || !a) return

    {error}

    ; const span = WINDOW_WORDS[a.window] ?? a.window; const series = [ ...a.people.map((p, i) => ({ @@ -48,15 +46,11 @@ export function Activity({ meta, params }: ScreenProps) { + {a.github && (
    - {a.github ? (
    - ) : ( -

    {githubWhy(meta.github, meta.github_history)}

    - )} + From GitHub's whole history of the repository, read through the gh CLI a page at a time and kept, so later visits read only what changed.
    + )}
    Each commit at the hour its author's clock showed, so a team across time zones still shows its working day.
    -
    0 ? `${share(told, told + a.unclassified)} of commits could be told` : undefined}> - {a.kinds.length === 0 ? ( -

    - Most commits here could not be told apart by their files or their message ({grouped(a.unclassified)} of them), - so no split is shown rather than a misleading one. -

    - ) : ( + {told >= (told + a.unclassified) / 4 && told > 0 && ( +
    ({ @@ -105,12 +93,12 @@ export function Activity({ meta, params }: ScreenProps) { shown: `${grouped(k.commits)} · ${share(k.commits, told + a.unclassified)}`, }))} /> - )} Judged from the files a commit changed first (only tests, only docs, only CI, only dependency manifests), and from its message when the files cannot tell. {grouped(a.unclassified)} commits could be told by neither.
    + )}
    ); diff --git a/packages/ui/src/screens/Commits.tsx b/packages/ui/src/screens/Commits.tsx index 11cf351..cdd339f 100644 --- a/packages/ui/src/screens/Commits.tsx +++ b/packages/ui/src/screens/Commits.tsx @@ -6,13 +6,13 @@ import type { CommitList } from "@commitscape/data"; import { Figure } from "../charts/common"; import { Key } from "../components/Key"; import { Name } from "../components/Name"; -import { useData } from "../data"; +import { useLazyData } from "../data"; import { Explain } from "../explain"; import { compact, date, grouped, many, WINDOW_WORDS } from "../format"; import { useLeading } from "../leading"; import { searcher } from "../searcher"; import type { ScreenProps } from "./props"; -import { ScreenSkeleton } from "../components/Loading"; +import { Skeleton } from "@astryxdesign/core/Skeleton"; const ROW = 46; const SHOWN = 14; @@ -20,7 +20,7 @@ const DAY = 86_400; const SPANS: Record = { "30d": 30 * DAY, "90d": 90 * DAY, "1y": 365 * DAY }; export function Commits({ meta, route, params, go }: ScreenProps) { - const { data: list, error } = useData("/api/commits", {}); + const { data: list, error } = useLazyData("/api/commits", {}); const [asked, setAsked] = useState(route.q ?? ""); const [kind, setKind] = useState(null); const found = useFound(list, { @@ -32,7 +32,7 @@ export function Commits({ meta, route, params, go }: ScreenProps) { const onPerson = useCallback((id: number) => go({ screen: "people", id }), [go]); const [top, setTop] = useState(0); if (error) return

    {error}

    ; - if (!list) return ; + if (!list) return ; const span = route.from !== undefined || route.to !== undefined ? "the dates chosen" : WINDOW_WORDS[String(params.window)] ?? "all time"; const first = Math.max(0, Math.floor(top / ROW) - 4); const last = Math.min(found.rows.length, first + SHOWN + 8); @@ -63,7 +63,7 @@ export function Commits({ meta, route, params, go }: ScreenProps) { )}
    setTop(e.currentTarget.scrollTop)} role="list" aria-label="Commits found" @@ -188,3 +188,18 @@ const CommitRow = memo(function CommitRow({
    ); }); + +function CommitsLoading() { + return ( +
    +
    }> +
    + +
    +
    + +
    +
    +
    + ); +} diff --git a/packages/ui/src/screens/Map.tsx b/packages/ui/src/screens/Map.tsx index 9328bfd..27d3130 100644 --- a/packages/ui/src/screens/Map.tsx +++ b/packages/ui/src/screens/Map.tsx @@ -3,9 +3,9 @@ import { Button } from "@astryxdesign/core/Button"; import { Card } from "@astryxdesign/core/Card"; import { Selector } from "@astryxdesign/core/Selector"; import type { File as FileData, MapBlock, MapLevel } from "@commitscape/data"; -import { useData } from "../data"; +import { useData, useLazyData } from "../data"; import { TableView } from "../charts/common"; -import { ranges, useWidth } from "../charts/scale"; +import { FULL, ranges } from "../charts/scale"; import { useTip } from "../charts/tip"; import { squarify, type Rect } from "../charts/treemap"; import { Name } from "../components/Name"; @@ -37,9 +37,9 @@ const AGES = [ export function MapScreen({ meta, params, route, go }: ScreenProps) { const path = route.path ?? ""; const { data: level, error, stale } = useData("/api/map", { ...params, path }); - const file = useData(route.file ? "/api/file" : null, { ...params, path: route.file }); + const file = useLazyData(route.file ? "/api/file" : null, { ...params, path: route.file }); const [colour, setColour] = useState("activity"); - const [ref, width] = useWidth(); + const width = FULL; const tip = useTip(); const open = openers(go); if (error) return

    {error}

    ; @@ -117,9 +117,9 @@ export function MapScreen({ meta, params, route, go }: ScreenProps) {
    -
    - {width > 0 && ( - +
    + {( + {drawn.map((d) => { const b = d.block; const folder = !b.file && d.depth === 0 && b.inside.length > 0; diff --git a/packages/ui/src/screens/Overview.tsx b/packages/ui/src/screens/Overview.tsx index 32514de..7a2d409 100644 --- a/packages/ui/src/screens/Overview.tsx +++ b/packages/ui/src/screens/Overview.tsx @@ -1,35 +1,34 @@ -import type { ReactNode } from "react"; -import type { Fact, Overview as Data, TimelineMoment, Worth } from "@commitscape/data"; +import { Button } from "@astryxdesign/core/Button"; +import type { Overview as Data, People } from "@commitscape/data"; import { useData } from "../data"; import { Bars } from "../charts/Bars"; import { Columns } from "../charts/Columns"; -import { Figure, TableView } from "../charts/common"; -import { useWidth } from "../charts/scale"; -import { useTip } from "../charts/tip"; -import { Name, Path } from "../components/Name"; +import { Figure } from "../charts/common"; +import { Name } from "../components/Name"; import { Tile } from "../components/Tile"; import { Explain } from "../explain"; import { compact, date, duration, grouped, many, share, WINDOW_WORDS } from "../format"; -import { personColour } from "../theme"; import { openers, type ScreenProps } from "./props"; -import { ScreenSkeleton } from "../components/Loading"; + +export const CONTRIBUTORS_SHOWN = 10; export function Overview({ params, go }: ScreenProps) { const { data: o, error, stale } = useData("/api/overview", params); + const people = useData("/api/people", params); const open = openers(go); - if (error) return

    {error}

    ; - if (!o) return ; + if (error || !o) return

    {error}

    ; const t = o.totals; const span = WINDOW_WORDS[o.window] ?? o.window; const releases = o.timeline.filter((m) => m.kind === "release"); - const total = o.people.reduce((n, p) => n + p.commits, 0); const age = t.first_commit !== null && t.last_commit !== null ? (t.last_commit - t.first_commit) / 86_400 : null; + const rows = people.data?.people ?? []; + const lines = rows.some((r) => r.lines_added !== null); const tiles: [string, string, string, string][] = [ [grouped(t.commits - t.merges), "commits", `${grouped(o.commits)} in ${span}`, "Every commit on every branch, merges left out; the small number is the commits in the Window."], [grouped(t.people), "people", `${grouped(o.people.length)} in ${span}`, "Everyone who made a commit, with the addresses of one person counted once. Bots are left out."], - [grouped(t.files), "files", `${grouped(t.code_files)} are code`, "Files at HEAD, the newest commit on the current branch; code is what is not prose, generated, vendored or binary."], - [compact(t.code_lines), "lines of code", `${compact(t.prose_lines)} of prose`, "Lines in code files at HEAD, blank lines left out. Prose is Markdown and other text."], - [age === null ? "—" : duration(age), "old", t.first_commit === null ? "no commits" : `since ${date(t.first_commit)}`, "From the first commit to the newest."], + [grouped(t.files), "files", `${grouped(t.code_files)} of them code`, "Files at HEAD, the newest commit on the current branch; code is what is not prose, generated, vendored or binary."], + [compact(t.code_lines), "lines of code", `and ${compact(t.prose_lines)} of prose`, "Lines in code files at HEAD, blank lines left out. Prose is Markdown and other text."], + [age === null ? "—" : duration(age), "of history", t.first_commit === null ? "no commits" : `since ${date(t.first_commit)}`, "From the first commit to the newest."], [grouped(o.active_days), "active days", `in ${span}`, "Days in the Window with at least one commit, on each author's own clock."], ]; return ( @@ -42,58 +41,38 @@ export function Overview({ params, go }: ScreenProps) { ))} -
    - - - Moments worth telling: the first commit, releases (version tags), someone who made at least a twentieth of the - commits joining or leaving (their last commit over 90 days before the end), the busiest day, the biggest - clean-up (the commit that removed the most lines net, lockfiles and generated files left out, once lines are - counted), stretches of a month or more with no commit, and a change in the language most lines were added in. - +
    +
    + ({ day: Math.floor(r.time / 86_400), label: r.name ?? "release" }))} /> +
    + Commits that are not merges, by the day they landed on their author's clock. Dashed lines are releases, drawn where they fit.
    -
    - ({ day: Math.floor(r.time / 86_400), label: r.name ?? "release" }))} - /> - Commits that are not merges, by the day they landed on their author's clock. Dashed lines are releases. -
    -
    - ({ - key: String(p.person.id), - label: , - value: p.commits, - shown: `${grouped(p.commits)} · ${share(p.commits, total)}`, - colour: personColour(p.person.colour), - tip: <>{p.person.name}: {grouped(p.commits)} commits, {share(p.commits, total)} of them, - onClick: () => open.person(p.person.id), - }))} - /> - Commits that are not merges, and each person's share of them. Click a name for their profile. -
    -
    - -
    -
    - - Only what is unusual for a repository: each fact appears only when it clears a bar most repositories fall short of. -
    -
    - - - A folder one person made most commits to, the file that is both changed most and most deeply nested, two files - in different folders that change together, files nobody has changed in a year, and names that may be one - person. - +
    +
      + {rows.slice(0, CONTRIBUTORS_SHOWN).map((r) => ( +
    1. + + {many(r.commits, "commit", "commits")} + {share(r.commits, o.commits)} + {lines && ( + + {r.lines_added === null ? ( + "—" + ) : ( + <> + +{compact(r.lines_added)} −{compact(r.lines_removed ?? 0)} + + )} + + )} +
    2. + ))} +
    + {rows.length > CONTRIBUTORS_SHOWN &&
    -
    - -
    {o.languages.length === 0 ? (

    No code at HEAD in a language this tool knows.

    @@ -104,277 +83,12 @@ export function Overview({ params, go }: ScreenProps) { key: l.name, label: l.name, value: l.lines, - shown: `${compact(l.lines)} · ${share(l.lines, t.code_lines)}`, + shown: `${compact(l.lines)} lines · ${share(l.lines, t.code_lines)}`, }))} /> )}
    -
    - - How much of today's code sits in files that appeared each year: old code that is still here, and how fast new code arrives. -
    ); } - -const SHAPES: Record = { - first_commit: "●", - release: "▲", - joined: "+", - left: "−", - busiest_day: "★", - cleanup: "✂", - quiet: "…", - language_shift: "⇄", -}; - -function momentWords(m: TimelineMoment, who: ReactNode = m.person?.name ?? "someone"): ReactNode { - switch (m.kind) { - case "first_commit": - return <>The first commit, by {who}; - case "release": - return `Released ${m.name ?? ""}`; - case "joined": - return <>{who} made their first commit; - case "left": - return <>{who}'s last commit so far; - case "busiest_day": - return `The busiest day: ${many(m.count ?? 0, "commit", "commits")}`; - case "cleanup": - return <>The biggest clean-up: {grouped(m.count ?? 0)} lines removed, by {who}; - case "quiet": - return `Quiet for ${duration(((m.until ?? m.time) - m.time) / 86_400)}`; - case "language_shift": - return `Most new lines were ${m.to ?? "?"}, no longer ${m.from ?? "?"}`; - default: - return m.kind; - } -} - -function Story({ - moments, - onPerson, - from, - until, -}: { - moments: TimelineMoment[]; - onPerson: (id: number) => void; - from: number; - until: number; -}) { - const [ref, width] = useWidth(); - const tip = useTip(); - if (moments.length === 0) return

    Nothing in this window stands out from the rest.

    ; - const start = Math.min(from, ...moments.map((m) => m.time)); - const end = Math.max(until, start + 1, ...moments.map((m) => m.until ?? m.time)); - const x = (t: number) => 12 + ((t - start) / (end - start)) * Math.max(1, width - 24); - const releases = moments.filter((m) => m.kind === "release"); - const told = moments.filter((m) => m.kind !== "release"); - const words = [...told]; - if (releases.length > 0) { - words.push(releases[0] as TimelineMoment); - if (releases.length > 1) words.push(releases.at(-1) as TimelineMoment); - words.sort((a, b) => a.time - b.time); - } - return ( -
    - {width > 0 && ( - - - {moments - .filter((m) => m.kind === "quiet") - .map((m) => ( - {momentWords(m)}, from {date(m.time)} to {date(m.until ?? m.time)})} - /> - ))} - {moments - .filter((m) => m.kind !== "quiet") - .map((m, i) => ( - {date(m.time)}
    {momentWords(m)}
    )}> - - - {SHAPES[m.kind] ?? "•"} - - - ))} - - )} -
      - {words.map((m, i) => ( -
    1. - - {SHAPES[m.kind]} - - {date(m.time)} - {momentWords(m, m.person ? : "someone")} -
    2. - ))} - {releases.length > 2 && ( -
    3. - - ▲ - - - {many(releases.length, "release", "releases")} in all, each a ▲ on the line -
    4. - )} -
    -
    - ); -} - -function Facts({ facts, onFile }: { facts: Fact[]; onFile: (path: string) => void }) { - if (facts.length === 0) { - return

    Nothing unusual stands out in this window: no late nights, no marathons, no giant files.

    ; - } - const line = (f: Fact) => { - switch (f.kind) { - case "night": - return <>Night owls: {f.value}% of commits land between 22:00 and 05:00.; - case "weekend": - return <>Weekend warriors: {f.value}% of commits land on a Saturday or Sunday.; - case "busiest_day": - return ( - <> - The busiest day, {f.time !== null ? date(f.time) : "?"}, had {grouped(f.value)} commits,{" "} - {Math.round(f.value / Math.max(0.1, f.other ?? 1))} times a usual day. - - ); - case "streak": - return ( - <> - A streak of {grouped(f.value)} days in a row with commits - {f.time !== null && <>, from {date(f.time)}}. - - ); - case "fixes": - return <>Twice as many fixes as features: {grouped(f.value)} to {grouped(f.other ?? 0)}.; - case "hottest_file": - return ( - <> - changed in {share(f.value, f.other ?? 0)} of all commits. - - ); - case "biggest_file": - return ( - <> - The biggest file, , has {grouped(f.value)} lines. - - ); - case "untouched": - return ( - <> - has not been touched in {duration(f.value)}. - - ); - default: - return f.kind; - } - }; - return ( -
      - {facts.map((f) => ( -
    • {line(f)}
    • - ))} -
    - ); -} - -function WorthList({ worth, open }: { worth: Worth[]; open: ReturnType }) { - const folder = (p: string) => (p === "(root)" ? "" : p); - const line = (w: Worth) => { - switch (w.kind) { - case "held": - return ( - <> - open.folder(folder(p))} />: {w.value}% of commits by{" "} - - {w.of !== null && ({many(w.of, "commit", "commits")})} - - ); - case "hotspot": - return ( - <> - Hottest file: - , changed {many(w.value, "time", "times")} - - ); - case "pair": - return ( - <> - and change - together ({w.value}% of the commits that change either) - - ); - case "untouched": - return ( - <> - {grouped(w.value)} of {many(w.of ?? 0, "file", "files")} untouched for a year - - ); - case "same_person": - return ( - <> - {w.value === 1 ? "A pair of names may be one person" : `${grouped(w.value)} groups of names may be one person each`}: - see People - - ); - default: - return w.kind; - } - }; - const marks: Record = { held: "▲", hotspot: "◆", pair: "⇄", untouched: "○", same_person: "●" }; - return ( -
      - {worth.filter((w) => w.kind !== "untouched" || w.value > 0).map((w, i) => ( -
    • - - {marks[w.kind]} - - {line(w)} -
    • - ))} -
    - ); -} - -function CodeAge({ o }: { o: Data }) { - const years = new Map(); - for (const q of o.code_age) years.set(q.year, (years.get(q.year) ?? 0) + q.lines); - const rows = [...years.entries()].sort((a, b) => a[0] - b[0]); - if (rows.length === 0) return

    There is no code at HEAD.

    ; - const all = rows.reduce((n, [, l]) => n + l, 0); - return ( - <> - ({ - key: String(year), - label: String(year), - value: lines, - shown: `${compact(lines)} · ${share(lines, all)}`, - }))} - /> - [`${q.year} Q${q.quarter}`, q.lines])} - /> - - ); -} - diff --git a/packages/ui/src/screens/People.tsx b/packages/ui/src/screens/People.tsx index 0283d38..56b62f0 100644 --- a/packages/ui/src/screens/People.tsx +++ b/packages/ui/src/screens/People.tsx @@ -1,5 +1,4 @@ -import { useContext, useState } from "react"; -import { Avatar } from "@astryxdesign/core/Avatar"; +import { useState } from "react"; import { Button } from "@astryxdesign/core/Button"; import { Heading } from "@astryxdesign/core/Heading"; import { pixel, proportional, Table, useTableSortable, type TableColumn } from "@astryxdesign/core/Table"; @@ -8,14 +7,13 @@ import { useData } from "../data"; import { Bars } from "../charts/Bars"; import { Figure } from "../charts/common"; import { Calendar, WeekGrid } from "../charts/Grid"; -import { avatarUrl } from "../components/avatar"; +import { Face } from "../components/Face"; +import { useLogin } from "../components/login"; import { Name, Path } from "../components/Name"; import { Tile } from "../components/Tile"; -import { AvatarsContext } from "../help"; import { Explain } from "../explain"; import { compact, date, githubWhy, grouped, many, WINDOW_WORDS } from "../format"; import { openers, type ScreenProps } from "./props"; -import { ScreenSkeleton } from "../components/Loading"; type Column = { key: string; @@ -40,7 +38,6 @@ const COLUMNS: Column[] = [ { key: "days", label: "Active days", value: (r) => r.active_days, shown: (r) => grouped(r.active_days), why: "Days with at least one of their commits, on their own clock." }, { key: "added", label: "Lines added", value: (r) => r.lines_added, shown: (r) => known(r.lines_added, compact), why: "Lines their commits added, lockfiles and generated files left out. — until the line pass has counted them." }, { key: "removed", label: "Lines removed", value: (r) => r.lines_removed, shown: (r) => known(r.lines_removed, compact), why: "Lines their commits removed, counted the same way." }, - { key: "areas", label: "Folders held", value: (r) => r.areas, shown: (r) => grouped(r.areas), why: "Folders where they made most of the commits: those that depend on them." }, { key: "prs", label: "PRs merged", value: (r) => r.prs_merged, shown: (r) => known(r.prs_merged), why: "Their pull requests merged in the Window. — until GitHub's history is read." }, { key: "reviews", label: "Reviews", value: (r) => r.reviews, shown: (r) => known(r.reviews), why: "Reviews they gave on others' pull requests." }, { key: "hours", label: "Time to merge", value: (r) => r.hours_to_merge, shown: (r) => known(r.hours_to_merge, hoursWords), why: "From opening to merging, the middle of their merged pull requests." }, @@ -56,10 +53,11 @@ type Row = PersonRow & Record; function PeopleTable({ meta, params, go }: ScreenProps) { const { data, error, stale } = useData("/api/people", params); const [sort, setSort] = useState([{ sortKey: "commits", direction: "descending" as "ascending" | "descending" }]); + const [shown, setShown] = useState(50); const sortable = useTableSortable({ sort, onSortChange: (next) => setSort(next.length > 0 ? next : sort) }); const open = openers(go); - if (error) return

    {error}

    ; - if (!data) return ; + if (error || !data) return

    {error}

    ; + const shownColumns = COLUMNS.filter((c) => data.people.some((r) => c.value(r) !== null)); const by = sort[0] ?? { sortKey: "commits", direction: "descending" }; const column = COLUMNS.find((c) => c.key === by.sortKey) ?? COLUMNS[0]; const rows = [...data.people].sort((a, b) => (column?.value(b) ?? -1) - (column?.value(a) ?? -1)); @@ -76,20 +74,20 @@ function PeopleTable({ meta, params, go }: ScreenProps) { ), }, - ...COLUMNS.map( + ...shownColumns.map( (c): TableColumn => ({ key: c.key, header: {c.label}, align: "end", sortable: true, - width: pixel(118), + width: pixel(132), renderCell: (r) => {c.shown(r)}, }), ), { key: "span", header: "First and last commit", - width: pixel(210), + width: pixel(240), renderCell: (r) => ( {date(r.first)} – {date(r.last)} @@ -106,7 +104,7 @@ function PeopleTable({ meta, params, go }: ScreenProps) {
    className="people-table" - data={rows.slice(0, 300) as Row[]} + data={rows.slice(0, shown) as Row[]} columns={columns} idKey={(r: Row) => r.person.id} density="compact" @@ -114,10 +112,12 @@ function PeopleTable({ meta, params, go }: ScreenProps) { plugins={{ sortable }} />
    - {rows.length > 300 &&

    The 300 with the most in this column are shown.

    } + {rows.length > shown && ( +
    - {avatars && p.person.login && } +
    {p.person.name}

    - {p.person.login && <>@{p.person.login} · } + {login && ( + <> + @{login} ·{" "} + + )} {p.email}

    @@ -259,7 +262,7 @@ function Profile({ params, go, id }: ScreenProps & { id: number }) {
    {p.areas.length > 0 && ( -
    +
      {p.areas.map((a) => (
    • diff --git a/packages/ui/src/screens/Risk.tsx b/packages/ui/src/screens/Risk.tsx deleted file mode 100644 index 9e8afd7..0000000 --- a/packages/ui/src/screens/Risk.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { pixel, proportional, Table, type TableColumn } from "@astryxdesign/core/Table"; -import type { HotspotRow, Risk as Data } from "@commitscape/data"; -import { useData } from "../data"; -import { Figure, TableView } from "../charts/common"; -import { ticks, useWidth } from "../charts/scale"; -import { useTip } from "../charts/tip"; -import { Name, Path } from "../components/Name"; -import { Explain } from "../explain"; -import { compact, grouped, many, WINDOW_WORDS } from "../format"; -import { openers, type ScreenProps } from "./props"; -import { ScreenSkeleton } from "../components/Loading"; - -const HEIGHT = 340; -const LEFT = 58; -const BOTTOM = 34; -const TOP = 12; - -export function Risk({ params, go }: ScreenProps) { - const { data: r, error, stale } = useData("/api/risk", params); - const open = openers(go); - if (error) return

      {error}

      ; - if (!r) return ; - const span = WINDOW_WORDS[r.window] ?? r.window; - return ( -
      -
      - {r.files.length === 0 ? ( -

      No code file changed in this window.

      - ) : ( - - )} - - How often: commits that changed the file, bulk commits left out. Nesting: every line's indentation level at HEAD, - added up, so a long file with deeply nested code scores highest. It is a cheap stand-in for how hard the code is - to follow, the same in every language. A file ranks high only when it is high on both; neither alone is a - problem. The shaded corner is the top quarter of both. - -
      - -
      -
      - data={r.hotspots as Hot[]} columns={hotColumns(open.file)} idKey="path" density="compact" hasHover /> -
      -
      - -
      -
      - {r.groups.length === 0 ? ( -

      No files change together often enough to say.

      - ) : ( -
        - {r.groups.map((g, i) => ( -
      1. - {g.paths.map((p, j) => ( - - {j > 0 && + } - - - ))} - - {" "} - · together in {many(g.together, "commit", "commits")} - {g.cross_directory ? " · across folders" : ""} - -
      2. - ))} -
      - )} - - Files changed in the same commits again and again, bulk commits left out. Across folders is worth a look: the - code may depend on each other in ways its layout does not show. - -
      -
      - {r.silos.length === 0 ? ( -

      No folder depends on one person.

      - ) : ( -
        - {r.silos.map((s) => ( -
      • - open.folder(f === "(root)" ? "" : f)} />: {" "} - of {many(s.commits, "commit", "commits")} -
        - {s.successor ? ( - <> - next most: with {grouped(s.successor.commits)} - - ) : ( - "nobody else has changed it" - )} -
        -
      • - ))} -
      - )} - If the person who holds a folder moved on, the next most would be the one who knows it best. -
      -
      -
      - ); -} - -type Hot = HotspotRow & Record; - -function hotColumns(onFile: (path: string) => void): TableColumn[] { - return [ - { key: "path", header: "File", width: proportional(3), renderCell: (h) => }, - { key: "churn", header: "Changed", align: "end", width: pixel(96), renderCell: (h) => {grouped(h.churn)} }, - { key: "nesting", header: "Nesting", align: "end", width: pixel(96), renderCell: (h) => {grouped(h.nesting)} }, - { - key: "place", - header: "Place by each", - width: proportional(3), - renderCell: (h) => ( - - {ordinal(h.churn_place)} of {grouped(h.churn_of)} for changes, {ordinal(h.nesting_place)} of {grouped(h.nesting_of)} for nesting - - ), - }, - ]; -} - -function ordinal(n: number): string { - const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : ({ 1: "st", 2: "nd", 3: "rd" } as Record)[n % 10] ?? "th"; - return `${grouped(n)}${s}`; -} - -function Scatter({ r, onFile }: { r: Data; onFile: (path: string) => void }) { - const [ref, width] = useWidth(); - const tip = useTip(); - const maxC = Math.max(1, ...r.files.map((f) => f.churn)); - const maxN = Math.max(1, ...r.files.map((f) => f.nesting)); - const cTicks = ticks(maxC, 4); - const nTicks = ticks(maxN, 4); - const cTop = cTicks.at(-1) ?? maxC; - const nTop = nTicks.at(-1) ?? maxN; - const x = (c: number) => LEFT + Math.sqrt(c / cTop) * (width - LEFT - 12); - const y = (n: number) => TOP + (1 - n / nTop) * (HEIGHT - TOP - BOTTOM); - const sortedC = r.files.map((f) => f.churn).sort((a, b) => a - b); - const sortedN = r.files.map((f) => f.nesting).sort((a, b) => a - b); - const q = (list: number[]) => list[Math.floor(list.length * 0.75)] ?? 0; - const cornerC = q(sortedC); - const cornerN = q(sortedN); - const top = new Set(r.hotspots.slice(0, 5).map((h) => h.path)); - return ( -
      - {width > 0 && ( - - - - changed often and heavily nested - - - {nTicks.map((v) => ( - - - - {compact(v)} - - - ))} - {cTicks.map((v) => ( - - {grouped(v)} - - ))} - - commits that changed it → - - - nesting → - - - {r.files.map((f) => ( - onFile(f.path)} {...tip(<>{f.path}
      changed in {many(f.churn, "commit", "commits")}
      nesting {grouped(f.nesting)}
      )}> - - - - ))} - {r.files - .filter((f) => top.has(f.path)) - .map((f) => ( - - {f.path.split("/").at(-1)} - - ))} - - )} - [f.path, f.churn, f.nesting])} /> -
      - ); -} diff --git a/packages/ui/src/standings/Standings.tsx b/packages/ui/src/standings/Standings.tsx new file mode 100644 index 0000000..b6c94ba --- /dev/null +++ b/packages/ui/src/standings/Standings.tsx @@ -0,0 +1,113 @@ +import { useMemo, useState } from "react"; +import { Button } from "@astryxdesign/core/Button"; +import { ordinal, placesOf, VIEWS, type Place, type StandingRow, type Standings, type View } from "@commitscape/data"; +import { Figure } from "../charts/common"; +import { placesFor, topLine } from "./places"; +import { Face } from "../components/Face"; +import { Tile } from "../components/Tile"; +import { compact, date, grouped, many } from "../format"; + +const SHOWN = 25; + +const STATUS: Record, string> = { + counting: "counting…", + counted: "", + over_budget: "not counted", + not_counted: "not counted", + failed: "not counted", + stale: "counting…", + not_asked: "—", +}; + +function valueOf(r: StandingRow, view: View): string { + if (view === "surviving") return r.surviving !== null ? grouped(r.surviving) : r.survivingStatus ? STATUS[r.survivingStatus] : "—"; + const v = r[view]; + return v === null ? "—" : grouped(v); +} + + + +/** One person's numbers in a repository, and where they stand in each view. */ +export function YourStanding({ standings, row }: { standings: Standings; row: StandingRow }) { + const places = placesFor(standings, row.key); + const top = topLine(standings, places); + const place = (view: View) => { + const p = places.find((x) => x.view === view); + return p ? `${ordinal(p.place)} of ${grouped(p.of)}` : "no place in this view"; + }; + return ( +
      + {top &&

      {top}

      } +
      + + + + + + +
      +
      + ); +} + +/** Everyone's Standings in a repository, a column a view, sorted by the view chosen; never one score. */ +export function StandingsTable({ standings, focus }: { standings: Standings; focus?: string | null }) { + const [by, setBy] = useState("surviving"); + const [all, setAll] = useState(false); + const places = useMemo(() => Object.fromEntries(VIEWS.map((v) => [v.id, placesOf(standings.people, v.id)])) as Record>, [standings]); + const shownViews = VIEWS.filter((v) => standings.people.some((r) => (v.id === "surviving" ? r.survivingStatus !== null : r[v.id] !== null))); + const sorted = [...standings.people].sort((a, b) => (places[by].get(a.key)?.place ?? Infinity) - (places[by].get(b.key)?.place ?? Infinity) || (b.commits ?? 0) - (a.commits ?? 0)); + const focused = focus?.toLowerCase(); + const rows = all ? sorted : sorted.slice(0, SHOWN); + const you = focused ? sorted.findIndex((r) => r.login?.toLowerCase() === focused) : -1; + if (!all && you >= SHOWN && sorted[you]) rows.push(sorted[you]); + const { owner, name } = standings.repo; + return ( +
      0 ? ` ${many(standings.hidden, "person has", "people have")} chosen to stay out.` : ""}`}> +
      + + + + + + {shownViews.map((v) => ( + + ))} + + + + + {rows.map((r) => { + const p = places[by].get(r.key); + return ( + + + + {shownViews.map((v) => ( + + ))} + + + ); + })} + +
      #Person + + Works on
      {p ? p.place : "—"} + + + {r.login ? {r.name} : {r.name}} + + + {valueOf(r, v.id)} + {r.worksOn ?? ""}
      +
      + {sorted.length > SHOWN &&
      + ); +} diff --git a/packages/ui/src/standings/places.ts b/packages/ui/src/standings/places.ts new file mode 100644 index 0000000..d7f35d7 --- /dev/null +++ b/packages/ui/src/standings/places.ts @@ -0,0 +1,20 @@ +import { placesOf, VIEWS, type Place, type Standings } from "@commitscape/data"; +import { grouped } from "../format"; + +/** Every view's place for one person, in the order the views lead. */ +export function placesFor(standings: Standings, key: string): Place[] { + return VIEWS.flatMap((v) => { + const p = placesOf(standings.people, v.id).get(key); + return p ? [p] : []; + }); +} + +/** The best of a person's places, as "Top 3% of facebook/react's contributors by …", when it is in the top tenth of twenty or more. */ +export function topLine(standings: Standings, places: Place[]): string | null { + const best = [...places].sort((a, b) => a.place / a.of - b.place / b.of)[0]; + if (!best || best.of < 20) return null; + const share = Math.max(1, Math.ceil((best.place * 100) / best.of)); + if (share > 10) return null; + const view = VIEWS.find((v) => v.id === best.view); + return `Top ${share}% of ${standings.repo.owner}/${standings.repo.name}'s ${grouped(best.of)} contributors by ${view?.label.toLowerCase()}`; +} diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index f62bc7c..c43b310 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,53 +1,35 @@ -import { useCallback, useSyncExternalStore } from "react"; -import { defineTheme } from "@astryxdesign/core/theme"; +import { createContext, useContext } from "react"; +import { dataTokenDefaults, defineTheme, generateThemeCSS, type DefinedTheme } from "@astryxdesign/core/theme"; import { neutralTheme } from "@astryxdesign/theme-neutral"; +import type { Mode } from "./mode"; -export const commitscapeTheme = defineTheme({ +const defined = defineTheme({ name: "commitscape", extends: neutralTheme, color: { accent: ["#2a78d6", "#3987e5"], neutralStyle: "warm" }, }); -export const MODES = [ - ["system", "Follow the system"], - ["light", "Light"], - ["dark", "Dark"], -] as const; -export type Mode = (typeof MODES)[number][0]; - -const STORED = "commitscape-theme"; -const listeners = new Set<() => void>(); -let chosen: Mode | null = null; - -function read(): Mode { - try { - const t = localStorage.getItem(STORED); - if (MODES.some(([name]) => name === t)) return t as Mode; - } catch { - } - return chosen ?? "system"; +/** The theme as written into the page by the server, so Astryx never injects it after hydration. */ +export const commitscapeTheme: DefinedTheme = { ...defined, __built: true }; + +/** The theme's CSS, in the layers Astryx would inject it into, for a ` \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/achievement-light.svg b/packages/ui/src/cards/__snapshots__/achievement-light.svg index 634a954..d4c4b93 100644 --- a/packages/ui/src/cards/__snapshots__/achievement-light.svg +++ b/packages/ui/src/cards/__snapshots__/achievement-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/archetype-dark.svg b/packages/ui/src/cards/__snapshots__/archetype-dark.svg index 3bd44b2..901cc6e 100644 --- a/packages/ui/src/cards/__snapshots__/archetype-dark.svg +++ b/packages/ui/src/cards/__snapshots__/archetype-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/archetype-light.svg b/packages/ui/src/cards/__snapshots__/archetype-light.svg index 6a6939a..6fa8ec4 100644 --- a/packages/ui/src/cards/__snapshots__/archetype-light.svg +++ b/packages/ui/src/cards/__snapshots__/archetype-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/calendar-dark.svg b/packages/ui/src/cards/__snapshots__/calendar-dark.svg index c36354c..180c258 100644 --- a/packages/ui/src/cards/__snapshots__/calendar-dark.svg +++ b/packages/ui/src/cards/__snapshots__/calendar-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/calendar-light.svg b/packages/ui/src/cards/__snapshots__/calendar-light.svg index e5d943c..38800be 100644 --- a/packages/ui/src/cards/__snapshots__/calendar-light.svg +++ b/packages/ui/src/cards/__snapshots__/calendar-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg b/packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg index 4d31f38..821fbce 100644 --- a/packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg +++ b/packages/ui/src/cards/__snapshots__/hall-of-fame-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg b/packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg index 83ed934..6a09006 100644 --- a/packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg +++ b/packages/ui/src/cards/__snapshots__/hall-of-fame-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/languages-dark.svg b/packages/ui/src/cards/__snapshots__/languages-dark.svg index 5f566b6..11dacbc 100644 --- a/packages/ui/src/cards/__snapshots__/languages-dark.svg +++ b/packages/ui/src/cards/__snapshots__/languages-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/languages-light.svg b/packages/ui/src/cards/__snapshots__/languages-light.svg index 6e36474..f8e4553 100644 --- a/packages/ui/src/cards/__snapshots__/languages-light.svg +++ b/packages/ui/src/cards/__snapshots__/languages-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/preview-dark.svg b/packages/ui/src/cards/__snapshots__/preview-dark.svg index 0b7d6ca..b2d8a38 100644 --- a/packages/ui/src/cards/__snapshots__/preview-dark.svg +++ b/packages/ui/src/cards/__snapshots__/preview-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/preview-light.svg b/packages/ui/src/cards/__snapshots__/preview-light.svg index ed8ce82..19002e2 100644 --- a/packages/ui/src/cards/__snapshots__/preview-light.svg +++ b/packages/ui/src/cards/__snapshots__/preview-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/race-dark.svg b/packages/ui/src/cards/__snapshots__/race-dark.svg index a220c97..421ed6b 100644 --- a/packages/ui/src/cards/__snapshots__/race-dark.svg +++ b/packages/ui/src/cards/__snapshots__/race-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/race-light.svg b/packages/ui/src/cards/__snapshots__/race-light.svg index bebc053..443c9dd 100644 --- a/packages/ui/src/cards/__snapshots__/race-light.svg +++ b/packages/ui/src/cards/__snapshots__/race-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/repositories-dark.svg b/packages/ui/src/cards/__snapshots__/repositories-dark.svg index 8a9a173..35af81a 100644 --- a/packages/ui/src/cards/__snapshots__/repositories-dark.svg +++ b/packages/ui/src/cards/__snapshots__/repositories-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/repositories-light.svg b/packages/ui/src/cards/__snapshots__/repositories-light.svg index 5facf63..0586278 100644 --- a/packages/ui/src/cards/__snapshots__/repositories-light.svg +++ b/packages/ui/src/cards/__snapshots__/repositories-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/season-dark.svg b/packages/ui/src/cards/__snapshots__/season-dark.svg index a220c97..421ed6b 100644 --- a/packages/ui/src/cards/__snapshots__/season-dark.svg +++ b/packages/ui/src/cards/__snapshots__/season-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/season-light.svg b/packages/ui/src/cards/__snapshots__/season-light.svg index bebc053..443c9dd 100644 --- a/packages/ui/src/cards/__snapshots__/season-light.svg +++ b/packages/ui/src/cards/__snapshots__/season-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/standing-dark.svg b/packages/ui/src/cards/__snapshots__/standing-dark.svg index 47f1ed3..c85b629 100644 --- a/packages/ui/src/cards/__snapshots__/standing-dark.svg +++ b/packages/ui/src/cards/__snapshots__/standing-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/standing-light.svg b/packages/ui/src/cards/__snapshots__/standing-light.svg index a781d59..3a7f88d 100644 --- a/packages/ui/src/cards/__snapshots__/standing-light.svg +++ b/packages/ui/src/cards/__snapshots__/standing-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/survival-dark.svg b/packages/ui/src/cards/__snapshots__/survival-dark.svg index 6b13e22..cf82fe5 100644 --- a/packages/ui/src/cards/__snapshots__/survival-dark.svg +++ b/packages/ui/src/cards/__snapshots__/survival-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/survival-light.svg b/packages/ui/src/cards/__snapshots__/survival-light.svg index d7db9de..f622425 100644 --- a/packages/ui/src/cards/__snapshots__/survival-light.svg +++ b/packages/ui/src/cards/__snapshots__/survival-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/totals-dark.svg b/packages/ui/src/cards/__snapshots__/totals-dark.svg index b6e795d..12920eb 100644 --- a/packages/ui/src/cards/__snapshots__/totals-dark.svg +++ b/packages/ui/src/cards/__snapshots__/totals-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/totals-light.svg b/packages/ui/src/cards/__snapshots__/totals-light.svg index 06ac2f3..e2b2b07 100644 --- a/packages/ui/src/cards/__snapshots__/totals-light.svg +++ b/packages/ui/src/cards/__snapshots__/totals-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/versus-dark.svg b/packages/ui/src/cards/__snapshots__/versus-dark.svg index 2964275..7b24fb9 100644 --- a/packages/ui/src/cards/__snapshots__/versus-dark.svg +++ b/packages/ui/src/cards/__snapshots__/versus-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/versus-light.svg b/packages/ui/src/cards/__snapshots__/versus-light.svg index 17c36f9..c0ca958 100644 --- a/packages/ui/src/cards/__snapshots__/versus-light.svg +++ b/packages/ui/src/cards/__snapshots__/versus-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg b/packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg index 8cb7168..2f2cd86 100644 --- a/packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg +++ b/packages/ui/src/cards/__snapshots__/wrapped-calendar-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg b/packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg index 97cbd18..325c0c5 100644 --- a/packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg +++ b/packages/ui/src/cards/__snapshots__/wrapped-calendar-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-dark.svg b/packages/ui/src/cards/__snapshots__/wrapped-dark.svg index 42f7db8..90a9eee 100644 --- a/packages/ui/src/cards/__snapshots__/wrapped-dark.svg +++ b/packages/ui/src/cards/__snapshots__/wrapped-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/__snapshots__/wrapped-light.svg b/packages/ui/src/cards/__snapshots__/wrapped-light.svg index 7670c98..248d900 100644 --- a/packages/ui/src/cards/__snapshots__/wrapped-light.svg +++ b/packages/ui/src/cards/__snapshots__/wrapped-light.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/cards/render.tsx b/packages/ui/src/cards/render.tsx index 7bf3518..9881ec0 100644 --- a/packages/ui/src/cards/render.tsx +++ b/packages/ui/src/cards/render.tsx @@ -4,6 +4,7 @@ import { INTER } from "./fonts"; import { PendingCard, SiteCard } from "./Cards"; import type { CardSpec } from "./kinds"; import { animate, animatedPaint, still } from "./paint"; +import type { CardStyle } from "./style"; import type { CardTheme } from "./tokens"; function bytes(dataUrl: string): ArrayBuffer { @@ -26,10 +27,10 @@ function loaded() { } /** Draws a Card as SVG twice from one render: animated for READMEs, and still, ready to become a PNG. */ -export async function renderCard(spec: CardSpec, data: T, theme: CardTheme, options: { images: CardImages; site: string }): Promise<{ animated: string; still: string; width: number; height: number }> { +export async function renderCard(spec: CardSpec, data: T, theme: CardTheme, options: { images: CardImages; site: string; style?: CardStyle }): Promise<{ animated: string; still: string; width: number; height: number }> { const { width, height } = spec.size(data); const paint = animatedPaint(); - const svg = await satori(, { width, height, fonts: loaded() }); + const svg = await satori(, { width, height, fonts: loaded() }); return { animated: animate(svg, paint.marks), still: still(svg, paint.marks), width, height }; } diff --git a/packages/ui/src/cards/style.test.ts b/packages/ui/src/cards/style.test.ts new file mode 100644 index 0000000..72bf496 --- /dev/null +++ b/packages/ui/src/cards/style.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { PROFILE } from "./fixture"; +import { CARDS } from "./kinds"; +import { renderCard } from "./render"; +import { DEFAULT_STYLE, hexOf, isOffered, lookOf, mix, parseStyle, PRESETS, styleKey, styleQuery, SWATCHES, type PresetId } from "./style"; + +describe("parseStyle", () => { + test("keeps only valid choices", () => { + expect(parseStyle(new URLSearchParams("preset=grape&accent=F97316&bg=dots&corner=square"))).toEqual({ preset: "grape", accent: "#f97316", background: "dots", corner: "square" }); + expect(parseStyle(new URLSearchParams("preset=nope&accent=red&bg=stars&corner=x"))).toEqual(DEFAULT_STYLE); + }); + + test("reads a plain object of search parameters", () => { + expect(parseStyle({ preset: "ocean", accent: "#0ea5e9" })).toMatchObject({ preset: "ocean", accent: "#0ea5e9" }); + }); +}); + +describe("styleQuery and styleKey", () => { + test("leave out what is already the default", () => { + expect(styleQuery(DEFAULT_STYLE)).toBe(""); + expect(styleKey(DEFAULT_STYLE)).toBe(""); + expect(styleQuery({ preset: "forest", accent: null, background: "dots", corner: "round" })).toBe("preset=forest"); + }); + + test("round-trip through an address", () => { + const style = { preset: "paper", accent: "#14b8a6", background: "glow", corner: "soft" } as const; + expect(parseStyle(new URLSearchParams(styleQuery(style)))).toEqual(style); + expect(styleKey(style)).toBe("preset-paper-accent-14b8a6-bg-glow-corner-soft"); + }); +}); + +describe("lookOf", () => { + test("darkens a pale accent until it reads on a light background", () => { + const look = lookOf("light", { ...DEFAULT_STYLE, accent: "#fefefe" }); + expect(look.t.accent).not.toBe("#fefefe"); + }); + + test("never gives an accent the colours Card animations mark elements with", () => { + expect(lookOf("dark", { ...DEFAULT_STYLE, accent: "#02ff88" }).t.accent).not.toMatch(/^#0[0-3]/); + }); + + test("draws every preset and background", () => { + for (const preset of Object.keys(PRESETS) as PresetId[]) for (const theme of ["light", "dark"] as const) expect(lookOf(theme, { ...DEFAULT_STYLE, preset }).t.bg).toMatch(/^#[0-9a-f]{6}$/); + expect(lookOf("dark", { ...DEFAULT_STYLE, background: "grid" }).backgroundSize).toBe("24px 24px"); + expect(lookOf("dark", { ...DEFAULT_STYLE, background: "plain" }).backgroundImage).toBeUndefined(); + }); +}); + +describe("helpers", () => { + test("hexOf and mix", () => { + expect(hexOf("abcdef")).toBe("#abcdef"); + expect(hexOf("#abc")).toBeNull(); + expect(mix("#000000", "#ffffff", 0.5)).toBe("#808080"); + }); + + test("only offered colours are worth keeping", () => { + expect(isOffered(DEFAULT_STYLE)).toBe(true); + expect(isOffered({ ...DEFAULT_STYLE, accent: SWATCHES[2] ?? null })).toBe(true); + expect(isOffered({ ...DEFAULT_STYLE, accent: "#123456" })).toBe(false); + }); +}); + +describe("a styled Card", () => { + test("is drawn in its accent and on its background", async () => { + const out = await renderCard(CARDS.totals, PROFILE, "dark", { images: {}, site: "commitscape.example", style: { preset: "grape", accent: "#f97316", background: "aurora", corner: "square" } }); + expect(out.animated).toContain("#f97316"); + expect(out.animated).toContain("radialGradient"); + expect(out.still).not.toMatch(/fill="#0[123][0-9a-f]{4}"/); + }); +}); diff --git a/packages/ui/src/cards/style.ts b/packages/ui/src/cards/style.ts new file mode 100644 index 0000000..07dc884 --- /dev/null +++ b/packages/ui/src/cards/style.ts @@ -0,0 +1,150 @@ +import { TOKENS, type CardTheme, type Tokens } from "./tokens"; + +export type Background = "plain" | "glow" | "gradient" | "aurora" | "grid" | "dots"; +export type Corner = "square" | "soft" | "round"; +export type PresetId = "classic" | "midnight" | "sunset" | "forest" | "grape" | "ocean" | "mono" | "paper" | "neon"; + +/** How someone chose to dress their Card: a preset, and their own accent, background and corners over it. */ +export type CardStyle = { preset: PresetId; accent: string | null; background: Background | null; corner: Corner | null }; + +type Palette = { bg: string; text: string; muted: string; faint: string; border: string; surface: string; empty: string; accent: string }; +type Preset = { label: string; background: Background; light: Palette; dark: Palette }; + +export const DEFAULT_STYLE: CardStyle = { preset: "classic", accent: null, background: null, corner: null }; + +export const BACKGROUNDS: { id: Background; label: string }[] = [ + { id: "plain", label: "Plain" }, + { id: "glow", label: "Glow" }, + { id: "gradient", label: "Gradient" }, + { id: "aurora", label: "Aurora" }, + { id: "grid", label: "Grid" }, + { id: "dots", label: "Dots" }, +]; + +export const CORNERS: { id: Corner; label: string; radius: number }[] = [ + { id: "square", label: "Square", radius: 0 }, + { id: "soft", label: "Soft", radius: 10 }, + { id: "round", label: "Round", radius: 20 }, +]; + +/** Accent colours offered as swatches; any other colour can be typed. */ +export const SWATCHES = ["#12b24a", "#2a78d6", "#7c5cff", "#d946ef", "#f43f5e", "#f97316", "#eab308", "#14b8a6", "#0ea5e9", "#64748b"]; + +const light = (bg: string, text: string, accent: string, over: Partial = {}): Palette => ({ bg, text, muted: mix(text, bg, 0.38), faint: mix(text, bg, 0.55), border: mix(text, bg, 0.88), surface: mix(text, bg, 0.95), empty: mix(text, bg, 0.91), accent, ...over }); +const dark = (bg: string, text: string, accent: string, over: Partial = {}): Palette => ({ bg, text, muted: mix(text, bg, 0.35), faint: mix(text, bg, 0.52), border: mix(text, bg, 0.86), surface: mix(text, bg, 0.94), empty: mix(text, bg, 0.9), accent, ...over }); + +export const PRESETS: Record = { + classic: { label: "Classic", background: "plain", light: light("#ffffff", "#141416", "#12a046"), dark: dark("#111214", "#f4f4f5", "#3ccf74") }, + midnight: { label: "Midnight", background: "glow", light: light("#f5f7ff", "#141a33", "#4f6bff"), dark: dark("#0b1020", "#e8ecff", "#8aa2ff") }, + sunset: { label: "Sunset", background: "gradient", light: light("#fff8f1", "#2a1a12", "#ea580c"), dark: dark("#1a0f0b", "#fff1e8", "#fb8a3c") }, + forest: { label: "Forest", background: "dots", light: light("#f3f8f3", "#122016", "#15803d"), dark: dark("#08130c", "#e6f4ea", "#4ade80") }, + grape: { label: "Grape", background: "aurora", light: light("#faf6ff", "#1f1233", "#9333ea"), dark: dark("#120a1f", "#f3eaff", "#c084fc") }, + ocean: { label: "Ocean", background: "glow", light: light("#f1f9ff", "#0c2233", "#0284c7"), dark: dark("#04121c", "#e2f4ff", "#38bdf8") }, + mono: { label: "Mono", background: "plain", light: light("#ffffff", "#0a0a0a", "#0a0a0a"), dark: dark("#0a0a0a", "#fafafa", "#fafafa") }, + paper: { label: "Paper", background: "grid", light: light("#f6f1e7", "#2b251d", "#b45309"), dark: dark("#1b1916", "#efe6d6", "#e2a35a") }, + neon: { label: "Neon", background: "grid", light: light("#f6fff9", "#06140d", "#00a862"), dark: dark("#050505", "#eafff3", "#00f593") }, +}; + +const HEX = /^#?([0-9a-f]{6})$/i; + +/** A colour as #rrggbb when it is one, else null. */ +export function hexOf(value: string | null | undefined): string | null { + const m = HEX.exec(value?.trim() ?? ""); + return m ? `#${m[1]?.toLowerCase()}` : null; +} + +function rgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** Two colours mixed, `amount` of the way from the first to the second. */ +export function mix(a: string, b: string, amount: number): string { + const [r1, g1, b1] = rgb(a); + const [r2, g2, b2] = rgb(b); + const c = (x: number, y: number) => Math.round(x + (y - x) * amount).toString(16).padStart(2, "0"); + return `#${c(r1, r2)}${c(g1, g2)}${c(b1, b2)}`; +} + +function luminance(hex: string): number { + const [r, g, b] = rgb(hex).map((v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }) as [number, number, number]; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a: string, b: string): number { + const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [number, number]; + return (x + 0.05) / (y + 0.05); +} + +/** An accent moved toward the text colour until it reads on the background, so a pale accent on white stays visible. */ +function readable(accent: string, p: Palette): string { + let out = accent; + for (let i = 0; i < 8 && contrast(out, p.bg) < 2.6; i++) out = mix(out, p.text, 0.18); + return out; +} + +/** The style a Card's address asks for, keeping only what is valid. */ +export function parseStyle(params: URLSearchParams | Record): CardStyle { + const get = (k: string) => { + const v = params instanceof URLSearchParams ? params.get(k) : params[k]; + return typeof v === "string" ? v : null; + }; + const preset = get("preset"); + const background = get("bg"); + const corner = get("corner"); + return { + preset: preset && preset in PRESETS ? (preset as PresetId) : "classic", + accent: hexOf(get("accent")), + background: BACKGROUNDS.some((b) => b.id === background) ? (background as Background) : null, + corner: CORNERS.some((c) => c.id === corner) ? (corner as Corner) : null, + }; +} + +/** The style as an address's query, leaving out what is already the default. */ +export function styleQuery(style: CardStyle): string { + const q = new URLSearchParams(); + if (style.preset !== "classic") q.set("preset", style.preset); + if (style.accent) q.set("accent", style.accent.slice(1)); + if (style.background && style.background !== PRESETS[style.preset].background) q.set("bg", style.background); + if (style.corner && style.corner !== "round") q.set("corner", style.corner); + return q.toString(); +} + +/** A short, stable name for a style, empty for the default, used to store each style's copy of a Card. */ +export function styleKey(style: CardStyle): string { + return styleQuery(style).replace(/[&=]/g, "-"); +} + +/** Whether a style is one of the offered ones, so its copies are worth keeping: any preset with a swatch accent or its own. */ +export function isOffered(style: CardStyle): boolean { + return !style.accent || SWATCHES.includes(style.accent); +} + +export type Look = { t: Tokens; background: Background; radius: number; backgroundImage: string | undefined; backgroundSize: string | undefined }; + +/** The colours, background and corners a Card is drawn with, for a theme and a style. */ +export function lookOf(theme: CardTheme, style: CardStyle = DEFAULT_STYLE): Look { + const preset = PRESETS[style.preset] ?? PRESETS.classic; + const p = preset[theme]; + const accent = readable(style.accent ?? p.accent, p).replace(/^#0[0-3]/, "#04"); + const base = TOKENS[theme]; + const ramp: Tokens["ramp"] = theme === "light" ? [mix(accent, p.bg, 0.6), mix(accent, p.bg, 0.3), accent, mix(accent, p.text, 0.35)] : [mix(accent, p.bg, 0.65), mix(accent, p.bg, 0.35), accent, mix(accent, p.text, 0.45)]; + const t: Tokens = { ...base, bg: p.bg, surface: p.surface, border: p.border, text: p.text, muted: p.muted, faint: p.faint, brand: accent, accent, empty: p.empty, ramp, other: p.faint }; + const background = style.background ?? preset.background; + const radius = CORNERS.find((c) => c.id === (style.corner ?? "round"))?.radius ?? 20; + const glow = (a: number) => `${accent}${Math.round(a * 255).toString(16).padStart(2, "0")}`; + const second = theme === "light" ? mix(accent, "#3b82f6", 0.6) : mix(accent, "#60a5fa", 0.6); + const images: Record = { + plain: [undefined, undefined], + glow: [`radial-gradient(circle at 100% 0%, ${glow(theme === "light" ? 0.2 : 0.28)} 0%, ${glow(0)} 55%)`, undefined], + gradient: [`linear-gradient(135deg, ${p.bg} 0%, ${mix(p.bg, accent, theme === "light" ? 0.14 : 0.2)} 100%)`, undefined], + aurora: [`radial-gradient(circle at 0% 100%, ${glow(theme === "light" ? 0.22 : 0.32)} 0%, ${glow(0)} 50%), radial-gradient(circle at 100% 0%, ${second}${theme === "light" ? "33" : "4d"} 0%, ${second}00 50%)`, undefined], + grid: [`linear-gradient(${mix(p.text, p.bg, 0.93)} 1px, transparent 1px), linear-gradient(90deg, ${mix(p.text, p.bg, 0.93)} 1px, transparent 1px)`, "24px 24px"], + dots: [`radial-gradient(circle, ${mix(p.text, p.bg, 0.78)} 1px, transparent 1.6px)`, "16px 16px"], + }; + const [backgroundImage, backgroundSize] = images[background]; + return { t, background, radius, backgroundImage, backgroundSize }; +} diff --git a/packages/ui/src/cards/tokens.ts b/packages/ui/src/cards/tokens.ts index f8f9b2e..4c771f4 100644 --- a/packages/ui/src/cards/tokens.ts +++ b/packages/ui/src/cards/tokens.ts @@ -51,4 +51,4 @@ export const TOKENS: Record = { other: "#6b6a65", }, }; -export const CARD_DESIGN = 3; +export const CARD_DESIGN = 4; diff --git a/packages/ui/src/charts/Bars.tsx b/packages/ui/src/charts/Bars.tsx deleted file mode 100644 index cb3e0f3..0000000 --- a/packages/ui/src/charts/Bars.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { ReactNode } from "react"; -import { grouped } from "../format"; -import { TableView } from "./common"; -import { useTip } from "./tip"; - -export type Bar = { - key: string; - label: ReactNode; - value: number; - shown?: string; - colour?: string; - tip?: ReactNode; - onClick?: () => void; -}; - -export function Bars({ bars, unit, max }: { bars: Bar[]; unit: string; max?: number }) { - const tip = useTip(); - const most = max ?? Math.max(1, ...bars.map((b) => b.value)); - return ( -
      -
        - {bars.map((b) => { - const row = ( - <> - {b.label} - - - - {b.shown ?? grouped(b.value)} - - ); - const hint = tip(b.tip ?? <>{b.label}: {b.shown ?? `${grouped(b.value)} ${unit}`}); - return ( -
      1. - {b.onClick ? ( - - ) : ( -
        - {row} -
        - )} -
      2. - ); - })} -
      - [typeof b.label === "string" ? b.label : b.key, b.shown ?? b.value])} - /> -
      - ); -} diff --git a/packages/ui/src/charts/Columns.tsx b/packages/ui/src/charts/Columns.tsx deleted file mode 100644 index fe4f482..0000000 --- a/packages/ui/src/charts/Columns.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { compact, day, grouped } from "../format"; -import { Legend, TableView, type Swatch } from "./common"; -import { ticks } from "./scale"; -import { useTip } from "./tip"; -import { binDays, thinMarks, yearsOf, type Mark } from "./marks"; - -export type Series = { label: string; colour: string; values: number[] }; -export type { Mark } from "./marks"; - -export const COLUMNS_HEIGHT = 200; - -export function Columns({ - firstDay, - series, - marks = [], - unit = "commits", - height = COLUMNS_HEIGHT, -}: { - firstDay: number; - series: Series[]; - marks?: Mark[]; - unit?: string; - height?: number; -}) { - const tip = useTip(); - const days = Math.max(0, ...series.map((s) => s.values.length)); - const per = binDays(days); - const bins = Math.max(1, Math.ceil(days / per)); - const stacks = Array.from({ length: bins }, (_, b) => - series.map((s) => { - let n = 0; - for (let d = b * per; d < Math.min(days, (b + 1) * per); d++) n += s.values[d] ?? 0; - return n; - }), - ); - const totals = stacks.map((s) => s.reduce((a, b) => a + b, 0)); - const scale = ticks(Math.max(1, ...totals)); - const top = scale.at(-1) ?? 1; - const span = (b: number) => { - const from = firstDay + b * per; - return per === 1 ? day(from) : `${day(from)} to ${day(Math.min(firstDay + days, from + per) - 1)}`; - }; - const drawn = thinMarks(marks, firstDay, bins * per); - const years = yearsOf(firstDay, days); - const legend: Swatch[] = series.map((s) => ({ label: s.label, colour: s.colour })); - if (drawn.length > 0 && series.length > 1) legend.push({ label: "Release", colour: "var(--text-2)", mark: "dash" }); - const unitWords = per === 1 ? "a column a day" : per === 7 ? "a column a week" : per === 28 ? "a column every four weeks" : "a column a quarter"; - const hidden = marks.length - drawn.length; - return ( -
      - -

      - {unitWords} - {hidden > 0 && `; ${drawn.length} of ${grouped(marks.length)} releases drawn, where they fit`} -

      -
      -
      - {scale.map((v) => ( -
      - {compact(v)} -
      - ))} - {drawn.map(({ mark, at, labelled }) => ( -
      {mark.label}
      released {day(mark.day)}
      )}> - {labelled && {mark.label}} -
      - ))} -
      - {stacks.map((stack, b) => ( -
      - {span(b)} - {series.length > 1 && - series.map((s, i) => - stack[i] ? ( -
      - {s.label}: {grouped(stack[i] ?? 0)} -
      - ) : null, - )} -
      - {grouped(totals[b] ?? 0)} {unit} -
      - , - )} - > - {stack.map((n, i) => - n === 0 ? null : , - )} -
      - ))} -
      -
      -
      - {years.length > 0 ? ( - years.map((d) => ( - - {new Date(d * 86_400_000).getUTCFullYear()} - - )) - ) : ( - <> - {day(firstDay)} - {day(firstDay + Math.max(0, days - 1))} - - )} -
      -
      - 1 ? series.map((s) => s.label) : []), `All ${unit}`]} - rows={stacks - .map((s, b) => [span(b), ...(series.length > 1 ? s : []), totals[b] ?? 0]) - .filter((r) => r.at(-1) !== 0)} - /> -
      - ); -} diff --git a/packages/ui/src/charts/Grid.tsx b/packages/ui/src/charts/Grid.tsx index 47bd906..514bf3f 100644 --- a/packages/ui/src/charts/Grid.tsx +++ b/packages/ui/src/charts/Grid.tsx @@ -1,18 +1,7 @@ -import { day, grouped } from "../format"; -import { ramp } from "../theme"; +import { grouped } from "../format"; import { TableView } from "./common"; -import { HALF, ranges } from "./scale"; import { useTip } from "./tip"; -function quantiles(values: number[]): number[] { - const sorted = values.filter((v) => v > 0).sort((a, b) => a - b); - if (sorted.length === 0) return [1, 1, 1, 1]; - const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 1; - const out = [at(0.25), at(0.5), at(0.75), sorted.at(-1) ?? 1]; - for (let i = 1; i < 4; i++) out[i] = Math.max(out[i] ?? 0, out[i - 1] ?? 0); - return out; -} - function steps(max: number): number[] { if (max <= 4) return [1, 2, 3, 4].map((s) => Math.min(s, max)); return [1, 2, 3, 4].map((s) => Math.ceil((max * s) / 4)); @@ -24,63 +13,44 @@ function step(n: number, bounds: number[]): number { return i === -1 ? 4 : i + 1; } -function RampLegend({ bounds, unit }: { bounds: number[]; unit: string }) { - return ( -
        -
      • - - - - none -
      • - {ranges(bounds).map((r) => ( -
      • - - - - {r.from === r.to ? grouped(r.to) : `${grouped(r.from)}–${grouped(r.to)}`} -
      • - ))} -
      • {unit}
      • -
      - ); -} - const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; +const green = (s: number) => (s === 0 ? "var(--empty)" : `var(--green-${s})`); + +/** Commits by weekday and hour as a grid of cells, shaded by how many, with the hours of the night set apart. */ export function WeekGrid({ week }: { week: number[][] }) { const tip = useTip(); - const width = HALF; const max = Math.max(0, ...week.flat()); const bounds = steps(max); - const left = 80; - const cell = Math.max(8, Math.min(26, (width - left) / 24)); + const left = 34; + const cell = 22; + const gap = 3; + const width = left + 24 * cell; + const height = 7 * cell + 18; return ( -
      - - +
      + {week.map((hours, d) => ( - + {WEEKDAYS[d]?.slice(0, 3)} {hours.map((n, h) => ( - {WEEKDAYS[d]}s, {String(h).padStart(2, "0")}:00–{String(h + 1).padStart(2, "0")}:00 + {WEEKDAYS[d]}s, {String(h).padStart(2, "0")}:00 to {String((h + 1) % 24).padStart(2, "0")}:00 -
      {grouped(n)} commits
      +
      {grouped(n)} commits
      , )} /> @@ -88,60 +58,21 @@ export function WeekGrid({ week }: { week: number[][] }) { ))} {[0, 6, 12, 18].map((h) => ( - + {String(h).padStart(2, "0")}:00 ))} - `${h}`)]} - rows={week.map((hours, d) => [WEEKDAYS[d] ?? "", ...hours])} - /> -
      - ); -} - -export function Calendar({ firstDay, days, unit = "commits", quantile = false }: { firstDay: number; days: number[]; unit?: string; quantile?: boolean }) { - const tip = useTip(); - const max = Math.max(0, ...days); - const bounds = quantile ? quantiles(days) : steps(max); - const weekday = (d: number) => (d + 3) % 7; - const offset = weekday(firstDay); - const weeks = Math.ceil((days.length + offset) / 7); - const cell = 14; - return ( -
      - - - {days.map((n, i) => { - const d = firstDay + i; - const col = Math.floor((i + offset) / 7); - return ( - - {day(d)} -
      - {grouped(n)} {unit} -
      - , - )} - /> - ); - })} - - (n > 0 ? [[day(firstDay + i), n]] : []))} - /> +
      + `${h}`)]} rows={week.map((hours, d) => [WEEKDAYS[d] ?? "", ...hours])} /> + + Fewer + {[0, 1, 2, 3, 4].map((s) => ( + + ))} + More + +
      ); } diff --git a/packages/ui/src/charts/Hours.tsx b/packages/ui/src/charts/Hours.tsx deleted file mode 100644 index f29c61d..0000000 --- a/packages/ui/src/charts/Hours.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { grouped } from "../format"; -import { TableView, YAxis } from "./common"; -import { HALF, ticks } from "./scale"; -import { useTip } from "./tip"; - -const HEIGHT = 150; -const LEFT = 36; -const TOP = 16; -const BOTTOM = 22; - -export function Hours({ hours }: { hours: number[] }) { - const width = HALF; - const tip = useTip(); - const scale = ticks(Math.max(1, ...hours)); - const top = scale.at(-1) ?? 1; - const bw = Math.max(1, (width - LEFT) / 24); - const y = (v: number) => TOP + (HEIGHT - TOP - BOTTOM) * (1 - v / top); - const x = (h: number) => LEFT + h * bw; - const label = (h: number) => `${String(h).padStart(2, "0")}:00`; - return ( -
      - {( - - - - - night - - - {hours.map((n, h) => ( - {label(h)}–{label((h + 1) % 24)}
      {grouped(n)} commits
      )}> - - {n > 0 && ( - - )} - - ))} - - {[0, 6, 12, 18].map((h) => ( - - {label(h)} - - ))} - - - )} - [label(h), n])} /> -
      - ); -} diff --git a/packages/ui/src/charts/Lines.tsx b/packages/ui/src/charts/Lines.tsx index b0a66e7..7acb7a2 100644 --- a/packages/ui/src/charts/Lines.tsx +++ b/packages/ui/src/charts/Lines.tsx @@ -5,11 +5,12 @@ import { HALF, ticks } from "./scale"; export type Line = { label: string; colour: string; values: number[]; dashed?: boolean }; -const HEIGHT = 160; +const HEIGHT = 170; const LEFT = 36; const TOP = 10; const BOTTOM = 22; +/** Weekly counts as lines, with a crosshair and the week's numbers on hover. */ export function Lines({ firstWeek, lines, unit }: { firstWeek: number; lines: Line[]; unit: string }) { const width = HALF; const [at, setAt] = useState(null); @@ -20,16 +21,19 @@ export function Lines({ firstWeek, lines, unit }: { firstWeek: number; lines: Li const x = (i: number) => LEFT + (weeks <= 1 ? 0 : (i * (width - LEFT - 4)) / (weeks - 1)); const y = (v: number) => TOP + (HEIGHT - TOP - BOTTOM) * (1 - v / top); const week = (i: number) => day(firstWeek + i * 7); + const empty = lines.every((l) => l.values.every((v) => v === 0)); return ( -
      +
      ({ label: l.label, colour: l.colour, mark: l.dashed ? "dash" : "line" }))} /> - {lines.every((l) => l.values.every((v) => v === 0)) ? ( -

      None in this window.

      - ) : weeks > 0 && ( -
      + {empty || weeks === 0 ? ( +

      + None in this Window. +

      + ) : ( +
      { @@ -41,46 +45,39 @@ export function Lines({ firstWeek, lines, unit }: { firstWeek: number; lines: Li > {lines.map((l) => ( - `${x(i)},${y(v)}`).join(" ")} - /> + `${x(i)},${y(v)}`).join(" ")} /> ))} {at !== null && ( - + {lines.map((l) => ( - + ))} )} - - {week(0)} - {week(weeks - 1)} + + + {week(0)} + + + {week(weeks - 1)} + {at !== null && ( -
      - Week of {week(at)} +
      +
      Week of {week(at)}
      {lines.map((l) => ( -
      - {l.label}: {grouped(l.values[at] ?? 0)} +
      + {l.label} + {grouped(l.values[at] ?? 0)}
      ))}
      )}
      )} - l.label)]} - rows={Array.from({ length: weeks }, (_, i) => [week(i), ...lines.map((l) => l.values[i] ?? 0)]).filter((r) => - r.slice(1).some((v) => v !== 0), - )} - /> + l.label)]} rows={Array.from({ length: weeks }, (_, i) => [week(i), ...lines.map((l) => l.values[i] ?? 0)]).filter((r) => r.slice(1).some((v) => v !== 0))} />
      ); } diff --git a/packages/ui/src/charts/Tip.tsx b/packages/ui/src/charts/Tip.tsx index 5b78568..02cd4ed 100644 --- a/packages/ui/src/charts/Tip.tsx +++ b/packages/ui/src/charts/Tip.tsx @@ -12,7 +12,7 @@ export function TipLayer({ children }: { children: ReactNode }) { {children} {tip && (
      ; +export type Series = { key: string; label: string; colour: string }; + +const axis = { fontSize: 11, fill: "var(--color-text-secondary)" }; + +function Tip({ active, payload, title, unit }: { active?: boolean; payload?: { name?: string; value?: number; color?: string; payload?: Point }[]; title?: (p: Point) => ReactNode; unit: string }) { + if (!active || !payload?.length) return null; + const point = payload[0]?.payload; + return ( +
      +
      {point ? (title?.(point) ?? point.label) : null}
      + {payload.map((p) => ( +
      + + {payload.length > 1 && } + {payload.length > 1 ? p.name : unit} + + {grouped(p.value ?? 0)} +
      + ))} +
      + ); +} + +/** One measure over time as a soft area with a line on top, a tooltip on hover. */ +export function AreaTrend({ points, series, height = 240, unit, title }: { points: Point[]; series: Series; height?: number; unit: string; title?: (p: Point) => ReactNode }) { + const id = useId().replace(/:/g, ""); + return ( +
      + + + + + + + + + + p.tick).map((p) => p.key)} tickFormatter={(k: string) => points.find((p) => p.key === k)?.tick ?? ""} interval="preserveStartEnd" tick={axis} tickLine={false} axisLine={false} minTickGap={14} /> + compact(v)} tick={axis} tickLine={false} axisLine={false} width={38} allowDecimals={false} /> + } cursor={{ stroke: "var(--color-border-emphasized)" }} /> + + + +
      + ); +} + +/** Several measures stacked in a column each period, with a legend. */ +export function StackedColumns({ points, series, height = 240, unit }: { points: Point[]; series: Series[]; height?: number; unit: string }) { + return ( +
      + {series.length > 1 && ( +
        + {series.map((s) => ( +
      • + + {s.label} +
      • + ))} +
      + )} +
      + + + + + compact(v)} tick={axis} tickLine={false} axisLine={false} width={38} allowDecimals={false} /> + } cursor={{ fill: "var(--color-overlay-hover)" }} /> + {series.map((s, i) => ( + + ))} + + +
      +
      + ); +} diff --git a/packages/ui/src/charts/Year.tsx b/packages/ui/src/charts/Year.tsx new file mode 100644 index 0000000..eea091e --- /dev/null +++ b/packages/ui/src/charts/Year.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from "react"; +import { day, grouped } from "../format"; +import { TableView } from "./common"; +import { useTip } from "./tip"; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const DAYS = ["Mon", "", "Wed", "", "Fri", "", ""]; +const CELL = 13; +const GAP = 3; +const LEFT = 28; +const TOP = 18; + +function bounds(values: number[]): number[] { + const sorted = values.filter((v) => v > 0).sort((a, b) => a - b); + if (sorted.length === 0) return [1, 1, 1, 1]; + const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 1; + const out = [at(0.25), at(0.5), at(0.75), sorted.at(-1) ?? 1]; + for (let i = 1; i < 4; i++) out[i] = Math.max(out[i] ?? 0, out[i - 1] ?? 0); + return out; +} + +function step(n: number, b: number[]): number { + if (n <= 0) return 0; + const i = b.findIndex((x) => n <= x); + return i === -1 ? 4 : i + 1; +} + +const fill = (s: number) => (s === 0 ? "var(--empty)" : `var(--green-${s})`); + +/** A year of days as a grid of squares, a column a week from Monday, shaded by how much happened each day. */ +export function YearGrid({ firstDay, days, unit = "contributions", label }: { firstDay: number; days: number[]; unit?: string; label?: string }) { + const tip = useTip(); + const scroller = useRef(null); + useEffect(() => { + const el = scroller.current; + if (el) el.scrollLeft = el.scrollWidth; + }, []); + const b = bounds(days); + const weekday = (d: number) => (d + 3) % 7; + const offset = weekday(firstDay); + const weeks = Math.ceil((days.length + offset) / 7); + const width = LEFT + weeks * (CELL + GAP); + const height = TOP + 7 * (CELL + GAP); + const months: { x: number; label: string }[] = []; + days.forEach((_, i) => { + const date = new Date((firstDay + i) * 86_400_000); + if (date.getUTCDate() === 1 || i === 0) { + const col = Math.floor((i + offset) / 7); + const x = LEFT + col * (CELL + GAP); + if (months.length === 0 || x - (months.at(-1)?.x ?? 0) > 30) months.push({ x, label: MONTHS[date.getUTCMonth()] ?? "" }); + } + }); + return ( +
      +
      + + {months.map((m) => ( + + {m.label} + + ))} + {DAYS.map((d, i) => + d ? ( + + {d} + + ) : null, + )} + {days.map((n, i) => { + const d = firstDay + i; + const col = Math.floor((i + offset) / 7); + return ( + + + {grouped(n)} {unit} + +
      {day(d)}
      + , + )} + /> + ); + })} + +
      +
      + (n > 0 ? [[day(firstDay + i), n]] : []))} /> + + Less + {[0, 1, 2, 3, 4].map((s) => ( + + ))} + More + +
      +
      + ); +} diff --git a/packages/ui/src/charts/buckets.test.ts b/packages/ui/src/charts/buckets.test.ts new file mode 100644 index 0000000..17f5919 --- /dev/null +++ b/packages/ui/src/charts/buckets.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import { buckets, sum, unitFor } from "./buckets"; + +const day = (iso: string) => Date.UTC(Number(iso.slice(0, 4)), Number(iso.slice(5, 7)) - 1, Number(iso.slice(8, 10))) / 86_400_000; + +test("a chart counts in days, weeks, months, quarters or years, by how long it covers", () => { + expect(unitFor(30, true)).toBe("day"); + expect(unitFor(365, true)).toBe("week"); + expect(unitFor(365, false)).toBe("month"); + expect(unitFor(3650, true)).toBe("quarter"); + expect(unitFor(365 * 20, true)).toBe("year"); +}); + +test("periods follow the calendar and are clipped to the days asked for", () => { + const list = buckets(day("2025-01-15"), 400, false); + expect(list[0]).toMatchObject({ from: day("2025-01-15"), to: day("2025-02-01"), label: "Jan 2025" }); + expect(list.at(-1)?.to).toBe(day("2025-01-15") + 400); + expect(list.filter((b) => b.tick).length).toBeLessThanOrEqual(8); +}); + +test("weeks start on Monday and quarters are named", () => { + expect(buckets(day("2026-10-01"), 200, true)[0]?.key).toBe(String(day("2026-09-28"))); + expect(buckets(day("2020-02-10"), 3000, false)[0]?.label).toBe("Q1 2020"); +}); + +test("a series sums into its periods", () => { + const first = day("2025-01-30"); + const list = buckets(first, 150, false); + expect(sum([1, 1, 1, 5], first, list).slice(0, 2)).toEqual([2, 6]); +}); diff --git a/packages/ui/src/charts/buckets.ts b/packages/ui/src/charts/buckets.ts new file mode 100644 index 0000000..624c15d --- /dev/null +++ b/packages/ui/src/charts/buckets.ts @@ -0,0 +1,91 @@ +export type Unit = "day" | "week" | "month" | "quarter" | "year"; + +export type Bucket = { from: number; to: number; key: string; label: string; short: string; tick?: string }; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const DAY_MS = 86_400_000; + +const ymd = (d: number) => { + const t = new Date(d * DAY_MS); + return { y: t.getUTCFullYear(), m: t.getUTCMonth(), day: t.getUTCDate() }; +}; +const dayOf = (y: number, m: number) => Date.UTC(y, m, 1) / DAY_MS; + +/** The period a chart counts in, by how many days it covers: fine enough to show a shape, coarse enough to stay readable. */ +export function unitFor(days: number, fine: boolean): Unit { + if (days <= 120) return "day"; + if (fine && days <= 550) return "week"; + if (days <= (fine ? 4 : 3) * 366) return "month"; + if (days <= 16 * 366) return "quarter"; + return "year"; +} + +function startOf(unit: Unit, d: number): number { + const { y, m } = ymd(d); + if (unit === "day") return d; + if (unit === "week") return d - ((d + 3) % 7); + if (unit === "month") return dayOf(y, m); + if (unit === "quarter") return dayOf(y, m - (m % 3)); + return dayOf(y, 0); +} + +function next(unit: Unit, d: number): number { + const { y, m } = ymd(d); + if (unit === "day") return d + 1; + if (unit === "week") return d + 7; + if (unit === "month") return dayOf(y, m + 1); + if (unit === "quarter") return dayOf(y, m + 3); + return dayOf(y + 1, 0); +} + +function words(unit: Unit, d: number): { label: string; short: string } { + const { y, m, day } = ymd(d); + if (unit === "day") return { label: `${day} ${MONTHS[m]} ${y}`, short: `${day} ${MONTHS[m]}` }; + if (unit === "week") return { label: `Week of ${day} ${MONTHS[m]} ${y}`, short: `${day} ${MONTHS[m]}` }; + if (unit === "month") return { label: `${MONTHS[m]} ${y}`, short: `${MONTHS[m]} ${String(y).slice(2)}` }; + if (unit === "quarter") return { label: `Q${Math.floor(m / 3) + 1} ${y}`, short: `Q${Math.floor(m / 3) + 1} ${String(y).slice(2)}` }; + return { label: String(y), short: String(y) }; +} + +function ticks(unit: Unit, list: Bucket[], most: number): void { + const first = list[0]?.from ?? 0; + const last = list.at(-1)?.from ?? 0; + const years = ymd(last).y - ymd(first).y; + const candidates: [number, string][] = []; + list.forEach((b, i) => { + const { y, m, day } = ymd(b.from); + const prev = i > 0 ? ymd(list[i - 1]?.from ?? 0) : null; + if (unit === "day") candidates.push([i, `${day} ${MONTHS[m]}`]); + else if (years >= 2 || unit === "quarter" || unit === "year") { + if (!prev || prev.y !== y) candidates.push([i, String(y)]); + } else if (!prev || prev.m !== m) candidates.push([i, m === 0 ? String(y) : (MONTHS[m] ?? "")]); + }); + const every = Math.max(1, Math.ceil(candidates.length / most)); + candidates.forEach(([i, text], k) => { + const b = list[i]; + if (b && k % every === 0) b.tick = text; + }); +} + +/** The periods covering `days` days from `firstDay`, each clipped to them, with a label, a short label and a tick where an axis should name it. */ +export function buckets(firstDay: number, days: number, fine: boolean, most = 8): Bucket[] { + if (days <= 0) return []; + const unit = unitFor(days, fine); + const end = firstDay + days; + const out: Bucket[] = []; + for (let at = startOf(unit, firstDay); at < end; at = next(unit, at)) { + const from = Math.max(at, firstDay); + out.push({ from, to: Math.min(next(unit, at), end), key: String(at), ...words(unit, at) }); + } + ticks(unit, out, most); + return out; +} + +/** Sums a day-by-day series into the periods given. */ +export function sum(values: number[], firstDay: number, list: Bucket[]): number[] { + return list.map((b) => { + let n = 0; + for (let d = b.from; d < b.to; d++) n += values[d - firstDay] ?? 0; + return n; + }); +} diff --git a/packages/ui/src/charts/columns.test.ts b/packages/ui/src/charts/columns.test.ts deleted file mode 100644 index f8d850a..0000000 --- a/packages/ui/src/charts/columns.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test } from "vitest"; -import { binDays, thinMarks } from "./marks"; - -test("columns are days, weeks, four weeks or quarters, by how long the window is", () => { - expect(binDays(90)).toBe(1); - expect(binDays(365)).toBe(7); - expect(binDays(3650)).toBe(28); - expect(binDays(365 * 20)).toBe(91); -}); - -test("releases close together are thinned to those that fit, the newest kept", () => { - const marks = Array.from({ length: 79 }, (_, i) => ({ day: 1000 + i * 10, label: `v${i}` })); - const drawn = thinMarks(marks, 1000, 800); - expect(drawn.length).toBeLessThanOrEqual(Math.ceil(100 / 3.5) + 1); - expect(drawn.at(-1)?.mark.label).toBe("v78"); - for (let i = 1; i < drawn.length; i++) expect((drawn[i]?.at ?? 0) - (drawn[i - 1]?.at ?? 0)).toBeGreaterThanOrEqual(3.5); - const labelled = drawn.filter((d) => d.labelled); - for (let i = 1; i < labelled.length; i++) expect((labelled[i]?.at ?? 0) - (labelled[i - 1]?.at ?? 0)).toBeGreaterThanOrEqual(13); -}); - -test("a release outside the window is not drawn", () => { - expect(thinMarks([{ day: 5, label: "old" }], 100, 50)).toEqual([]); -}); diff --git a/packages/ui/src/charts/common.tsx b/packages/ui/src/charts/common.tsx index 46054b1..bd56d4a 100644 --- a/packages/ui/src/charts/common.tsx +++ b/packages/ui/src/charts/common.tsx @@ -1,6 +1,3 @@ -import type { ReactNode } from "react"; -import { Card } from "@astryxdesign/core/Card"; -import { Heading } from "@astryxdesign/core/Heading"; import { compact, grouped } from "../format"; export type Swatch = { label: string; colour: string; mark?: "line" | "dash" | "block" }; @@ -8,20 +5,12 @@ export type Swatch = { label: string; colour: string; mark?: "line" | "dash" | " export function Legend({ items }: { items: Swatch[] }) { if (items.length < 2) return null; return ( -
        +
          {items.map((s) => ( -
        • - +
        • + {s.mark === "line" || s.mark === "dash" ? ( - + ) : ( )} @@ -33,32 +22,27 @@ export function Legend({ items }: { items: Swatch[] }) { ); } -export function TableView({ - head, - rows, - caption = "Show the numbers", -}: { - head: string[]; - rows: (string | number)[][]; - caption?: string; -}) { +/** A chart's numbers as a plain table, folded away until asked for. */ +export function TableView({ head, rows, caption = "Show the numbers" }: { head: string[]; rows: (string | number)[][]; caption?: string }) { return ( -
          - {caption} -
          - +
          + {caption} +
          +
          - {head.map((h) => ( - + {head.map((h, i) => ( + ))} {rows.map((r, i) => ( - + {r.map((c, j) => ( - ))} @@ -71,23 +55,13 @@ export function TableView({ ); } -export function YAxis({ - values, - y, - width, - left, -}: { - values: number[]; - y: (v: number) => number; - width: number; - left: number; -}) { +export function YAxis({ values, y, width, left }: { values: number[]; y: (v: number) => number; width: number; left: number }) { return ( - + {values.map((v) => ( - - + + {compact(v)} @@ -95,25 +69,3 @@ export function YAxis({ ); } - -export function Figure({ - title, - note, - children, - id, -}: { - title: string; - note?: ReactNode; - children: ReactNode; - id?: string; -}) { - return ( - - - {title} - - {note &&
          {note}
          } - {children} -
          - ); -} diff --git a/packages/ui/src/charts/marks.ts b/packages/ui/src/charts/marks.ts deleted file mode 100644 index e4ed9fa..0000000 --- a/packages/ui/src/charts/marks.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type Mark = { day: number; label: string }; - -const MARK_GAP = 3.5; -const LABEL_GAP = 13; - -export function binDays(days: number): number { - if (days <= 120) return 1; - if (days <= 840) return 7; - if (days <= 28 * 160) return 28; - return 91; -} - -/** The releases drawn on a chart: only those at least a few percent apart, newest kept first, and labels only where they fit. */ -export function thinMarks(marks: Mark[], firstDay: number, days: number): { mark: Mark; at: number; labelled: boolean }[] { - const span = Math.max(1, days); - const placed = marks - .map((mark) => ({ mark, at: ((mark.day - firstDay) / span) * 100 })) - .filter((m) => m.at >= 0 && m.at <= 100) - .sort((a, b) => b.at - a.at); - const kept: { mark: Mark; at: number; labelled: boolean }[] = []; - let lastMark = Infinity; - let lastLabel = Infinity; - for (const m of placed) { - if (lastMark - m.at < MARK_GAP) continue; - const labelled = lastLabel - m.at >= LABEL_GAP && m.at <= 100 - 4; - kept.push({ ...m, labelled }); - lastMark = m.at; - if (labelled) lastLabel = m.at; - } - return kept.reverse(); -} - -export function yearsOf(firstDay: number, days: number): number[] { - const out: number[] = []; - const first = new Date(firstDay * 86_400_000).getUTCFullYear(); - const last = new Date((firstDay + days) * 86_400_000).getUTCFullYear(); - for (let y = first + 1; y <= last; y++) out.push(Date.UTC(y, 0, 1) / 86_400_000); - const every = Math.ceil(out.length / 10); - return out.filter((_, i) => i % every === 0); -} diff --git a/packages/ui/src/components/Face.tsx b/packages/ui/src/components/Face.tsx index 7598ffc..a553046 100644 --- a/packages/ui/src/components/Face.tsx +++ b/packages/ui/src/components/Face.tsx @@ -1,9 +1,9 @@ import { Avatar } from "@astryxdesign/core/Avatar"; import { avatarUrl } from "./avatar"; -export type FaceSize = 20 | 24 | 32 | 40 | 48 | 64 | 96 | 128; +export type FaceSize = 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144; /** A person's GitHub avatar, or their initials when no GitHub account is known. */ -export function Face({ login, name, size = 24 }: { login: string | null | undefined; name: string; size?: FaceSize }) { - return ; +export function Face({ login, name, size = 24, shape = "circle" }: { login: string | null | undefined; name: string; size?: FaceSize; shape?: "circle" | "rounded" }) { + return ; } diff --git a/packages/ui/src/components/Key.tsx b/packages/ui/src/components/Key.tsx index 89ce330..a7d14bf 100644 --- a/packages/ui/src/components/Key.tsx +++ b/packages/ui/src/components/Key.tsx @@ -1,8 +1,12 @@ +import { useContext } from "react"; import { Kbd } from "@astryxdesign/core/Kbd"; +import { HelpContext } from "../help"; +/** A keyboard key, ringed while help is shown so every shortcut on screen stands out. */ export function Key({ keys }: { keys: string }) { + const help = useContext(HelpContext); return ( - + ); diff --git a/packages/ui/src/components/Loading.tsx b/packages/ui/src/components/Loading.tsx index c47617b..bc3eb9c 100644 --- a/packages/ui/src/components/Loading.tsx +++ b/packages/ui/src/components/Loading.tsx @@ -1,93 +1,167 @@ import { Skeleton } from "@astryxdesign/core/Skeleton"; -import { Figure } from "../charts/common"; +import { Panel } from "../kit/layout"; +import { CommitsLoading } from "../screens/Commits"; +import { NARROW, WIDE } from "../screens/Map"; +import { CONTRIBUTORS_SHOWN, FOLDERS_SHOWN } from "../screens/Overview"; +import { NumberCell, NumberStrip, ScreenFrame } from "../screens/kit"; -function Note() { +function line(width: string | number, index: number, height = 14) { + return ; +} + +function Cells({ labels }: { labels: string[] }) { return ( -
          - -
          + <> + {labels.map((l, i) => ( + } label={l} note={{line("70%", i, 12)}} /> + ))} + ); } -export function TilesSkeleton({ count = 6, className }: { count?: number; className?: string }) { +function Bars({ count }: { count: number }) { return ( -
          +
          {Array.from({ length: count }, (_, i) => ( - +
          +
          + {line(`${40 + ((i * 23) % 35)}%`, i, 12)} + {line(70, i, 12)} +
          + +
          ))} -
          + ); } export function OverviewSkeleton() { return ( -
          - -
          }> -
          - -
          -
          -
          -
          }> -
          - + + + + +
          + +
          + {Array.from({ length: CONTRIBUTORS_SHOWN }, (_, i) => ( +
          + + + + {line(`${30 + ((i * 17) % 30)}%`, i)} + + + + + {line(56, i, 12)} +
          + ))}
          -
          -
          - -
          +
          {line(220, 0, 11)}
          + +
          + + + + +
          + +
          + {Array.from({ length: 6 }, (_, i) => ( +
          + {line("70%", i, 12)} +
          + ))} +
          +
          +
          +
          -
          + +
          + +
          +
          + + ); } export function PeopleSkeleton() { return ( -
          -
          - - -
          -
          + + +
          +
          + {Array.from({ length: 12 }, (_, i) => ( +
          + + + {line("80%", i)} + {line("50%", i, 4)} + + + {line(48, i, 12)} + {line(48, i, 12)} + {line(64, i, 12)} + +
          + ))} +
          + + ); } export function ActivitySkeleton() { return ( -
          -
          - -
          - + + +
          +
          + {[0, 1, 2, 3, 4].map((i) => ( + {line(72, i, 10)} + ))} +
          +
          -
          -
          - - + +
          + + + + + +
          -
          + ); } export function MapSkeleton() { return ( -
          - - -
          + +
          +
          {line(140, 0, 14)}
          + +
          +
          +
          {line(320, 0, 12)}
          +
          + +
          +
          + +
          +
          +
          + ); } export function CommitsSkeleton() { - return ( -
          -
          - - -
          -
          - ); + return ; } export function ScreenSkeleton() { diff --git a/packages/ui/src/components/Name.tsx b/packages/ui/src/components/Name.tsx index d23c6ca..8401a4a 100644 --- a/packages/ui/src/components/Name.tsx +++ b/packages/ui/src/components/Name.tsx @@ -1,35 +1,46 @@ +import { createContext, useContext } from "react"; import type { PersonRef } from "@commitscape/data"; import { useLogin } from "./login"; -import { Face } from "./Face"; +import { Face, type FaceSize } from "./Face"; -export function Name({ p, onOpen }: { p: PersonRef | null | undefined; onOpen?: (id: number) => void }) { +/** Where a GitHub login's Standing in this repository lives, when the page knows one. */ +export const StandingContext = createContext<((login: string) => string) | null>(null); + +/** The address of a person's Standing in this repository, or null. */ +export function useStanding(login: string | null | undefined): string | null { + const href = useContext(StandingContext); + return href && login ? href(login) : null; +} + +/** A person's face and name; a button that opens what they did here when it can be opened. */ +export function Name({ p, onOpen, size = 20, className = "" }: { p: PersonRef | null | undefined; onOpen?: (id: number) => void; size?: FaceSize; className?: string }) { const login = useLogin(p); - if (!p) return someone unknown; - const mark = ( - - - - ); - if (!onOpen) { - return ( - - {mark} - {p.name} + if (!p) return someone unknown; + const body = ( + <> + + - ); - } + {p.name} + + ); + if (!onOpen) return {body}; return ( - ); } +/** A path in the code; a button that opens it when it can be opened. */ export function Path({ path, onOpen }: { path: string; onOpen?: (path: string) => void }) { - if (!onOpen) return {path}; + if (!onOpen) return {path}; return ( - ); diff --git a/packages/ui/src/components/Tile.tsx b/packages/ui/src/components/Tile.tsx deleted file mode 100644 index d3b886e..0000000 --- a/packages/ui/src/components/Tile.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReactNode } from "react"; -import { Card } from "@astryxdesign/core/Card"; - -export function Tile({ value, label, note, children }: { value: string; label: string; note?: string; children?: ReactNode }) { - return ( - - {value} - {label} - {note && {note}} - {children} - - ); -} diff --git a/packages/ui/src/components/avatar.ts b/packages/ui/src/components/avatar.ts index 6e909a5..f87e262 100644 --- a/packages/ui/src/components/avatar.ts +++ b/packages/ui/src/components/avatar.ts @@ -1,3 +1,3 @@ export function avatarUrl(login: string, size = 24): string { - return `https://avatars.githubusercontent.com/${encodeURIComponent(login)}?s=${size * 2}`; + return `https://github.com/${encodeURIComponent(login)}.png?size=${size * 2}`; } diff --git a/packages/ui/src/explain.tsx b/packages/ui/src/explain.tsx index a9bb66d..278fe08 100644 --- a/packages/ui/src/explain.tsx +++ b/packages/ui/src/explain.tsx @@ -1,8 +1,9 @@ import { useContext, type ReactNode } from "react"; import { HelpContext } from "./help"; +/** What a number means, shown only while help is on. */ export function Explain({ children }: { children: ReactNode }) { const on = useContext(HelpContext); if (!on) return null; - return
          {children}
          ; + return
          {children}
          ; } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index c98a4bc..4e2b6c6 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -9,8 +9,8 @@ --muted: var(--color-text-secondary); --line: var(--color-border); --radius: var(--radius-element); - --empty: light-dark(#e8e6e0, #2c2c2a); - --other: light-dark(#a3a19b, #6b6a65); + --empty: light-dark(#0c0d0f0f, #ffffff12); + --other: light-dark(#a6a8ad, #62656c); --danger: light-dark(rgba(227, 73, 72, 0.08), rgba(230, 103, 103, 0.1)); --s1: light-dark(#2a78d6, #3987e5); --s2: light-dark(#eb6834, #d95926); @@ -28,280 +28,36 @@ --orange-2: light-dark(#ce4e2f, #ce4e2f); --orange-3: light-dark(#a42602, #fc7856); --orange-4: light-dark(#6e1a00, #ffd2c2); + --green-1: light-dark(#62c387, #1c6b3e); + --green-2: light-dark(#31a15d, #26965a); + --green-3: light-dark(#1a7b42, #37c46e); + --green-4: light-dark(#0c5029, #79f0a6); + --brand: light-dark(#0f7a37, #3ccf74); + --brand-soft: light-dark(#0f7a371a, #3ccf741f); + --added: light-dark(#11733a, #4ac776); + --removed: light-dark(#c9303a, #f07178); --error: var(--color-error); --mono: var(--font-family-code); + --page: 76rem; + --ease-out: cubic-bezier(0.2, 0.7, 0.2, 1); } -body { background: var(--color-background-body); color: var(--text); font-family: var(--font-family-body); } +html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; scrollbar-gutter: stable; } +body { background: var(--color-background-body); color: var(--text); font-family: var(--font-family-body); font-feature-settings: "cv11", "ss01", "ss03"; } +::selection { background: var(--brand-soft); } +:focus-visible { outline-color: var(--color-accent); } -.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } -code, .path { font-family: var(--mono); font-size: 0.88em; } -button.link { - font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; text-align: left; - text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.2em; word-break: break-word; -} -button.link:hover { text-decoration-color: var(--text-2); } -button.link:focus-visible, .bar-row:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; } - -.app { width: 100%; padding: 0.25rem 0 3rem; font-size: 0.94rem; line-height: 1.45; } .logo { display: block; } -.tools { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; } -.screen-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 0.5rem 1.5rem; } -.windows { display: flex; align-items: center; gap: 0.4rem; padding-bottom: 0.35rem; } -footer { margin-top: 3rem; } - -.key-hint { display: inline-flex; transition: box-shadow var(--duration-fast); border-radius: var(--radius-inner); } -.keys-on .key-hint, .help .key-hint { opacity: 1; box-shadow: 0 0 0 2px var(--color-accent); background: var(--color-accent-muted); } -.help { - margin: 0.9rem 0 0; padding: 0.75rem 1rem; border-radius: var(--radius-container); background: var(--color-background-surface); - border: 1px solid var(--color-accent); font-size: 0.9rem; -} -.help p { margin: 0 0 0.5rem; } -.shortcuts { display: grid; grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr)); gap: 0.35rem 1.5rem; margin: 0; } -.shortcuts div { display: grid; grid-template-columns: 6.5rem 1fr; gap: 0.5rem; align-items: center; } -.shortcuts dt { display: flex; gap: 0.25rem; align-items: center; color: var(--text-2); } -.shortcuts dd { margin: 0; } - -.screen { display: flex; flex-direction: column; gap: 1.25rem; margin-top: 1rem; transition: opacity var(--duration-fast); } -.screen.stale { opacity: 0.6; } -.two { display: grid; grid-template-columns: repeat(auto-fit, minmax(26rem, 1fr)); gap: 1.25rem; } -.figure { min-width: 0; } -.figure-title { margin: 0 0 0.1rem; font-size: 1.05rem; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +code, .path { font-family: var(--mono); font-size: 0.88em; } +.tnum { font-variant-numeric: tabular-nums; } .note { color: var(--muted); } .small { font-size: 0.82rem; } -.figure > .note, .figure-note { margin: 0 0 0.6rem; font-size: 0.88rem; min-height: 1.3em; } -.error { color: var(--error); } -.explain { - font-size: 0.85rem; color: var(--text-2); border-left: 3px solid var(--color-accent); padding: 0.3rem 0.7rem; - margin: 0.5rem 0; background: var(--color-accent-muted); border-radius: 0 var(--radius-inner) var(--radius-inner) 0; -} -.explain ul { margin: 0.2rem 0; padding-left: 1.1rem; } - -.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.75rem; } -.tile { display: flex; flex-direction: column; gap: 0.1rem; height: 104px; box-sizing: border-box; overflow: hidden; } -.tile strong { font-size: 1.55rem; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; } -.tile span { color: var(--text-2); } -.tile .note { color: var(--muted); } - -.chart { position: relative; min-width: 0; } -.chart svg { display: block; overflow: visible; } -.axis line { stroke: var(--line); stroke-width: 1; } -.axis text, .axis-label { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; } -.crosshair { stroke: var(--muted); stroke-width: 1; } -.empty-cell { fill: var(--empty); } -.legend { display: flex; flex-wrap: wrap; gap: 0.25rem 0.9rem; list-style: none; padding: 0; margin: 0 0 0.4rem; font-size: 0.82rem; color: var(--text-2); } -.legend li { display: flex; align-items: center; gap: 0.35rem; } -.legend svg { flex: none; } -.tip { - position: fixed; z-index: 1000; pointer-events: none; max-width: 270px; font-size: 0.82rem; - background: var(--color-background-popover); color: var(--text); border: 1px solid var(--line); - border-radius: var(--radius-element); padding: 0.45rem 0.6rem; box-shadow: 0 6px 20px var(--color-shadow); - word-break: break-word; -} -.tip.inline { position: absolute; top: 20px; } -.tip .note { color: var(--muted); } -.lines-wrap { position: relative; } -.table-view { margin-top: 0.4rem; font-size: 0.82rem; } -.table-view summary { color: var(--muted); cursor: pointer; } -.scroll { overflow: auto; max-height: 32rem; } -.table-wrap { min-width: 0; } -.table-wrap .num { display: block; } -.plain { border-collapse: collapse; width: 100%; font-size: 0.88rem; } -.plain th, .plain td { text-align: left; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--line); vertical-align: top; } -.plain th { color: var(--text-2); font-weight: 600; position: sticky; top: 0; background: var(--surface); } -.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } - -.bars { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.2rem; } -.bar-row { - display: grid; grid-template-columns: minmax(6rem, 14rem) 1fr auto; gap: 0.6rem; align-items: center; - font-size: 0.88rem; padding: 0.12rem 0.3rem; border-radius: var(--radius-inner); -} -button.bar-row { width: 100%; font: inherit; font-size: 0.88rem; color: inherit; text-align: left; background: none; border: none; } -.bar-row.clickable { cursor: pointer; } -.bar-row.clickable:hover { background: var(--color-overlay-hover); } -.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.bar-track { height: 0.7rem; } -.bar { display: block; height: 100%; border-radius: 0 4px 4px 0; min-width: 2px; } -.bar-value { color: var(--text-2); font-variant-numeric: tabular-nums; white-space: nowrap; } - -.name { display: inline-flex; align-items: center; gap: 0.4rem; vertical-align: middle; } -.profile-head { display: flex; align-items: center; gap: 0.9rem; } -.profile-name { margin: 0; } -.facts, .suspects, .groups { padding-left: 0; list-style: none; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; } -.groups { padding-left: 1.2rem; list-style: decimal; } -.actions { display: flex; gap: 0.5rem; align-items: center; margin: 0.75rem 0; flex-wrap: wrap; } -.mailmap { background: var(--surface-2); padding: 0.6rem; border-radius: var(--radius-element); overflow: auto; font-size: 0.82rem; font-family: var(--mono); } - - -.map-bar { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; } -.crumbs { font-size: 0.95rem; } -.map-and-file { display: flex; gap: 1rem; align-items: flex-start; } -.treemap { flex: 1; min-width: 0; } -.block { cursor: pointer; } -.block rect { stroke: var(--surface); stroke-width: 1; } -.block:hover rect { stroke: var(--text); stroke-width: 1.5; } -.block.chosen rect { stroke: var(--text); stroke-width: 2.5; } -.block-label { - fill: var(--text); font-size: 11px; paint-order: stroke; stroke: var(--surface); stroke-width: 3px; - stroke-linejoin: round; pointer-events: none; -} -.coupling line { stroke: var(--text); stroke-opacity: 0.75; stroke-linecap: round; } -.coupling circle { fill: var(--text); stroke: var(--surface); stroke-width: 2; } -.chosen-dot { fill: var(--surface); stroke: var(--text); stroke-width: 2.5; pointer-events: none; } -.file-panel { width: 22rem; flex: none; font-size: 0.88rem; position: relative; max-height: 560px; overflow: auto; } -.file-panel .close { position: absolute; right: 0.5rem; top: 0.5rem; } -.file-panel h3 { margin: 0 2rem 0.5rem 0; word-break: break-all; font-size: 0.95rem; } -.file-panel h4 { font-size: 0.85rem; margin: 1rem 0 0.25rem; color: var(--text-2); } -.file-panel dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 0.8rem; margin: 0; } -.file-panel dt { color: var(--muted); } -.file-panel dd { margin: 0; } -.commits li { font-size: 0.82rem; } - - -.night { fill: var(--surface-2); } -.night-label { fill: var(--muted); font-size: 11px; } -.hour-bar { fill: var(--s3); } - -@media (max-width: 760px) { - .map-and-file { flex-direction: column; } - .file-panel { width: auto; } - .two { grid-template-columns: 1fr; } - .app { padding: 0.5rem 0.75rem 2rem; } -} - -.commit-tools { display: flex; gap: 0.75rem; align-items: center; margin: 0.25rem 0 0.75rem; } -.commit-search { flex: 1; display: flex; align-items: center; gap: 0.5rem; } -.commit-search > :first-child { flex: 1; } -.commit-list { position: relative; overflow-y: auto; border-top: 1px solid var(--line); } -.commit-list .empty { padding: 1rem 0; } -.commit-row { - position: absolute; left: 0; right: 0; display: grid; align-items: center; gap: 0.75rem; - grid-template-columns: 7rem minmax(8rem, 12rem) minmax(0, 1fr) auto 9rem 5.5rem; - border-bottom: 1px solid var(--line); padding: 0 0.25rem; font-size: 0.88rem; -} -.commit-row:hover { background: var(--color-overlay-hover); } -.commit-when { color: var(--muted); font-variant-numeric: tabular-nums; } -.commit-who, .commit-subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.commit-tags { display: flex; gap: 0.3rem; } -.commit-size { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } -.commit-size .added { color: var(--color-success); } -.commit-size .removed { color: var(--color-error); } -.commit-id { font-family: var(--mono); font-size: 0.82rem; color: var(--color-text-accent); text-align: right; } -@media (max-width: 760px) { - .commit-row { grid-template-columns: 5.5rem minmax(0, 1fr) 4.5rem; } - .commit-who, .commit-tags, .commit-size { display: none; } -} - -.dialog-body { padding: 0 1.25rem 1.25rem; } - -.cols { position: relative; display: flex; flex-direction: column; padding: 16px 0 0 38px; box-sizing: border-box; } -.cols-plot { position: relative; flex: 1; min-height: 0; } -.cols-grid { position: absolute; left: 0; right: 0; height: 0; border-top: 1px solid var(--line); } -.cols-grid span { position: absolute; right: calc(100% + 6px); transform: translateY(-50%); font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } -.cols-bars { position: absolute; inset: 0; display: flex; align-items: stretch; gap: 1px; } -.cols-bin { flex: 1; min-width: 0; display: flex; flex-direction: column-reverse; border-radius: 2px 2px 0 0; } -.cols-bin:hover { background: var(--color-overlay-hover); } -.cols-bin span { display: block; width: 100%; min-height: 1px; } -.cols-bin span:last-child { border-radius: 2px 2px 0 0; } -.cols-mark { position: absolute; top: -12px; bottom: 0; width: 0; border-left: 1px dashed var(--text-2); opacity: 0.55; z-index: 1; } -.cols-mark::after { content: ""; position: absolute; left: -5px; width: 10px; top: 0; bottom: 0; } -.cols-mark:hover { opacity: 1; } -.cols-mark span { position: absolute; left: 3px; top: -4px; font-size: 10.5px; color: var(--text-2); white-space: nowrap; } -.cols-axis { position: relative; height: 20px; } -.cols-axis span { position: absolute; top: 4px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; } -.fluid { display: block; width: 100%; height: auto; overflow: visible; } -.face { display: inline-flex; flex: none; } - -.overview-chart { height: 262px; } -.contributors { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; height: 380px; } -.contributors li { display: grid; grid-template-columns: minmax(0, 1fr) auto 3rem auto; gap: 0.75rem; align-items: center; height: 38px; border-bottom: 1px solid var(--line); font-size: 0.9rem; } -.contributors li:last-child { border-bottom: none; } -.contributors .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } -.lines-changed { min-width: 6.5rem; } -.added { color: var(--color-success); } -.removed { color: var(--color-error); } -.hit { fill: transparent; } -g:hover > .hit { fill: var(--text); fill-opacity: 0.05; } -.swatch { display: inline-block; width: 0.65rem; height: 0.65rem; border-radius: 2px; flex: none; } -@media (max-width: 760px) { - .screen-bar { grid-template-columns: minmax(0, 1fr); } - .windows { flex-wrap: wrap; } - .cols-mark span { display: none; } - .contributors li { grid-template-columns: minmax(0, 1fr) auto auto; } - .contributors .lines-changed { display: none; } -} - -.profile-hero { display: flex; gap: 1.25rem; align-items: flex-start; padding: 1.5rem 0 1rem; } -.profile-hero > :first-child { flex: none; } -.profile-title { margin: 0; font-size: 2rem; line-height: 1.15; letter-spacing: -0.02em; } -.profile-login { margin: 0.15rem 0 0; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; font-size: 1rem; } -.profile-login a { color: var(--text-2); text-decoration: none; } -.profile-bio { margin: 0.5rem 0 0; max-width: 46rem; } -.profile-facts { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin: 0.5rem 0 0; } -.profile-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; } -.profile-tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); } -@media (max-width: 760px) { .profile-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -.repo-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; } -.repo-row { display: grid; grid-template-columns: 32px minmax(0, 1fr) 4.5rem 4.5rem 4rem 6.5rem; gap: 0.6rem; align-items: center; height: 60px; border-bottom: 1px solid var(--line); font-size: 0.9rem; } -.repo-head-row { height: 28px; } -@media (max-width: 760px) { .repo-row { grid-template-columns: 32px minmax(0, 1fr) 4rem 4rem; } .hide-narrow { display: none; } } -.repo-row:last-child { border-bottom: none; } -.repo-row-name { font-weight: 600; text-decoration: none; color: var(--text) !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } -.repo-row-meta { display: flex; gap: 0.75rem; align-items: center; flex-wrap: nowrap; overflow: hidden; white-space: nowrap; } -.repo-row-numbers { display: flex; flex-direction: column; align-items: flex-end; font-size: 0.86rem; font-variant-numeric: tabular-nums; text-align: right; } -.lang { display: inline-flex; align-items: center; gap: 0.3rem; } -.lang-dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; display: inline-block; } -.lang-years { list-style: none; padding: 0; margin: 0.25rem 0 0; display: flex; flex-direction: column; gap: 0.35rem; } -.lang-years li { display: grid; grid-template-columns: 3rem 1fr; gap: 0.6rem; align-items: center; } -.lang-year { font-size: 0.82rem; color: var(--muted); font-variant-numeric: tabular-nums; } -.lang-bar { display: flex; height: 0.85rem; gap: 2px; } -.lang-bar span { display: block; min-width: 2px; } -.lang-bar span:first-child { border-radius: 4px 0 0 4px; } -.lang-bar span:last-child { border-radius: 0 4px 4px 0; } -.partners { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; } -.partners li { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 0.6rem; align-items: center; height: 44px; border-bottom: 1px solid var(--line); } -.partners li:last-child { border-bottom: none; } -.partners .note { text-align: right; } -@media (max-width: 760px) { - .profile-hero { flex-wrap: wrap; } - .profile-title { font-size: 1.5rem; } - .repo-row-numbers .small { display: none; } -} -.engine-row { grid-template-columns: 32px minmax(0, 1fr) 4.5rem 7rem minmax(6rem, auto) 4.5rem; } -@media (max-width: 760px) { .engine-row { grid-template-columns: 32px minmax(0, 1fr) 4rem minmax(5rem, auto); } } -.standing-top { margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--color-text-accent); } -.standings-wrap { overflow-x: auto; } -.standings { border-collapse: collapse; width: 100%; font-size: 0.9rem; } -.standings th, .standings td { padding: 0 0.55rem; height: 40px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; } -.standings th { color: var(--text-2); font-weight: 600; font-size: 0.82rem; } -.standings .place { width: 2.5rem; color: var(--muted); font-variant-numeric: tabular-nums; } -.standings td.on { font-weight: 600; } -.standings .standing-you td { background: var(--color-accent-muted); } -.standings .works-on { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); } -.sorter { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 0.2em; } -.sorter.on { color: var(--text); text-decoration: underline; } -.work-month { display: flex; flex-direction: column; gap: 0.75rem; } -.work-month-title { font-size: 1.15rem; margin: 0.5rem 0 0; } -.work-repo { border: 1px solid var(--line); border-radius: var(--radius-container); padding: 0.75rem 1rem; background: var(--color-background-surface); } -.work-repo-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.4rem; } -.work-repo-name { font-weight: 600; color: var(--text) !important; text-decoration: none; } -.work-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } -.work-items li { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr) auto 6.5rem; gap: 0.6rem; align-items: baseline; padding: 0.3rem 0; border-top: 1px solid var(--line); font-size: 0.9rem; } -.work-items a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.work-tag { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 0.82rem; } -.work-when { font-variant-numeric: tabular-nums; } -@media (max-width: 760px) { .work-items li { grid-template-columns: 4rem minmax(0, 1fr); } .work-items .num, .work-items .work-when { display: none; } } -.archetype { display: flex; flex-direction: column; gap: 0.3rem; } -.archetype-title { font-size: 2rem; letter-spacing: -0.02em; line-height: 1.1; } -.archetype p { margin: 0; } -.rules { display: flex; flex-direction: column; gap: 0.4rem; margin: 0.5rem 0 0; } -.rules div { display: grid; grid-template-columns: 9rem 1fr; gap: 0.5rem; } -.rules dt { font-weight: 600; } -.rules dd { margin: 0; color: var(--text-2); } -.achievements { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; } -.achievement { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.75rem; align-items: center; min-height: 48px; border-bottom: 1px solid var(--line); color: var(--text-2); } -.achievement:last-child { border-bottom: none; } -.achievement.earned { color: var(--text); } -.achievement > span:nth-child(2) { display: flex; flex-direction: column; } -.medal { width: 1.6rem; height: 1.6rem; border-radius: 50%; border: 0.42rem solid var(--color-background-muted); background: var(--surface); box-sizing: border-box; } -.achievement.earned .medal { border-color: #00b82b; } +.added { color: var(--added); } +.removed { color: var(--removed); } + +@keyframes rise { from { opacity: 0; transform: translateY(6px); } } +@keyframes fade { from { opacity: 0; } } +.rise { animation: rise 0.5s var(--ease-out) backwards; } +.fade { animation: fade 0.4s ease-out backwards; } +@media (prefers-reduced-motion: reduce) { .rise, .fade { animation: none; } } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9b474dc..a5516d1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,26 +2,25 @@ export { default as App } from "./App"; export { Key } from "./components/Key"; export { LinesSkeleton, ScreenSkeleton } from "./components/Loading"; export { Logo } from "./components/Logo"; -export { Tile } from "./components/Tile"; export { dataQuery, SourceContext, useData, useSource } from "./data"; export { paramsOf, primaryRequest, SCREENS, toRoute, toSearch, type Go, type Route, type Screen, type Search } from "./route"; export { commitscapeTheme, MODE_COOKIE, MODES, ModeContext, modeOf, themeCss, useMode, type Mode } from "./theme"; export { Face, type FaceSize } from "./components/Face"; export { avatarUrl } from "./components/avatar"; -export { TilesSkeleton } from "./components/Loading"; export { SCREEN_SKELETONS } from "./components/skeletons"; -export { Activity as ProfileActivity, LanguagesByYear, Partners, ProfileHero, ProfileTotals, PullRequestTiles, Repositories, REPOS_SHOWN, YearCalendar } from "./profile/sections"; -export { Figure } from "./charts/common"; -export { Columns } from "./charts/Columns"; -export { Calendar } from "./charts/Grid"; export { TipLayer } from "./charts/Tip"; -export { compact, grouped, many, share } from "./format"; -export { EngineRepos, SurvivingTile } from "./profile/engine"; +export { compact, date, day, duration, grouped, many, share } from "./format"; export { survival } from "./profile/survival"; -export { StandingsTable, YourStanding } from "./standings/Standings"; export { placesFor, topLine } from "./standings/places"; export { ACHIEVEMENT_CARD_SIZE, ARCHETYPE_CARD_SIZE, WRAPPED_CALENDAR_CARD_SIZE, WRAPPED_CARD_SIZE, CARDS, PERSON_CARDS, STANDING_CARD_SIZE, type CardSpec } from "./cards/kinds"; export { STILL, type Paint } from "./cards/paint"; export { CARD_DESIGN, TOKENS, type CardTheme } from "./cards/tokens"; export { WorkView } from "./work/WorkView"; -export { Traits } from "./profile/traits"; +export { AddedRemoved, LangDot, Meter, Page, PageHead, Panel, PanelSkeleton, Stat } from "./kit/layout"; +export { A } from "./kit/A"; +export { ArchetypePill, Cell, CommitClock, HeadlineNumbers, hours, LanguagesOverTime, LastYear, OverTheYears, PeoplePanel, ProfileHeader, PullRequestNumbers, RepositoryTable, SurvivalPanel, SurvivingNumber, TraitsPanel } from "./profile/view"; +export { YearGrid } from "./charts/Year"; +export { AreaTrend, StackedColumns, type Point, type Series } from "./charts/Trend"; +export { BACKGROUNDS, CORNERS, DEFAULT_STYLE, hexOf, isOffered, lookOf, parseStyle, PRESETS, styleKey, styleQuery, SWATCHES, type Background, type CardStyle, type Corner, type PresetId } from "./cards/style"; +export { Leaderboard, NextUp, PlaceCards, viewsOf } from "./standings/view"; +export { periodWords, WorkSkeleton } from "./work/WorkView"; diff --git a/packages/ui/src/keys.ts b/packages/ui/src/keys.ts index a148ed3..d880b11 100644 --- a/packages/ui/src/keys.ts +++ b/packages/ui/src/keys.ts @@ -12,6 +12,11 @@ export function typing(target: EventTarget | null): boolean { return !["checkbox", "radio", "button", "submit", "reset", "range", "color"].includes(type); } +function inDialog(target: EventTarget | null): boolean { + return target instanceof Element && !!target.closest('[role="dialog"], [aria-modal="true"]'); +} + +/** Runs a page's keyboard shortcuts before anything else on the page hears the key, outside dialogs and text boxes. */ export function useShortcuts(shortcuts: Shortcuts) { const current = useRef(shortcuts); useLayoutEffect(() => { @@ -19,11 +24,12 @@ export function useShortcuts(shortcuts: Shortcuts) { }); useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.defaultPrevented) return; + if (e.defaultPrevented || inDialog(e.target)) return; if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k") { const run = current.current["mod+k"]; if (run) { e.preventDefault(); + e.stopPropagation(); run(e); } return; @@ -32,11 +38,12 @@ export function useShortcuts(shortcuts: Shortcuts) { const run = current.current[e.key]; if (run) { e.preventDefault(); + e.stopPropagation(); run(e); } }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); }, []); } diff --git a/packages/ui/src/kit/A.tsx b/packages/ui/src/kit/A.tsx new file mode 100644 index 0000000..3569479 --- /dev/null +++ b/packages/ui/src/kit/A.tsx @@ -0,0 +1,8 @@ +import type { AnchorHTMLAttributes } from "react"; +import { useLinkComponent } from "@astryxdesign/core/Link"; + +/** An anchor that uses the page's router for the Site's own addresses. */ +export function A({ href, ...rest }: AnchorHTMLAttributes & { href: string }) { + const Component = useLinkComponent(); + return ; +} diff --git a/packages/ui/src/kit/layout.tsx b/packages/ui/src/kit/layout.tsx new file mode 100644 index 0000000..9129db3 --- /dev/null +++ b/packages/ui/src/kit/layout.tsx @@ -0,0 +1,112 @@ +import type { ReactNode } from "react"; +import { Card } from "@astryxdesign/core/Card"; +import { Heading } from "@astryxdesign/core/Heading"; +import { Skeleton } from "@astryxdesign/core/Skeleton"; + +const WIDTHS = { default: "max-w-[var(--page)]", narrow: "max-w-3xl", wide: "max-w-[88rem]" } as const; + +/** A page's column: centred, padded, and as wide as its kind of page. */ +export function Page({ children, width = "default", className = "" }: { children: ReactNode; width?: keyof typeof WIDTHS; className?: string }) { + return
          {children}
          ; +} + +/** A page's title block: what it is, a line about it, and what can be done from it. */ +export function PageHead({ eyebrow, title, description, actions, media }: { eyebrow?: ReactNode; title: ReactNode; description?: ReactNode; actions?: ReactNode; media?: ReactNode }) { + return ( +
          +
          + {media} +
          + {eyebrow &&
          {eyebrow}
          } + + {title} + + {description &&

          {description}

          } +
          +
          + {actions &&
          {actions}
          } +
          + ); +} + +/** One section of a page: a titled surface with an explanation and its own actions. */ +export function Panel({ title, description, actions, children, id, className = "", padding = 5, bare = false }: { title?: ReactNode; description?: ReactNode; actions?: ReactNode; children: ReactNode; id?: string; className?: string; padding?: 0 | 4 | 5 | 6; bare?: boolean }) { + const head = (title || actions) && ( +
          +
          + {title && ( + + {title} + + )} + {description &&
          {description}
          } +
          + {actions &&
          {actions}
          } +
          + ); + if (bare) + return ( +
          + {head} + {children} +
          + ); + return ( +
          + +
          + {head} + {children} +
          +
          +
          + ); +} + +/** A Panel's stand-in while its data streams in, the same size as what will replace it. */ +export function PanelSkeleton({ title, height, className = "" }: { title?: string; height: number; className?: string }) { + return ( + } className={className}> + + + ); +} + +/** A number with what it counts beneath it; the large ones lead a page. */ +export function Stat({ value, label, note, size = "md", tone, children }: { value: ReactNode; label: ReactNode; note?: ReactNode; size?: "lg" | "md" | "sm"; tone?: "brand"; children?: ReactNode }) { + const big = size === "lg" ? "text-[clamp(1.9rem,3.6vw,2.6rem)]" : size === "md" ? "text-[1.6rem]" : "text-[1.25rem]"; + return ( +
          + {value} + {label} + {note && {note}} + {children} +
          + ); +} + +/** A thin bar showing how much of a whole something is. */ +export function Meter({ value, label, tone = "brand", className = "" }: { value: number; label: string; tone?: "brand" | "neutral" | "added"; className?: string }) { + const colour = tone === "brand" ? "bg-brand" : tone === "added" ? "bg-added" : "bg-[var(--color-text-secondary)]"; + return ( + + 0 ? 2 : 0, Math.min(100, value * 100))}%` }} /> + + ); +} + +/** Lines added and removed side by side, as a split bar. */ +export function AddedRemoved({ added, removed, className = "" }: { added: number; removed: number; className?: string }) { + const all = Math.max(1, added + removed); + return ( + + + + + ); +} + +/** A coloured dot for a programming language, as GitHub colours it. */ +export function LangDot({ colour }: { colour: string | null }) { + return ; +} diff --git a/packages/ui/src/profile/engine.tsx b/packages/ui/src/profile/engine.tsx deleted file mode 100644 index eb6d4ba..0000000 --- a/packages/ui/src/profile/engine.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { EngineRepo, EngineView } from "@commitscape/data"; -import { Figure } from "../charts/common"; -import { Face } from "../components/Face"; -import { Tile } from "../components/Tile"; -import { compact, grouped, many } from "../format"; -import { survival } from "./survival"; - -const WORDS: Record = { - counting: "counting…", - counted: "", - over_budget: "not counted: too big to count in time", - not_counted: "not counted: its history is read without old files", - failed: "not counted: the count failed", - stale: "counting…", -}; - - -/** The headline Surviving Lines, across the repositories commitscape has read. */ -export function SurvivingTile({ engine }: { engine: EngineView }) { - const counted = engine.repos.filter((r) => r.surviving.status === "counted").length; - if (engine.repos.length === 0) return ; - if (engine.surviving === null) return ; - const share = survival(engine.surviving, engine.added); - return ( - 0 ? `in ${grouped(counted)} of ${grouped(engine.repos.length)} read, counting…` : `${share ? `${share} of what they added, ` : ""}${many(counted, "repository", "repositories")}`} - /> - ); -} - -/** Each repository commitscape has read that the person committed to: their Lines Changed and Surviving Lines from its history. */ -export function EngineRepos({ engine, login }: { engine: EngineView; login: string }) { - if (engine.repos.length === 0) return null; - return ( -
          -
            -
          1. - - Repository - commits - lines changed - still run - survival -
          2. - {engine.repos.map((r) => ( -
          3. - - - {r.owner}/ - {r.name} - - {grouped(r.commits)} - - {r.linesAdded === null ? ( - — - ) : ( - <> - +{compact(r.linesAdded)} −{compact(r.linesRemoved ?? 0)} - - )} - - {r.surviving.status === "counted" && r.surviving.lines !== null ? grouped(r.surviving.lines) : {WORDS[r.surviving.status]}} - {survival(r.surviving.lines, r.surviving.added) ?? —} -
          4. - ))} -
          -
          - ); -} diff --git a/packages/ui/src/profile/sections.tsx b/packages/ui/src/profile/sections.tsx deleted file mode 100644 index 6cfd907..0000000 --- a/packages/ui/src/profile/sections.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { Avatar } from "@astryxdesign/core/Avatar"; -import { Badge } from "@astryxdesign/core/Badge"; -import { Button } from "@astryxdesign/core/Button"; -import { Heading } from "@astryxdesign/core/Heading"; -import type { Identity, Profile, ProfileRepo } from "@commitscape/data"; -import { Columns } from "../charts/Columns"; -import { Figure, Legend, TableView } from "../charts/common"; -import { Calendar } from "../charts/Grid"; -import { useTip } from "../charts/tip"; -import { avatarUrl } from "../components/avatar"; -import { Face } from "../components/Face"; -import { Tile } from "../components/Tile"; -import { compact, grouped, many } from "../format"; - -const DAY = 86_400; - -export const REPOS_SHOWN = 8; - -function hours(h: number): string { - if (h < 1) return `${Math.max(1, Math.round(h * 60))} min`; - if (h < 48) return `${Math.round(h)} h`; - return `${Math.round(h / 24)} days`; -} - -function joined(iso: string): string { - return new Date(iso).toLocaleDateString("en-GB", { month: "long", year: "numeric", timeZone: "UTC" }); -} - -/** A person's name, face and what GitHub says of them, drawn at once from their identity. */ -export function ProfileHero({ identity, children, badges }: { identity: Identity; children?: ReactNode; badges?: ReactNode }) { - const facts = [identity.company, identity.location, `on GitHub since ${joined(identity.createdAt)}`, identity.followers > 0 ? many(identity.followers, "follower", "followers") : null].filter(Boolean); - return ( -
          - -
          - - {identity.name ?? identity.login} - -

          - @{identity.login} - {badges} -

          - {identity.bio &&

          {identity.bio}

          } -

          - {facts.map((f) => ( - {f} - ))} -

          -
          - {children &&
          {children}
          } -
          - ); -} - -/** The headline numbers of a Profile, each saying what it counts; `slow` is where the numbers from its pull requests stream in. */ -export function ProfileTotals({ profile, slow, extra }: { profile: Profile; slow?: ReactNode; extra?: ReactNode }) { - const t = profile.totals; - return ( -
          - - {extra} - - - {slow ?? } - - 0 ? `${many(t.currentStreak, "day", "days")} running now` : "of days in a row"} /> -
          - ); -} - -/** The two headline numbers that need a person's pull requests read. */ -export function PullRequestTiles({ profile }: { profile: Profile }) { - const t = profile.totals; - const capped = profile.read.prs < profile.read.prsTotal; - return ( - <> - - - - ); -} - -/** The contribution calendar of the last year. */ -export function YearCalendar({ profile }: { profile: Profile }) { - const { firstDay, days } = profile.calendar; - const from = Math.max(0, days.length - 364 - (((firstDay + days.length - 1 + 4) % 7) + 1)); - const shown = days.slice(from); - const total = shown.reduce((a, b) => a + b, 0); - return ( -
          - -
          - ); -} - -/** Contributions over every year the person has been on GitHub. */ -export function Activity({ profile }: { profile: Profile }) { - return ( -
          -
          - -
          -
          - ); -} - -function RepoRow({ r, login, onStanding }: { r: ProfileRepo; login: string; onStanding?: (r: ProfileRepo) => ReactNode }) { - const n = (v: number) => (v > 0 ? grouped(v) : —); - return ( -
        • - -
          - - {r.owner}/ - {r.name} - -
          - {r.language && ( - - - {r.language} - - )} - {r.stars > 0 && {many(r.stars, "star", "stars")}} - {r.private && } - {onStanding?.(r)} -
          -
          - {n(r.prsMerged)} - {n(r.commits)} - {n(r.reviews)} - - {r.linesAdded + r.linesRemoved > 0 ? ( - <> - +{compact(r.linesAdded)} −{compact(r.linesRemoved)} - - ) : ( - — - )} - -
        • - ); -} - -/** Where a person's work is: each repository with their merged pull requests, commits and reviews in it. */ -export function Repositories({ profile, onStanding }: { profile: Profile; onStanding?: (r: ProfileRepo) => ReactNode }) { - const [all, setAll] = useState(false); - const list = profile.repositories; - const shown = all ? list : list.slice(0, REPOS_SHOWN); - return ( -
          - {list.length === 0 ? ( -

          No public work on GitHub yet.

          - ) : ( -
            -
          1. - - Repository - merged PRs - commits - reviews - lines merged -
          2. - {shown.map((r) => ( - - ))} -
          - )} - {list.length > REPOS_SHOWN &&
          - ); -} - -const LANGUAGE_COLOURS = 6; - -/** The languages of the repositories a person committed to, year by year, weighted by their commits. */ -export function LanguagesByYear({ profile }: { profile: Profile }) { - const tip = useTip(); - const totals = new Map(); - for (const y of profile.years) for (const l of y.languages) totals.set(l.name, (totals.get(l.name) ?? 0) + l.commits); - const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n); - const named = ranked.slice(0, LANGUAGE_COLOURS); - const colour = (name: string) => (named.includes(name) ? `var(--s${named.indexOf(name) + 1})` : "var(--other)"); - const years = profile.years.filter((y) => y.languages.length > 0); - return ( -
          - {years.length === 0 ? ( -

          No commits on GitHub to tell from.

          - ) : ( - <> - ({ label: n, colour: colour(n) })), ...(ranked.length > named.length ? [{ label: "Other", colour: "var(--other)" }] : [])]} /> -
            - {years.map((y) => { - const sum = y.languages.reduce((n, l) => n + l.commits, 0); - const other = y.languages.filter((l) => !named.includes(l.name)).reduce((n, l) => n + l.commits, 0); - const parts = [...y.languages.filter((l) => named.includes(l.name)).map((l) => ({ name: l.name, commits: l.commits })), ...(other > 0 ? [{ name: "Other", commits: other }] : [])]; - return ( -
          1. - {y.year} - - {parts.map((p) => ( - {y.year}: {p.name}, {many(p.commits, "commit", "commits")})} /> - ))} - -
          2. - ); - })} -
          - y.languages.map((l) => [String(y.year), l.name, l.commits]))} /> - - )} -
          - ); -} - -/** The people who review a person's pull requests, and whose pull requests they review. */ -export function Partners({ profile }: { profile: Profile }) { - return ( -
          - {profile.partners.length === 0 ? ( -

          No reviews either way yet.

          - ) : ( -
            - {profile.partners.slice(0, 10).map((p) => ( -
          • - - {p.login} - - {[p.reviewedYours > 0 && `${many(p.reviewedYours, "review", "reviews")} from them`, p.reviewedTheirs > 0 && `${many(p.reviewedTheirs, "review", "reviews")} to them`].filter(Boolean).join(" · ")} - -
          • - ))} -
          - )} -
          - ); -} diff --git a/packages/ui/src/profile/traits.tsx b/packages/ui/src/profile/traits.tsx deleted file mode 100644 index 3d6a86e..0000000 --- a/packages/ui/src/profile/traits.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { ARCHETYPES, type Achievement, type Archetype } from "@commitscape/data"; -import { Figure } from "../charts/common"; - -/** A person's Archetype with the rule that gave it, every rule on request, and their Achievements, the next ones showing what reaches them. */ -export function Traits({ archetypes, achievements, login, complete }: { archetypes: Archetype[]; achievements: Achievement[]; login: string; complete: boolean }) { - const main = archetypes[0]; - const earned = achievements.filter((a) => a.earned); - const next = achievements.filter((a) => !a.earned); - return ( -
          -
          -
          - {main ? main.title : "Not decided yet"} -

          {main ? main.rule : "None of the rules fits yet. They need 50 contributions or more, and then each its own threshold."}

          - {archetypes.length > 1 &&

          Also: {archetypes.slice(1).map((a) => a.title).join(", ")}

          } - {!complete &&

          Some rules wait for the pull requests to be read.

          } -
          -
          - Every rule, in the order they are tried -
          - {ARCHETYPES.map((a) => ( -
          -
          {a.title}
          -
          {a.rule}
          -
          - ))} -
          -
          -
          -
          -
            - {earned.map((a) => ( -
          • - - - {a.title} - {[a.detail, a.at ? `reached ${a.at}` : null].filter(Boolean).join(" · ") || a.rule} - - - Card - -
          • - ))} - {next.map((a) => ( -
          • - - - {a.title} - {a.rule} - -
          • - ))} -
          -
          -
          - ); -} diff --git a/packages/ui/src/profile/view.tsx b/packages/ui/src/profile/view.tsx new file mode 100644 index 0000000..6bb150b --- /dev/null +++ b/packages/ui/src/profile/view.tsx @@ -0,0 +1,650 @@ +import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { Button } from "@astryxdesign/core/Button"; +import { HoverCard } from "@astryxdesign/core/HoverCard"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; +import { Spinner } from "@astryxdesign/core/Spinner"; +import { Award, Building2, CalendarDays, Flame, Lock, MapPin, Users } from "lucide-react"; +import { ARCHETYPES, type Achievement, type Archetype, type EngineRepo, type EngineView, type Identity, type Profile, type ProfileRepo } from "@commitscape/data"; +import { AreaTrend, StackedColumns, type Point } from "../charts/Trend"; +import { useTip } from "../charts/tip"; +import { YearGrid } from "../charts/Year"; +import { avatarUrl } from "../components/avatar"; +import { Face } from "../components/Face"; +import { compact, day, grouped, many } from "../format"; +import { A } from "../kit/A"; +import { AddedRemoved, LangDot, Meter, Page, Panel, Stat } from "../kit/layout"; +import { survival } from "./survival"; + +const DAY = 86_400; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +export function hours(h: number): string { + if (h < 1) return `${Math.max(1, Math.round(h * 60))} min`; + if (h < 48) return `${Math.round(h)} h`; + return `${Math.round(h / 24)} days`; +} + +function joined(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { month: "short", year: "numeric", timeZone: "UTC" }); +} + +/** A person's face, name, Archetype and what GitHub says of them, over a glow made from their own avatar. */ +export function ProfileHeader({ identity, archetype, also = [], badges, actions }: { identity: Identity; archetype?: Archetype | null; also?: string[]; badges?: ReactNode; actions?: ReactNode }) { + const name = identity.name ?? identity.login; + const facts: [typeof MapPin, string][] = [ + ...(identity.company ? [[Building2, identity.company] as [typeof MapPin, string]] : []), + ...(identity.location ? [[MapPin, identity.location] as [typeof MapPin, string]] : []), + [CalendarDays, `Joined GitHub ${joined(identity.createdAt)}`], + ...(identity.followers > 0 ? [[Users, many(identity.followers, "follower", "followers")] as [typeof MapPin, string]] : []), + ]; + return ( +
          + +
          + +
          +
          +

          {name}

          + {archetype && } + {badges} +
          +
          + + @{identity.login} + + {facts.map(([Glyph, text]) => ( + + + {text} + + ))} +
          + {identity.bio &&

          {identity.bio}

          } +
          +
          + {actions &&
          {actions}
          } +
          +
          + ); +} + +/** A person's Archetype as a small label, its rule on hover. */ +export function ArchetypePill({ archetype, also }: { archetype: Archetype; also: string[] }) { + return ( + + Archetype, by a written rule + {archetype.title} + {archetype.rule} + {also.length > 0 && Also: {also.join(", ")}} +
          + } + > + + + ); +} + +/** The headline numbers: four large, each saying what it counts, and four smaller beneath; the slower ones arrive in their own slots. */ +export function HeadlineNumbers({ profile, surviving, slow }: { profile: Profile; surviving: ReactNode; slow: ReactNode }) { + const t = profile.totals; + const rate = t.prsOpened > 0 ? t.prsMerged / t.prsOpened : 0; + return ( +
          +
          + + 0 ? `${Math.round(rate * 100)}% of ${grouped(t.prsOpened)} opened` : "none opened yet"}> + + + + + + + {surviving} + + 0 ? `with ${grouped(t.hidden)} private contributions` : "as GitHub counts them"} /> + +
          +
          + {slow} + + + + + 0 ? `${many(t.currentStreak, "day", "days")} running now` : "days in a row with a contribution"} /> + +
          +
          + ); +} + +/** One slot of the headline numbers. */ +export function Cell({ children, small = false }: { children: ReactNode; small?: boolean }) { + return
          {children}
          ; +} + +/** The two headline numbers that need a person's pull requests read. */ +export function PullRequestNumbers({ profile }: { profile: Profile }) { + const t = profile.totals; + const capped = profile.read.prs < profile.read.prsTotal; + return ( + <> + + +{compact(t.linesAdded)} / −{compact(t.linesRemoved ?? 0)}} label="Lines merged" note={capped ? `in the newest ${grouped(profile.read.prs)} pull requests` : "added and removed in merged pull requests"}> + {t.linesAdded !== null && } + + + + + + + ); +} + +/** The headline Surviving Lines, across the repositories commitscape has read, and how far the count has got. */ +export function SurvivingNumber({ engine, suggest }: { engine: EngineView; suggest?: string | null }) { + const counted = engine.repos.filter((r) => r.surviving.status === "counted").length; + if (engine.repos.length === 0) + return ( + —} label="Lines that still run" note="no repository of theirs read here yet"> + {suggest && ( + + Read {suggest} to count them → + + )} + + ); + if (engine.surviving === null) + return ( + Counting} label="Lines that still run" note={`blaming ${many(engine.repos.length, "repository", "repositories")}; takes a few minutes`} /> + ); + const share = survival(engine.surviving, engine.added); + return ( + 0 ? `${counted} of ${engine.repos.length} repositories counted, more coming` : share ? `${share} of the ${compact(engine.added ?? 0)} they added` : `in ${many(counted, "repository", "repositories")}`}> + {share && engine.added && } + + ); +} + +function lastYear(profile: Profile) { + const { firstDay, days } = profile.calendar; + const from = Math.max(0, days.length - 364 - (((firstDay + days.length - 1 + 3) % 7) + 1)); + return { firstDay: firstDay + from, days: days.slice(from) }; +} + +/** The last year, a square a day, with its busiest day and the streak running now. */ +export function LastYear({ profile }: { profile: Profile }) { + const year = lastYear(profile); + const total = year.days.reduce((a, b) => a + b, 0); + const best = year.days.reduce((b, n, i) => (n > b.n ? { n, i } : b), { n: 0, i: -1 }); + return ( + + {best.i >= 0 && ( + + {grouped(best.n)} + best day, {day(year.firstDay + best.i)} + + )} + + + {profile.totals.currentStreak > 0 && } + {many(profile.totals.currentStreak, "day", "days")} + + streak now + +
          + } + > + + + ); +} + +/** Contributions since they joined: month by month, or year by year by kind. */ +export function OverTheYears({ profile }: { profile: Profile }) { + const [by, setBy] = useState<"month" | "year">("month"); + const months = useMemo(() => { + const list = profile.months.length > 0 ? profile.months : []; + const first = list.findIndex((m) => m.contributions > 0); + return list.slice(Math.max(0, first)).map((m) => { + const [y = "", mm = "01"] = m.month.split("-"); + return { key: m.month, label: `${MONTHS[Number(mm) - 1]} ${y}`, tick: mm === "01" ? y : undefined, contributions: m.contributions }; + }); + }, [profile.months]); + const years = useMemo(() => profile.years.filter((y) => y.commits + y.prs + y.reviews + y.issues > 0).map((y) => ({ key: String(y.year), label: String(y.year), commits: y.commits, prs: y.prs, reviews: y.reviews, issues: y.issues })), [profile.years]); + const since = new Date(profile.calendar.firstDay * DAY * 1000).getUTCFullYear(); + return ( + setBy(v as "month" | "year")}> + + + + } + > + {by === "month" ? ( + + ) : ( + + )} + + ); +} + +const LANGUAGE_COLOURS = 6; + +/** The languages of the repositories a person committed to, year by year, weighted by their commits. */ +export function LanguagesOverTime({ profile }: { profile: Profile }) { + const tip = useTip(); + const totals = new Map(); + for (const y of profile.years) for (const l of y.languages) totals.set(l.name, (totals.get(l.name) ?? 0) + l.commits); + const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n); + const named = ranked.slice(0, LANGUAGE_COLOURS); + const colour = (name: string) => (named.includes(name) ? `var(--s${named.indexOf(name) + 1})` : "var(--other)"); + const years = profile.years.filter((y) => y.languages.length > 0).slice(-10); + return ( + + {years.length === 0 ? ( + No commits on GitHub to tell from yet. + ) : ( +
          +
            + {[...named, ...(ranked.length > named.length ? ["Other"] : [])].map((n) => ( +
          • + + {n} +
          • + ))} +
          +
            + {years.map((y) => { + const sum = y.languages.reduce((n, l) => n + l.commits, 0); + const other = y.languages.filter((l) => !named.includes(l.name)).reduce((n, l) => n + l.commits, 0); + const parts = [...y.languages.filter((l) => named.includes(l.name)).map((l) => ({ name: l.name, commits: l.commits })), ...(other > 0 ? [{ name: "Other", commits: other }] : [])]; + return ( +
          1. + {y.year} + + {parts.map((p, i) => ( + + {p.name} +
            + {y.year}: {Math.round((p.commits * 100) / Math.max(1, sum))}%, {many(p.commits, "commit", "commits")} +
            + , + )} + /> + ))} +
            +
          2. + ); + })} +
          +
          + )} +
          + ); +} + +const NIGHT = new Set([22, 23, 0, 1, 2, 3, 4]); + +/** The hour of day of their newest commits, on each commit's own clock. */ +export function CommitClock({ profile }: { profile: Profile }) { + const tip = useTip(); + const clock = profile.clock; + if (!clock || clock.sampled === 0) + return ( + + GitHub has no commit times to read for them. + + ); + const most = Math.max(1, ...clock.hours); + const night = clock.hours.reduce((n, v, h) => (NIGHT.has(h) ? n + v : n), 0); + const peak = clock.hours.indexOf(Math.max(...clock.hours)); + return ( + +
          +
          + {clock.hours.map((n, h) => ( + {String(h).padStart(2, "0")}:00 to {String((h + 1) % 24).padStart(2, "0")}:00
          {many(n, "commit", "commits")}
          )}> + 0 ? 3 : 1, (n * 100) / most)}%`, opacity: n > 0 ? 1 : 0.25 }} /> + + ))} +
          +
          + {["00", "06", "12", "18", "23"].map((h) => ( + {h}:00 + ))} +
          +

          + Busiest at {String(peak).padStart(2, "0")}:00; {Math.round((night * 100) / clock.sampled)}% between 22:00 and 05:00 . +

          +
          +
          + ); +} + +type Row = { owner: string; name: string; colour: string | null; language: string | null; stars: number; private: boolean; prsMerged: number; commits: number; reviews: number; linesAdded: number; linesRemoved: number; engine: EngineRepo | null }; + +const SHOWN = 8; + +function rowsOf(repos: ProfileRepo[], engine: EngineView | null): Row[] { + const byName = new Map((engine?.repos ?? []).map((e) => [`${e.owner}/${e.name}`.toLowerCase(), e])); + const rows: Row[] = repos.map((r) => { + const key = `${r.owner}/${r.name}`.toLowerCase(); + const e = byName.get(key) ?? null; + byName.delete(key); + return { ...r, engine: e }; + }); + for (const e of byName.values()) rows.push({ owner: e.owner, name: e.name, colour: null, language: null, stars: 0, private: e.private, prsMerged: 0, commits: e.commits, reviews: 0, linesAdded: e.linesAdded ?? 0, linesRemoved: e.linesRemoved ?? 0, engine: e }); + return rows; +} + +/** Where a person's work is: each repository with their pull requests, commits, reviews and lines, and their lines still running where commitscape has read it; each opens their Standing there. */ +export function RepositoryTable({ profile, engine }: { profile: Profile; engine: EngineView | null }) { + const [all, setAll] = useState(false); + const [query, setQuery] = useState(""); + const rows = useMemo(() => rowsOf(profile.repositories, engine), [profile.repositories, engine]); + const filtered = query ? rows.filter((r) => `${r.owner}/${r.name}`.toLowerCase().includes(query.toLowerCase())) : rows; + const shown = all || query ? filtered : filtered.slice(0, SHOWN); + const most = Math.max(1, ...rows.map((r) => r.prsMerged * 4 + r.commits + r.reviews)); + const login = profile.identity.login; + return ( + SHOWN ? ( + setQuery(e.target.value)} + placeholder="Find a repository" + aria-label="Find a repository" + className="h-8 w-48 rounded-[var(--radius-element)] border border-line bg-[var(--color-background-body)] px-2.5 text-sm text-primary outline-none placeholder:text-secondary focus:border-[var(--color-accent)]" + /> + ) : null + } + > + {rows.length === 0 ? ( +
          + No public work on GitHub yet. +
          + ) : ( +
          +
          {h} + {h} +
          + {typeof c === "number" ? grouped(c) : c}
          + + + + + + + + + + + + {shown.map((r) => ( + + ))} + +
          RepositoryMerged PRsCommitsReviewsLines mergedStill running
          +
          + )} + {(filtered.length > SHOWN && !query) || profile.totals.hidden > 0 ? ( +
          + {filtered.length > SHOWN && !query ?
          + ) : null} + + ); +} + +function RepoRow({ r, login, weight }: { r: Row; login: string; weight: number }) { + const n = (v: number) => (v > 0 ? grouped(v) : —); + const e = r.engine; + return ( + + + + + + + {r.owner}/ + {r.name} + + + {r.language && ( + + + {r.language} + + )} + {r.stars > 0 && ★ {compact(r.stars)}} + {r.private && ( + + only you see this + + )} + + + + + + + + {n(r.prsMerged)} + {n(r.commits)} + {n(r.reviews)} + + {r.linesAdded + r.linesRemoved > 0 ? ( + <> + +{compact(r.linesAdded)} −{compact(r.linesRemoved)} + + ) : ( + — + )} + + + + + + ); +} + +function SurvivingCell({ repo }: { repo: EngineRepo | null }) { + if (!repo) return —; + const s = repo.surviving; + if (s.status === "counted" && s.lines !== null) return {compact(s.lines)}; + if (s.status === "failed") return could not count; + return ( + + counting + + ); +} + +/** Lines that still run, repository by repository, against the lines they added there. */ +export function SurvivalPanel({ engine, login }: { engine: EngineView; login: string }) { + if (engine.repos.length === 0) return null; + const top = engine.repos.slice(0, 6); + const most = Math.max(1, ...top.map((r) => r.surviving.added ?? r.surviving.lines ?? 0)); + const share = survival(engine.surviving, engine.added); + return ( + +
          +
          + {engine.surviving === null ? "…" : compact(engine.surviving)} + lines still running + {share && {share} of the {compact(engine.added ?? 0)} lines they added in these repositories} +
          +
            + {top.map((r) => { + const counted = r.surviving.status === "counted" && r.surviving.lines !== null; + return ( +
          • +
            + + + {r.owner}/{r.name} + + + {counted ? ( + <> + {grouped(r.surviving.lines ?? 0)} + {r.surviving.added ? ` of ${grouped(r.surviving.added)} · ${survival(r.surviving.lines, r.surviving.added) ?? ""}` : ""} + + ) : ( + + )} + +
            + + {many(r.commits, "commit", "commits")} + {r.linesAdded !== null && ( + <> + {" · "} + +{compact(r.linesAdded)} −{compact(r.linesRemoved ?? 0)} lines changed + + )} + + + {counted && r.surviving.added ? : null} + {counted ? : } + +
          • + ); + })} +
          +
          +
          + ); +} + +/** Their Archetype, by its written rule, beside every Achievement: reached ones in colour, the rest with what reaches them. */ +export function TraitsPanel({ archetypes, achievements, complete, onCard }: { archetypes: Archetype[]; achievements: Achievement[]; complete: boolean; onCard?: (id: string) => void }) { + const main = archetypes[0]; + const earned = achievements.filter((a) => a.earned); + return ( +
          + +
          + {main ? "They are a" : "Not decided yet"} + {main ? main.title : "No Archetype"} + {main ? main.rule : "None of the rules fits yet. They need 50 contributions, then each rule its own threshold."} +
          + {archetypes.length > 1 &&

          Also {archetypes.slice(1).map((a) => a.title).join(", ")}.

          } + {!complete &&

          Some rules wait for their pull requests to be read.

          } +
          + Every rule, in the order they are tried +
          + {ARCHETYPES.map((a) => ( +
          +
          {a.title}
          +
          {a.rule}
          +
          + ))} +
          +
          +
          + +
            + {achievements.map((a) => ( +
          • + {a.earned ? : } + + {a.title} + {a.earned ? [a.detail, a.at ? `reached ${a.at}` : null].filter(Boolean).join(" · ") || a.rule : a.rule} + + {a.earned && onCard &&
          • + ))} +
          +
          +
          + ); +} + +/** The people who review their pull requests, and whose they review, each a Versus away. */ +export function PeoplePanel({ profile, versus }: { profile: Profile; versus?: (login: string) => string }) { + const people = profile.partners.slice(0, 12); + const most = Math.max(1, ...people.map((p) => p.reviewedTheirs + p.reviewedYours)); + return ( + + {people.length === 0 ? ( + No reviews either way yet. + ) : ( + + )} + {people.length > 0 && ( +
          + + reviews of their pull requests + + + their reviews of others + +
          + )} +
          + ); +} + +function Quiet({ children }: { children: ReactNode }) { + return

          {children}

          ; +} diff --git a/packages/ui/src/screens/Activity.tsx b/packages/ui/src/screens/Activity.tsx index f587b75..7cafff2 100644 --- a/packages/ui/src/screens/Activity.tsx +++ b/packages/ui/src/screens/Activity.tsx @@ -1,105 +1,130 @@ import type { Activity as Data } from "@commitscape/data"; import { useData } from "../data"; -import { Bars } from "../charts/Bars"; -import { Columns } from "../charts/Columns"; -import { Figure } from "../charts/common"; import { WeekGrid } from "../charts/Grid"; import { Lines } from "../charts/Lines"; import { Explain } from "../explain"; -import { grouped, many, share, WINDOW_WORDS } from "../format"; +import { grouped, many, share } from "../format"; +import { Panel } from "../kit/layout"; import { personColour } from "../theme"; +import { BarList, Failed, Quiet, ScreenFrame, StacksOverTime, type Stack, within } from "./kit"; import type { ScreenProps } from "./props"; +const WEEKDAYS = ["Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays", "Sundays"]; + +/** How the work moved: commits over time by person, pull requests and issues, the hours of the week, and the kinds of work. */ export function Activity({ params }: ScreenProps) { const { data: a, error, stale } = useData("/api/activity", params); - if (error || !a) return

          {error}

          ; - const span = WINDOW_WORDS[a.window] ?? a.window; - const series = [ - ...a.people.map((p, i) => ({ - label: p.name, - colour: personColour(p.colour), - values: a.days.map((d) => d[i] ?? 0), - })), - { - label: "Everyone else", - colour: "var(--other)", - values: a.days.map((d) => d[a.people.length] ?? 0), - }, - ].filter((s) => s.values.some((v) => v > 0)); + if (error || !a) return ; + const span = within(a.window); + const stacks: Stack[] = [ + ...a.people.map((p, i) => ({ key: `p${i}`, label: p.name, colour: personColour(p.colour), days: a.days.map((d) => d[i] ?? 0) })), + { key: "rest", label: "Everyone else", colour: "var(--other)", days: a.days.map((d) => d[a.people.length] ?? 0) }, + ].filter((s) => s.days.some((v) => v > 0)); const commits = a.days.reduce((n, d) => n + d.reduce((m, v) => m + v, 0), 0); const told = a.kinds.reduce((n, k) => n + k.commits, 0); + const kinds = told >= (told + a.unclassified) / 4 && told > 0; + const releases = a.releases.filter((r) => { + const d = Math.floor(r.time / 86_400); + return d >= a.first_day && d < a.first_day + a.days.length; + }); + const busiest = busiestHour(a.week); return ( -
          -
          + 0 ? {many(releases.length, "release", "releases")} in this Window : undefined} > - ({ day: Math.floor(r.time / 86_400), label: r.name }))} - /> - - Commits that are not merges, stacked by who made them. Each person keeps one colour on every screen, fixed by - their commits over all of history. Dashed lines are releases: the repository's version tags, and GitHub's - releases once they are read. - -
          + {commits === 0 ? No commits in this Window. Choose a longer one above. : } + Commits that are not merges, stacked by who made them. Each person keeps one colour on every screen, fixed by their commits over all of history. + {a.github && ( -
          -
          + +
          - - - From GitHub's whole history of the repository, read through the gh CLI a page at a time and kept, so later - visits read only what changed. - -
          + From GitHub's history of the repository, read a page at a time and kept, so later Builds read only what changed. + )} -
          -
          - +
          + +
          +
          + +
          + +
          Each commit at the hour its author's clock showed, so a team across time zones still shows its working day. -
          - {told >= (told + a.unclassified) / 4 && told > 0 && ( -
          - ({ + + {kinds && ( + + ({ key: k.kind, label: k.kind, value: k.commits, shown: `${grouped(k.commits)} · ${share(k.commits, told + a.unclassified)}`, + colour: `var(--s${(i % 8) + 1})`, }))} /> - - Judged from the files a commit changed first (only tests, only docs, only CI, only dependency manifests), and - from its message when the files cannot tell. {grouped(a.unclassified)} commits could be told by neither. - -
          + + Judged from the files a commit changed first (only tests, only docs, only CI, only dependency manifests), and from its message when the files cannot tell. {grouped(a.unclassified)} commits could be told by neither. + + )}
          -
          + ); } + +const NIGHT = new Set([22, 23, 0, 1, 2, 3, 4]); + +function Rhythm({ week }: { week: number[][] }) { + const all = week.flat().reduce((a, b) => a + b, 0); + if (all === 0) return null; + const weekend = [5, 6].reduce((n, d) => n + (week[d] ?? []).reduce((a, b) => a + b, 0), 0); + const night = week.reduce((n, hours) => n + hours.reduce((m, v, h) => (NIGHT.has(h) ? m + v : m), 0), 0); + const days = week.map((hours) => hours.reduce((a, b) => a + b, 0)); + const top = days.indexOf(Math.max(...days)); + const facts: [string, string][] = [ + [share(all - weekend, all), "on weekdays"], + [share(weekend, all), "on weekends"], + [share(night, all), "between 22:00 and 05:00"], + [WEEKDAYS[top] ?? "", "the busiest day"], + ]; + return ( +
          + {facts.map(([value, label]) => ( +
          +
          {label}
          +
          {value}
          +
          + ))} +
          + ); +} + +function busiestHour(week: number[][]): string | null { + let best = { n: 0, d: -1, h: -1 }; + week.forEach((hours, d) => hours.forEach((n, h) => n > best.n && (best = { n, d, h }))); + if (best.d < 0) return null; + return `${WEEKDAYS[best.d]} at ${String(best.h).padStart(2, "0")}:00`; +} diff --git a/packages/ui/src/screens/Commits.tsx b/packages/ui/src/screens/Commits.tsx index cdd339f..a124076 100644 --- a/packages/ui/src/screens/Commits.tsx +++ b/packages/ui/src/screens/Commits.tsx @@ -1,9 +1,9 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { Badge } from "@astryxdesign/core/Badge"; import { Selector } from "@astryxdesign/core/Selector"; +import { Skeleton } from "@astryxdesign/core/Skeleton"; import { TextInput } from "@astryxdesign/core/TextInput"; +import { GitMerge } from "lucide-react"; import type { CommitList } from "@commitscape/data"; -import { Figure } from "../charts/common"; import { Key } from "../components/Key"; import { Name } from "../components/Name"; import { useLazyData } from "../data"; @@ -11,14 +11,29 @@ import { Explain } from "../explain"; import { compact, date, grouped, many, WINDOW_WORDS } from "../format"; import { useLeading } from "../leading"; import { searcher } from "../searcher"; +import { Failed, ScreenFrame } from "./kit"; import type { ScreenProps } from "./props"; -import { Skeleton } from "@astryxdesign/core/Skeleton"; -const ROW = 46; -const SHOWN = 14; +export const ROW = 52; +export const SHOWN = 13; const DAY = 86_400; const SPANS: Record = { "30d": 30 * DAY, "90d": 90 * DAY, "1y": 365 * DAY }; +const GRID = "grid grid-cols-[minmax(0,1fr)_4.5rem] items-center gap-3 md:grid-cols-[6.5rem_11rem_minmax(0,1fr)_7.5rem_5rem]"; +const KIND_COLOURS: Record = { + features: "green", + fixes: "red", + docs: "blue", + refactors: "orange", + tests: "purple", + performance: "yellow", + style: "gray", + "build and CI": "teal", + chores: "gray", + reverts: "pink", +}; + +/** Every commit, newest first, searched in the browser by words, person, kind and the Window. */ export function Commits({ meta, route, params, go }: ScreenProps) { const { data: list, error } = useLazyData("/api/commits", {}); const [asked, setAsked] = useState(route.q ?? ""); @@ -31,57 +46,50 @@ export function Commits({ meta, route, params, go }: ScreenProps) { }); const onPerson = useCallback((id: number) => go({ screen: "people", id }), [go]); const [top, setTop] = useState(0); - if (error) return

          {error}

          ; + if (error) return ; if (!list) return ; - const span = route.from !== undefined || route.to !== undefined ? "the dates chosen" : WINDOW_WORDS[String(params.window)] ?? "all time"; + const span = route.from !== undefined || route.to !== undefined ? "the dates chosen" : (WINDOW_WORDS[String(params.window)] ?? "all time"); const first = Math.max(0, Math.floor(top / ROW) - 4); const last = Math.min(found.rows.length, first + SHOWN + 8); const drawn: number[] = []; for (let k = first; k < last; k++) drawn.push(found.rows[k] ?? 0); return ( -
          -
          -
          - - setKind(v)} - options={list.kinds.map((k, i) => ({ value: String(i), label: k }))} - width={180} - /> + +
          +
          +
          +

          Commits

          +

          + {grouped(found.rows.length)} of {many(list.ids.length, "commit", "commits")}, over {span}. Newest first. +

          +
          +
          + + setKind(v)} options={list.kinds.map((k, i) => ({ value: String(i), label: k }))} width={200} /> +
          + {route.folder &&

          The folder filter is left out here: the Commit List does not keep each commit's files.

          }
          - {route.folder && ( -

          The folder filter is left out here: the Commit List does not keep each commit's files.

          - )} -
          setTop(e.currentTarget.scrollTop)} - role="list" - aria-label="Commits found" - > -
          +
          + When + Who + What + Size + Commit +
          +
          setTop(e.currentTarget.scrollTop)} role="list" aria-label="Commits found"> +
          {drawn.map((i, k) => ( ))}
          - {found.rows.length === 0 &&

          No commit matches.

          } + {found.rows.length === 0 &&

          No commit matches. Try fewer words, or a longer Window.

          }
          - - Every word typed must appear in a commit's subject line, or in the name, GitHub login or address of who made it, - in any case. The filters above and the Window narrow it further; the folder filter cannot, as the list keeps - no files. Lines are what the commit added and removed{list.lines ? "" : ", once they are counted"}. - -
          -
          + + + Every word typed must appear in a commit's subject line, or in the name, GitHub login or address of who made it, in any case. The kind and the Window narrow it further; the folder filter cannot, as the list keeps no files. + Lines are what the commit added and removed{list.lines ? "" : ", once they are counted"}. + + ); } @@ -93,10 +101,7 @@ function rangeOf(params: ScreenProps["params"], anchor: number): { from?: number return span === undefined ? {} : { from: anchor - span, to: anchor }; } -function useFound( - list: CommitList | null, - q: { text: string; person?: number; kind?: number; range: { from?: number; to?: number } }, -): { rows: Int32Array } { +function useFound(list: CommitList | null, q: { text: string; person?: number; kind?: number; range: { from?: number; to?: number } }): { rows: Int32Array } { const s = useMemo(() => (list ? searcher(list) : null), [list]); useEffect(() => () => s?.close(), [s]); const [rows, setRows] = useState(new Int32Array()); @@ -105,9 +110,7 @@ function useFound( useEffect(() => { if (!s) return; let current = true; - void s - .run({ text: q.text, person: person === -1 ? -2 : person, kind: q.kind, from: q.range.from, to: q.range.to }) - .then((r) => current && setRows(r)); + void s.run({ text: q.text, person: person === -1 ? -2 : person, kind: q.kind, from: q.range.from, to: q.range.to }).then((r) => current && setRows(r)); return () => { current = false; }; @@ -120,7 +123,7 @@ function SearchBox({ initial, onAsk, go }: { initial: string; onAsk: (text: stri const [text, setText] = useState(initial); const ask = useLeading(onAsk, 100); return ( -
          +
          - + {!text && ( + + + + )}
          ); } -const CommitRow = memo(function CommitRow({ - list, - i, - y, - onPerson, -}: { - list: CommitList; - i: number; - y: number; - onPerson: (id: number) => void; -}) { +const CommitRow = memo(function CommitRow({ list, i, y, onPerson }: { list: CommitList; i: number; y: number; onPerson: (id: number) => void }) { const id = list.ids[i] ?? ""; const who = list.people[list.person[i] ?? 0]?.person; const kind = list.kinds[list.kind[i] ?? 0]; const added = list.added[i]; const removed = list.removed[i]; const when = (list.times[i] ?? 0) + (list.offsets[i] ?? 0) * 60; + const subject = list.subjects[i]; return ( -
          - {date(when)} - - {who && who.id !== 0xffffffff ? : } - - - {list.subjects[i] || (no subject)} +
          + {date(when)} + {who && who.id !== 0xffffffff ? : } + + + {list.merge[i] && } + {kind && kind !== "other" && ( + {kind} + )} + + {subject || (no subject)} + + + + {who?.name ?? "someone unknown"} · {date(when)} + - - {list.merge[i] && } - {kind && kind !== "other" && } - - - {many(list.files[i] ?? 0, "file", "files")} - {added !== null && added !== undefined && ( + + {added !== null && added !== undefined ? ( <> - {" "} - +{compact(added)} −{compact(removed ?? 0)} + +{compact(added)} −{compact(removed ?? 0)} + ) : ( + many(list.files[i] ?? 0, "file", "files") )} {list.link ? ( - - {id.slice(0, 8)} + + {id.slice(0, 7)} ) : ( - {id.slice(0, 8)} + {id.slice(0, 7)} )}
          ); }); -function CommitsLoading() { +const KIND_TONE: Record = { + green: "bg-[color-mix(in_srgb,var(--s3)_16%,transparent)] text-[color-mix(in_srgb,var(--s3)_62%,var(--color-text-primary))]", + red: "bg-[color-mix(in_srgb,var(--s8)_16%,transparent)] text-[color-mix(in_srgb,var(--s8)_62%,var(--color-text-primary))]", + blue: "bg-[color-mix(in_srgb,var(--s1)_16%,transparent)] text-[color-mix(in_srgb,var(--s1)_62%,var(--color-text-primary))]", + purple: "bg-[color-mix(in_srgb,var(--s7)_16%,transparent)] text-[color-mix(in_srgb,var(--s7)_62%,var(--color-text-primary))]", + teal: "bg-[color-mix(in_srgb,var(--s6)_16%,transparent)] text-[color-mix(in_srgb,var(--s6)_62%,var(--color-text-primary))]", + orange: "bg-[color-mix(in_srgb,var(--s2)_16%,transparent)] text-[color-mix(in_srgb,var(--s2)_62%,var(--color-text-primary))]", + yellow: "bg-[color-mix(in_srgb,var(--s4)_16%,transparent)] text-[color-mix(in_srgb,var(--s4)_62%,var(--color-text-primary))]", + pink: "bg-[color-mix(in_srgb,var(--s5)_16%,transparent)] text-[color-mix(in_srgb,var(--s5)_62%,var(--color-text-primary))]", + gray: "bg-[var(--color-background-muted)] text-secondary", +}; + +/** The Commits screen while its list is fetched, the same size as the screen. */ +export function CommitsLoading() { return ( -
          -
          }> -
          - + +
          +
          +
          +

          Commits

          + +
          +
          +
          + +
          +
          + +
          +
          -
          - +
          +
          + {Array.from({ length: SHOWN }, (_, k) => ( +
          + + + + + + + + + + + +
          + ))}
          -
          -
          + + ); } diff --git a/packages/ui/src/screens/Map.tsx b/packages/ui/src/screens/Map.tsx index 27d3130..3a2a091 100644 --- a/packages/ui/src/screens/Map.tsx +++ b/packages/ui/src/screens/Map.tsx @@ -1,29 +1,33 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { Button } from "@astryxdesign/core/Button"; -import { Card } from "@astryxdesign/core/Card"; -import { Selector } from "@astryxdesign/core/Selector"; -import type { File as FileData, MapBlock, MapLevel } from "@commitscape/data"; +import { IconButton } from "@astryxdesign/core/IconButton"; +import { Icon } from "@astryxdesign/core/Icon"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; +import { ChevronLeft, ChevronRight, House, X } from "lucide-react"; +import { NOT_IN_REPORT, type File as FileData, type MapBlock, type MapLevel } from "@commitscape/data"; import { useData, useLazyData } from "../data"; import { TableView } from "../charts/common"; -import { FULL, ranges } from "../charts/scale"; +import { ranges } from "../charts/scale"; import { useTip } from "../charts/tip"; import { squarify, type Rect } from "../charts/treemap"; +import { LinesSkeleton } from "../components/Loading"; import { Name } from "../components/Name"; import { Explain } from "../explain"; import { compact, date, day, duration, grouped, many } from "../format"; import { personColour, ramp } from "../theme"; +import { Failed, ScreenFrame } from "./kit"; import { folderOf, openers, type ScreenProps } from "./props"; -import { ScreenSkeleton, LinesSkeleton } from "../components/Loading"; type Colour = "activity" | "age" | "owner"; const COLOURS: [Colour, string][] = [ - ["activity", "How often it changes"], - ["age", "When it last changed"], - ["owner", "Who changes it most"], + ["activity", "How often"], + ["age", "When last"], + ["owner", "Who"], ]; -const HEIGHT = 540; -const HEADER = 16; +export const WIDE = { w: 1200, h: 540 }; +export const NARROW = { w: 400, h: 560 }; +const HEADER = 22; type Drawn = Rect & { block: MapBlock; depth: number }; @@ -34,53 +38,61 @@ const AGES = [ [Infinity, "a year or more"], ] as const; -export function MapScreen({ meta, params, route, go }: ScreenProps) { - const path = route.path ?? ""; - const { data: level, error, stale } = useData("/api/map", { ...params, path }); - const file = useLazyData(route.file ? "/api/file" : null, { ...params, path: route.file }); - const [colour, setColour] = useState("activity"); - const width = FULL; - const tip = useTip(); - const open = openers(go); - if (error) return

          {error}

          ; - if (!level) return ; - +function layout(children: MapBlock[], w: number, h: number): Drawn[] { const drawn: Drawn[] = []; - for (const r of squarify(level.children, (b) => b.lines, { x: 0, y: 0, w: width, h: HEIGHT })) { + for (const r of squarify(children, (b) => b.lines, { x: 0, y: 0, w, h })) { drawn.push({ ...r, block: r.item, depth: 0 }); if (!r.item.file && r.item.inside.length > 0 && r.w > 30 && r.h > HEADER + 12) { const inner = { x: r.x + 2, y: r.y + HEADER, w: r.w - 4, h: r.h - HEADER - 2 }; for (const c of squarify(r.item.inside, (b) => b.lines, inner)) drawn.push({ ...c, block: c.item, depth: 1 }); } } - const churns = drawn.filter((d) => d.depth === 1 || d.block.file || d.block.inside.length === 0).map((d) => d.block.churn); + return drawn; +} + +function centre(drawn: Drawn[], p: string) { + let best: Drawn | undefined; + for (const d of drawn) { + const b = d.block; + if (b.path === p || (!b.file && p.startsWith(b.path))) { + if (!best || b.path.length > best.block.path.length) best = d; + } + } + return best ? { x: best.x + best.w / 2, y: best.y + best.h / 2 } : null; +} + +/** The code at HEAD as nested blocks sized by lines, coloured by how often, when or by whom it changes; entered a folder at a time, a file opening its details. */ +export function MapScreen({ meta, params, route, go }: ScreenProps) { + const path = route.path ?? ""; + const { data: level, error, stale } = useData("/api/map", { ...params, path }); + const file = useLazyData(route.file ? "/api/file" : null, { ...params, path: route.file }); + const [colour, setColour] = useState("activity"); + const open = openers(go); + if (error || !level) { + const up = path.split("/").filter(Boolean).slice(0, -1); + return ( + 0 ? `Back to ${up.join("/")}/` : "Back to the top"} variant="secondary" size="sm" icon={} onClick={() => open.folder(up.length > 0 ? `${up.join("/")}/` : "")} /> : undefined} + /> + ); + } + + const wide = layout(level.children, WIDE.w, WIDE.h); + const narrow = layout(level.children, NARROW.w, NARROW.h); + const churns = wide.filter((d) => d.depth === 1 || d.block.file || d.block.inside.length === 0).map((d) => d.block.churn); const most = Math.max(1, ...churns); const activityStep = (churn: number) => (churn === 0 ? 0 : Math.max(1, Math.ceil((Math.log1p(churn) / Math.log1p(most)) * 4))); const bounds = [1, 2, 3, 4].map((s) => Math.round(Math.expm1((Math.log1p(most) * s) / 4))); - const ageStep = (last: number) => { - const days = (meta.anchor - last) / 86_400; - return AGES.findIndex(([limit]) => days < limit) + 1; - }; + const ageStep = (last: number) => AGES.findIndex(([limit]) => (meta.anchor - last) / 86_400 < limit) + 1; const fill = (b: MapBlock) => { if (colour === "owner") return b.owner ? personColour(b.owner.colour) : "var(--empty)"; if (colour === "age") return b.last_touched > 0 ? ramp("orange", ageStep(b.last_touched)) : "var(--empty)"; const s = activityStep(b.churn); return s === 0 ? "var(--empty)" : ramp("blue", s); }; - const owners = new Map(); - for (const d of drawn) if (d.block.owner) owners.set(d.block.owner.id, d.block.owner); - - const centre = (p: string) => { - let best: Drawn | undefined; - for (const d of drawn) { - const b = d.block; - if (b.path === p || (!b.file && p.startsWith(b.path))) { - if (!best || b.path.length > best.block.path.length) best = d; - } - } - return best ? { x: best.x + best.w / 2, y: best.y + best.h / 2 } : null; - }; - const chosen = route.file ? centre(route.file) : null; + const owners = new Map>(); + for (const d of wide) if (d.block.owner) owners.set(d.block.owner.id, d.block.owner); const coupled = file.data && !file.stale ? file.data.coupled : []; const crumbs = [{ name: "everything", path: "" }]; @@ -89,257 +101,298 @@ export function MapScreen({ meta, params, route, go }: ScreenProps) { at += `${part}/`; crumbs.push({ name: part, path: at }); } + const tree = (drawn: Drawn[], size: { w: number; h: number }, className: string) => ( + (b.file ? go({ file: b.path }, true) : open.folder(b.path))} /> + ); return ( -
          -
          -
    +
    + +
      + {columns.map((c) => ( +
    • + {c.label}: {c.why} +
    • + ))} +
    + {meta.lines !== "counted" &&

    Lines are {meta.lines === "counting" ? "being counted" : "not counted in this Report"}.

    } +

    {meta.github_history === "complete" ? "— under pull requests means GitHub knows no account for the person." : githubWhy(meta.github, meta.github_history)}

    +
    +
    + - {data.suspects.length > 0 && ( -
    -
      - {data.suspects.map((g, i) => ( -
    • - {g.map((p, j) => ( - - {j > 0 && " · "} - - + {(data.suspects.length > 0 || data.bots.length > 0) && ( +
      + {data.suspects.length > 0 && ( + +
        + {data.suspects.map((g) => ( +
      • p.id).join("-")} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-[var(--radius-element)] border border-line px-3 py-2 text-sm"> + {g.map((p) => ( + + ))} +
      • ))} - - ))} -
      - To join them for good, add their lines to the repository's .mailmap: each profile shows them. -
    + + To join them for good, add their lines to the repository's .mailmap: each person's page shows them. + + )} + {data.bots.length > 0 && ( + + ({ + key: `${b.name}-${b.id}`, + label: b.accounts > 1 ? `${b.name} (${b.accounts} addresses)` : b.name, + lead: , + value: b.commits, + shown: many(b.commits, "commit", "commits"), + colour: "var(--other)", + onClick: () => open.person(b.id), + }))} + /> + + )} +
    )} + + ); +} - {data.bots.length > 0 && ( -
    - ({ - key: b.name, - label: b.accounts > 1 ? `${b.name} (${b.accounts} addresses)` : b.name, - value: b.commits, - colour: "var(--other)", - onClick: () => open.person(b.id), - }))} - /> -
    - )} -
    +function PersonLine({ r, columns, sortKey, most, commits, onOpen }: { r: PersonRow; columns: Column[]; sortKey: string; most: number; commits: number; onOpen: (id: number) => void }) { + const login = useLogin(r.person); + const standing = useStanding(login); + return ( + + + + + + + + {standing && ( + + + + )} + + + + + + + {share(r.commits, commits)} + {r.identities > 1 ? ` · ${r.identities} addresses` : ""} + + + + + + {columns.map((c) => ( + + {c.value(r) === null ? dash : c.shown(r)} + + ))} + + {date(r.first)} – {date(r.last)} + + ); } @@ -193,111 +250,132 @@ function Profile({ params, go, id }: ScreenProps & { id: number }) { const { data: p, error, stale } = useData("/api/person", { ...params, id }); const [copied, setCopied] = useState(false); const login = useLogin(p?.person); + const standing = useStanding(login); const open = openers(go); - if (error || !p) return

    {error}

    ; + if (error || !p) + return ( + } onClick={() => go({ id: undefined })} />} + /> + ); const r = p.row; - const copy = () => { - void navigator.clipboard?.writeText(p.mailmap).then(() => setCopied(true)); - }; + const span = within(params.window === undefined ? "range" : String(params.window)); + const copy = () => void navigator.clipboard?.writeText(p.mailmap).then(() => setCopied(true)); + const total = p.days.reduce((a, b) => a + b, 0); return ( -
    -
    -
    -
    - -
    - - {p.person.name} - -

    - {login && ( - <> - @{login} ·{" "} - - )} - {p.email} -

    +
    +
    + +
    + + {p.person.name} + +

    + {login && ( + + @{login} + + )} + {p.email && {p.email}} + {span} +

    +
    -
    + {standing &&
    +
    + )} + Addresses are joined only on strong evidence: a .mailmap, the same GitHub account, or the same full name. + )} - - Addresses are joined only on strong evidence: a .mailmap, the same GitHub account, or the same full name. - - -
    +
    + ); } diff --git a/packages/ui/src/screens/kit.tsx b/packages/ui/src/screens/kit.tsx new file mode 100644 index 0000000..3f4a299 --- /dev/null +++ b/packages/ui/src/screens/kit.tsx @@ -0,0 +1,160 @@ +import type { ReactNode } from "react"; +import { AreaTrend, StackedColumns, type Point } from "../charts/Trend"; +import { buckets, sum } from "../charts/buckets"; +import { useTip } from "../charts/tip"; +import { day, grouped, WINDOW_WORDS } from "../format"; + +/** A Window in words that follow a count: "in the last 30 days", "over all time". */ +export function within(window: string): string { + if (window === "all") return "over all time"; + if (window === "range") return "over the dates chosen"; + return `in ${WINDOW_WORDS[window] ?? window}`; +} + +/** A screen's column of Panels, faded while a new Window's numbers are on their way. */ +export function ScreenFrame({ stale = false, children, label }: { stale?: boolean; children: ReactNode; label?: string }) { + return ( +
    + {children} +
    + ); +} + +/** A plain line saying there is nothing to show, and why. */ +export function Quiet({ children }: { children: ReactNode }) { + return

    {children}

    ; +} + +/** What went wrong reading an answer, in place of the screen. */ +export function Failed({ words, action }: { words: string | null; action?: ReactNode }) { + return ( + +
    + {words ?? "This part of the Report could not be read."} + {action} +
    +
    + ); +} + +/** A row of numbers in one bordered strip, each saying what it counts. */ +export function NumberStrip({ children, columns = 5 }: { children: ReactNode; columns?: 4 | 5 | 6 | 7 }) { + const lg = { 4: "lg:grid-cols-4", 5: "lg:grid-cols-5", 6: "lg:grid-cols-6", 7: "lg:grid-cols-7" }[columns]; + return
    {children}
    ; +} + +/** One number of a NumberStrip. */ +export function NumberCell({ id, value, label, note, tone }: { id: string; value: ReactNode; label: string; note?: ReactNode; tone?: "brand" }) { + return ( +
    + + {value} + + {label} + {note ?? " "} +
    + ); +} + +export type BarItem = { key: string; label: ReactNode; value: number; shown: ReactNode; colour?: string; lead?: ReactNode; onClick?: () => void; title?: string }; + +/** Ranked bars, each with what it is and its number; rows that can be entered are buttons. */ +export function BarList({ items, label, max }: { items: BarItem[]; label: string; max?: number }) { + const most = max ?? Math.max(1, ...items.map((i) => i.value)); + return ( +
      + {items.map((i) => { + const body = ( + <> + + {i.lead} + {i.label} + + {i.shown} + + 0 ? 1.5 : 0, (i.value * 100) / most)}%`, background: i.colour ?? "var(--s1)" }} /> + + + ); + const grid = "grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1.5 rounded-[var(--radius-element)] px-2 py-2 text-start"; + return ( +
    1. + {i.onClick ? ( + + ) : ( +
      + {body} +
      + )} +
    2. + ); + })} +
    + ); +} + +export type Release = { day: number; label: string }; + +/** Commits over time as one soft area, in periods that suit the span, with its releases as ticks beneath. */ +export function TrendOverTime({ firstDay, days, unit, height = 240, releases }: { firstDay: number; days: number[]; unit: string; height?: number; releases?: Release[] }) { + const list = buckets(firstDay, days.length, true); + const sums = sum(days, firstDay, list); + const points: Point[] = list.map((b, i) => ({ key: b.key, label: b.label, tick: b.tick, value: sums[i] ?? 0 })); + return ( +
    + + {releases && b.from)} />} +
    + ); +} + +function ReleaseRug({ releases, buckets: starts, firstDay, days }: { releases: Release[]; buckets: number[]; firstDay: number; days: number }) { + const tip = useTip(); + const inside = releases.filter((r) => r.day >= firstDay && r.day < firstDay + days); + const n = starts.length; + if (n < 2) return
    ; + const at = (d: number) => { + let i = 0; + while (i + 1 < n && (starts[i + 1] ?? Infinity) <= d) i++; + const from = starts[i] ?? 0; + const to = starts[i + 1] ?? firstDay + days; + return Math.max(0, Math.min(1, (i + (d - from) / Math.max(1, to - from) - 0.5) / (n - 1))); + }; + return ( +
    + {inside.length > 0 && } + {inside.map((r) => ( + + {r.label} +
    released {day(r.day)}
    + , + )} + /> + ))} +
    + ); +} + +export type Stack = { key: string; label: string; colour: string; days: number[] }; + +/** Several day-by-day series stacked into columns, in calendar periods that suit the span. */ +export function StacksOverTime({ firstDay, stacks, unit, height = 260 }: { firstDay: number; stacks: Stack[]; unit: string; height?: number }) { + const length = Math.max(0, ...stacks.map((s) => s.days.length)); + const list = buckets(firstDay, length, false); + const sums = stacks.map((s) => sum(s.days, firstDay, list)); + const points: Point[] = list.map((b, i) => { + const p: Point = { key: b.key, label: b.short }; + stacks.forEach((s, j) => { + p[s.key] = sums[j]?.[i] ?? 0; + }); + return p; + }); + return ({ key: s.key, label: s.label, colour: s.colour }))} unit={unit} height={height} />; +} diff --git a/packages/ui/src/standings/Standings.tsx b/packages/ui/src/standings/Standings.tsx deleted file mode 100644 index b6c94ba..0000000 --- a/packages/ui/src/standings/Standings.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useMemo, useState } from "react"; -import { Button } from "@astryxdesign/core/Button"; -import { ordinal, placesOf, VIEWS, type Place, type StandingRow, type Standings, type View } from "@commitscape/data"; -import { Figure } from "../charts/common"; -import { placesFor, topLine } from "./places"; -import { Face } from "../components/Face"; -import { Tile } from "../components/Tile"; -import { compact, date, grouped, many } from "../format"; - -const SHOWN = 25; - -const STATUS: Record, string> = { - counting: "counting…", - counted: "", - over_budget: "not counted", - not_counted: "not counted", - failed: "not counted", - stale: "counting…", - not_asked: "—", -}; - -function valueOf(r: StandingRow, view: View): string { - if (view === "surviving") return r.surviving !== null ? grouped(r.surviving) : r.survivingStatus ? STATUS[r.survivingStatus] : "—"; - const v = r[view]; - return v === null ? "—" : grouped(v); -} - - - -/** One person's numbers in a repository, and where they stand in each view. */ -export function YourStanding({ standings, row }: { standings: Standings; row: StandingRow }) { - const places = placesFor(standings, row.key); - const top = topLine(standings, places); - const place = (view: View) => { - const p = places.find((x) => x.view === view); - return p ? `${ordinal(p.place)} of ${grouped(p.of)}` : "no place in this view"; - }; - return ( -
    - {top &&

    {top}

    } -
    - - - - - - -
    -
    - ); -} - -/** Everyone's Standings in a repository, a column a view, sorted by the view chosen; never one score. */ -export function StandingsTable({ standings, focus }: { standings: Standings; focus?: string | null }) { - const [by, setBy] = useState("surviving"); - const [all, setAll] = useState(false); - const places = useMemo(() => Object.fromEntries(VIEWS.map((v) => [v.id, placesOf(standings.people, v.id)])) as Record>, [standings]); - const shownViews = VIEWS.filter((v) => standings.people.some((r) => (v.id === "surviving" ? r.survivingStatus !== null : r[v.id] !== null))); - const sorted = [...standings.people].sort((a, b) => (places[by].get(a.key)?.place ?? Infinity) - (places[by].get(b.key)?.place ?? Infinity) || (b.commits ?? 0) - (a.commits ?? 0)); - const focused = focus?.toLowerCase(); - const rows = all ? sorted : sorted.slice(0, SHOWN); - const you = focused ? sorted.findIndex((r) => r.login?.toLowerCase() === focused) : -1; - if (!all && you >= SHOWN && sorted[you]) rows.push(sorted[you]); - const { owner, name } = standings.repo; - return ( -
    0 ? ` ${many(standings.hidden, "person has", "people have")} chosen to stay out.` : ""}`}> -
    - - - - - - {shownViews.map((v) => ( - - ))} - - - - - {rows.map((r) => { - const p = places[by].get(r.key); - return ( - - - - {shownViews.map((v) => ( - - ))} - - - ); - })} - -
    #Person - - Works on
    {p ? p.place : "—"} - - - {r.login ? {r.name} : {r.name}} - - - {valueOf(r, v.id)} - {r.worksOn ?? ""}
    -
    - {sorted.length > SHOWN &&
    - ); -} diff --git a/packages/ui/src/standings/view.tsx b/packages/ui/src/standings/view.tsx new file mode 100644 index 0000000..b04befa --- /dev/null +++ b/packages/ui/src/standings/view.tsx @@ -0,0 +1,183 @@ +import { useMemo, useState, type CSSProperties } from "react"; +import { Button } from "@astryxdesign/core/Button"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; +import { Spinner } from "@astryxdesign/core/Spinner"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { ordinal, placesOf, VIEWS, type Place, type StandingRow, type Standings, type View } from "@commitscape/data"; +import { Face } from "../components/Face"; +import { compact, date, grouped, many } from "../format"; +import { A } from "../kit/A"; +import { Panel } from "../kit/layout"; + +const SHOWN = 20; + +const SHORT: Record = { surviving: "Still running", prsMerged: "PRs merged", reviews: "Reviews", linesAdded: "Lines added", commits: "Commits" }; + +function valueOf(r: StandingRow, view: View): number | null { + return view === "surviving" ? r.surviving : r[view]; +} + +function placesByView(standings: Standings): Record> { + return Object.fromEntries(VIEWS.map((v) => [v.id, placesOf(standings.people, v.id)])) as Record>; +} + +/** The views this repository has numbers for. */ +export function viewsOf(standings: Standings) { + return VIEWS.filter((v) => standings.people.some((r) => (v.id === "surviving" ? r.survivingStatus !== null : r[v.id] !== null))); +} + +function Pending({ status }: { status: StandingRow["survivingStatus"] }) { + if (status === "counting" || status === "stale" || status === "not_asked" || status === null) + return ( + + counting + + ); + return not counted; +} + +/** One person's place in each view of a repository: where they rank, out of how many, and their number. */ +export function PlaceCards({ standings, row }: { standings: Standings; row: StandingRow }) { + const places = useMemo(() => placesByView(standings), [standings]); + return ( +
    + {viewsOf(standings).map((v, i) => { + const p = places[v.id].get(row.key); + const value = valueOf(row, v.id); + const share = p ? 1 - (p.place - 1) / Math.max(1, p.of) : 0; + const best = p?.place === 1; + return ( +
    + {v.label} + {p ? ( + + {ordinal(p.place)} + of {grouped(p.of)} + + ) : ( + — + )} + + {p && } + + {value !== null ? {grouped(value)} : v.id === "surviving" ? : none} +
    + ); + })} +
    + ); +} + +/** Who is just ahead of a person in a view and how far, and who is just behind. */ +export function NextUp({ standings, row, view }: { standings: Standings; row: StandingRow; view: View }) { + const places = useMemo(() => placesByView(standings)[view], [standings, view]); + const mine = valueOf(row, view); + const unit = VIEWS.find((v) => v.id === view)?.unit ?? ["", ""]; + if (mine === null || !places.get(row.key)) return null; + const valued = standings.people.filter((r) => (valueOf(r, view) ?? 0) > 0 && r.key !== row.key); + const ahead = valued.filter((r) => (valueOf(r, view) ?? 0) > mine).sort((a, b) => (valueOf(a, view) ?? 0) - (valueOf(b, view) ?? 0))[0]; + const behind = valued.filter((r) => (valueOf(r, view) ?? 0) < mine).sort((a, b) => (valueOf(b, view) ?? 0) - (valueOf(a, view) ?? 0))[0]; + const line = (r: StandingRow, up: boolean) => { + const gap = Math.abs((valueOf(r, view) ?? 0) - mine); + return ( +
    + {up ? : } + + + {r.name} + + {up ? `${many(gap, ...unit)} ahead` : `${many(gap, ...unit)} behind`} + + +
    + ); + }; + if (!ahead && !behind) return null; + return ( +
    + {ahead ? line(ahead, true) : Nobody ahead: first in this view.} + {behind ? line(behind, false) : Nobody behind in this view.} +
    + ); +} + +/** Everyone's place in a repository for the view chosen, the focused person highlighted and kept in sight; never one score. */ +export function Leaderboard({ standings, focus, view, onView, link = true }: { standings: Standings; focus?: string | null; view: View; onView: (v: View) => void; link?: boolean }) { + const [all, setAll] = useState(false); + const places = useMemo(() => placesByView(standings), [standings]); + const views = viewsOf(standings); + const by = views.some((v) => v.id === view) ? view : (views[0]?.id ?? "commits"); + const sorted = useMemo(() => [...standings.people].sort((a, b) => (places[by].get(a.key)?.place ?? Infinity) - (places[by].get(b.key)?.place ?? Infinity) || (b.commits ?? 0) - (a.commits ?? 0)), [standings, places, by]); + const focused = focus?.toLowerCase(); + const top = Math.max(1, ...sorted.map((r) => valueOf(r, by) ?? 0)); + const rows = all ? sorted : sorted.slice(0, SHOWN); + const you = focused ? sorted.findIndex((r) => r.login?.toLowerCase() === focused || r.you) : sorted.findIndex((r) => r.you); + const pinned = !all && you >= SHOWN ? sorted[you] : null; + const { owner, name } = standings.repo; + const meta = VIEWS.find((v) => v.id === by); + return ( + 0 ? ` ${many(standings.hidden, "person has", "people have")} chosen to stay out.` : ""}`} + actions={ +
    + onView(v as View)}> + {views.map((v) => ( + + ))} + +
    + } + > +
      + {rows.map((r) => ( + + ))} + {pinned && ( + <> +
    1. + ⋯ +
    2. + + + )} +
    +
    + {sorted.length > SHOWN ?
    +
    + ); +} + +function Entry({ r, place, value, top, view, you, owner, name, link }: { r: StandingRow; place: Place | undefined; value: number | null; top: number; view: View; you: boolean; owner: string; name: string; link: boolean }) { + const medal = place && place.place <= 3 ? ["#e8b931", "#b8bec7", "#c98a54"][place.place - 1] : null; + const body = ( + <> + {medal ? {place?.place} : (place?.place ?? "—")} + + + + {r.name} + {you && you} + + + + + + {value !== null ? {view === "linesAdded" || value >= 100_000 ? compact(value) : grouped(value)} : view === "surviving" ? : —} + + ); + return ( +
  • + {link && r.login ? ( + + {body} + + ) : ( +
    {body}
    + )} +
  • + ); +} diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index c43b310..b544859 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -3,10 +3,39 @@ import { dataTokenDefaults, defineTheme, generateThemeCSS, type DefinedTheme } f import { neutralTheme } from "@astryxdesign/theme-neutral"; import type { Mode } from "./mode"; +const INK = "light-dark(#0c0d0f, #f4f4f5)"; +const PAPER = "light-dark(#ffffff, #0c0d0f)"; + const defined = defineTheme({ name: "commitscape", extends: neutralTheme, - color: { accent: ["#2a78d6", "#3987e5"], neutralStyle: "warm" }, + color: { accent: ["#0f7a37", "#3ccf74"], neutralStyle: "neutral" }, + typography: { + scale: { base: 15, ratio: 1.25 }, + body: { family: "Inter Variable", fallbacks: "Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" }, + heading: { family: "Inter Variable", fallbacks: "Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" }, + code: { family: "ui-monospace", fallbacks: "'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace" }, + }, + radius: { base: 4, multiplier: 1.25 }, + tokens: { + "--color-background-body": ["#f6f6f4", "#08090b"], + "--color-background-surface": ["#ffffff", "#101114"], + "--color-background-card": ["#ffffff", "#101114"], + "--color-background-popover": ["#ffffff", "#16181c"], + "--color-background-muted": ["#0c0d0f0a", "#ffffff0d"], + "--color-border": ["#0c0d0f14", "#ffffff14"], + "--color-border-emphasized": ["#0c0d0f29", "#ffffff2b"], + "--color-text-primary": ["#0c0d0f", "#f4f4f5"], + "--color-text-secondary": ["#5d6068", "#9b9ea6"], + "--color-text-disabled": ["#868990", "#7d8088"], + "--color-skeleton": ["#0c0d0f12", "#ffffff12"], + "--color-track": ["#0c0d0f14", "#ffffff17"], + }, + components: { + button: { + "variant:primary": { backgroundColor: INK, color: PAPER }, + }, + }, }); /** The theme as written into the page by the server, so Astryx never injects it after hydration. */ @@ -36,6 +65,6 @@ export function personColour(colour: number | null | undefined): string { return colour === null || colour === undefined ? "var(--other)" : `var(--s${colour + 1})`; } -export function ramp(hue: "blue" | "orange", step: number): string { +export function ramp(hue: "blue" | "orange" | "green", step: number): string { return `var(--${hue}-${Math.max(1, Math.min(4, step))})`; } diff --git a/packages/ui/src/work/WorkView.tsx b/packages/ui/src/work/WorkView.tsx index 17c947b..b7a681b 100644 --- a/packages/ui/src/work/WorkView.tsx +++ b/packages/ui/src/work/WorkView.tsx @@ -1,60 +1,207 @@ +import type { ReactNode } from "react"; import { Badge } from "@astryxdesign/core/Badge"; -import { CheckboxInput } from "@astryxdesign/core/CheckboxInput"; -import { groupWork, monthName, workTotals, type Work } from "@commitscape/data"; -import { Tile } from "../components/Tile"; +import { Banner } from "@astryxdesign/core/Banner"; +import { Skeleton } from "@astryxdesign/core/Skeleton"; +import { GitCommitHorizontal, GitMerge, Lock } from "lucide-react"; +import { groupWork, monthName, workTotals, type Work, type WorkItem } from "@commitscape/data"; import { Face } from "../components/Face"; +import { Cell } from "../profile/view"; +import { Stat } from "../kit/layout"; import { compact, grouped, many } from "../format"; -/** A Proof of Work: its numbers, then every merged pull request and commit by month and repository, with links. */ -export function WorkView({ work, chosen, onChoose }: { work: Work; chosen?: Set; onChoose?: (repo: string, on: boolean) => void }) { +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const LONG = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }); + +const day = (iso: string) => `${Number(iso.slice(8, 10))} ${MONTHS[Number(iso.slice(5, 7)) - 1] ?? ""}`; +const longDay = (iso: string) => LONG.format(new Date(`${iso.slice(0, 10)}T00:00:00Z`)); + +/** A period as words: "1 September to 30 September 2026". */ +export function periodWords(from: string, to: string): string { + const a = new Date(`${from}T00:00:00Z`); + const b = new Date(`${to}T00:00:00Z`); + const sameYear = a.getUTCFullYear() === b.getUTCFullYear(); + const first = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" }).format(a); + return `${first} to ${LONG.format(b)}`; +} + +/** A Proof of Work as a document: who and when, its numbers, then every merged pull request and commit by month and repository, with links. */ +export function WorkView({ work, footer }: { work: Work; footer?: ReactNode }) { const t = workTotals(work.items); const months = groupWork(work.items); + const name = work.name ?? work.login; + return ( +
    +
    +
    + +
    + {name} + @{work.login} +
    +
    +
    + Proof of Work + {periodWords(work.from, work.to)} + {work.filter ? `Only in ${work.filter}` : "Everywhere on GitHub"} +
    +
    +
    + + + + + + +{compact(t.additions)} / −{compact(t.deletions)} + + } + label="Lines merged" + note="added and removed, in those pull requests" + /> + + + + + + + +
    + {work.truncated && ( +
    + +
    + )} + {months.length === 0 ? ( +
    + Nothing merged or committed in this period + + {work.scope === "public" ? "Only public repositories count here. " : ""}Try a longer period{work.filter ? `, or look beyond ${work.filter}` : ""}. + +
    + ) : ( + months.map((m) => ) + )} + {footer &&
    {footer}
    } +
    + ); +} + +function Month({ month, repositories, work }: { month: string; repositories: { repo: string; private: boolean; items: WorkItem[] }[]; work: Work }) { + const items = repositories.flatMap((r) => r.items); + const prs = items.filter((i) => i.kind === "pr").length; + return ( +
    +
    +

    + {monthName(month)} +

    + {[prs > 0 && many(prs, "pull request", "pull requests"), items.length - prs > 0 && many(items.length - prs, "commit", "commits"), many(repositories.length, "repository", "repositories")].filter(Boolean).join(" · ")} +
    +
    + {repositories.map((r) => ( + + ))} +
    +
    + ); +} + +function Repository({ repo, isPrivate, items, work }: { repo: string; isPrivate: boolean; items: WorkItem[]; work: Work }) { + const [owner = "", name = repo] = repo.split("/"); + const prs = items.filter((i) => i.kind === "pr").length; + const commits = items.length - prs; + return ( +
    +
    + +

    + + {owner}/ + {name} + +

    + {isPrivate && } variant="neutral" />} + {[prs > 0 && many(prs, "pull request", "pull requests"), commits > 0 && many(commits, "commit", "commits")].filter(Boolean).join(" · ")} +
    +
      + {items.map((i) => ( + + ))} +
    +
    + ); +} + +function Row({ item }: { item: WorkItem }) { + const pr = item.kind === "pr"; + const ref = pr ? `#${item.number}` : (item.sha ?? "").slice(0, 7); + const lines = pr && ( + + +{compact(item.additions ?? 0)} −{compact(item.deletions ?? 0)} + + ); + return ( +
  • + + {pr ? : } + + + + {item.title} + + + {ref} + {pr ? `merged ${day(item.at)}` : day(item.at)} + {lines} + + + + + {lines} +
  • + ); +} + +/** A Proof of Work's stand-in while it is read from GitHub, the same shape as the document. */ +export function WorkSkeleton() { return ( -
    -
    - - - - -
    - {work.truncated &&

    GitHub returns at most 1,000 pull requests and 1,000 commits for one search; narrow the period to see everything.

    } - {months.length === 0 &&

    Nothing merged or committed in this period{work.scope === "public" ? " in public repositories" : ""}.

    } - {months.map((m) => ( -
    -

    {monthName(m.month)}

    - {m.repositories.map((r) => { - const prs = r.items.filter((i) => i.kind === "pr"); - const commits = r.items.length - prs.length; - return ( -
    -
    - - - {r.repo} - - {r.private && } - {[prs.length > 0 && many(prs.length, "pull request", "pull requests"), commits > 0 && many(commits, "commit", "commits")].filter(Boolean).join(" · ")} - {r.private && chosen && onChoose && onChoose(r.repo, on)} />} -
    -
      - {r.items.map((i) => ( -
    • - {i.kind === "pr" ? #{i.number} : {(i.sha ?? "").slice(0, 7)}} - {i.title} - {i.at.slice(0, 10)} - {i.kind === "pr" && ( - - +{compact(i.additions ?? 0)} −{compact(i.deletions ?? 0)} - - )} -
    • - ))} -
    -
    - ); - })} -
    - ))} +
    +
    +
    + +
    + + +
    +
    +
    + + + +
    +
    +
    + {[0, 1, 2, 3].map((i) => ( + +
    + + + +
    +
    + ))} +
    +
    + +
    +
    + {Array.from({ length: 8 }, (_, i) => ( + + ))} +
    ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 303d81f..231c1d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,11 +165,11 @@ settings: catalogs: default: '@astryxdesign/core': - specifier: 0.6.3 - version: 0.6.3 + specifier: 0.6.5 + version: 0.6.5 '@astryxdesign/theme-neutral': - specifier: 0.6.3 - version: 0.6.3 + specifier: 0.6.5 + version: 0.6.5 '@aws-sdk/client-s3': specifier: 3.1141.0 version: 3.1141.0 @@ -300,10 +300,10 @@ importers: dependencies: '@astryxdesign/core': specifier: 'catalog:' - version: 0.6.3(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.6.5(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@astryxdesign/theme-neutral': specifier: 'catalog:' - version: 0.6.3(@astryxdesign/core@0.6.3(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 0.6.5(@astryxdesign/core@0.6.5(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) '@commitscape/data': specifier: workspace:* version: link:../../packages/data @@ -313,6 +313,9 @@ importers: '@commitscape/ui': specifier: workspace:* version: link:../../packages/ui + '@fontsource-variable/inter': + specifier: 5.3.0 + version: 5.3.0 '@modelcontextprotocol/sdk': specifier: 1.30.1 version: 1.30.1(supports-color@8.1.1)(zod@4.6.5) @@ -349,6 +352,9 @@ importers: drizzle-orm: specifier: 'catalog:' version: 0.45.3(@cloudflare/workers-types@5.20260925.2)(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0) + lucide-react: + specifier: 1.48.0 + version: 1.48.0(react@19.3.0) pdfkit: specifier: 0.20.2 version: 0.20.2 @@ -500,16 +506,22 @@ importers: dependencies: '@astryxdesign/core': specifier: 'catalog:' - version: 0.6.3(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.6.5(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@astryxdesign/theme-neutral': specifier: 'catalog:' - version: 0.6.3(@astryxdesign/core@0.6.3(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 0.6.5(@astryxdesign/core@0.6.5(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) '@commitscape/data': specifier: workspace:* version: link:../data '@stylexjs/stylex': specifier: 'catalog:' version: 0.19.1 + lucide-react: + specifier: 1.48.0 + version: 1.48.0(react@19.3.0) + recharts: + specifier: 3.10.1 + version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1) satori: specifier: 0.35.0 version: 0.35.0 @@ -544,17 +556,17 @@ importers: packages: - '@astryxdesign/core@0.6.3': - resolution: {integrity: sha512-ieaZZn0wjP8saWkfSXFgzvrA0UfXWeOBsvuvsbtJ0789LdYacTJS83UXsROcvPLONUIOO/Iy1Ar5+70xvcVpgg==} + '@astryxdesign/core@0.6.5': + resolution: {integrity: sha512-fI2ELLMxoPHKOJWKYyoTWi31q9Dg0SLWjuBMrkl6tQY3WFf3TjTrDn4f7KyOF6mryjbTsf93uQNOUJKbay1o/A==} peerDependencies: '@stylexjs/stylex': ^0.19.0 react: '>=19.0.0' react-dom: '>=19.0.0' - '@astryxdesign/theme-neutral@0.6.3': - resolution: {integrity: sha512-6l2u3qVcs5TkN4YUMUrSlMdmRmzt+/nB1pJqZopn3/nylVZcxSql+wzeD7T1VyinM+wHAokoaRD6xmCa2abNDg==} + '@astryxdesign/theme-neutral@0.6.5': + resolution: {integrity: sha512-8Ylen70//sYhVJvst2prflxng5t3ryRlsJJkS44vvY0/9A4KGjCs7bxfRAfSrFxF/dSXZ8sQHuGxbXZiRn7PHQ==} peerDependencies: - '@astryxdesign/core': 0.6.3 + '@astryxdesign/core': 0.6.5 react: '>=19' '@aws-sdk/checksums@3.1001.1': @@ -1268,6 +1280,9 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@formatjs/fast-memoize@3.1.7': resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} @@ -1782,6 +1797,17 @@ packages: '@posthog/types@1.412.4': resolution: {integrity: sha512-Q7lV9O9TbLngjOYw1ucm3bS3tn48xb1B4ZQdDy8gv7yfKe99hCTeWYG4xZ36nJM80VxpTF9TriiJt5hngDOkBg==} + '@reduxjs/toolkit@2.13.0': + resolution: {integrity: sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} engines: {node: '>= 10'} @@ -2135,6 +2161,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stylexjs/stylex@0.19.1': resolution: {integrity: sha512-JwNFm0MOYPqi+M4Ax5Un4ZxfZp4HEAX2sAWetZ88KALFcv1eEiHNJs75oO3V3U7RVWnzfkiM8i9eBowC3hqgUw==} @@ -2528,6 +2557,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2554,6 +2610,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2863,6 +2922,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dayjs@1.11.23: resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} @@ -2878,6 +2981,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -3043,6 +3149,9 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -3072,6 +3181,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -3238,9 +3350,16 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + immer@11.1.21: + resolution: {integrity: sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intl-messageformat@11.2.15: resolution: {integrity: sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==} @@ -3749,6 +3868,21 @@ packages: peerDependencies: react: ^19.3.0 + react-is@19.3.0: + resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} @@ -3757,6 +3891,22 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + remove-accents@0.5.0: resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} @@ -3764,6 +3914,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3965,6 +4118,9 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} engines: {node: '>=20.0.0'} @@ -4083,6 +4239,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4233,16 +4392,16 @@ packages: snapshots: - '@astryxdesign/core@0.6.3(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@astryxdesign/core@0.6.5(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@stylexjs/stylex': 0.19.1 intl-messageformat: 11.2.15 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@astryxdesign/theme-neutral@0.6.3(@astryxdesign/core@0.6.3(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': + '@astryxdesign/theme-neutral@0.6.5(@astryxdesign/core@0.6.5(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': dependencies: - '@astryxdesign/core': 0.6.3(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@astryxdesign/core': 0.6.5(@stylexjs/stylex@0.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) lucide-react: 1.48.0(react@19.3.0) react: 19.3.0 @@ -4848,6 +5007,8 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@fontsource-variable/inter@5.3.0': {} + '@formatjs/fast-memoize@3.1.7': {} '@formatjs/icu-messageformat-parser@3.5.18': @@ -5177,6 +5338,18 @@ snapshots: '@posthog/types@1.412.4': {} + '@reduxjs/toolkit@2.13.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.21 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.3.0 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) + '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true @@ -5490,6 +5663,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@stylexjs/stylex@0.19.1': dependencies: css-mediaquery: 0.1.2 @@ -5969,6 +6144,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -5998,6 +6197,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/use-sync-external-store@0.0.6': {} + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -6240,6 +6441,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + dayjs@1.11.23: {} db0@0.4.1: {} @@ -6250,6 +6489,8 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decimal.js-light@2.5.1: {} + defu@6.1.7: {} depd@2.0.0: {} @@ -6318,6 +6559,8 @@ snapshots: dependencies: es-errors: 1.3.0 + es-toolkit@1.52.0: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -6411,6 +6654,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.1.1: {} eventsource@3.0.7: @@ -6593,8 +6838,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 + immer@11.1.21: {} + inherits@2.0.4: {} + internmap@2.0.3: {} + intl-messageformat@11.2.15: dependencies: '@formatjs/fast-memoize': 3.1.7 @@ -7069,14 +7318,53 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-is@19.3.0: {} + + react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.3.0 + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + redux: 5.0.1 + react@19.3.0: {} readdirp@5.1.1: {} + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.13.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.52.0 + eventemitter3: 5.0.4 + immer: 11.1.21 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-is: 19.3.0 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.7.0(react@19.3.0) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + remove-accents@0.5.0: {} require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-pkg-maps@1.0.0: {} restructure@3.0.2: {} @@ -7295,6 +7583,8 @@ snapshots: tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} + tinybench@6.1.4: {} tinyexec@1.3.0: {} @@ -7381,6 +7671,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@8.3.1(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e629664..bf6c653 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,8 +3,8 @@ packages: - packages/* catalog: - "@astryxdesign/core": 0.6.3 - "@astryxdesign/theme-neutral": 0.6.3 + "@astryxdesign/core": 0.6.5 + "@astryxdesign/theme-neutral": 0.6.5 "@stylexjs/stylex": 0.19.1 "@playwright/test": 1.63.0 "@types/node": 24.13.6 From d7f8beab989336e7b1957b424ab87e836bb47a5b Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:32:51 +0530 Subject: [PATCH 04/10] feat(phase-34): rebuild the Site UI and repository insights - Add animated home scenes, richer profile and comparison views, and expanded work and Wrapped charts - Fix renamed repository tracking, leaderboard counting, Build persistence, and origin handling --- STATE.md | 16 + apps/builder/src/builder.test.ts | 4 +- apps/builder/src/job.test.ts | 35 +- apps/builder/src/job.ts | 80 +- apps/builder/src/main.ts | 2 +- apps/builder/src/seeds.ts | 16 +- apps/site/e2e/boards.spec.ts | 4 + apps/site/e2e/profile.spec.ts | 12 +- apps/site/e2e/site.spec.ts | 2 +- apps/site/e2e/versus.spec.ts | 7 +- apps/site/e2e/work.spec.ts | 28 +- apps/site/e2e/wrapped.spec.ts | 1 + apps/site/package.json | 3 + apps/site/src/components/CardStudio.tsx | 14 +- apps/site/src/components/Facts.tsx | 6 +- apps/site/src/components/Footer.tsx | 96 + apps/site/src/components/Frame.tsx | 42 +- apps/site/src/components/Lookup.tsx | 2 +- apps/site/src/components/ShareDialog.tsx | 2 +- apps/site/src/functions/versus.ts | 6 +- apps/site/src/home/Closing.tsx | 26 + apps/site/src/home/Features.tsx | 59 + apps/site/src/home/Hero.tsx | 68 + apps/site/src/home/Install.tsx | 75 + apps/site/src/home/layout.tsx | 39 + apps/site/src/home/scenes/CardsScene.tsx | 135 ++ apps/site/src/home/scenes/HeroCards.tsx | 72 + apps/site/src/home/scenes/ProfileScene.tsx | 67 + apps/site/src/home/scenes/RaceScene.tsx | 91 + apps/site/src/home/scenes/StandingScene.tsx | 119 + apps/site/src/home/scenes/TerminalScene.tsx | 114 + apps/site/src/home/scenes/VersusScene.tsx | 80 + apps/site/src/home/scenes/WorkScene.tsx | 84 + apps/site/src/home/scenes/WrappedScene.tsx | 63 + apps/site/src/home/scenes/data.ts | 54 + apps/site/src/home/scenes/kit.ts | 66 + apps/site/src/home/scenes/parts.tsx | 27 + apps/site/src/lib/lookup.tsx | 2 +- apps/site/src/lib/window.ts | 12 +- apps/site/src/routes/__root.tsx | 9 +- apps/site/src/routes/gh.$owner.$repo.tsx | 86 +- apps/site/src/routes/index.tsx | 349 +-- apps/site/src/routes/leaderboards.tsx | 331 ++- apps/site/src/routes/s.$id.tsx | 34 +- apps/site/src/routes/u.$login.tsx | 95 +- .../src/routes/u.$login_.$owner.$repo.tsx | 208 +- apps/site/src/routes/u.$login_.work.tsx | 116 +- apps/site/src/routes/u.$login_.work_.$id.tsx | 7 +- .../src/routes/u.$login_.wrapped.$year.tsx | 199 +- apps/site/src/routes/vs.$a.$b.tsx | 329 ++- .../__snapshots__/cards/hall-of-fame-dark.png | Bin 57926 -> 88345 bytes .../cards/hall-of-fame-dark.png.sha256 | 2 +- .../cards/hall-of-fame-light.png | Bin 57534 -> 87666 bytes .../cards/hall-of-fame-light.png.sha256 | 2 +- apps/site/src/server/boards.test.ts | 13 + apps/site/src/server/boards.ts | 44 +- apps/site/src/server/cards.ts | 27 +- apps/site/src/server/engine.ts | 31 +- apps/site/src/server/http.ts | 12 +- apps/site/src/server/people-boards.test.ts | 54 +- apps/site/src/server/people-boards.ts | 75 +- apps/site/src/server/profiles.test.ts | 103 +- apps/site/src/server/profiles.ts | 70 +- apps/site/src/server/repos.test.ts | 105 + apps/site/src/server/repos.ts | 62 +- apps/site/src/server/security.test.ts | 12 +- apps/site/src/server/standings.test.ts | 14 + apps/site/src/server/standings.ts | 34 +- apps/site/src/server/traits.ts | 6 +- apps/site/src/server/versus.test.ts | 34 +- apps/site/src/server/versus.ts | 47 +- apps/site/src/server/work-pdf.test.ts | 18 + apps/site/src/server/work-pdf.ts | 276 ++- apps/site/src/styles/look.css | 169 +- packages/data/src/cards.ts | 2 +- packages/data/src/profile.ts | 6 +- packages/data/src/traits.test.ts | 11 +- packages/data/src/traits.ts | 55 +- packages/data/src/versus.test.ts | 22 +- packages/data/src/versus.ts | 55 +- packages/data/src/work.test.ts | 52 +- packages/data/src/work.ts | 210 +- packages/data/src/wrapped.test.ts | 5 + packages/data/src/wrapped.ts | 29 +- packages/server/drizzle/0010_repo_names.sql | 9 + .../server/drizzle/meta/0010_snapshot.json | 2118 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 14 +- packages/server/src/integration.test.ts | 16 +- packages/server/src/repos.ts | 108 +- packages/ui/package.json | 4 + packages/ui/src/App.tsx | 148 +- packages/ui/src/cards/Cards.tsx | 124 +- .../cards/__snapshots__/hall-of-fame-dark.svg | 2 +- .../__snapshots__/hall-of-fame-light.svg | 2 +- packages/ui/src/cards/fixture.ts | 9 +- packages/ui/src/cards/sizes.ts | 7 +- packages/ui/src/cards/tokens.ts | 2 +- packages/ui/src/charts/Grid.tsx | 150 +- packages/ui/src/charts/Sunburst.tsx | 139 ++ packages/ui/src/charts/Year.tsx | 4 +- packages/ui/src/components/Loading.tsx | 25 +- packages/ui/src/index.ts | 18 +- packages/ui/src/kit/layout.tsx | 6 +- packages/ui/src/motion/Nothing.tsx | 74 + packages/ui/src/motion/constants.ts | 59 + packages/ui/src/motion/index.ts | 4 + packages/ui/src/motion/primitives.tsx | 82 + packages/ui/src/motion/scene.ts | 50 + packages/ui/src/profile/cell.tsx | 6 + packages/ui/src/profile/clock.tsx | 122 + packages/ui/src/profile/days.ts | 68 + packages/ui/src/profile/languages.tsx | 200 ++ packages/ui/src/profile/streak.tsx | 40 + packages/ui/src/profile/traits.tsx | 97 + packages/ui/src/profile/view.tsx | 343 +-- packages/ui/src/profile/year.tsx | 195 ++ packages/ui/src/profile/years.tsx | 232 ++ packages/ui/src/screens/Activity.tsx | 100 +- packages/ui/src/screens/Map.tsx | 25 +- packages/ui/src/screens/Overview.tsx | 182 +- packages/ui/src/screens/People.tsx | 61 +- packages/ui/src/screens/WhereWork.tsx | 280 +++ packages/ui/src/standings/view.tsx | 18 +- packages/ui/src/versus/duel.tsx | 84 + packages/ui/src/versus/full.tsx | 556 +++++ packages/ui/src/versus/tone.test.ts | 11 + packages/ui/src/versus/tone.ts | 21 + packages/ui/src/work/PeriodPicker.tsx | 99 + packages/ui/src/work/RepoPicker.tsx | 38 + packages/ui/src/work/WorkView.tsx | 307 ++- packages/ui/src/work/helpers.ts | 55 + packages/ui/src/wrapped/charts.tsx | 235 ++ pnpm-lock.yaml | 85 + 134 files changed, 9970 insertions(+), 1722 deletions(-) create mode 100644 apps/site/src/components/Footer.tsx create mode 100644 apps/site/src/home/Closing.tsx create mode 100644 apps/site/src/home/Features.tsx create mode 100644 apps/site/src/home/Hero.tsx create mode 100644 apps/site/src/home/Install.tsx create mode 100644 apps/site/src/home/layout.tsx create mode 100644 apps/site/src/home/scenes/CardsScene.tsx create mode 100644 apps/site/src/home/scenes/HeroCards.tsx create mode 100644 apps/site/src/home/scenes/ProfileScene.tsx create mode 100644 apps/site/src/home/scenes/RaceScene.tsx create mode 100644 apps/site/src/home/scenes/StandingScene.tsx create mode 100644 apps/site/src/home/scenes/TerminalScene.tsx create mode 100644 apps/site/src/home/scenes/VersusScene.tsx create mode 100644 apps/site/src/home/scenes/WorkScene.tsx create mode 100644 apps/site/src/home/scenes/WrappedScene.tsx create mode 100644 apps/site/src/home/scenes/data.ts create mode 100644 apps/site/src/home/scenes/kit.ts create mode 100644 apps/site/src/home/scenes/parts.tsx create mode 100644 apps/site/src/server/repos.test.ts create mode 100644 apps/site/src/server/work-pdf.test.ts create mode 100644 packages/server/drizzle/0010_repo_names.sql create mode 100644 packages/server/drizzle/meta/0010_snapshot.json create mode 100644 packages/ui/src/charts/Sunburst.tsx create mode 100644 packages/ui/src/motion/Nothing.tsx create mode 100644 packages/ui/src/motion/constants.ts create mode 100644 packages/ui/src/motion/index.ts create mode 100644 packages/ui/src/motion/primitives.tsx create mode 100644 packages/ui/src/motion/scene.ts create mode 100644 packages/ui/src/profile/cell.tsx create mode 100644 packages/ui/src/profile/clock.tsx create mode 100644 packages/ui/src/profile/days.ts create mode 100644 packages/ui/src/profile/languages.tsx create mode 100644 packages/ui/src/profile/streak.tsx create mode 100644 packages/ui/src/profile/traits.tsx create mode 100644 packages/ui/src/profile/year.tsx create mode 100644 packages/ui/src/profile/years.tsx create mode 100644 packages/ui/src/screens/WhereWork.tsx create mode 100644 packages/ui/src/versus/duel.tsx create mode 100644 packages/ui/src/versus/full.tsx create mode 100644 packages/ui/src/versus/tone.test.ts create mode 100644 packages/ui/src/versus/tone.ts create mode 100644 packages/ui/src/work/PeriodPicker.tsx create mode 100644 packages/ui/src/work/RepoPicker.tsx create mode 100644 packages/ui/src/work/helpers.ts create mode 100644 packages/ui/src/wrapped/charts.tsx diff --git a/STATE.md b/STATE.md index c78c0f9..7520b96 100644 --- a/STATE.md +++ b/STATE.md @@ -2697,6 +2697,22 @@ The owner asked for a from-scratch UI and UX rewrite of the Site, customizable C - **Checks:** `pnpm check` (17 tasks), `cargo test --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`, and `pnpm e2e` (57 tests; the specs were updated to the new pages, the theme's accessibility checks included). Card snapshot tests were re-recorded; in the PNG test the snapshot is now checked before its 200 ms budget, which fails when the machine is busy. - **Needs the owner:** the dev Site's `BETTER_AUTH_URL` is `localhost`, so starting a Build from the Site opened by another address (such as the Tailscale IP) answers "Not from this Site"; that is the origin check working as designed. facebook/react's Report was written without line counts, so its page says a fresh read is coming; that full clone is about 1.1 GB. +### The Site's UI, second round (2026-10-05, uncommitted) + +The owner reviewed the rebuilt Site and listed gaps; this round fixed them and the logic bugs behind some of them. + +- **Motion:** `motion` (Framer Motion) for simple animation and GSAP for sequenced scenes, from `@commitscape/ui/motion`: timings in `constants.ts`, `Reveal`, `Stagger`, `CountUp`, `Lift` in `primitives.tsx`, `useScene` in `scene.ts` (a scoped GSAP timeline that plays on screen, pauses off it, and stops on a still frame with reduced motion), and `Nothing`, the empty-state mascot. `MotionProvider` sits at the root. +- **Home:** a new hero (two-tone headline, live Card stack), a ruled feature grid whose tiles are live HTML scenes (`apps/site/src/home/scenes/`), a typed terminal scene, and a new footer (`components/Footer.tsx`) with Damn Labs and Pixelact Studio. The scenes' numbers for gaearon, acdlite and react/react are copied by hand into `home/scenes/data.ts` and will drift. +- **Profile:** a streak cell with a flame and a colour per length, an eye button opening the last year's breakdown and the Archetype's rules in dialogs, pull requests per month under the contributions, a denser languages view for careers under four years, pages of 12 in the repository table, a weekday-by-hour commit heat map, equal-height rows, and hidden or mascot empty sections. A failed GitHub commit search is no longer saved as "no commit times" for a day (`fetchClock` in `server/profiles.ts`, tested). +- **Versus:** a two-sided header without blurred avatars or Swap, and Short / Full views (`?view=full`) with calendars, years, pull requests, streaks, languages, shared repositories, commit hours, Archetypes and Achievements (`server/versus.ts` `versusFullOf`). +- **Leaderboards:** the counted repositories are listed with a "How these boards work" dialog; boards are aligned row for row. Counting fixes: a renamed repository counted twice, bots on the lines board, shared places for ties. +- **Proof of Work and Wrapped:** one period switcher with a Custom range calendar, a repository/organisation dropdown, one header, kinds of work, grouped lists that load as you scroll, and a PDF that opens with a one-page summary. Wrapped gained month, running-total, weekday, ring, outcome and tile charts. +- **Repository page:** the screen tabs are part of the header and stick under the top bar; Overview adapts to one to three people; "Where the work is" is a drill-down sunburst (`charts/Sunburst.tsx`); Kinds of work shows only when 30% of commits are conventional; a new heat map and Map scale; the repository Card shows the owner, description, stars, forks and language. `CARD_DESIGN` is 5. +- **Renamed repositories:** one row per GitHub repository under its current name; earlier names live in `repo_names` (migration `0010_repo_names`) and redirect. `settleRepository` in `packages/server/src/repos.ts` merges duplicates when GitHub reports a new name. On the dev database facebook/react became react/react this way. +- **Origin check:** `sameOrigin` also accepts an Origin equal to the request's own address, so Builds start from the dev Site opened by IP. +- **Standing page:** no more dead end: a button to read the repository, live Build progress, and clear states for private, missing and absent people. The Leaderboard's "you" now means the signed-in viewer only; the page's person is highlighted, and two people with one name show their logins. +- **Checks:** `pnpm check` (17 tasks) and `pnpm e2e` (57 tests). No Rust changed. + On this machine the dev stack was `pnpm services`, `pnpm dev:builder` and `pnpm dev:site` with `apps/site/.env` and `apps/builder/.env` (not in the repository; `GITHUB_TOKEN` was the owner's `gh` token, so the dev Site's "public" numbers include what that token can see, as finding 7 of Phase 33 explains). Seams signed off by the user and not open for revision (from earlier runs) still hold: `RepoSource` (fake and real), `Index`, the `Analysis` methods, `--json` golden files, the TUI render via `insta`, the forge, the HTTP API, generated types, the problem-solvers, the browser screens through Playwright. This run added two of its own: `BlameSource` and `BlameThreads` beside `RepoSource` (with scripted fakes), and the Card pipeline (`renderCard`: a component, Satori, the paint's animation, resvg). diff --git a/apps/builder/src/builder.test.ts b/apps/builder/src/builder.test.ts index 13c09fe..41024b5 100644 --- a/apps/builder/src/builder.test.ts +++ b/apps/builder/src/builder.test.ts @@ -226,14 +226,14 @@ test("the seed list is the most starred per language, each once", async () => { const fetcher = (async (url: string) => { asked.push(decodeURIComponent(url)); const both = [ - { full_name: "a/one", stargazers_count: 900, size: 10 }, + { id: 1, full_name: "a/one", stargazers_count: 900, size: 10 }, { full_name: "b/two", stargazers_count: 500, size: 20 }, ]; return new Response(JSON.stringify({ items: url.includes("Rust") ? both : both.slice(0, 1) })); }) as unknown as typeof fetch; const list = await seedList({ languages: ["Rust", "Go"], perLanguage: 2, budget: 5, api: "http://gh", token: undefined }, fetcher); expect(list).toEqual([ - { owner: "a", name: "one", language: "Rust", stars: 900, sizeKb: 10 }, + { owner: "a", name: "one", language: "Rust", stars: 900, sizeKb: 10, githubId: 1 }, { owner: "b", name: "two", language: "Rust", stars: 500, sizeKb: 20 }, ]); expect(asked[0]).toContain('q=language:"Rust" archived:false fork:false&sort=stars&order=desc&per_page=2'); diff --git a/apps/builder/src/job.test.ts b/apps/builder/src/job.test.ts index 603e4e4..f21d30a 100644 --- a/apps/builder/src/job.test.ts +++ b/apps/builder/src/job.test.ts @@ -8,11 +8,12 @@ import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/pglite"; import { migrate } from "drizzle-orm/pglite/migrator"; import { expect, test } from "vitest"; -import { memoryStorage, now, readEntry, readIndex, schema, type BuildJob, type Db, type SurvivalJob } from "@commitscape/server"; +import { memoryStorage, now, readEntry, readIndex, schema, settleRepository, type BuildJob, type Db, type Queue, type SurvivalJob } from "@commitscape/server"; import type { Config } from "./config"; import { cacheKey, inUse } from "./disk"; import { runJob, type JobContext } from "./job"; import { BUILD_ATTEMPTS, type Ran } from "./run"; +import { queueSeeds } from "./seeds"; const { builds, repositories, surviving } = schema; const migrations = fileURLToPath(new URL("../../../packages/server/drizzle", import.meta.url)); @@ -65,6 +66,38 @@ test("a Build stores its Report an answer at a time and replaces the one before" expect([...ctx.storage.objects.keys()].filter((k) => k.endsWith("index.json"))).toHaveLength(1); }); +test("a Build of a repository found renamed while it ran stores its Report and people under the current name", async () => { + const db = await database(); + const ctx = await context(db, {}, { ...report, data: { ...report.data, "/api/people?window=all": { people: [person(1, "dan", 5)] } } }); + await db.insert(repositories).values({ id: "facebook/react", owner: "facebook", name: "react", githubId: 10, sizeKb: 10 }); + await db.insert(builds).values({ id: "b9", repoId: "facebook/react", state: "queued", requestedAt: 1 }); + const run = ctx.run; + ctx.run = async (cmd, args, env, t) => { + await settleRepository(db, ctx.storage, { githubId: 10, owner: "react", name: "react" }); + return run(cmd, args, env, t); + }; + await runJob("b9", ctx); + const rows = await db.select().from(repositories); + expect(rows.map((r) => [r.id, r.reportKey])).toEqual([["react/react", "reports/gh/react/react/b9"]]); + expect((await db.select().from(schema.repoPeople)).map((p) => [p.repoId, p.login])).toEqual([["react/react", "dan"]]); + expect((await db.select().from(builds)).map((b) => [b.repoId, b.state])).toEqual([["react/react", "done"]]); +}); + +test("seeds are kept under the name GitHub's search gives, and a row under an older name moves there", async () => { + const db = await database(); + const storage = memoryStorage(); + await db.insert(repositories).values({ id: "facebook/react", owner: "facebook", name: "react", githubId: 10, reportKey: "reports/gh/facebook/react/b1", reportAt: now() }); + const sent: string[] = []; + const queue: Queue = { send: async (job) => void sent.push(job.buildId) }; + await queueSeeds(db, queue, [{ owner: "react", name: "react", language: "JavaScript", stars: 9, sizeKb: 10, githubId: 10 }, { owner: "a", name: "one", language: "Rust", stars: 1, sizeKb: 1 }], 5, storage); + const rows = await db.select().from(repositories); + expect(rows.map((r) => [r.id, r.seed, r.reportKey]).sort()).toEqual([ + ["a/one", true, null], + ["react/react", true, "reports/gh/facebook/react/b1"], + ]); + expect((await db.select().from(builds)).map((b) => b.repoId)).toEqual(["a/one"]); +}); + test("a private repository with no installation token fails as private and reads nothing", async () => { const db = await database(); let ran = 0; diff --git a/apps/builder/src/job.ts b/apps/builder/src/job.ts index 5824a95..3729e33 100644 --- a/apps/builder/src/job.ts +++ b/apps/builder/src/job.ts @@ -86,42 +86,59 @@ export async function runJob(buildId: string, ctx: JobContext, attempt = 1): Pro return; } - const prefix = reportPrefix(repo.id, buildId); + const [before] = await db + .select({ id: repositories.id, owner: repositories.owner, name: repositories.name }) + .from(builds) + .innerJoin(repositories, eq(repositories.id, builds.repoId)) + .where(eq(builds.id, buildId)); + const named = before ?? repo; + const prefix = reportPrefix(named.id, buildId); const { index, bytes } = await storeReport(storage, prefix, outcome.report); const stats = outcome.stats ?? index.stats; - const [updated] = await db - .update(repositories) - .set({ - reportKey: prefix, - reportAt: now(), - reportBytes: bytes, - reportLines: true, - ...(stats - ? { - busFactor: stats.bus_factor, - maintainers: stats.maintainers, - commits30d: stats.commits_30d, - people30d: stats.people_30d, - codeLines: stats.code_lines, - untouched5y: stats.untouched_5y, - ...(stats.answered !== undefined ? { answered: stats.answered, answerHours: stats.answer_hours ?? null } : {}), - } - : {}), - }) - .where(eq(repositories.id, repo.id)) - .returning({ id: repositories.id }); - if (!updated) { + const people = await peopleRows(ctx, named.id, named.owner, named.name, outcome.report, token); + const target = await db.transaction(async (tx) => { + const [current] = await tx + .select({ id: repositories.id, reportKey: repositories.reportKey }) + .from(builds) + .innerJoin(repositories, eq(repositories.id, builds.repoId)) + .where(eq(builds.id, buildId)); + if (!current) return null; + await tx + .update(repositories) + .set({ + reportKey: prefix, + reportAt: now(), + reportBytes: bytes, + reportLines: true, + ...(stats + ? { + busFactor: stats.bus_factor, + maintainers: stats.maintainers, + commits30d: stats.commits_30d, + people30d: stats.people_30d, + codeLines: stats.code_lines, + untouched5y: stats.untouched_5y, + ...(stats.answered !== undefined ? { answered: stats.answered, answerHours: stats.answer_hours ?? null } : {}), + } + : {}), + }) + .where(eq(repositories.id, current.id)); + const rows = people.map((p) => ({ ...p, repoId: current.id, reportKey: prefix })); + await tx.delete(repoPeople).where(eq(repoPeople.repoId, current.id)); + for (let i = 0; i < rows.length; i += 1000) await tx.insert(repoPeople).values(rows.slice(i, i + 1000)); + return current; + }); + if (!target) { await storage.deletePrefix(`${prefix}/`); return; } - const people = await storePeople(ctx, repo.id, repo.owner, repo.name, prefix, outcome.report, token); await db .update(builds) .set({ state: "done", step: null, finishedAt: now(), seconds: Math.round(outcome.seconds), partial: false }) .where(eq(builds.id, buildId)); - if (repo.reportKey && repo.reportKey !== prefix) await storage.deletePrefix(`${repo.reportKey}/`); - await ctx.queuePulls?.(repo.id).catch((e: unknown) => log(`pulls for ${repo.id} not queued: ${(e as Error).message}`)); - if (repo.seed) await queueSeedCounts(ctx, repo.id, prefix, people).catch((e: unknown) => log(`counts for ${repo.id} not queued: ${(e as Error).message}`)); + if (target.reportKey && target.reportKey !== prefix) await storage.deletePrefix(`${target.reportKey}/`); + await ctx.queuePulls?.(target.id).catch((e: unknown) => log(`pulls for ${target.id} not queued: ${(e as Error).message}`)); + if (repo.seed) await queueSeedCounts(ctx, target.id, prefix, people).catch((e: unknown) => log(`counts for ${target.id} not queued: ${(e as Error).message}`)); } async function queueSeedCounts(ctx: JobContext, repoId: string, reportKey: string, people: { personId: number; login: string | null; commits: number }[]): Promise { @@ -136,7 +153,7 @@ async function queueSeedCounts(ctx: JobContext, repoId: string, reportKey: strin for (let i = 0; i < ids.length; i += COUNTED_AT_ONCE) await ctx.queueCounts({ repoId, reportKey, personIds: ids.slice(i, i + COUNTED_AT_ONCE) }); } -async function storePeople(ctx: JobContext, repoId: string, owner: string, name: string, reportKey: string, report: Uint8Array, token: string | null) { +async function peopleRows(ctx: JobContext, repoId: string, owner: string, name: string, report: Uint8Array, token: string | null) { const { people, newest } = peopleOf(report); let found = new Map(); try { @@ -144,10 +161,5 @@ async function storePeople(ctx: JobContext, repoId: string, owner: string, name: } catch (e) { ctx.log(`logins for ${repoId}: ${(e as Error).message}`); } - const rows = people.map((p) => ({ ...p, login: p.login ?? found.get(p.personId) ?? null, repoId, reportKey })); - await ctx.db.transaction(async (tx) => { - await tx.delete(repoPeople).where(eq(repoPeople.repoId, repoId)); - for (let i = 0; i < rows.length; i += 1000) await tx.insert(repoPeople).values(rows.slice(i, i + 1000)); - }); - return rows; + return people.map((p) => ({ ...p, login: p.login ?? found.get(p.personId) ?? null })); } diff --git a/apps/builder/src/main.ts b/apps/builder/src/main.ts index 3a65f19..864052d 100644 --- a/apps/builder/src/main.ts +++ b/apps/builder/src/main.ts @@ -97,7 +97,7 @@ async function once(what: string) { const s = seedConfig(env); const list = await seedList(s); const boss = await startQueue(env.DATABASE_URL, { worker: false }); - const queued = await queueSeeds(db, bossQueue(boss), list, s.budget); + const queued = await queueSeeds(db, bossQueue(boss), list, s.budget, storage); await boss.stop(); console.log(`seeds: ${list.length} from GitHub's search, ${queued} queued`); } diff --git a/apps/builder/src/seeds.ts b/apps/builder/src/seeds.ts index 8e36f36..096e86e 100644 --- a/apps/builder/src/seeds.ts +++ b/apps/builder/src/seeds.ts @@ -1,5 +1,5 @@ import { and, desc, eq, sql } from "drizzle-orm"; -import { busy, now, PRIORITY, queueBuild, REPORT_FOR, repoId, schema, type Db, type Queue } from "@commitscape/server"; +import { busy, now, PRIORITY, queueBuild, REPORT_FOR, repoId, schema, settleRepository, type Db, type Queue, type Storage } from "@commitscape/server"; import type { Env } from "./config"; const { builds, repositories } = schema; @@ -26,7 +26,7 @@ export function seedConfig(env: Env): SeedConfig { }; } -export type Seed = { owner: string; name: string; language: string; stars: number; sizeKb: number }; +export type Seed = { owner: string; name: string; language: string; stars: number; sizeKb: number; githubId?: number }; /** The most starred repositories per language, from GitHub's search. */ export async function seedList(s: SeedConfig, fetcher: typeof fetch = fetch): Promise { @@ -46,10 +46,10 @@ export async function seedList(s: SeedConfig, fetcher: typeof fetch = fetch): Pr answer = await ask(); } if (!answer.ok) throw new Error(`GitHub's search answered ${answer.status} for ${language}`); - const items = ((await answer.json()) as { items?: { full_name: string; stargazers_count: number; size: number }[] }).items ?? []; + const items = ((await answer.json()) as { items?: { id?: number; full_name: string; stargazers_count: number; size: number }[] }).items ?? []; for (const i of items) { const [owner = "", name = ""] = i.full_name.split("/"); - if (!out.some((o) => o.owner === owner && o.name === name)) out.push({ owner, name, language, stars: i.stargazers_count, sizeKb: i.size }); + if (!out.some((o) => o.owner === owner && o.name === name)) out.push({ owner, name, language, stars: i.stargazers_count, sizeKb: i.size, ...(typeof i.id === "number" ? { githubId: i.id } : {}) }); } } return out; @@ -59,11 +59,15 @@ function pause(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -/** Marks the list as Leaderboard seeds and queues tonight's Builds within the budget. */ -export async function queueSeeds(db: Db, queue: Queue, list: Seed[], budget: number): Promise { +/** Marks the list as Leaderboard seeds under their current names, moving rows kept under older ones, and queues tonight's Builds within the budget. */ +export async function queueSeeds(db: Db, queue: Queue, list: Seed[], budget: number, storage: Storage | null = null): Promise { const valid = list.filter((r) => repoId(r.owner, r.name)).slice(0, 500); for (const r of valid) { const fields = { seed: true, language: r.language, stars: r.stars, sizeKb: r.sizeKb }; + if (r.githubId) { + await settleRepository(db, storage, { githubId: r.githubId, owner: r.owner, name: r.name, set: fields }); + continue; + } await db .insert(repositories) .values({ id: repoId(r.owner, r.name) ?? "", owner: r.owner, name: r.name, ...fields }) diff --git a/apps/site/e2e/boards.spec.ts b/apps/site/e2e/boards.spec.ts index d543e11..140b8c1 100644 --- a/apps/site/e2e/boards.spec.ts +++ b/apps/site/e2e/boards.spec.ts @@ -12,6 +12,10 @@ test("people on the Leaderboards: this Season by merged pull requests and review await expect(merged.getByRole("link").first()).toContainText("bob"); await expect(merged.getByRole("link").first()).toContainText("2"); await expect(page.getByRole("region", { name: "Most pull requests reviewed" }).getByRole("link")).toHaveCount(3); + await expect(page.getByRole("region", { name: "Repositories counted" }).getByRole("link", { name: /acme\/ownership/ })).toHaveAttribute("href", "/gh/acme/ownership"); + await page.getByRole("button", { name: "How these boards work" }).click(); + await expect(page.getByRole("dialog")).toContainText("Why a small number can top a board"); + await page.keyboard.press("Escape"); await page.goto("/leaderboards?repo=acme/ownership&window=all"); await expect(merged.getByRole("link")).toHaveCount(3); await expect(merged.getByRole("link", { name: /bob/ })).toHaveAttribute("href", "/u/bob/acme/ownership"); diff --git a/apps/site/e2e/profile.spec.ts b/apps/site/e2e/profile.spec.ts index 029733f..0b49196 100644 --- a/apps/site/e2e/profile.spec.ts +++ b/apps/site/e2e/profile.spec.ts @@ -104,16 +104,20 @@ test("you in this repository: your place in each view, who is next, and everyone await page.getByRole("radio", { name: "Commits", exact: true }).click(); await expect(board.getByRole("listitem")).toHaveCount(3); await expect(board.getByRole("listitem").first()).toContainText("Alice Example"); - await expect(board.getByRole("listitem").first()).toContainText("you"); + await expect(board.getByRole("listitem").first()).toHaveAttribute("aria-current", "true"); await page.goto("/u/alice/acme/never-read"); - await expect(page.getByText(/has no commits, pull requests or reviews in acme\/never-read|No Standings here|not read/).first()).toBeVisible(); + await expect(page.getByText("GitHub shows no repository called acme/never-read")).toBeVisible(); + await expect(page.getByRole("link", { name: "Alice Example's profile" })).toHaveAttribute("href", "/u/alice"); }); test("a Profile shows its Archetype with the rule behind it, and every Achievement with what reaches it", async ({ page, request }) => { await page.goto("/u/alice"); const archetype = page.getByRole("region", { name: "Archetype" }); - await archetype.getByText("Every rule, in the order they are tried").click(); - await expect(archetype.locator("dt")).toHaveText(["Reviewer", "Janitor", "Firefighter", "Night Owl", "Polyglot", "Weekend Warrior", "Marathoner", "Builder"]); + await archetype.getByRole("button", { name: "Every Archetype rule" }).click(); + const rules = page.getByRole("dialog"); + await expect(rules.locator("dt")).toHaveText([/Reviewer/, /Janitor/, /Firefighter/, /Night Owl/, /Polyglot/, /Weekend Warrior/, /Marathoner/, /Builder/]); + await expect(rules).toContainText("reviews given"); + await page.keyboard.press("Escape"); await expect(page.getByRole("region", { name: "Achievements" }).getByRole("listitem")).toHaveCount(10); await expect(page.getByText("A pull request of theirs merged into a repository with 10,000 stars or more, as it has now.")).toBeVisible(); const card = await request.get("/api/cards/u/alice/archetype.svg"); diff --git a/apps/site/e2e/site.spec.ts b/apps/site/e2e/site.spec.ts index d1c2943..937f2b3 100644 --- a/apps/site/e2e/site.spec.ts +++ b/apps/site/e2e/site.spec.ts @@ -207,7 +207,7 @@ test("the Leaderboards rank repositories from the night's seed Builds", async ({ .toMatchObject({ seed: true, answered: 12 }); await page.goto("/leaderboards?tab=repositories"); await expect(page.getByRole("heading", { name: "Leaderboards", level: 1 })).toBeVisible(); - await expect(page.getByText(/from 1 of the most starred repositories/)).toBeVisible(); + await expect(page.getByText(/The people boards count 1 repository; here it is ranked/)).toBeVisible(); const answers = page.locator("#answers"); await expect(answers.getByRole("link", { name: "acme/ownership" })).toHaveAttribute("href", "/gh/acme/ownership"); await expect(answers.getByText("4 h")).toBeVisible(); diff --git a/apps/site/e2e/versus.spec.ts b/apps/site/e2e/versus.spec.ts index cfa117a..7a8b481 100644 --- a/apps/site/e2e/versus.spec.ts +++ b/apps/site/e2e/versus.spec.ts @@ -18,13 +18,18 @@ async function sql(text: string) { test("two people side by side, a winner for each view, and their Card", async ({ page, request }) => { await page.goto("/vs/alice/bob"); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("View by view"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("alice versus bob"); + await expect(page.getByRole("heading", { name: "View by view" })).toBeVisible(); const row = (label: string) => page.getByRole("listitem").filter({ hasText: label }); await expect(row("Commits")).toContainText("30"); await expect(row("Commits")).toContainText("40"); await expect(row("Commits").getByLabel("leads this view")).toHaveCount(1); await expect(row("Reviews given")).toContainText("5"); await expect(page.locator("body")).not.toContainText(/wins \d|\d+ views? won|overall winner:|leads \d+ views?/i); + await page.getByRole("radio", { name: "Full" }).click(); + await expect(page).toHaveURL(/view=full/); + await expect(page.getByRole("region", { name: "The last year" })).toBeAttached(); + await expect(page.getByRole("region", { name: "Where both work" })).toBeAttached(); const card = await request.get("/api/cards/vs/alice/bob/versus.png"); expect(card.headers()["content-type"]).toBe("image/png"); await page.goto("/vs/alice/nobody-here"); diff --git a/apps/site/e2e/work.spec.ts b/apps/site/e2e/work.spec.ts index 9d9e715..efd1f91 100644 --- a/apps/site/e2e/work.spec.ts +++ b/apps/site/e2e/work.spec.ts @@ -1,10 +1,11 @@ import { expect, test } from "@playwright/test"; -test("a Proof of Work for a period: merged pull requests and commits by repository, public work only, as a link, Markdown and PDF", async ({ page, request }) => { +test("a Proof of Work for a period: merged pull requests and commits by repository, the kinds of work, public work only, as a link, Markdown and PDF", async ({ page, request }) => { await page.goto("/u/alice/work?from=2026-08-01&to=2026-08-31"); await expect(page.getByRole("heading", { level: 1 })).toContainText("Alice Example's Proof of Work"); const work = page.getByRole("article", { name: "Alice Example's Proof of Work" }); - await expect(work).toContainText("1 August to 31 August 2026"); + await expect(page.getByRole("button", { name: /^Period: .*1–31 Aug 2026$/ })).toBeVisible(); + await expect(work.getByRole("region", { name: "Kind of work" })).toBeVisible(); await expect(work).toContainText("+120 / −20"); await expect(work.getByRole("heading", { level: 3 })).toHaveText(["acme/ownership"]); await expect(page.getByRole("link", { name: "Change 3" })).toHaveAttribute("href", "https://github.com/acme/ownership/pull/3"); @@ -13,7 +14,9 @@ test("a Proof of Work for a period: merged pull requests and commits by reposito const md = await request.get("/api/work/u/alice/proof.md?from=2026-08-01&to=2026-08-31"); expect(md.headers()["content-type"]).toBe("text/markdown; charset=utf-8"); const text = await md.text(); - expect(text).toContain("## August 2026"); + expect(text).toContain("## Kind of work"); + expect(text).toContain("## Appendix: everything, by month"); + expect(text).toContain("### August 2026"); expect(text).toContain("- [#3 Change 3](https://github.com/acme/ownership/pull/3), merged 2026-08-01, +120 −20"); expect(text).not.toContain("private-thing"); const pdf = await request.get("/api/work/u/alice/proof.pdf?from=2026-08-01&to=2026-08-31"); @@ -24,11 +27,20 @@ test("a Proof of Work for a period: merged pull requests and commits by reposito await expect(page.getByRole("link", { name: "Markdown" })).toHaveAttribute("href", "/api/work/u/alice/proof.md?from=2026-08-01&to=2026-08-31"); }); -test("choosing a period goes to its dates", async ({ page }) => { +test("one switcher chooses the period, and a dropdown of the repositories in the work narrows it", async ({ page }) => { await page.goto("/u/alice/work?from=2026-08-01&to=2026-08-31"); - await page.getByLabel("From", { exact: true }).fill("2026-03-01"); - await page.getByLabel("To", { exact: true }).fill("2026-03-31"); - await page.getByRole("button", { name: "Show", exact: true }).click(); - await expect(page).toHaveURL(/from=2026-03-01&to=2026-03-31/); + await page.getByRole("button", { name: /^Period: / }).click(); + await page.getByRole("button", { name: /^This year/ }).click(); + await expect(page).toHaveURL(new RegExp(`from=${new Date().getUTCFullYear()}-01-01`)); await expect(page.getByRole("link", { name: "Change 1" })).toBeVisible(); + await page.getByRole("button", { name: /^Period: / }).click(); + await page.getByRole("button", { name: /^Custom/ }).click(); + await expect(page.getByRole("button", { name: "Show these days" })).toBeVisible(); + await page.keyboard.press("Escape"); + await page.locator("button", { hasText: "Every repository" }).click(); + await page.getByRole("option", { name: /acme\/ownership/ }).click(); + await expect(page).toHaveURL(/filter=acme%2Fownership/); + const repositories = page.getByRole("article").getByRole("heading", { level: 3 }); + await expect(repositories.first()).toHaveText("acme/ownership"); + await expect(repositories.filter({ hasNotText: "acme/ownership" })).toHaveCount(0); }); diff --git a/apps/site/e2e/wrapped.spec.ts b/apps/site/e2e/wrapped.spec.ts index 0fc1451..17b48ab 100644 --- a/apps/site/e2e/wrapped.spec.ts +++ b/apps/site/e2e/wrapped.spec.ts @@ -9,6 +9,7 @@ test("a person's year: its numbers, its calendar, and its Cards", async ({ page, await expect(stat("Commits")).toContainText("21"); await expect(stat("Lines added, merged")).toContainText("+210"); await expect(page.getByRole("region", { name: "The year, a square a day" })).toBeVisible(); + for (const chart of ["Month by month", "How it added up", "What it was made of", "The week", "Pull requests"]) await expect(page.getByRole("region", { name: chart })).toBeVisible(); for (const card of ["wrapped", "wrapped-calendar"]) { const answer = await request.get(`/api/cards/u/alice/wrapped/2026/${card}.png`); expect(answer.headers()["content-type"]).toBe("image/png"); diff --git a/apps/site/package.json b/apps/site/package.json index b695973..6ae896e 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -23,6 +23,7 @@ "@commitscape/server": "workspace:*", "@commitscape/ui": "workspace:*", "@fontsource-variable/inter": "5.3.0", + "@gsap/react": "2.1.2", "@modelcontextprotocol/sdk": "1.30.1", "@posthog/react": "1.11.2", "@resvg/resvg-js": "2.6.2", @@ -35,7 +36,9 @@ "@tanstack/react-start": "1.168.58", "better-auth": "catalog:", "drizzle-orm": "catalog:", + "gsap": "3.15.0", "lucide-react": "1.48.0", + "motion": "14.0.0", "pdfkit": "0.20.2", "posthog-js": "1.434.15", "react": "catalog:", diff --git a/apps/site/src/components/CardStudio.tsx b/apps/site/src/components/CardStudio.tsx index a8d85d2..53c0c61 100644 --- a/apps/site/src/components/CardStudio.tsx +++ b/apps/site/src/components/CardStudio.tsx @@ -49,14 +49,14 @@ export function SteadyImage({ src, alt, width, height, className = "", eager = f } /** Picks a Card and dresses it: a preset, an accent, a background, corners; then gives the README Markdown, links and images, all carrying the style. */ -export function CardStudio({ choices, state, onChange, origin }: { choices: CardChoice[]; state: StudioState; onChange: (next: StudioState) => void; origin: string }) { +export function CardStudio({ choices, state, onChange, origin, pinned = true }: { choices: CardChoice[]; state: StudioState; onChange: (next: StudioState) => void; origin: string; pinned?: boolean }) { const choice = choices.find((c) => c.id === state.card) ?? choices[0]; const query = useSettled(styleQuery(state.style), 220); const set = (style: Partial) => onChange({ ...state, style: { ...state.style, ...style } }); if (!choice) return null; return (
    -
    +
    onChange({ ...state, mode: m as "light" | "dark" })}> @@ -70,7 +70,7 @@ export function CardStudio({ choices, state, onChange, origin }: { choices: Card
    -
    +
    onChange({ ...state, card })} /> onChange({ ...state, style: DEFAULT_STYLE })} />
    @@ -233,16 +233,16 @@ function Outputs({ choice, query, origin }: { choice: CardChoice; query: string;
    - - - + + +
    -
    +
    {tab === "readme" && (

    Paste into a README. GitHub shows the dark Card to readers in dark mode, animated, and it refreshes every six hours.

    diff --git a/apps/site/src/components/Facts.tsx b/apps/site/src/components/Facts.tsx index f5a7139..a161c13 100644 --- a/apps/site/src/components/Facts.tsx +++ b/apps/site/src/components/Facts.tsx @@ -20,11 +20,11 @@ export function Chip({ icon, children, title, tone }: { icon?: ReactNode; childr } /** A repository's hero: its owner's face, its name and what GitHub says of it, drawn at once from the lookup, with the page's actions. */ -export function RepoHero({ owner, name, facts, status, actions }: { owner: string; name: string; facts: Data | null; status?: ReactNode; actions?: ReactNode }) { +export function RepoHero({ owner, name, facts, status, actions, attached = false }: { owner: string; name: string; facts: Data | null; status?: ReactNode; actions?: ReactNode; attached?: boolean }) { const language = facts?.languages[0]?.name; return ( -
    - +
    +
    diff --git a/apps/site/src/components/Footer.tsx b/apps/site/src/components/Footer.tsx new file mode 100644 index 0000000..605c5d8 --- /dev/null +++ b/apps/site/src/components/Footer.tsx @@ -0,0 +1,96 @@ +import { Link, useRouteContext } from "@tanstack/react-router"; +import { PRODUCT } from "@commitscape/data"; +import { Logo } from "@commitscape/ui"; + +const YEAR = new Date().getUTCFullYear(); +const SOURCE = "https://github.com/pixelactstudio/commitscape"; + +type Column = { title: string; links: [string, string][] }; + +/** Every page's footer: what commitscape is, who makes it, and where to go next. */ +export function Footer() { + const { user } = useRouteContext({ from: "__root__" }); + const me = user ? `/u/${user.login}` : null; + const columns: Column[] = [ + { title: "Explore", links: [["/leaderboards", "Leaderboards"], ["/vs", "Versus"], ["/races", "Races"], ["/crews", "Crews"]] }, + { + title: "You", + links: [ + [me ?? "/me", me ? "Your Profile" : "Sign in"], + [me ? `${me}/cards` : "/me", "Your Cards"], + [me ? `${me}/work` : "/me", "Proof of Work"], + [me ? `${me}/wrapped/${YEAR}` : "/me", `Wrapped ${YEAR}`], + ["/privacy", "What we keep"], + ], + }, + { + title: "Open source", + links: [ + [SOURCE, "Source"], + [`${SOURCE}#install`, "The command line"], + [`${SOURCE}/blob/main/CHANGELOG.md`, "Changelog"], + [`${SOURCE}/blob/main/LICENSE-MIT`, "MIT or Apache-2.0"], + ], + }, + { + title: "Damn Labs", + links: [ + ["https://damnlabs.com", "Damn Labs"], + ["https://hexlode.damnlabs.com", "Hexlode"], + ["https://envsift.damnlabs.com", "EnvSift"], + ["https://pixelactstudio.com", "Pixelact Studio"], + ], + }, + ]; + return ( +
    +
    +
    + + + {PRODUCT} + +

    What you have built on GitHub, where you stand among the people you build with, and Cards to share it. Every number says what it counts. Open source under MIT or Apache-2.0.

    +

    + Built by{" "} + + Damn Labs + + , a{" "} + + Pixelact Studio + {" "} + product. +

    +
    + +
    +
    + + © {YEAR} Dev Talan + + Numbers from GitHub and git history, never guessed. +
    +
    + {PRODUCT} +
    +
    + ); +} diff --git a/apps/site/src/components/Frame.tsx b/apps/site/src/components/Frame.tsx index 8bad031..5b2dd42 100644 --- a/apps/site/src/components/Frame.tsx +++ b/apps/site/src/components/Frame.tsx @@ -11,6 +11,7 @@ import { Briefcase, Flag, Image as ImageIcon, LogOut, Menu, Monitor, Moon, Searc import { PRODUCT } from "@commitscape/data"; import { Logo, useMode, type Mode } from "@commitscape/ui"; import { signIn, signOut } from "#/lib/auth-client"; +import { Footer } from "#/components/Footer"; import { SearchPalette } from "#/components/SearchPalette"; const NAV = [ @@ -165,44 +166,3 @@ function MobileMenu() { /> ); } - -function Footer() { - const { user } = useRouteContext({ from: "__root__" }); - return ( -
    -
    -
    - - - {PRODUCT} - -

    What you have built, how you stand next to the people you build with, and cards to share it. Every number says what it counts.

    -
    - -
    -
    - ); -} - -function FootLinks({ title, links }: { title: string; links: [string, string][] }) { - return ( -
    - {title} - {links.map(([to, label]) => - to.startsWith("http") ? ( - - {label} - - ) : ( - - {label} - - ), - )} -
    - ); -} diff --git a/apps/site/src/components/Lookup.tsx b/apps/site/src/components/Lookup.tsx index 78b3eb6..ca13035 100644 --- a/apps/site/src/components/Lookup.tsx +++ b/apps/site/src/components/Lookup.tsx @@ -52,7 +52,7 @@ export function Lookup({ autoFocus = false, label = "Show me" }: { autoFocus?: b if (to) void navigate({ to: to as "/" }); }} renderItem={(item) => } - status={wrong ? { type: "error", message: "Type a GitHub username, like gaearon, or a repository, like facebook/react." } : undefined} + status={wrong ? { type: "error", message: "Type a GitHub username, like gaearon, or a repository, like react/react." } : undefined} statusVariant="detached" />
    diff --git a/apps/site/src/components/ShareDialog.tsx b/apps/site/src/components/ShareDialog.tsx index 2441dc8..f8e8a53 100644 --- a/apps/site/src/components/ShareDialog.tsx +++ b/apps/site/src/components/ShareDialog.tsx @@ -18,7 +18,7 @@ export function ShareButton({ choices, origin, label = "Share", title = "Share a {" "} + to see your private work counted too. Read-only, and you can hide. + + )} +
    +
    + +
    +
    + ); +} diff --git a/apps/site/src/home/Install.tsx b/apps/site/src/home/Install.tsx new file mode 100644 index 0000000..54cdb8c --- /dev/null +++ b/apps/site/src/home/Install.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Button } from "@astryxdesign/core/Button"; +import { Icon } from "@astryxdesign/core/Icon"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; +import { Check, Copy, SquareTerminal } from "lucide-react"; +import { Reveal, STAGGER } from "@commitscape/ui/motion"; +import { Band } from "./layout"; +import { TerminalScene } from "./scenes/TerminalScene"; + +const INSTALL: Record = { + npm: { label: "npm", command: "npx commitscape", note: "Or npm install -g commitscape." }, + brew: { label: "Homebrew", command: "brew install pixelactstudio/commitscape/commitscape", note: "Then run commitscape in any repository." }, + nix: { label: "Nix", command: "nix run github:pixelactstudio/commitscape", note: "No install; Nix builds and runs it." }, + share: { label: "Share", command: "npx commitscape share", note: "Prints a link only its holder can open. It expires within hours." }, +}; + +/** The command line: how to install it, and a terminal showing a run and a shared link. */ +export function Install() { + const [tab, setTab] = useState("npm"); + const [copied, setCopied] = useState(false); + const pick = INSTALL[tab] ?? INSTALL.npm; + return ( + +
    +
    + + + + Or keep it on your machine + + + + The same numbers, from your terminal + + + Run it in any git repository. Nothing leaves your machine, and a link shares a Report from a server without holding the terminal open. + + + { + setTab(v); + setCopied(false); + }} + size="sm" + > + {Object.entries(INSTALL).map(([k, v]) => ( + + ))} + +
    + + $ + + {pick?.command} +
    +

    {pick?.note}

    +
    +
    +
    + +
    +
    +
    + ); +} diff --git a/apps/site/src/home/layout.tsx b/apps/site/src/home/layout.tsx new file mode 100644 index 0000000..204780f --- /dev/null +++ b/apps/site/src/home/layout.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { Reveal, STAGGER } from "@commitscape/ui/motion"; + +/** The home page's frame: a centred column ruled on both sides that every band sits in. */ +export function Rails({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +/** One band of the home page, ruled above, with a cross where the rule meets each side. */ +export function Band({ children, className = "", label }: { children: ReactNode; className?: string; label?: string }) { + return ( +
    + + + {children} +
    + ); +} + +/** A section's centred heading that blurs into view: a small labelled chip, the title and a line of explanation. */ +export function Heading({ icon: Glyph, eyebrow, title, words }: { icon: LucideIcon; eyebrow: string; title: ReactNode; words: string }) { + return ( +
    + + + + {eyebrow} + + + + {title} + + + {words} + +
    + ); +} diff --git a/apps/site/src/home/scenes/CardsScene.tsx b/apps/site/src/home/scenes/CardsScene.tsx new file mode 100644 index 0000000..9d8d0fc --- /dev/null +++ b/apps/site/src/home/scenes/CardsScene.tsx @@ -0,0 +1,135 @@ +import type { CSSProperties } from "react"; +import { Check, Copy } from "lucide-react"; +import { BEAT, DURATION, GSAP_EASE, useScene } from "@commitscape/ui/motion"; +import { DEFAULT_STYLE, Face, Logo, lookOf, PRESETS, styleQuery, type CardStyle, type PresetId } from "@commitscape/ui"; +import { DAN } from "./data"; +import { compact, point, press, whole } from "./kit"; +import { Cursor } from "./parts"; + +type Step = { style: CardStyle; theme: "light" | "dark" }; + +const SHOWN: PresetId[] = ["classic", "ocean", "sunset", "grape", "paper"]; +const ACCENTS = ["#12b24a", "#7c5cff", "#f43f5e", "#eab308"]; +const PINK = "#f43f5e"; + +const STEPS: { target: string; step: Step }[] = [ + { target: "[data-preset=ocean]", step: { style: { ...DEFAULT_STYLE, preset: "ocean" }, theme: "dark" } }, + { target: `[data-accent="${PINK}"]`, step: { style: { ...DEFAULT_STYLE, preset: "ocean", accent: PINK }, theme: "dark" } }, + { target: "[data-preset=paper]", step: { style: { ...DEFAULT_STYLE, preset: "paper" }, theme: "light" } }, +]; + +const FIRST: Step = { style: DEFAULT_STYLE, theme: "dark" }; + +function vars({ style, theme }: Step): Record { + const look = lookOf(theme, style); + return { "--c-bg": look.t.bg, "--c-text": look.t.text, "--c-muted": look.t.muted, "--c-accent": look.t.accent, "--c-border": look.t.border }; +} + +function art({ style, theme }: Step) { + const look = lookOf(theme, style); + return { backgroundImage: look.backgroundImage ?? "none", backgroundSize: look.backgroundSize ?? "auto" }; +} + +function swatch(id: PresetId): CSSProperties { + const p = PRESETS[id].dark; + return { background: `linear-gradient(135deg, ${p.bg} 50%, ${p.accent} 50%)` }; +} + +/** The Card studio at work: the cursor picks presets and an accent, the Card changes colour, and the Markdown follows. */ +export function CardsScene() { + const ref = useScene( + (tl, q, root) => { + const cursor = q("[data-cursor]")[0]; + const card = q("[data-card]")[0] as HTMLElement | undefined; + const glow = q("[data-glow]")[0] as HTMLElement | undefined; + const query = q("[data-query]")[0]; + const copy = q("[data-copy]")[0]; + const wide = window.matchMedia("(min-width: 40rem)").matches; + const dress = (s: Step) => { + if (!card) return; + for (const [k, v] of Object.entries(vars(s))) card.style.setProperty(k, v); + if (glow) Object.assign(glow.style, art(s)); + if (query) query.textContent = styleQuery(s.style) || "preset=classic"; + q("[data-preset]").forEach((el) => el.toggleAttribute("data-on", el.getAttribute("data-preset") === s.style.preset)); + q("[data-accent]").forEach((el) => el.toggleAttribute("data-on", el.getAttribute("data-accent") === s.style.accent)); + }; + const pick = (target: Element | undefined, s: Step, at: string | number) => { + point(tl, cursor, target, root, at); + press(tl, cursor, target, ">"); + tl.to(glow ?? {}, { opacity: 0, duration: DURATION.fast }, "<"); + tl.call(() => dress(s), undefined, ">"); + tl.to(glow ?? {}, { opacity: 1, duration: DURATION.slow }, ">"); + }; + tl.set(cursor ?? {}, { x: () => root.clientWidth * 0.5, y: () => root.clientHeight * 0.95, opacity: 0 }, 0); + STEPS.filter((s) => wide || !s.target.startsWith("[data-accent")).forEach((s, i) => pick(q(s.target)[0], s.step, i === 0 ? BEAT.short : `+=${BEAT.base}`)); + if (wide && copy) { + point(tl, cursor, copy, root, `+=${BEAT.base}`); + press(tl, cursor, copy, ">"); + tl.call(() => copy.toggleAttribute("data-on", true), undefined, ">"); + tl.call(() => copy.toggleAttribute("data-on", false), undefined, `+=${BEAT.long}`); + } + pick(q("[data-preset=classic]")[0], FIRST, `+=${BEAT.base}`); + tl.to(cursor ?? {}, { opacity: 0, duration: DURATION.base, ease: GSAP_EASE.in }, `+=${BEAT.short}`); + }, + { still: 0 }, + ); + const first = vars(FIRST) as CSSProperties; + return ( +
    +
    + +
    + +
    + {DAN.name} + @{DAN.login} +
    +
    +
    + {[ + [compact(DAN.lines), "lines still running"], + [whole(DAN.merged), "pull requests merged"], + [whole(DAN.reviews), "reviews given"], + ].map(([v, l], i) => ( +
    + {v} + {l} +
    + ))} +
    +
    + + commitscape +
    +
    +
    +
    + Preset +
    + {SHOWN.map((id) => ( + + ))} +
    +
    +
    + Accent +
    + {ACCENTS.map((c) => ( + + ))} +
    +
    +
    + + totals.svg?preset=classic + + + + + +
    +
    + +
    + ); +} diff --git a/apps/site/src/home/scenes/HeroCards.tsx b/apps/site/src/home/scenes/HeroCards.tsx new file mode 100644 index 0000000..59c0a20 --- /dev/null +++ b/apps/site/src/home/scenes/HeroCards.tsx @@ -0,0 +1,72 @@ +import { useRef, type PointerEvent, type ReactNode } from "react"; +import { AtSign, BriefcaseBusiness, FileText } from "lucide-react"; +import { BEAT, DISTANCE, DURATION, GSAP_EASE, motion, SPRING, useReducedMotion, useScene, useSpring, useTransform, type MotionValue } from "@commitscape/ui/motion"; +import { ThemedCard } from "#/components/ThemedCard"; + +const CARDS = [ + { src: "/api/cards/u/gaearon/repositories", alt: "gaearon's top repositories", width: 600, height: 400, place: "top-[3%] right-0 w-[80%]", turn: 5, depth: 10, chip: { icon: FileText, label: "In your README" }, chipPlace: "-top-3 left-6" }, + { src: "/api/cards/u/gaearon/calendar", alt: "gaearon's last year", width: 855, height: 236, place: "top-[41%] left-0 w-[90%]", turn: -3, depth: 18, chip: { icon: BriefcaseBusiness, label: "On LinkedIn" }, chipPlace: "-top-3 right-8" }, + { src: "/api/cards/u/gaearon/totals", alt: "gaearon's totals", width: 600, height: 236, place: "bottom-[1%] right-[4%] w-[72%]", turn: 1.5, depth: 26, chip: { icon: AtSign, label: "In a post on X" }, chipPlace: "-bottom-3 left-8" }, +]; + +function Layer({ x, y, depth, children }: { x: MotionValue; y: MotionValue; depth: number; children: ReactNode }) { + const tx = useTransform(x, (v) => v * depth); + const ty = useTransform(y, (v) => v * depth); + return {children}; +} + +/** Real Cards made for gaearon, leaning toward the pointer; one at a time steps forward and says where it goes. */ +export function HeroCards() { + const reduce = useReducedMotion(); + const box = useRef(null); + const x = useSpring(0, SPRING.gentle); + const y = useSpring(0, SPRING.gentle); + const ref = useScene( + (tl, q) => { + const slots = q("[data-hero-slot]"); + const cards = q("[data-hero-card]"); + const chips = q("[data-hero-chip]"); + cards.forEach((card, i) => { + const turn = CARDS[i]?.turn ?? 0; + const chip = chips[i]; + const at = i === 0 ? BEAT.base : `+=${BEAT.short}`; + tl.set(slots[i] ?? {}, { zIndex: CARDS.length + 1 }, at); + tl.to(card, { y: -DISTANCE.rise, rotation: turn / 3, scale: 1.035, duration: DURATION.slow, ease: GSAP_EASE.out }, "<"); + tl.to(chip ?? {}, { opacity: 1, y: 0, duration: DURATION.base, ease: GSAP_EASE.pop }, `<+${DURATION.fast}`); + tl.to(chip ?? {}, { opacity: 0, y: DISTANCE.nudge, duration: DURATION.fast, ease: GSAP_EASE.in }, `+=${BEAT.hold}`); + tl.to(card, { y: 0, rotation: turn, scale: 1, duration: DURATION.slow, ease: GSAP_EASE.inOut }, "<"); + tl.set(slots[i] ?? {}, { zIndex: i + 1 }, ">"); + }); + }, + { repeatDelay: BEAT.base, still: 0 }, + ); + const move = (e: PointerEvent) => { + if (reduce || e.pointerType !== "mouse" || !box.current) return; + const r = box.current.getBoundingClientRect(); + x.set(0.5 - (e.clientX - r.left) / r.width); + y.set(0.5 - (e.clientY - r.top) / r.height); + }; + const leave = () => { + x.set(0); + y.set(0); + }; + return ( +
    +
    + {CARDS.map((c, i) => ( +
    + +
    + + + + {c.chip.label} + +
    +
    +
    + ))} +
    +
    + ); +} diff --git a/apps/site/src/home/scenes/ProfileScene.tsx b/apps/site/src/home/scenes/ProfileScene.tsx new file mode 100644 index 0000000..7078df4 --- /dev/null +++ b/apps/site/src/home/scenes/ProfileScene.tsx @@ -0,0 +1,67 @@ +import { Flame } from "lucide-react"; +import { BEAT, DISTANCE, DURATION, GSAP_EASE, STAGGER, useScene } from "@commitscape/ui/motion"; +import { Face } from "@commitscape/ui"; +import { CALENDAR_WEEKS, DAN, levels } from "./data"; +import { compact, count, whole } from "./kit"; +import { Mini } from "./parts"; + +const STATS = [ + { label: "pull requests merged", value: DAN.merged, format: whole }, + { label: "reviews given", value: DAN.reviews, format: whole }, + { label: "lines still running", value: DAN.lines, format: compact }, +]; + +const CELLS = levels(CALENDAR_WEEKS * 7, 3); + +/** A Profile filling in: the numbers count up and the last year lights up week by week. */ +export function ProfileScene() { + const ref = useScene( + (tl, q) => { + const stats = q("[data-stat]"); + const lit = q("[data-lit]"); + const streak = q("[data-streak]"); + tl.from(stats, { opacity: 0, y: DISTANCE.nudge, duration: DURATION.slow, stagger: STAGGER.loose }, 0); + const resets = q("[data-num]").map((el, i) => count(tl, el, 0, STATS[i]?.value ?? 0, i * STAGGER.loose, { format: STATS[i]?.format, duration: BEAT.long })); + tl.from(lit, { opacity: 0, scale: 0.3, duration: DURATION.base, ease: GSAP_EASE.pop, stagger: { amount: BEAT.long } }, DURATION.fast); + tl.from(streak, { opacity: 0, scale: 0.85, duration: DURATION.base, ease: GSAP_EASE.pop }, `>-${DURATION.base}`); + tl.addLabel("shown"); + tl.to([...stats, ...lit, ...streak], { opacity: 0, duration: DURATION.base, ease: GSAP_EASE.in }, `+=${BEAT.hold}`); + tl.call(() => resets.forEach((reset) => reset())); + }, + { still: "shown" }, + ); + return ( +
    + +
    + +
    + {DAN.name} + @{DAN.login} +
    + + + {DAN.streak}-day streak + +
    +
    + {STATS.map((s) => ( +
    + + {s.format(s.value)} + + {s.label} +
    + ))} +
    +
    +
    + {CELLS.map((v, i) => + v ? : , + )} +
    +
    +
    +
    + ); +} diff --git a/apps/site/src/home/scenes/RaceScene.tsx b/apps/site/src/home/scenes/RaceScene.tsx new file mode 100644 index 0000000..cddc149 --- /dev/null +++ b/apps/site/src/home/scenes/RaceScene.tsx @@ -0,0 +1,91 @@ +import { Crown } from "lucide-react"; +import { BEAT, DURATION, GSAP_EASE, useScene } from "@commitscape/ui/motion"; +import { Face } from "@commitscape/ui"; +import { RACERS } from "./data"; +import { count } from "./kit"; +import { Mini, MiniHead } from "./parts"; + +const DAYS = RACERS[0]?.days.length ?? 7; +const MAX = Math.max(...RACERS.flatMap((r) => r.days)); + +function leader(day: number) { + let best = 0; + RACERS.forEach((r, i) => { + if ((r.days[day] ?? 0) > (RACERS[best]?.days[day] ?? 0)) best = i; + }); + return best; +} + +/** A week's Race between friends: day by day the bars grow and the lead changes hands. */ +export function RaceScene() { + const ref = useScene( + (tl, q) => { + const bars = q("[data-rbar]"); + const nums = q("[data-rnum]"); + const crowns = q("[data-crown]"); + const day = q("[data-day]")[0]; + const won = q("[data-won]")[0]; + const resets: (() => void)[] = []; + tl.set(bars, { scaleX: 0 }, 0); + tl.set(crowns, { opacity: 0, scale: 0.6 }, 0); + tl.set(won ?? {}, { opacity: 0, scale: 0.8 }, 0); + for (let d = 0; d < DAYS; d++) { + const at = d === 0 ? BEAT.short : `+=${DURATION.base}`; + tl.addLabel(`d${d}`, at); + tl.call(() => day && (day.textContent = `Day ${d + 1} of ${DAYS}`), undefined, `d${d}`); + RACERS.forEach((r, i) => { + tl.to(bars[i] ?? {}, { scaleX: (r.days[d] ?? 0) / MAX, duration: DURATION.slow, ease: GSAP_EASE.out }, `d${d}`); + const reset = count(tl, nums[i], d === 0 ? 0 : (r.days[d - 1] ?? 0), r.days[d] ?? 0, `d${d}`, { duration: DURATION.slow, prime: d === 0 }); + if (d === 0) resets.push(reset); + }); + const lead = leader(d); + tl.to(crowns, { opacity: (i) => (i === lead ? 1 : 0), scale: (i) => (i === lead ? 1 : 0.6), duration: DURATION.base, ease: GSAP_EASE.pop }, `d${d}`); + } + tl.to(won ?? {}, { opacity: 1, scale: 1, duration: DURATION.base, ease: GSAP_EASE.pop }, `+=${DURATION.base}`); + tl.addLabel("shown"); + tl.to([...bars, won], { opacity: 0, duration: DURATION.base, ease: GSAP_EASE.in }, `+=${BEAT.hold}`); + tl.call(() => resets.forEach((reset) => reset())); + tl.set(bars, { opacity: 1, scaleX: 0 }); + }, + { still: "shown" }, + ); + const last = DAYS - 1; + const lead = leader(last); + return ( +
    + + + Day {DAYS} of {DAYS} + + } + /> +
    + {RACERS.map((r, i) => ( +
    + +
    +
    + {r.name} + + + {r.days[last]} + +
    + + + +
    +
    + ))} +
    +
    + Ends Sunday at midnight + You won +
    +
    +
    + ); +} diff --git a/apps/site/src/home/scenes/StandingScene.tsx b/apps/site/src/home/scenes/StandingScene.tsx new file mode 100644 index 0000000..5193f1b --- /dev/null +++ b/apps/site/src/home/scenes/StandingScene.tsx @@ -0,0 +1,119 @@ +import { BEAT, DURATION, GSAP_EASE, useScene } from "@commitscape/ui/motion"; +import { Face } from "@commitscape/ui"; +import { STANDING_FOCUS, STANDING_PEOPLE, STANDING_VIEWS } from "./data"; +import { compact, count, point, press } from "./kit"; +import { Cursor, Mini } from "./parts"; + +const ROW = 30; + +const ORDINAL = ["1st", "2nd", "3rd", "4th", "5th"]; + +function slots(view: number) { + const order = STANDING_PEOPLE.map((p, i) => ({ i, v: p.values[view] ?? 0 })).sort((a, b) => b.v - a.v); + const out: number[] = []; + order.forEach((o, slot) => (out[o.i] = slot)); + return out; +} + +function widest(view: number) { + return Math.max(...STANDING_PEOPLE.map((p) => p.values[view] ?? 0)); +} + +const FIRST = slots(0); + +/** A repository's people ranked view by view: the cursor picks a view and the rows trade places. */ +export function StandingScene() { + const ref = useScene( + (tl, q, root) => { + const cursor = q("[data-cursor]")[0]; + const tabs = q("[data-tab]"); + const pill = q("[data-pill]")[0]; + const rows = q("[data-row]"); + const nums = q("[data-num]"); + const bars = q("[data-bar]"); + const rank = q("[data-rank]")[0]; + const unit = q("[data-unit]")[0]; + const focus = STANDING_PEOPLE.findIndex((p) => p.login === STANDING_FOCUS); + const steps = [1, 2, 0]; + let from = 0; + tl.set(cursor ?? {}, { x: () => root.clientWidth * 0.72, y: () => root.clientHeight * 0.9, opacity: 0 }, 0); + steps.forEach((view, n) => { + const at = n === 0 ? BEAT.base : `+=${BEAT.hold}`; + point(tl, cursor, tabs[view], root, at); + press(tl, cursor, tabs[view], ">"); + tl.addLabel(`view${view}`, ">"); + tl.to(pill ?? {}, { xPercent: view * 100, duration: DURATION.base, ease: GSAP_EASE.out }, `view${view}`); + tl.call(() => { + tabs.forEach((t, i) => t.toggleAttribute("data-on", i === view)); + if (unit) unit.textContent = STANDING_VIEWS[view]?.unit ?? ""; + if (rank) rank.textContent = ORDINAL[slots(view)[focus] ?? 0] ?? ""; + }, undefined, `view${view}`); + const to = slots(view); + rows.forEach((row, i) => { + tl.to(row, { y: ((to[i] ?? 0) - (FIRST[i] ?? 0)) * ROW, duration: DURATION.slow, ease: GSAP_EASE.inOut }, `view${view}`); + const a = STANDING_PEOPLE[i]?.values[from] ?? 0; + const b = STANDING_PEOPLE[i]?.values[view] ?? 0; + count(tl, nums[i], a, b, `view${view}`, { format: compact, duration: DURATION.slow, prime: false }); + tl.to(bars[i] ?? {}, { scaleX: b / widest(view), duration: DURATION.slow, ease: GSAP_EASE.inOut }, `view${view}`); + }); + from = view; + }); + tl.to(cursor ?? {}, { opacity: 0, duration: DURATION.base }, `+=${BEAT.base}`); + }, + { still: 0 }, + ); + return ( +
    + +
    + react/react + + gaearon is 5th + +
    +
    + + {STANDING_VIEWS.map((v, i) => ( + + {v.label} + + ))} +
    +
    +
      + {STANDING_PEOPLE.map((_, i) => ( +
    1. + {i + 1} +
    2. + ))} +
    +
    + {STANDING_PEOPLE.map((p, i) => { + const me = p.login === STANDING_FOCUS; + return ( +
    + + + + + {p.login} + + {compact(p.values[0] ?? 0)} + +
    + ); + })} +
    +
    +
    + {STANDING_VIEWS[0].unit} +
    +
    + +
    + ); +} diff --git a/apps/site/src/home/scenes/TerminalScene.tsx b/apps/site/src/home/scenes/TerminalScene.tsx new file mode 100644 index 0000000..a951d57 --- /dev/null +++ b/apps/site/src/home/scenes/TerminalScene.tsx @@ -0,0 +1,114 @@ +import { BEAT, DISTANCE, DURATION, GSAP_EASE, STAGGER, useScene } from "@commitscape/ui/motion"; +import { count, type } from "./kit"; + +const RUN = "npx commitscape"; +const SHARE = "npx commitscape share --yes"; +const LINK = "https://commitscape.damnlabs.com/s/k3f9q2x7#0dbf…"; +const TABS = ["Overview", "People", "Activity", "Map", "Commits"]; +const WEEKS = [3, 5, 4, 7, 6, 9, 5, 8, 11, 7, 10, 12]; +const PEAK = Math.max(...WEEKS); +const ROWS: { label: string; value?: number; text?: string; note?: string; bars?: boolean }[] = [ + { label: "Commits", value: 412, bars: true }, + { label: "People", value: 9, note: "4 Maintainers" }, + { label: "Bus Factor", value: 2, note: "src/engine/" }, + { label: "Top Hotspot", text: "src/engine/blame.rs", note: "2nd most changed of 40" }, +]; + +/** The command line at work: a run draws a small Report in the terminal, then `share` prints a link. */ +export function TerminalScene() { + const ref = useScene( + (tl, q) => { + const runText = q("[data-run]")[0]; + const shareText = q("[data-share]")[0]; + const report = q("[data-report]")[0]; + const lines = q("[data-line]"); + const bars = q("[data-week]"); + const out = q("[data-out]"); + const first = q("[data-first]")[0]; + const second = q("[data-second]")[0]; + tl.set(first ?? {}, { opacity: 1 }, 0); + tl.set(second ?? {}, { opacity: 0 }, 0); + type(tl, runText, RUN, BEAT.short); + tl.from(report ?? {}, { opacity: 0, y: DISTANCE.nudge, duration: DURATION.base }, `+=${DURATION.base}`); + tl.from(lines, { opacity: 0, x: -DISTANCE.nudge, duration: DURATION.base, stagger: STAGGER.loose }, "<"); + tl.from(bars, { scaleY: 0, duration: DURATION.base, ease: GSAP_EASE.out, stagger: STAGGER.tight }, "<"); + q("[data-tnum]").forEach((el, i) => { + const v = ROWS.filter((r) => r.value !== undefined)[i]?.value ?? 0; + count(tl, el, 0, v, "<", { duration: DURATION.scene }); + }); + tl.addLabel("ran", `+=${DURATION.scene}`); + tl.to(first ?? {}, { opacity: 0, duration: DURATION.base, ease: GSAP_EASE.in }, `+=${BEAT.hold}`); + tl.set(second ?? {}, { opacity: 1 }); + type(tl, shareText, SHARE, ">"); + tl.from(out, { opacity: 0, y: DISTANCE.nudge, duration: DURATION.base, stagger: STAGGER.loose * 3 }, `+=${DURATION.slow}`); + tl.to(second ?? {}, { opacity: 0, duration: DURATION.base, ease: GSAP_EASE.in }, `+=${BEAT.hold}`); + }, + { still: "ran" }, + ); + return ( +
    +
    + {["#ff5f57", "#febc2e", "#28c840"].map((c) => ( + + ))} + ~/code/my-project +
    +
    +
    +
    + $ + {RUN} + +
    +
    +
    + {TABS.map((t, i) => ( + + {i + 1} {t} + + ))} +
    +
    +
    + my-project · last 90 days +
    + {ROWS.map((r) => ( +
    + {r.label} + {r.value !== undefined ? ( + + {r.value} + + ) : ( + {r.text} + )} + {r.bars && ( + + {WEEKS.map((h, i) => ( + + ))} + + )} + {r.note && {r.note}} +
    + ))} +
    +
    +
    +
    +
    + $ + {SHARE} + +
    +
    + {LINK} +
    +
    + It works in any browser and expires in 4 hours. +
    +
    +
    +
    + ); +} diff --git a/apps/site/src/home/scenes/VersusScene.tsx b/apps/site/src/home/scenes/VersusScene.tsx new file mode 100644 index 0000000..13db4e9 --- /dev/null +++ b/apps/site/src/home/scenes/VersusScene.tsx @@ -0,0 +1,80 @@ +import { BEAT, DURATION, GSAP_EASE, STAGGER, useScene } from "@commitscape/ui/motion"; +import { Face } from "@commitscape/ui"; +import { ANDREW, DAN, VERSUS } from "./data"; +import { compact, count, whole } from "./kit"; +import { Mini } from "./parts"; + +const short = (n: number) => (n >= 10_000 ? compact(n) : whole(n)); + +/** Two people's numbers race view by view; each view gets its own winner. */ +export function VersusScene() { + const ref = useScene( + (tl, q) => { + const resets: (() => void)[] = []; + tl.set(q("[data-vbar], [data-win]"), { opacity: 1 }, 0); + q("[data-vrow]").forEach((row, i) => { + const r = VERSUS[i]; + if (!r) return; + const at = i * (DURATION.slow + STAGGER.loose); + const [left, right] = Array.from(row.querySelectorAll("[data-vbar]")); + const [a, b] = Array.from(row.querySelectorAll("[data-vnum]")); + const win = row.querySelector("[data-win]"); + tl.from([left, right], { scaleX: 0, duration: DURATION.scene, ease: GSAP_EASE.out }, at); + resets.push(count(tl, a, 0, r.a, at, { format: short, suffix: r.unit, duration: DURATION.scene })); + resets.push(count(tl, b, 0, r.b, at, { format: short, suffix: r.unit, duration: DURATION.scene })); + tl.from(win ?? {}, { scale: 0, opacity: 0, duration: DURATION.base, ease: GSAP_EASE.pop }, at + DURATION.scene - DURATION.fast); + }); + tl.addLabel("shown"); + tl.to(q("[data-vbar], [data-win]"), { opacity: 0, duration: DURATION.base, ease: GSAP_EASE.in }, `+=${BEAT.hold}`); + tl.call(() => resets.forEach((reset) => reset())); + }, + { still: "shown" }, + ); + return ( +
    + +
    + + + {DAN.login} + + vs + + {ANDREW.login} + + +
    +
    + {VERSUS.map((r) => { + const max = Math.max(r.a, r.b); + const leftWins = r.a > r.b; + return ( +
    +
    + + {short(r.a)} + {r.unit} + + {r.label} + + {short(r.b)} + {r.unit} + +
    +
    + + + + + + + +
    +
    + ); + })} +
    +
    +
    + ); +} diff --git a/apps/site/src/home/scenes/WorkScene.tsx b/apps/site/src/home/scenes/WorkScene.tsx new file mode 100644 index 0000000..aa0e9e3 --- /dev/null +++ b/apps/site/src/home/scenes/WorkScene.tsx @@ -0,0 +1,84 @@ +import { Check } from "lucide-react"; +import { BEAT, DURATION, GSAP_EASE, STAGGER, useScene } from "@commitscape/ui/motion"; +import { WORK } from "./data"; +import { count, point, press, whole } from "./kit"; +import { Cursor, Mini, MiniHead } from "./parts"; + +const TOTAL = WORK.reduce((n, w) => n + w.added, 0); +const EXPORTS = ["Markdown", "Link", "PDF"]; + +/** A month of merged pull requests checked off one by one, totalled, then saved as a PDF. */ +export function WorkScene() { + const ref = useScene( + (tl, q, root) => { + const cursor = q("[data-cursor]")[0]; + const ticks = q("[data-tick]"); + const rows = q("[data-item]"); + const pdf = q("[data-export=PDF]")[0]; + tl.set(cursor ?? {}, { x: () => root.clientWidth * 0.3, y: () => root.clientHeight * 0.95, opacity: 0 }, 0); + tl.from(rows, { opacity: 0.35, duration: DURATION.base, stagger: DURATION.slow }, BEAT.short); + tl.from(ticks, { scale: 0, duration: DURATION.base, ease: GSAP_EASE.pop, stagger: DURATION.slow }, BEAT.short); + const reset = count(tl, q("[data-total]")[0], 0, TOTAL, BEAT.short, { duration: DURATION.slow * WORK.length, format: (v) => `+${whole(v)}`, suffix: " lines" }); + const n = count(tl, q("[data-count]")[0], 0, WORK.length, BEAT.short, { duration: DURATION.slow * WORK.length }); + point(tl, cursor, pdf, root, `+=${BEAT.short}`); + press(tl, cursor, pdf, ">"); + tl.call(() => pdf?.toggleAttribute("data-on", true), undefined, ">"); + tl.to(cursor ?? {}, { opacity: 0, duration: DURATION.base }, `+=${BEAT.base}`); + tl.addLabel("shown"); + tl.to([...ticks], { scale: 0, duration: DURATION.fast, stagger: STAGGER.tight, ease: GSAP_EASE.in }, `+=${BEAT.base}`); + tl.to(rows, { opacity: 0.35, duration: DURATION.fast }, "<"); + tl.call(() => { + reset(); + n(); + pdf?.toggleAttribute("data-on", false); + }); + }, + { still: "shown" }, + ); + return ( +
    + + + {EXPORTS.map((e) => ( + + {e} + + ))} + + } + /> +
      + {WORK.map((w) => ( +
    • + + + + + + + {w.title} + {w.repo} + + +{w.added} +
    • + ))} +
    +
    + + + {WORK.length} + {" "} + pull requests merged + + + +{whole(TOTAL)} lines + +
    +
    + +
    + ); +} diff --git a/apps/site/src/home/scenes/WrappedScene.tsx b/apps/site/src/home/scenes/WrappedScene.tsx new file mode 100644 index 0000000..d816ebf --- /dev/null +++ b/apps/site/src/home/scenes/WrappedScene.tsx @@ -0,0 +1,63 @@ +import { CalendarDays, Code2, Flame } from "lucide-react"; +import { BEAT, DISTANCE, DURATION, GSAP_EASE, STAGGER, useScene } from "@commitscape/ui/motion"; +import { levels, YEAR } from "./data"; + +const LINE = 72; +const YEARS = Array.from({ length: 8 }, (_, i) => YEAR - 7 + i); +const DAYS = levels(20 * 7, 9); +const CHIPS = [ + { icon: CalendarDays, label: "busiest day" }, + { icon: Code2, label: "top language" }, + { icon: Flame, label: "longest streak" }, +]; + +/** A year counter rolls up to this year, then the year fills in and its highlights appear. */ +export function WrappedScene() { + const ref = useScene( + (tl, q) => { + const list = q("[data-years]")[0]; + const year = q("[data-year]")[0]; + const days = q("[data-day]"); + const chips = q("[data-chip]"); + tl.set(list ?? {}, { y: 0 }, 0); + tl.from(year ?? {}, { opacity: 0, duration: DURATION.base }, 0); + YEARS.slice(1).forEach((_, i) => { + const last = i === YEARS.length - 2; + tl.to(list ?? {}, { y: -(i + 1) * LINE, duration: last ? DURATION.slow : DURATION.fast, ease: last ? GSAP_EASE.pop : GSAP_EASE.inOut }, i === 0 ? BEAT.short : `+=${STAGGER.base}`); + }); + tl.fromTo(year ?? {}, { scale: 1 }, { scale: 1.06, duration: DURATION.fast, yoyo: true, repeat: 1, ease: GSAP_EASE.inOut }, ">"); + tl.from(days, { opacity: 0, scale: 0.3, duration: DURATION.base, ease: GSAP_EASE.pop, stagger: { amount: DURATION.scene } }, "<"); + tl.from(chips, { opacity: 0, y: DISTANCE.nudge, duration: DURATION.base, stagger: STAGGER.loose }, `-=${DURATION.slow}`); + tl.addLabel("shown"); + tl.to([year, ...days, ...chips], { opacity: 0, duration: DURATION.base, ease: GSAP_EASE.in }, `+=${BEAT.hold}`); + }, + { still: "shown" }, + ); + return ( +
    + Wrapped +
    +
    + {YEARS.map((y) => ( + + {y} + + ))} +
    +
    +
    + {DAYS.map((v, i) => ( + + ))} +
    +
    + {CHIPS.map(({ icon: Glyph, label }) => ( + + + {label} + + ))} +
    +
    + ); +} diff --git a/apps/site/src/home/scenes/data.ts b/apps/site/src/home/scenes/data.ts new file mode 100644 index 0000000..5e75e0e --- /dev/null +++ b/apps/site/src/home/scenes/data.ts @@ -0,0 +1,54 @@ +export const YEAR = new Date().getUTCFullYear(); + +export const DAN = { login: "gaearon", name: "dan", lines: 78_000, merged: 2928, reviews: 3669, streak: 43 }; +export const ANDREW = { login: "acdlite", name: "Andrew Clark", lines: 75_000, merged: 1465, reviews: 1364, streak: 28 }; + +export const VERSUS = [ + { label: "Lines that still run", a: DAN.lines, b: ANDREW.lines, unit: "" }, + { label: "Pull requests merged", a: DAN.merged, b: ANDREW.merged, unit: "" }, + { label: "Reviews given", a: DAN.reviews, b: ANDREW.reviews, unit: "" }, + { label: "Longest streak", a: DAN.streak, b: ANDREW.streak, unit: " days" }, +]; + +export const STANDING_VIEWS = [ + { id: "lines", label: "Lines", unit: "lines still running" }, + { id: "prs", label: "PRs", unit: "pull requests" }, + { id: "commits", label: "Commits", unit: "commits" }, +] as const; + +export const STANDING_PEOPLE = [ + { login: "josephsavona", name: "Joe Savona", values: [140_000, 285, 3500] }, + { login: "sebmarkbage", name: "sebmarkbage", values: [137_000, 1500, 2700] }, + { login: "acdlite", name: "acdlite", values: [75_000, 1000, 1800] }, + { login: "bvaughn", name: "Brian Vaughn", values: [67_000, 738, 1600] }, + { login: "gaearon", name: "gaearon", values: [56_000, 968, 2000] }, +]; + +export const STANDING_FOCUS = "gaearon"; + +export const WORK = [ + { title: "Count streaks in the person's own time zone", repo: "acme/api", added: 184 }, + { title: "Dark theme for README Cards", repo: "acme/web", added: 312 }, + { title: "Follow renamed folders through history", repo: "acme/engine", added: 421 }, + { title: "Blame large files in one pass", repo: "acme/engine", added: 263 }, +]; + +export const RACERS = [ + { name: "You", you: true, days: [2, 3, 5, 8, 9, 12, 15] }, + { name: "Mira Kovacs", you: false, days: [3, 5, 6, 7, 10, 11, 13] }, + { name: "Sam Okafor", you: false, days: [1, 4, 7, 9, 9, 10, 11] }, + { name: "Leo Park", you: false, days: [2, 2, 3, 5, 6, 8, 9] }, +]; + +export const CALENDAR_WEEKS = 46; + +/** A steady, made-up year of activity for drawings: the same levels on the server and in the browser. */ +export function levels(count: number, seed: number): number[] { + return Array.from({ length: count }, (_, i) => { + const x = Math.sin(i * 12.9898 + seed * 78.233) * 43758.5453; + const v = x - Math.floor(x); + const busy = 0.55 + 0.45 * Math.sin(i / 23 + seed); + const lit = v * busy; + return lit > 0.5 ? 4 : lit > 0.36 ? 3 : lit > 0.24 ? 2 : lit > 0.14 ? 1 : 0; + }); +} diff --git a/apps/site/src/home/scenes/kit.ts b/apps/site/src/home/scenes/kit.ts new file mode 100644 index 0000000..2cc8b9e --- /dev/null +++ b/apps/site/src/home/scenes/kit.ts @@ -0,0 +1,66 @@ +import { DURATION, GSAP_EASE, TYPING } from "@commitscape/ui/motion"; + +type Timeline = gsap.core.Timeline; +type Position = gsap.Position; + +export const whole = (n: number) => Math.round(n).toLocaleString("en-US"); + +export const compact = (n: number) => (n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : `${Math.round(n)}`); + +/** + * Counts an element's text from one number to another, showing the first number from the start unless `prime` is + * false; returns a call that puts the first number back. + */ +export function count(tl: Timeline, el: Element | undefined, from: number, to: number, at: Position, { duration = DURATION.scene, format = whole, suffix = "", prime = true }: { duration?: number; format?: (n: number) => string; suffix?: string; prime?: boolean } = {}) { + if (!el) return () => {}; + const box = { v: from }; + const write = () => { + el.textContent = `${format(box.v)}${suffix}`; + }; + tl.fromTo(box, { v: from }, { v: to, duration, ease: GSAP_EASE.out, onUpdate: write, immediateRender: false }, at); + if (prime) write(); + return () => { + box.v = from; + write(); + }; +} + +/** Types text into an element one character at a time; the element starts empty. */ +export function type(tl: Timeline, el: Element | undefined, text: string, at: Position) { + if (!el) return; + const box = { n: 0 }; + el.textContent = ""; + tl.fromTo( + box, + { n: 0 }, + { + n: text.length, + duration: text.length * TYPING.char, + ease: "none", + immediateRender: false, + onUpdate: () => { + el.textContent = text.slice(0, Math.round(box.n)); + }, + }, + at, + ); +} + +function centre(el: Element, root: Element) { + const a = el.getBoundingClientRect(); + const b = root.getBoundingClientRect(); + return { x: a.left - b.left + a.width / 2, y: a.top - b.top + a.height / 2 }; +} + +/** Glides the scene's cursor to the middle of an element, measured when the move starts. */ +export function point(tl: Timeline, cursor: Element | undefined, target: Element | undefined, root: Element | null, at: Position) { + if (!cursor || !target || !root) return; + tl.to(cursor, { x: () => centre(target, root).x, y: () => centre(target, root).y, opacity: 1, duration: DURATION.slow, ease: GSAP_EASE.inOut }, at); +} + +/** The cursor presses and the element under it gives a little. */ +export function press(tl: Timeline, cursor: Element | undefined, target: Element | undefined, at: Position) { + if (!cursor || !target) return; + tl.to(cursor, { scale: 0.82, duration: DURATION.instant, yoyo: true, repeat: 1, ease: GSAP_EASE.inOut }, at); + tl.to(target, { scale: 0.94, duration: DURATION.instant, yoyo: true, repeat: 1, ease: GSAP_EASE.inOut }, "<"); +} diff --git a/apps/site/src/home/scenes/parts.tsx b/apps/site/src/home/scenes/parts.tsx new file mode 100644 index 0000000..564f920 --- /dev/null +++ b/apps/site/src/home/scenes/parts.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +/** The pointer that moves and clicks inside a scene. Starts hidden in the top-left corner. */ +export function Cursor() { + return ( + + + + + + ); +} + +/** A small window of the product drawn inside a tile. */ +export function Mini({ children, className = "" }: { children: ReactNode; className?: string }) { + return
    {children}
    ; +} + +/** The row at the top of a Mini: a title and something on the right. */ +export function MiniHead({ title, end }: { title: ReactNode; end?: ReactNode }) { + return ( +
    + {title} + {end && {end}} +
    + ); +} diff --git a/apps/site/src/lib/lookup.tsx b/apps/site/src/lib/lookup.tsx index 12385dc..93dbd29 100644 --- a/apps/site/src/lib/lookup.tsx +++ b/apps/site/src/lib/lookup.tsx @@ -12,7 +12,7 @@ const EXAMPLES: Visit[] = [ { kind: "person", id: "gaearon" }, { kind: "person", id: "torvalds" }, { kind: "person", id: "sindresorhus" }, - { kind: "repo", id: "facebook/react" }, + { kind: "repo", id: "react/react" }, ]; function visitItem(v: Visit, group: string): Item { diff --git a/apps/site/src/lib/window.ts b/apps/site/src/lib/window.ts index e09d92c..945caa3 100644 --- a/apps/site/src/lib/window.ts +++ b/apps/site/src/lib/window.ts @@ -1,22 +1,14 @@ import type { WindowStandings } from "@commitscape/data"; import { many } from "@commitscape/ui"; +export { shortRange } from "@commitscape/ui"; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const DAY_MS = 86_400_000; const parts = (iso: string) => ({ y: Number(iso.slice(0, 4)), m: Number(iso.slice(5, 7)), d: Number(iso.slice(8, 10)) }); const days = (a: string, b: string) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAY_MS); -/** Two days as a short range: "1–7 Oct 2026", "28 Sep – 4 Oct 2026". */ -export function shortRange(from: string, to: string): string { - const a = parts(from); - const b = parts(to); - const end = `${b.d} ${MONTHS[b.m - 1]} ${b.y}`; - if (a.y !== b.y) return `${a.d} ${MONTHS[a.m - 1]} ${a.y} – ${end}`; - if (a.m !== b.m) return `${a.d} ${MONTHS[a.m - 1]} – ${end}`; - return `${a.d}–${end}`; -} - /** Where a window stands today, on UTC's calendar: which day of how many, and how long until it starts or ends. */ export function windowClock(from: string, to: string, today: string = new Date().toISOString().slice(0, 10)) { const total = days(from, to) + 1; diff --git a/apps/site/src/routes/__root.tsx b/apps/site/src/routes/__root.tsx index 2f43859..df15d4d 100644 --- a/apps/site/src/routes/__root.tsx +++ b/apps/site/src/routes/__root.tsx @@ -10,6 +10,7 @@ import { LinkProvider } from "@astryxdesign/core/Link"; import { ToastViewport } from "@astryxdesign/core/Toast"; import { PRODUCT } from "@commitscape/data"; import { commitscapeTheme, MODE_COOKIE, ModeContext, themeCss, type Mode } from "@commitscape/ui"; +import { MotionProvider } from "@commitscape/ui/motion"; import styles from "@commitscape/ui/styles.css?url"; import type { getViewer } from "#/functions/account"; import { rememberViewer, viewer } from "#/lib/viewer"; @@ -89,9 +90,11 @@ function Root() { - - - + + + + + diff --git a/apps/site/src/routes/gh.$owner.$repo.tsx b/apps/site/src/routes/gh.$owner.$repo.tsx index 46c4362..e3f1437 100644 --- a/apps/site/src/routes/gh.$owner.$repo.tsx +++ b/apps/site/src/routes/gh.$owner.$repo.tsx @@ -1,14 +1,14 @@ -import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Banner } from "@astryxdesign/core/Banner"; import { Button } from "@astryxdesign/core/Button"; import { Icon } from "@astryxdesign/core/Icon"; import { Skeleton } from "@astryxdesign/core/Skeleton"; import { Spinner } from "@astryxdesign/core/Spinner"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router"; import { Check, Download, ExternalLink, History, Share2 } from "lucide-react"; import { FAILURE_WORDS, PRODUCT, type Lookup, type View } from "@commitscape/data"; -import { App, Leaderboard, Page, Panel, ScreenSkeleton, SourceContext, toRoute, toSearch, type Route as Where } from "@commitscape/ui"; +import { App, hallOfFameCardSize, Leaderboard, Page, Panel, SCREEN_SKELETONS, ScreenBar, SourceContext, toRoute, toSearch, type Route as Where } from "@commitscape/ui"; import { startBuild } from "#/functions/repos"; import { lookupQuery, reportHeadQuery, running, standingsQuery } from "#/lib/queries"; import { siteSource } from "#/lib/source"; @@ -34,6 +34,7 @@ export const Route = createFileRoute("/gh/$owner/$repo")({ validateSearch: (search: Record) => toSearch(search), loader: async ({ params, context }) => { const lookup = await context.queryClient.ensureQueryData(lookupQuery(params.owner, params.repo)); + if (lookup.id !== `${params.owner}/${params.repo}`.toLowerCase()) throw redirect({ to: "/gh/$owner/$repo", params: { owner: lookup.owner, repo: lookup.name }, search: true, replace: true }); return { name: `${lookup.owner}/${lookup.name}`, description: lookup.facts?.description ?? null, @@ -99,6 +100,7 @@ function Repository() { return ( <> } /> - - {lookup.report ? ( - <> - {!lookup.report.lines && ( -
    - -
    - )} - }> - - - - ) : ( + {lookup.report ? ( + }> + + +
    + ) + } + /> + + ) : ( + - )} - + + )} ); } function ReportSkeleton() { + const where = toRoute(Route.useSearch()); + const Screen = SCREEN_SKELETONS[where.screen]; return ( -
    -
    - {[64, 52, 58, 36, 64].map((w, i) => ( - - ))} -
    - +
    + + + +
    ); } -function Report({ owner, repo, at }: { owner: string; repo: string; at: number }) { +function Report({ owner, repo, at, notice }: { owner: string; repo: string; at: number; notice?: ReactNode }) { const where = toRoute(Route.useSearch()); const navigate = useNavigate({ from: Route.fullPath }); const { card } = Route.useLoaderData(); @@ -168,14 +174,14 @@ function Report({ owner, repo, at }: { owner: string; repo: string; at: number } ), overview: card && !head.private ? ( -
    }> +
    }>
    ) : undefined, }; return ( - + ); } @@ -195,19 +201,15 @@ function Standings({ owner, repo }: { owner: string; repo: string }) { return ; } -function hallSize(people: number) { - return { width: 720, height: 150 + Math.min(10, people) * 40 }; -} - function HallShare({ owner, repo, label, variant }: { owner: string; repo: string; label: string; variant: "primary" | "secondary" }) { const { origin } = Route.useLoaderData(); const { data } = useSuspenseQuery(standingsQuery(owner, repo)); const choice = { id: "hall-of-fame", - title: "Hall of fame", - about: "The people who built it, with their faces and numbers.", + title: "Repository card", + about: "What it is, its stars and forks, and the people who built it, with their faces and numbers.", url: `/api/cards/gh/${owner}/${repo}/hall-of-fame`, - ...hallSize(data.people.length), + ...hallOfFameCardSize(data.people.length), link: `/gh/${owner}/${repo}`, share: `The people who built ${owner}/${repo}.`, alt: `The people who built ${owner}/${repo}`, @@ -218,12 +220,12 @@ function HallShare({ owner, repo, label, variant }: { owner: string; repo: strin function HallOfFame({ owner, repo }: { owner: string; repo: string }) { const { data } = useSuspenseQuery(standingsQuery(owner, repo)); if (data.people.length === 0) return null; - const size = hallSize(data.people.length); + const size = hallOfFameCardSize(data.people.length); const src = `/api/cards/gh/${owner}/${repo}/hall-of-fame`; return ( diff --git a/apps/site/src/routes/index.tsx b/apps/site/src/routes/index.tsx index 9aea743..61e96b6 100644 --- a/apps/site/src/routes/index.tsx +++ b/apps/site/src/routes/index.tsx @@ -1,348 +1,23 @@ -import { useState, type ReactNode } from "react"; -import { Button } from "@astryxdesign/core/Button"; -import { Icon } from "@astryxdesign/core/Icon"; -import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; -import { createFileRoute, Link, useRouteContext } from "@tanstack/react-router"; -import { ArrowRight, Briefcase, Check, Copy, Image as ImageIcon, LayoutDashboard, Sparkles, Swords, Trophy, Users } from "lucide-react"; +import { createFileRoute } from "@tanstack/react-router"; import { PRODUCT } from "@commitscape/data"; -import { Face, Page } from "@commitscape/ui"; -import { signIn } from "#/lib/auth-client"; -import { Lookup } from "#/components/Lookup"; -import { ThemedCard } from "#/components/ThemedCard"; +import { Closing } from "#/home/Closing"; +import { Features } from "#/home/Features"; +import { Hero } from "#/home/Hero"; +import { Install } from "#/home/Install"; +import { Rails } from "#/home/layout"; export const Route = createFileRoute("/")({ head: () => ({ meta: [{ title: `${PRODUCT}: what you have built, in numbers worth sharing` }] }), component: Landing, }); -const EXAMPLES: [string, string][] = [ - ["gaearon", "React, then Bluesky"], - ["BurntSushi", "ripgrep"], - ["sindresorhus", "a thousand packages"], - ["torvalds", "Linux and git"], -]; - -const YEAR = new Date().getUTCFullYear(); - function Landing() { - const { user } = useRouteContext({ from: "__root__" }); - return ( - <> -
    - -
    - - - Open source. Reads GitHub and git history; never guesses. - -

    - What you've built, in numbers worth sharing. -

    -

    - Your pull requests, reviews and the lines of yours that still run. Where you stand in every repository you work on. And Cards for your README, LinkedIn and X. -

    -
    - -
    -
    - Try - {EXAMPLES.map(([login, words]) => ( - - - {login} - - ))} -
    -
    - {user ? ( - - Open your Profile, @{user.login} - - ) : ( - <> - Or{" "} - {" "} - to see your private work counted too. Read-only, and you can hide. - - )} -
    -
    - -
    -
    - - -
    - -
    - - - - - - - - - - - - - - - - - - - - - -
    -
    - -
    - - -
    - -
    -
    -

    Your turn.

    -

    Type your GitHub username. Your Profile is read from GitHub at once.

    -
    - -
    -
    -
    -
    - - ); -} - -function Intro({ eyebrow, title, words }: { eyebrow: string; title: string; words: string }) { - return ( -
    - {eyebrow} -

    {title}

    -

    {words}

    -
    - ); -} - -function Feature({ icon, title, words, to, cta, children, className = "" }: { icon: typeof Trophy; title: string; words: string; to: string; cta: string; children: ReactNode; className?: string }) { - const Glyph = icon; - return ( - -
    - {children} -
    -
    - - - {title} - - {words} - - {cta} - - -
    - - ); -} - -function wave(n: number, seed: number) { - return Array.from({ length: n }, (_, i) => { - const x = Math.sin(i * 12.9898 + seed * 78.233) * 43758.5453; - return x - Math.floor(x); - }); -} - -function ProfileArt() { - const cells = wave(26 * 7, 3); - return ( -
    -
    - {["pull requests merged", "reviews given", "lines still running"].map((l, i) => ( -
    - - {l} -
    - ))} -
    -
    - {cells.map((v, i) => ( - 0.62 ? `var(--green-${Math.min(4, 1 + Math.floor((v - 0.62) * 10))})` : "var(--empty)" }} /> - ))} -
    -
    - ); -} - -function StandingArt() { - const rows = [0.92, 0.74, 0.61, 0.43, 0.3]; - return ( -
      - {rows.map((w, i) => ( -
    1. - {i + 1} - - - {i === 2 && you} -
    2. - ))} -
    - ); -} - -function VersusArt() { - const rows = [ - [0.8, 0.55], - [0.4, 0.9], - [0.7, 0.62], - [0.3, 0.45], - ]; - return ( -
    -
    - you - vs - them -
    - {rows.map(([a = 0, b = 0], i) => ( -
    - - b ? "bg-brand" : "bg-[var(--color-track)]"}`} style={{ width: `${a * 100}%` }} /> - - a ? "bg-[var(--s1)]" : "bg-[var(--color-track)]"}`} style={{ width: `${b * 100}%` }} /> -
    - ))} -
    - ); -} - -const PRESET_ART = [ - ["#0c0d0f", "#3ccf74"], - ["linear-gradient(135deg,#1e1b4b,#4c1d95)", "#c4b5fd"], - ["linear-gradient(135deg,#fff7ed,#fde68a)", "#ea580c"], - ["linear-gradient(135deg,#042f2e,#0e7490)", "#5eead4"], - ["#ffffff", "#2a78d6"], -]; - -function CardsArt() { - return ( -
    - {PRESET_ART.map(([bg, accent], i) => ( - - - - {[0.4, 0.7, 0.55, 0.9, 0.65].map((h, j) => ( - - ))} - - - ))} -
    - ); -} - -function WrappedArt() { - return ( -
    - {YEAR} -
    - ); -} - -function WorkArt() { - return ( -
    - {[0.8, 0.62, 0.7, 0.48].map((w, i) => ( -
    - {i < 3 && } - - +{Math.round(w * 400)} -
    - ))} -
    - ); -} - -function RaceArt() { - return ( -
    - {[0.86, 0.7, 0.52].map((w, i) => ( -
    - - - - -
    - ))} -
    - ); -} - -function CardFan() { - return ( -
    -
    - -
    -
    - -
    -
    - -
    -
    - ); -} - -const INSTALL: Record = { - npm: { label: "npm", command: "npx commitscape", note: "Or npm install -g commitscape." }, - brew: { label: "Homebrew", command: "brew install pixelactstudio/commitscape/commitscape", note: "Then run commitscape in any repository." }, - nix: { label: "Nix", command: "nix run github:pixelactstudio/commitscape", note: "No install; Nix builds and runs it." }, - share: { label: "Share", command: "npx commitscape share", note: "Prints a link only its holder can open. It expires within hours." }, -}; - -function Terminal() { - const [tab, setTab] = useState("npm"); - const [copied, setCopied] = useState(false); - const pick = INSTALL[tab] ?? INSTALL.npm; return ( -
    - { setTab(v); setCopied(false); }} size="sm"> - {Object.entries(INSTALL).map(([k, v]) => ( - - ))} - -
    -
    - {["#ff5f57", "#febc2e", "#28c840"].map((c) => ( - - ))} - ~/code/my-project -
    -
    - $ - {pick?.command} -
    -
    -

    {pick?.note}

    -
    + + + + + + ); } diff --git a/apps/site/src/routes/leaderboards.tsx b/apps/site/src/routes/leaderboards.tsx index 2a15fac..f0b5c59 100644 --- a/apps/site/src/routes/leaderboards.tsx +++ b/apps/site/src/routes/leaderboards.tsx @@ -1,15 +1,20 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { Button } from "@astryxdesign/core/Button"; +import { Dialog, DialogHeader } from "@astryxdesign/core/Dialog"; +import { Icon } from "@astryxdesign/core/Icon"; import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; import { Selector } from "@astryxdesign/core/Selector"; import { Skeleton } from "@astryxdesign/core/Skeleton"; import { Tab, TabList } from "@astryxdesign/core/TabList"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { ChevronDown, CircleHelp, Star } from "lucide-react"; import { monthName, PRODUCT, type Board } from "@commitscape/data"; -import { compact, date, Face, grouped, many, Page, PageHead, Panel } from "@commitscape/ui"; +import { compact, date, Face, grouped, many, Page, PageHead } from "@commitscape/ui"; +import { CountUp, Nothing } from "@commitscape/ui/motion"; import { Section } from "#/components/Boundary"; import { boardsQuery, peopleBoardsQuery } from "#/lib/queries"; +import type { PeopleBoard, PeopleBoards, SeedRepo } from "#/server/people-boards"; type Window = "season" | "last-season" | "90d" | "all"; const WINDOWS: [Window, string][] = [ @@ -33,16 +38,24 @@ export const Route = createFileRoute("/leaderboards")({ component: Leaderboards, }); +const shortDay = (iso: string) => new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" }); + +function spanOf(data: Pick): { title: string; dates: string | null } { + if (!data.from || !data.to) return { title: "All time", dates: null }; + const to = data.to > data.today ? data.today : data.to; + const dates = data.from === to ? shortDay(to) : `${shortDay(data.from)} to ${shortDay(to)}`; + if (data.window === "season") return { title: `${monthName(data.from.slice(0, 7))}, so far`, dates }; + if (data.window === "last-season") return { title: monthName(data.from.slice(0, 7)), dates }; + return { title: "The last 90 days", dates }; +} + function Leaderboards() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); const tab = search.tab ?? "people"; return ( - + } />
    void navigate({ search: (s) => ({ ...s, tab: t === "people" ? undefined : (t as "repositories") }), resetScroll: false })}> @@ -62,13 +75,97 @@ function Leaderboards() { ); } +function HowItWorks({ label = "How these boards work", variant = "secondary" }: { label?: string; variant?: "secondary" | "ghost" }) { + const [open, setOpen] = useState(false); + const { data } = useQuery({ ...peopleBoardsQuery("season", null), enabled: open }); + return ( + <> +
    + )}
    ); } +function Counted({ data }: { data: PeopleBoards }) { + const shown = data.repo ? data.repositories.filter((r) => `${r.owner}/${r.name}` === data.repo) : data.repositories; + return ( +
    +
    +

    + {data.repo ? `Counting ${data.repo} only.` : `Counting ${many(data.repositories.length, "repository", "repositories")}:`} {data.repo ? "Pick All repositories to count every one." : "the most starred on GitHub in each language, read by commitscape every night. Work elsewhere on GitHub is not on these boards, so a dozen pull requests here can lead."} + {!data.repo && data.waiting > 0 && ` ${many(data.waiting, "more waits", "more wait")} for a first read.`} +

    + +
    + {shown.length === 0 ? ( + No repository has been read yet: the boards fill after the first night. + ) : ( +
      + {shown.map((r) => ( +
    • + +
    • + ))} +
    + )} +
    + ); +} + const MEDALS = ["#e8b931", "#b8bec7", "#c98a54"]; -function PersonBoard({ board, repo }: { board: { id: string; title: string; how: string; unit: [string, string]; short: [string, string]; rows: { login: string; value: number; repositories: number }[] }; repo: string | null }) { - const [all, setAll] = useState(false); +function PersonBoard({ board, repo, places }: { board: PeopleBoard; repo: string | null; places: number }) { const podium = board.rows.slice(0, 3); - const rest = board.rows.slice(3, all ? undefined : 10); + const rest = board.rows.slice(3, 3 + places); const href = (login: string) => (repo ? `/u/${login}/${repo}` : `/u/${login}`); const order = ["order-2", "order-1", "order-3"]; return ( - +
    +
    +

    {board.title}

    +

    {board.how}

    +
    {board.rows.length === 0 ? ( -

    Nobody in this window yet.

    +
    + +
    ) : ( <> -
    - {podium.map((r, place) => { - const height = [96, 72, 56][place]; +
    + {[0, 1, 2].map((place) => { + const r = podium[place]; + if (!r) return ; + const height = [92, 68, 52][place]; return ( - - {place + 1} + + {r.place} {r.login} - - {compact(r.value)} + + compact(Math.round(n))} /> + {r.value === 1 ? board.short[0] : board.short[1]} ); })}
    -
      - {rest.map((r, i) => ( -
    1. - - {i + 4} +
        + {rest.map((r) => ( +
      1. + + {r.place} {r.login} + {!repo && r.repositories > 1 && in {r.repositories}} {grouped(r.value)} - {!repo && r.repositories > 1 && · {r.repositories} repos}
      2. ))} + {rest.length < places &&
      3. {board.rows.length <= 3 ? "Nobody else in this window." : "Nobody else yet."}
      4. }
      - {board.rows.length > 10 && ( -
      -
      - )} )} - +
    ); } +const REPO_ROWS = 5; + function Repositories() { const { data: boards } = useSuspenseQuery(boardsQuery()); - if (boards.boards.length === 0) return

    No boards yet: they are written once the first night's repositories are read.

    ; + const [all, setAll] = useState(false); + const filled = boards.boards.filter((b) => b.rows.length > 0); + const empty = boards.boards.filter((b) => b.rows.length === 0); + if (filled.length === 0) return ; + const longest = Math.max(...filled.map((b) => b.rows.length)); + const rows = all ? longest : REPO_ROWS; return (
    - {boards.from > 0 && ( -

    - Ranked by what commitscape measures in their history. Updated {date(boards.builtAt)}, from {grouped(boards.from)} of the most starred repositories on GitHub in each language. -

    - )} -
    - {boards.boards.map((b) => ( - +

    + The people boards count {many(boards.from, "repository", "repositories")}; here {boards.from === 1 ? "it is" : "they are"} ranked by what commitscape measures in their history. Updated {date(boards.builtAt)}. + {empty.length > 0 && ` No repository qualifies for ${empty.map((b) => `"${b.title}"`).join(" or ")} yet, so ${empty.length === 1 ? "it is" : "they are"} left out.`} +

    +
    + {filled.map((b, i) => ( + ))}
    + {longest > REPO_ROWS && ( +
    +
    + )}
    ); } -function RepoBoard({ board }: { board: Board }) { +function RepoBoard({ board, rows, wide }: { board: Board; rows: number; wide: boolean }) { + const lower = board.id === "answers"; + const values = board.rows.map((r) => r.value); + const best = lower ? Math.min(...values) : Math.max(...values); + const width = (v: number) => (lower ? best / Math.max(v, 0.001) : v / Math.max(best, 0.001)); return ( - - {board.rows.length === 0 ? ( -

    None yet.

    - ) : ( -
      - {board.rows.map((r, i) => ( -
    1. - - {i + 1} - - - +
      +
      +

      {board.title}

      +

      {board.how}

      +
      +
        + {board.rows.slice(0, rows).map((r, i) => ( +
      1. + + + {i + 1} + + + + + {r.owner}/ {r.name} - - {[r.language, r.stars > 0 ? `★ ${compact(r.stars)}` : null].filter(Boolean).join(" · ")} + {r.shown} + + + + {r.language && {r.language}} + {r.stars > 0 && ( + + {compact(r.stars)} + + )} + + + - {r.shown} - -
      2. - ))} -
      - )} - + + +
    2. + ))} + {board.rows.length < rows &&
    3. No other repository qualifies.
    4. } +
    + ); } diff --git a/apps/site/src/routes/s.$id.tsx b/apps/site/src/routes/s.$id.tsx index b85ab6d..bd66d72 100644 --- a/apps/site/src/routes/s.$id.tsx +++ b/apps/site/src/routes/s.$id.tsx @@ -6,7 +6,7 @@ import { Skeleton } from "@astryxdesign/core/Skeleton"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { KeyRound, Lock, Timer, Trash2 } from "lucide-react"; import { deleteToken, readReport, reportSource, unlock, type DataSource } from "@commitscape/data"; -import { App, Page, PageHead, ScreenSkeleton, SourceContext, toRoute, toSearch, type Route as Where } from "@commitscape/ui"; +import { App, Page, PageHead, SCREEN_SKELETONS, ScreenBar, SourceContext, toRoute, toSearch, type Route as Where } from "@commitscape/ui"; import { Chip } from "#/components/Facts"; import { shareKey } from "#/share-key"; @@ -27,9 +27,9 @@ function left(expiresAt: number): string { const ABOUT = "Locked on the machine that made it, with a key only its link holds: the Site stores what it cannot read."; -function Head({ title, chips, actions }: { title: ReactNode; chips?: ReactNode; actions?: ReactNode }) { +function Head({ title, chips, actions, attached = false }: { title: ReactNode; chips?: ReactNode; actions?: ReactNode; attached?: boolean }) { return ( -
    +
    @@ -114,26 +116,22 @@ function SharedReport() { } actions={
    @@ -76,14 +89,35 @@ function PersonInRepository() {
    }> - +
    ); } -function Headline({ login, owner, repo }: { login: string; owner: string; repo: string }) { +function useRepository(owner: string, repo: string): Lookup { + return useSuspenseQuery(lookupQuery(owner, repo)).data; +} + +function Headline(where: Where) { + const found = useRepository(where.owner, where.repo); + if (found.report) return ; + if (running(found)) + return ( + + Reading its history now + + ); + if (found.status !== "ok") return null; + return ( + + Not read by {PRODUCT} yet + + ); +} + +function Place({ login, owner, repo }: Where) { const { data: standings } = useSuspenseQuery(standingsQuery(owner, repo, login)); const row = standings.people.find((r) => r.login?.toLowerCase() === login.toLowerCase()); const top = row ? topLine(standings, placesFor(standings, row.key)) : null; @@ -95,7 +129,12 @@ function Headline({ login, owner, repo }: { login: string; owner: string; repo: ); } -function Share({ login, name, owner, repo }: { login: string; name: string; owner: string; repo: string }) { +function Share(where: Where) { + const found = useRepository(where.owner, where.repo); + return found.report ? : null; +} + +function ShareStanding({ login, name, owner, repo }: Where) { const { origin } = Route.useLoaderData(); const { data: standings } = useSuspenseQuery(standingsQuery(owner, repo, login)); const row = standings.people.find((r) => r.login?.toLowerCase() === login.toLowerCase()); @@ -106,7 +145,7 @@ function Share({ login, name, owner, repo }: { login: string; name: string; owne title="Share where they stand" choices={[ { id: "standing", title: "Standing", about: "Their place in each view.", url: `/api/cards/u/${login}/${owner}/${repo}/standing`, ...STANDING_CARD_SIZE, link: `/u/${login}/${owner}/${repo}`, share: `Where I stand in ${owner}/${repo}.`, alt: `${name} in ${owner}/${repo}` }, - { id: "hall", title: "Hall of fame", about: "The repository's people.", url: `/api/cards/gh/${owner}/${repo}/hall-of-fame`, width: 720, height: 150 + Math.min(10, standings.people.length) * 40, link: `/gh/${owner}/${repo}`, share: `The people who built ${owner}/${repo}.`, alt: `The people who built ${owner}/${repo}` }, + { id: "hall", title: "Hall of fame", about: "The repository's people.", url: `/api/cards/gh/${owner}/${repo}/hall-of-fame`, ...hallOfFameCardSize(standings.people.length), link: `/gh/${owner}/${repo}`, share: `The people who built ${owner}/${repo}.`, alt: `The people who built ${owner}/${repo}` }, ]} /> ); @@ -126,7 +165,119 @@ function BodySkeleton() { ); } -function Body({ login, owner, repo, name }: { login: string; owner: string; repo: string; name: string }) { +function Standing(where: Where) { + const found = useRepository(where.owner, where.repo); + if (found.report) return ; + return ; +} + +function WayOut({ login, name, owner, repo, tone = "secondary" }: Where & { tone?: "secondary" | "ghost" }) { + return ( + <> +
    + ); +} + +function Boxed({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +function Actions({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +function useSince(from: number): number | null { + const hydrated = useHydrated(); + const [at, setAt] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const t = setInterval(() => setAt(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(t); + }, []); + return hydrated ? Math.max(0, at - from) : null; +} + +function Building({ build, ...where }: Where & { build: NonNullable }) { + const { owner, repo, name } = where; + const since = useSince(build.requestedAt); + const step = build.state === "queued" ? "queued" : (build.step ?? "reading"); + return ( + + + {since === null ? " " : since >= 60 ? `${Math.floor(since / 60)} min ${String(since % 60).padStart(2, "0")} s` : `${since} s`} + + } + > +
    +

    + {STEP_WORDS[step] ?? STEP_WORDS.reading} +

    +
    +
    +
    +
    + ); +} + +function Body(where: Where) { + const { login, owner, repo, name } = where; const { data: standings } = useSuspenseQuery(standingsQuery(owner, repo, login)); const row = standings.people.find((r) => r.login?.toLowerCase() === login.toLowerCase()); const [view, setView] = useState(() => viewsOf(standings)[0]?.id ?? "commits"); @@ -142,11 +293,20 @@ function Body({ login, owner, repo, name }: { login: string; owner: string; repo

    ) : ( -
    - {name} has no commits, pull requests or reviews in {owner}/{repo} that commitscape has read. -
    + + + + + } + /> + )} - + {standings.people.length > 0 && } ); } diff --git a/apps/site/src/routes/u.$login_.work.tsx b/apps/site/src/routes/u.$login_.work.tsx index 9f43a26..45fb600 100644 --- a/apps/site/src/routes/u.$login_.work.tsx +++ b/apps/site/src/routes/u.$login_.work.tsx @@ -1,38 +1,38 @@ import { useState } from "react"; import { Button } from "@astryxdesign/core/Button"; import { CheckboxInput } from "@astryxdesign/core/CheckboxInput"; -import type { ISODateString } from "@astryxdesign/core/Calendar"; -import { DateInput } from "@astryxdesign/core/DateInput"; import { Dialog, DialogHeader } from "@astryxdesign/core/Dialog"; import { Icon } from "@astryxdesign/core/Icon"; -import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; import { Selector } from "@astryxdesign/core/Selector"; import { Skeleton } from "@astryxdesign/core/Skeleton"; import { TextInput } from "@astryxdesign/core/TextInput"; import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { ArrowLeft, Copy, Download, Link2, Lock, Search, Share2 } from "lucide-react"; -import { groupWork, periodDates, PERIODS, PRODUCT, type Period, type Work } from "@commitscape/data"; -import { Face, many, Page, PageHead, WorkSkeleton, WorkView } from "@commitscape/ui"; +import { ArrowLeft, Copy, Download, Link2, Lock, Share2 } from "lucide-react"; +import { groupWork, inFilter, periodDates, PRODUCT, WORK_KINDS, type Work, type WorkKind } from "@commitscape/data"; +import { Face, many, Page, PageHead, PeriodPicker, RepoPicker, WorkSkeleton, WorkView, type WorkLook } from "@commitscape/ui"; import { Section } from "#/components/Boundary"; import { Missing } from "#/components/Missing"; import { shareMyWork } from "#/functions/work"; import { profileLookupQuery, workQuery } from "#/lib/queries"; import { useToast } from "#/lib/toast"; -type Search = { from: string; to: string; filter?: string }; -type Iso = ISODateString; +type Search = { from: string; to: string; filter?: string; group?: "repository"; kind?: WorkKind }; const DATE = /^\d{4}-\d{2}-\d{2}$/; -const SHORT: Record = { "last-month": "Last month", "this-month": "This month", "last-3-months": "3 months", "this-year": "This year", "last-year": "Last year" }; - export const Route = createFileRoute("/u/$login_/work")({ validateSearch: (s: Record): Search => { const last = periodDates("last-month"); const from = typeof s.from === "string" && DATE.test(s.from) ? s.from : last.from; const to = typeof s.to === "string" && DATE.test(s.to) ? s.to : last.to; - return { from, to, ...(typeof s.filter === "string" && s.filter ? { filter: s.filter } : {}) }; + return { + from, + to, + ...(typeof s.filter === "string" && s.filter ? { filter: s.filter } : {}), + ...(s.group === "repository" ? { group: "repository" as const } : {}), + ...(WORK_KINDS.some((k) => k.kind === s.kind) ? { kind: s.kind as WorkKind } : {}), + }; }, loader: async ({ params, context }) => ({ lookup: await context.queryClient.ensureQueryData(profileLookupQuery(params.login)), origin: context.origin ?? "" }), head: ({ params }) => ({ meta: [{ title: `@${params.login}'s Proof of Work on ${PRODUCT}` }, { name: "robots", content: "noindex" }] }), @@ -61,12 +61,12 @@ function ProofOfWork() { title={lookup.self ? "Your Proof of Work" : `${name}'s Proof of Work`} description={ lookup.self - ? "Every pull request merged and every commit, by month and repository, with links. Your private work shows to you alone, and goes into a shared link only if you choose it." - : "Every pull request merged and every commit, by month and repository, with links. Public work only." + ? "Every pull request you merged and every commit you made, with links. Your private work shows to you alone, and goes into a shared link only if you choose it." + : "Every pull request they merged and every commit they made, with links. Public work only." } actions={} /> - +
    }>
    @@ -169,60 +169,52 @@ function ShareChoices({ login, search }: { login: string; search: Search }) { ); } -function Filters({ search }: { search: Search }) { +function Filters({ login, search }: { login: string; search: Search }) { const navigate = useNavigate({ from: Route.fullPath }); - const [from, setFrom] = useState(search.from); - const [to, setTo] = useState(search.to); - const [filter, setFilter] = useState(search.filter ?? ""); - const preset = PERIODS.find(([p]) => { - const d = periodDates(p); - return d.from === search.from && d.to === search.to; - })?.[0]; - const pick = (v: string) => { - if (v === "custom") return; - void navigate({ search: { ...periodDates(v as Period), ...(search.filter ? { filter: search.filter } : {}) } }); - }; - const changed = from !== search.from || to !== search.to || filter.trim() !== (search.filter ?? ""); return ( -
    { - e.preventDefault(); - if (from > to) return; - void navigate({ search: { from, to, ...(filter.trim() ? { filter: filter.trim() } : {}) } }); - }} - > -
    - - {PERIODS.map(([value]) => ( - - ))} - - -
    -
    - ({ value, label })), { value: "custom", label: "These dates" }]} width="100%" /> -
    -
    -
    - v && setFrom(v)} format="date" width="100%" /> -
    -
    - v && setTo(v)} format="date" width="100%" /> -
    -
    - -
    -
    -
    -
    -
    +
    + void navigate({ search: (s) => ({ ...s, ...p, kind: undefined }) })} /> +
    + +
    + } + > + + +
    ); } +function Places({ login, search }: { login: string; search: Search }) { + const navigate = useNavigate({ from: Route.fullPath }); + const { data: base } = useSuspenseQuery(workQuery(login, search.from, search.to, null)); + return void navigate({ search: (s) => ({ ...s, filter: filter ?? undefined, kind: undefined }) })} />; +} + function Results({ login, search }: { login: string; search: Search }) { + const { data: base } = useSuspenseQuery(workQuery(login, search.from, search.to, null)); + if (search.filter && base.truncated) return ; + const work: Work = search.filter ? { ...base, filter: search.filter, items: base.items.filter((i) => inFilter(i, search.filter ?? null)) } : base; + return ; +} + +function Narrowed({ login, search }: { login: string; search: Search }) { const { data: work } = useSuspenseQuery(workQuery(login, search.from, search.to, search.filter ?? null)); + return ; +} + +function Shown({ work, search }: { work: Work; search: Search }) { + const navigate = useNavigate({ from: Route.fullPath }); const read = new Date(work.at * 1000).toLocaleString("en-GB", { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "UTC" }); - return ; + const look: WorkLook = { group: search.group ?? "month", kind: search.kind ?? null }; + return ( + void navigate({ search: (s) => ({ ...s, group: l.group === "repository" ? "repository" : undefined, kind: l.kind ?? undefined }), replace: true, resetScroll: false })} + footer={`Read from GitHub on ${read} UTC. Pull requests are counted on the day they were merged, commits on the day their author made them.`} + /> + ); } diff --git a/apps/site/src/routes/u.$login_.work_.$id.tsx b/apps/site/src/routes/u.$login_.work_.$id.tsx index 0145c4b..5dc1891 100644 --- a/apps/site/src/routes/u.$login_.work_.$id.tsx +++ b/apps/site/src/routes/u.$login_.work_.$id.tsx @@ -49,7 +49,12 @@ function SharedProof() { } title={`${name}'s Proof of Work`} - description={`${periodWords(work.from, work.to)}${work.filter ? `, in ${work.filter}` : ""}. Shared as it was on ${LONG.format(new Date(work.at * 1000))}: it does not change.`} + description={ + <> + {periodWords(work.from, work.to)} + {work.filter ? `, only in ${work.filter}` : ", everywhere on GitHub"}. Shared as it was on {LONG.format(new Date(work.at * 1000))}: it does not change. + + } actions={ <>
    ); } -function BoardSkeleton() { +function Change({ side, other }: { side: Side; other: string }) { + const navigate = useNavigate(); + const { view } = Route.useSearch(); + const [open, setOpen] = useState(false); + const [text, setText] = useState(""); + const login = text.trim().replace(/^@/, ""); + const valid = isLogin(login) && login.toLowerCase() !== other.toLowerCase(); return ( -
    - {Array.from({ length: 8 }, (_, i) => ( - - ))} + { + e.preventDefault(); + if (!valid) return; + setOpen(false); + setText(""); + void navigate({ to: "/vs/$a/$b", params: side === "a" ? { a: login, b: other } : { a: other, b: login }, search: view ? { view } : {} }); + }} + > + +
    - ); -} - /** The people who review their pull requests, and whose they review, each a Versus away. */ -export function PeoplePanel({ profile, versus }: { profile: Profile; versus?: (login: string) => string }) { +export function PeoplePanel({ profile, versus, wide = false }: { profile: Profile; versus?: (login: string) => string; wide?: boolean }) { const people = profile.partners.slice(0, 12); const most = Math.max(1, ...people.map((p) => p.reviewedTheirs + p.reviewedYours)); return ( - + {people.length === 0 ? ( - No reviews either way yet. + ) : ( -
    + } + > + + {open && } + + ); +} + +function Tile({ label, value, note, tone }: { label: string; value: ReactNode; note?: ReactNode; tone?: string }) { + return ( +
    + {label} + + {value} + + {note && {note}} +
    + ); +} + +function Heading({ children, note }: { children: ReactNode; note?: ReactNode }) { + return ( +
    +

    {children}

    + {note && {note}} +
    + ); +} + +function YearDialog({ profile, open, onOpenChange }: { profile: Profile; open: boolean; onOpenChange: (open: boolean) => void }) { + const b = useMemo(() => breakdown(profile), [profile]); + const t = profile.totals; + const mostMonth = Math.max(1, ...b.monthList.map((m) => m.n)); + const mostWeekday = Math.max(1, ...b.weekdays.map((w) => w.n)); + const topWeekday = b.weekdays.reduce((best, w, i) => (w.n > (b.weekdays[best]?.n ?? 0) ? i : best), 0); + return ( + + +
    +
    + 0 ? `${(b.total / b.active).toFixed(1)} on an active day` : "none yet"} /> + + 0 ? day(b.best.d) : "—"} /> + 0 ? `from ${day(b.bestWeek.d)}` : "—"} /> + 0 ? monthName(b.bestMonth.key) : "—"} /> + 0 ? `from ${day(b.firstDay + b.run.start)}` : "—"} /> + 0 ? streakHeat(t.currentStreak).colour : undefined} /> + +
    + +
    + 0 ? `busiest: ${monthName(b.bestMonth.key)}` : undefined}>Month by month +
      + {b.monthList.map((m) => { + const [, mm = "01"] = m.key.split("-"); + const top = m.key === b.bestMonth.key && m.n > 0; + return ( +
    1. + {m.n > 0 ? grouped(m.n) : ""} + 0 ? 3 : 1, (m.n * 100) / mostMonth)}%` }} /> + {MONTHS[Number(mm) - 1]?.slice(0, 1)} +
    2. + ); + })} +
    +
    + +
    +
    + Day of the week +
      + {b.weekdays.map((w, i) => ( +
    • + {WEEKDAYS[i]?.slice(0, 3)} + + = 5 ? "bg-[var(--s7)]" : "bg-[var(--s1)]"}`} style={{ width: `${(w.n * 100) / mostWeekday}%` }} /> + + + {grouped(w.n)} · active {Math.round((w.active * 100) / Math.max(1, w.days))}% + +
    • + ))} +
    +
    + +
    + By kind + {b.kinds.length === 0 ? ( +

    GitHub has no counts by kind for these years.

    + ) : ( +
      + {b.kinds.map((y) => { + const sum = Math.max(1, y.commits + y.prs + y.reviews + y.issues + y.hidden); + return ( +
    • + + {y.year === b.thisYear ? `${y.year} so far` : y.year} + {grouped(y.commits + y.prs + y.reviews + y.issues + y.hidden)} + + + {KINDS.map((k) => (y[k.key] > 0 ? : null))} + + + {KINDS.filter((k) => k.key !== "hidden" || y.hidden > 0).map((k) => ( + + + {k.label} {grouped(y[k.key])} + + ))} + +
    • + ); + })} +
    + )} +

    GitHub splits contributions by kind per calendar year, so these cover the whole years the last 12 months touch. Private ones are counted, never split.

    +
    +
    +
    +
    + ); +} diff --git a/packages/ui/src/profile/years.tsx b/packages/ui/src/profile/years.tsx new file mode 100644 index 0000000..4780fee --- /dev/null +++ b/packages/ui/src/profile/years.tsx @@ -0,0 +1,232 @@ +import { useId, useMemo, useState, type ReactNode } from "react"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; +import { Skeleton } from "@astryxdesign/core/Skeleton"; +import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import type { Profile } from "@commitscape/data"; +import { StackedColumns, type Point } from "../charts/Trend"; +import { compact, grouped, many } from "../format"; +import { Panel } from "../kit/layout"; +import { Nothing } from "../motion"; +import { DAY, monthKey, monthLabel, monthsBetween } from "./days"; + +const OUTCOMES = [ + { key: "merged", label: "Merged", colour: "var(--s7)" }, + { key: "open", label: "Still open", colour: "var(--s3)" }, + { key: "closed", label: "Closed unmerged", colour: "var(--other)" }, +] as const; + +type Outcome = (typeof OUTCOMES)[number]["key"]; +type Month = { key: string; label: string; tick?: string; contributions: number; merged: number; open: number; closed: number; read: boolean }; + +const axis = { fontSize: 11, fill: "var(--color-text-secondary)" }; +const Y_WIDTH = 38; + +function monthsOf(profile: Profile, full: Profile | null): { list: Month[]; since: string | null } { + const source = full ?? profile; + const contributions = new Map(profile.months.map((m) => [m.month, m.contributions])); + const prs = new Map(); + let since: string | null = null; + for (const m of source.months) { + if (!m.prs || m.prs.merged + m.prs.open + m.prs.closed === 0) continue; + if (!since || m.month < since) since = m.month; + prs.set(m.month, m.prs); + } + const keys = [...profile.months.filter((m) => m.contributions > 0).map((m) => m.month), ...prs.keys()].sort(); + if (keys.length === 0) return { list: [], since }; + const last = monthKey(Math.floor(profile.fetchedAt / DAY)); + const capped = source.read.prs < source.read.prsTotal; + const list = monthsBetween(keys[0] ?? last, last > (keys.at(-1) ?? "") ? last : (keys.at(-1) ?? last)).map((key) => { + const p = prs.get(key) ?? { merged: 0, open: 0, closed: 0 }; + const mm = key.slice(5); + return { key, label: monthLabel(key), tick: mm === "01" ? key.slice(0, 4) : undefined, contributions: contributions.get(key) ?? 0, ...p, read: !capped || (since !== null && key >= since) }; + }); + return { list, since }; +} + +/** Whether a Profile carries its pull requests month by month. */ +function hasPrMonths(p: Profile | null): p is Profile { + return !!p && p.read.complete && (p.read.prs === 0 || p.months.some((m) => m.prs)); +} + +function MonthTip({ active, payload, unit }: { active?: boolean; payload?: { name?: string; value?: number; color?: string; dataKey?: string; payload?: Month }[]; unit?: string }) { + if (!active || !payload?.length) return null; + const m = payload[0]?.payload; + if (!m) return null; + const prs = m.merged + m.open + m.closed; + return ( +
    +
    {m.label}
    +
    + Contributions + {grouped(m.contributions)} +
    + {unit !== "contributions" && ( + <> +
    + Pull requests opened + {m.read ? grouped(prs) : "not read"} +
    + {m.read && + prs > 0 && + OUTCOMES.map((o) => ( +
    + + + {o.label} + + {grouped(m[o.key])} +
    + ))} + + )} +
    + ); +} + +function coverage(full: Profile | null, given: Profile | null, since: string | null): string { + if (!full) return given ? "Pull requests by month arrive with their next read from GitHub." : "Reading their pull requests…"; + const counted = full.months.reduce((n, m) => n + (m.prs ? m.prs.merged + m.prs.open + m.prs.closed : 0), 0); + const total = full.read.prsTotal; + if (counted >= total) return `All ${grouped(total)} of their pull requests, in the month each was opened.`; + if (counted >= full.read.prs) return `From the newest ${grouped(counted)} of their ${grouped(total)} pull requests, back to ${since ? monthLabel(since) : "the start"}; older months are not read.`; + return `From ${grouped(counted)} of their ${grouped(total)} pull requests; the rest arrive with their next read from GitHub.`; +} + +function Fact({ label, value, note }: { label: string; value: ReactNode; note?: ReactNode }) { + return ( +
    + {label} + {value} + {note && {note}} +
    + ); +} + +/** Contributions month by month with the pull requests opened each month beneath, split by how they ended; or every year by kind. */ +export function OverTheYears({ profile, full: given = null }: { profile: Profile; full?: Profile | null }) { + const full = hasPrMonths(given) ? given : hasPrMonths(profile) ? profile : null; + const [by, setBy] = useState<"month" | "year">("month"); + const [hidden, setHidden] = useState([]); + const id = useId().replace(/:/g, ""); + const sync = `years${id}`; + const { list: months, since } = useMemo(() => monthsOf(profile, full), [profile, full]); + const years = useMemo(() => profile.years.filter((y) => y.commits + y.prs + y.reviews + y.issues > 0).map((y) => ({ key: String(y.year), label: String(y.year), commits: y.commits, prs: y.prs, reviews: y.reviews, issues: y.issues })), [profile.years]); + const first = new Date(profile.calendar.firstDay * DAY * 1000).getUTCFullYear(); + if (profile.totals.contributions === 0 && months.length === 0) + return ( + + + + ); + const busiest = months.reduce((b, m) => (m.contributions > (b?.contributions ?? 0) ? m : b), null); + const contributed = profile.totals.contributions > 0; + const read = months.filter((m) => m.read); + const opened = read.reduce((n, m) => n + m.merged + m.open + m.closed, 0); + const merged = read.reduce((n, m) => n + m.merged, 0); + const mostPrs = read.reduce((b, m) => (m.merged + m.open + m.closed > (b ? b.merged + b.open + b.closed : 0) ? m : b), null); + const ticks = months.filter((m) => m.tick).map((m) => m.key); + const shown = OUTCOMES.filter((o) => !hidden.includes(o.key)); + return ( + 0 && ( + setBy(v as "month" | "year")}> + + + + ) + } + > + {by === "month" || years.length === 0 ? ( +
    +
    + + } note={full ? (mostPrs ? `opened in ${mostPrs.label}` : "none opened") : "reading"} /> + 0 ? `${Math.round((merged * 100) / opened)}%` : "—") : } note={full ? (opened > 0 ? `${grouped(merged)} of ${grouped(opened)} opened` : "none opened") : "reading"} /> +
    + {contributed && ( +
    + Contributions a month +
    + + + + + + + + + + + compact(v)} tick={axis} tickLine={false} axisLine={false} width={Y_WIDTH} allowDecimals={false} tickCount={3} /> + } cursor={{ stroke: "var(--color-border-emphasized)" }} /> + + + +
    +
    + )} +
    +
    + Pull requests opened a month, by how they ended + {(!full || opened > 0) && + {OUTCOMES.map((o) => { + const on = !hidden.includes(o.key); + return ( + + ); + })} + } +
    + {full && opened === 0 ? ( +

    No pull requests opened yet. Each one they open lands here, in its month.

    + ) : full ? ( +
    + + + + months.find((m) => m.key === k)?.tick ?? ""} interval="preserveStartEnd" tick={axis} tickLine={false} axisLine={false} minTickGap={14} /> + compact(v)} tick={axis} tickLine={false} axisLine={false} width={Y_WIDTH} allowDecimals={false} tickCount={3} /> + } cursor={{ fill: "var(--color-overlay-hover)" }} /> + {shown.map((o, i) => ( + + ))} + + +
    + ) : ( + + )} + {(!full || opened > 0) && + {coverage(full, given, since)} + } +
    +
    + ) : ( + + )} +
    + ); +} diff --git a/packages/ui/src/screens/Activity.tsx b/packages/ui/src/screens/Activity.tsx index 7cafff2..7de5da7 100644 --- a/packages/ui/src/screens/Activity.tsx +++ b/packages/ui/src/screens/Activity.tsx @@ -1,16 +1,23 @@ import type { Activity as Data } from "@commitscape/data"; import { useData } from "../data"; -import { WeekGrid } from "../charts/Grid"; +import { Clock } from "lucide-react"; +import { peakOf, WeekGrid } from "../charts/Grid"; +import { useTip } from "../charts/tip"; import { Lines } from "../charts/Lines"; import { Explain } from "../explain"; import { grouped, many, share } from "../format"; import { Panel } from "../kit/layout"; import { personColour } from "../theme"; -import { BarList, Failed, Quiet, ScreenFrame, StacksOverTime, type Stack, within } from "./kit"; +import { Failed, Quiet, ScreenFrame, StacksOverTime, type Stack, within } from "./kit"; import type { ScreenProps } from "./props"; const WEEKDAYS = ["Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays", "Sundays"]; +const TYPED = new Set(["features", "fixes", "refactors", "performance", "style", "build", "chores"]); +const CONVENTIONAL = 0.3; + +const hh = (h: number) => `${String(h % 24).padStart(2, "0")}:00`; + /** How the work moved: commits over time by person, pull requests and issues, the hours of the week, and the kinds of work. */ export function Activity({ params }: ScreenProps) { const { data: a, error, stale } = useData("/api/activity", params); @@ -22,12 +29,13 @@ export function Activity({ params }: ScreenProps) { ].filter((s) => s.days.some((v) => v > 0)); const commits = a.days.reduce((n, d) => n + d.reduce((m, v) => m + v, 0), 0); const told = a.kinds.reduce((n, k) => n + k.commits, 0); - const kinds = told >= (told + a.unclassified) / 4 && told > 0; + const typed = a.kinds.filter((k) => TYPED.has(k.kind)).reduce((n, k) => n + k.commits, 0); + const kinds = told > 0 && typed >= (told + a.unclassified) * CONVENTIONAL; const releases = a.releases.filter((r) => { const d = Math.floor(r.time / 86_400); return d >= a.first_day && d < a.first_day + a.days.length; }); - const busiest = busiestHour(a.week); + const peak = peakOf(a.week); return ( )} -
    - -
    -
    +
    + = 10 ? 0 : 1)}× a typical hour` : "Commits by weekday and hour"} + actions={} + > +
    +
    - Each commit at the hour its author's clock showed, so a team across time zones still shows its working day. + Each commit at the hour its author's clock showed, so a team across time zones still shows its working day. The bars above are each hour's total across the week, those beside each day's share; the ringed cell is the busiest hour.
    {kinds && ( - - ({ - key: k.kind, - label: k.kind, - value: k.commits, - shown: `${grouped(k.commits)} · ${share(k.commits, told + a.unclassified)}`, - colour: `var(--s${(i % 8) + 1})`, - }))} - /> + + Judged from the files a commit changed first (only tests, only docs, only CI, only dependency manifests), and from its message when the files cannot tell. {grouped(a.unclassified)} commits could be told by neither. @@ -102,19 +105,20 @@ function Rhythm({ week }: { week: number[][] }) { if (all === 0) return null; const weekend = [5, 6].reduce((n, d) => n + (week[d] ?? []).reduce((a, b) => a + b, 0), 0); const night = week.reduce((n, hours) => n + hours.reduce((m, v, h) => (NIGHT.has(h) ? m + v : m), 0), 0); + const office = week.slice(0, 5).reduce((n, hours) => n + hours.reduce((m, v, h) => (h >= 9 && h < 18 ? m + v : m), 0), 0); const days = week.map((hours) => hours.reduce((a, b) => a + b, 0)); const top = days.indexOf(Math.max(...days)); const facts: [string, string][] = [ - [share(all - weekend, all), "on weekdays"], + [share(office, all), "in office hours, 09:00 to 18:00 on weekdays"], [share(weekend, all), "on weekends"], - [share(night, all), "between 22:00 and 05:00"], + [share(night, all), "at night, 22:00 to 05:00"], [WEEKDAYS[top] ?? "", "the busiest day"], ]; return ( -
    +
    {facts.map(([value, label]) => (
    -
    {label}
    +
    {label}
    {value}
    ))} @@ -122,9 +126,47 @@ function Rhythm({ week }: { week: number[][] }) { ); } -function busiestHour(week: number[][]): string | null { - let best = { n: 0, d: -1, h: -1 }; - week.forEach((hours, d) => hours.forEach((n, h) => n > best.n && (best = { n, d, h }))); - if (best.d < 0) return null; - return `${WEEKDAYS[best.d]} at ${String(best.h).padStart(2, "0")}:00`; +function KindsBar({ kinds, all }: { kinds: Data["kinds"]; all: number }) { + const tip = useTip(); + const parts = kinds.map((k, i) => ({ ...k, colour: `var(--s${(i % 8) + 1})` })); + const told = kinds.reduce((n, k) => n + k.commits, 0); + return ( +
    +
    + {parts.map((p) => ( + {p.kind}
    {many(p.commits, "commit", "commits")} · {share(p.commits, all)} of all
    )} /> + ))} +
    +
      + {parts.map((p) => ( +
    • + + {p.kind} + + {grouped(p.commits)} · {share(p.commits, all)} + +
    • + ))} +
    +
    + ); +} + +function ClockNote() { + const tip = useTip(); + return ( + + On each author's own clock +
    The hour their commit recorded, in their own time zone: not your time, and not UTC. A team spread across the world still shows its working day.
    + , + )} + > + + Authors' local time +
    + ); } diff --git a/packages/ui/src/screens/Map.tsx b/packages/ui/src/screens/Map.tsx index 3a2a091..891e8f3 100644 --- a/packages/ui/src/screens/Map.tsx +++ b/packages/ui/src/screens/Map.tsx @@ -7,7 +7,6 @@ import { ChevronLeft, ChevronRight, House, X } from "lucide-react"; import { NOT_IN_REPORT, type File as FileData, type MapBlock, type MapLevel } from "@commitscape/data"; import { useData, useLazyData } from "../data"; import { TableView } from "../charts/common"; -import { ranges } from "../charts/scale"; import { useTip } from "../charts/tip"; import { squarify, type Rect } from "../charts/treemap"; import { LinesSkeleton } from "../components/Loading"; @@ -277,16 +276,22 @@ function MapLegend({ colour, bounds, owners }: { colour: Colour; bounds: number[ ); } + const most = bounds.at(-1) ?? 0; return ( -
      -
    • Commits in the Window
    • - none - {ranges(bounds).map((r) => ( - - {r.from === r.to ? grouped(r.to) : `${grouped(r.from)}–${grouped(r.to)}`} - - ))} -
    +
    + How often it changes + + + none + 1 + + {[1, 2, 3, 4].map((step) => ( + + ))} + + {many(most, "commit", "commits")} + +
    ); } diff --git a/packages/ui/src/screens/Overview.tsx b/packages/ui/src/screens/Overview.tsx index ff54b22..cf02f05 100644 --- a/packages/ui/src/screens/Overview.tsx +++ b/packages/ui/src/screens/Overview.tsx @@ -1,8 +1,8 @@ import { useContext } from "react"; import { Button } from "@astryxdesign/core/Button"; import { useSuspenseQueries } from "@tanstack/react-query"; -import { ChevronRight, File, Folder, Trophy } from "lucide-react"; -import type { MapBlock, MapLevel, Overview as Data, People, PersonRow } from "@commitscape/data"; +import { ChevronRight, Trophy } from "lucide-react"; +import type { MapLevel, Overview as Data, People, PersonRow, QuarterLines } from "@commitscape/data"; import { dataQuery, StaleContext, useSource } from "../data"; import { Face } from "../components/Face"; import { useLogin } from "../components/login"; @@ -12,15 +12,16 @@ import { compact, date, duration, grouped, many, share } from "../format"; import { A } from "../kit/A"; import { Panel } from "../kit/layout"; import { personColour } from "../theme"; -import { BarList, Failed, NumberCell, NumberStrip, Quiet, ScreenFrame, TrendOverTime, within } from "./kit"; +import { useTip } from "../charts/tip"; +import { Failed, NumberCell, NumberStrip, Quiet, ScreenFrame, TrendOverTime, within } from "./kit"; import { openers, type ScreenProps } from "./props"; +import { WhereWork } from "./WhereWork"; export const CONTRIBUTORS_SHOWN = 8; -export const FOLDERS_SHOWN = 5; export const LANGUAGES_SHOWN = 6; -/** The repository at a glance: its size and age, who built it, where the work is now, its languages, and how its commits fell over time. */ -export function Overview({ params, go }: ScreenProps) { +/** The repository at a glance: its size and age, who built it, its languages, where the work is as a sunburst, and how its commits fell over time; laid out to fit how many people and how much history it has. */ +export function Overview({ meta, params, go }: ScreenProps) { const source = useSource(); const stale = useContext(StaleContext); const [overview, people, map] = useSuspenseQueries({ @@ -32,16 +33,22 @@ export function Overview({ params, go }: ScreenProps) { const span = within(o.window); const releases = o.timeline.filter((m) => m.kind === "release").map((r) => ({ day: Math.floor(r.time / 86_400), label: r.name ?? "a release" })); const inWindow = releases.filter((r) => r.day >= o.first_day && r.day < o.first_day + o.days.length); + const rows = people.data.data?.people ?? []; + const few = rows.length > 0 && rows.length <= FEW; + const ages = ageBuckets(o.code_age); + const who = go({ screen: "people", id: undefined })} few={few} />; + const name = meta.name.split("/").at(-1) || meta.name; return (
    - go({ screen: "people", id: undefined })} /> + {who}
    - (b.file ? open.file(b.path) : open.folder(b.path))} /> + {!few && ages.length >= 3 && }
    + void; onAll: () => void }) { +function WhoBuiltIt({ rows, everyone, commits, span, onPerson, onAll, few }: { rows: PersonRow[]; everyone: number | null; commits: number; span: string; onPerson: (id: number) => void; onAll: () => void; few: boolean }) { const people = everyone !== null && everyone > rows.length ? grouped(everyone) : counted(rows.length); const shown = rows.slice(0, CONTRIBUTORS_SHOWN); const lines = rows.some((r) => r.lines_added !== null); const most = Math.max(1, ...shown.map((r) => r.commits)); + const description = rows.length === 0 ? `Nobody committed ${span}` : rows.length === 1 ? `One person ${span}` : `${people} people ${span}, most commits first`; + if (few) + return ( + } />}> +
      + {shown.map((r) => ( + + ))} +
    +
    + ); return ( - + {shown.length === 0 ? (
    No commits in this Window. Choose a longer one above. @@ -115,6 +135,53 @@ function WhoBuiltIt({ rows, everyone, commits, span, onPerson, onAll }: { rows: ); } +function Builder({ r, commits, lines, solo, onPerson }: { r: PersonRow; commits: number; lines: boolean; solo: boolean; onPerson: (id: number) => void }) { + const login = useLogin(r.person); + const standing = useStanding(login); + const facts: [string, string, string?][] = [ + [grouped(r.commits), r.commits === 1 ? "commit" : "commits", solo ? undefined : `${share(r.commits, commits)} of all`], + [grouped(r.active_days), r.active_days === 1 ? "active day" : "active days"], + ...(lines && r.lines_added !== null ? ([[`+${compact(r.lines_added)}`, "lines added", `−${compact(r.lines_removed ?? 0)} removed`]] as [string, string, string][]) : []), + ...(r.prs_merged !== null && r.prs_merged > 0 ? ([[grouped(r.prs_merged), r.prs_merged === 1 ? "PR merged" : "PRs merged"]] as [string, string][]) : []), + ]; + return ( +
  • +
    + + + + + + + {login ? `@${login} · ` : ""} + {date(r.first)} – {date(r.last)} + + + {standing && ( + + + + )} +
    +
    + {facts.slice(0, 3).map(([value, label, note]) => ( +
    +
    {value}
    +
    {note ?? label}
    +
    + ))} +
    + {standing && ( +
    + + + )} +
  • + ); +} + function Contributor({ r, place, commits, most, lines, onPerson }: { r: PersonRow; place: number; commits: number; most: number; lines: boolean; onPerson: (id: number) => void }) { const login = useLogin(r.person); const standing = useStanding(login); @@ -163,44 +230,6 @@ function Contributor({ r, place, commits, most, lines, onPerson }: { r: PersonRo ); } -function WhereWorkIs({ level, span, onOpen }: { level: MapLevel | null; span: string; onOpen: (b: MapBlock) => void }) { - if (!level) return null; - const busy = [...level.children].filter((b) => b.churn > 0).sort((a, b) => b.churn - a.churn); - const all = level.children.reduce((n, b) => n + b.churn, 0); - return ( - - {busy.length === 0 ? ( - Nothing changed in this Window. - ) : ( - ({ - key: b.path, - label: {b.file ? b.name : `${b.name}/`}, - lead: b.file ? : , - value: b.churn, - colour: "var(--brand)", - shown: ( - - {b.owner && } - {many(b.churn, "commit", "commits")} · {share(b.churn, all)} - - ), - title: b.owner ? `Most commits by ${b.owner.name}` : undefined, - onClick: () => onOpen(b), - }))} - /> - )} - Commits in the Window that touched each top folder, merges and bulk commits left out; a commit touching two folders counts in both. The face is who made most of them. Click one to open it on the Map. - - ); -} - -function Owner({ p }: { p: NonNullable }) { - const login = useLogin(p); - return ; -} - function Languages({ o }: { o: Data }) { const total = Math.max(1, o.languages.reduce((n, l) => n + l.lines, 0)); const named = o.languages.slice(0, LANGUAGES_SHOWN); @@ -233,3 +262,60 @@ function Languages({ o }: { o: Data }) { ); } + +type Age = { key: string; label: string; short: string; lines: number }; + +function ageBuckets(quarters: QuarterLines[]): Age[] { + if (quarters.length === 0) return []; + const sorted = [...quarters].sort((a, b) => a.year - b.year || a.quarter - b.quarter); + const first = sorted[0]; + const last = sorted.at(-1); + if (!first || !last) return []; + const span = (last.year - first.year) * 4 + last.quarter - first.quarter + 1; + if (span <= 12) { + const out: Age[] = []; + for (let i = 0; i < span; i++) { + const year = first.year + Math.floor((first.quarter - 1 + i) / 4); + const quarter = ((first.quarter - 1 + i) % 4) + 1; + const lines = sorted.find((q) => q.year === year && q.quarter === quarter)?.lines ?? 0; + out.push({ key: `${year}q${quarter}`, label: `Q${quarter} ${year}`, short: quarter === 1 || i === 0 ? `Q${quarter} ’${String(year).slice(2)}` : `Q${quarter}`, lines }); + } + return out; + } + const out: Age[] = []; + for (let year = first.year; year <= last.year; year++) { + const lines = sorted.filter((q) => q.year === year).reduce((n, q) => n + q.lines, 0); + out.push({ key: String(year), label: String(year), short: `’${String(year).slice(2)}`, lines }); + } + return out; +} + +function CodeAge({ ages }: { ages: Age[] }) { + const tip = useTip(); + const total = Math.max(1, ages.reduce((n, a) => n + a.lines, 0)); + const most = Math.max(1, ...ages.map((a) => a.lines)); + const old = ages.slice(0, Math.max(1, Math.floor(ages.length / 2))).reduce((n, a) => n + a.lines, 0); + const label = (i: number) => ages.length <= 8 || i % Math.ceil(ages.length / 7) === 0 || i === ages.length - 1; + return ( + +
    +
    +
    + {ages.map((a) => ( + {a.label}
    {compact(a.lines)} lines still at HEAD · {share(a.lines, total)}
    )}> + 0 ? 2 : 0, (a.lines * 100) / most)}%` }} /> + + ))} +
    +
    +
    + {ages.map((a, i) => ( + + {label(i) ? a.short : ""} + + ))} +
    +
    +
    + ); +} diff --git a/packages/ui/src/screens/People.tsx b/packages/ui/src/screens/People.tsx index bd9209a..07a3c1f 100644 --- a/packages/ui/src/screens/People.tsx +++ b/packages/ui/src/screens/People.tsx @@ -289,15 +289,7 @@ function Profile({ params, go, id }: ScreenProps & { id: number }) { {standing && + )} + + ))} + + {depthOf(focus) <= 1 && ( + + )} +
    +
    +
    + {loading ? ( +
    + +
    + ) : arcs.length === 0 ? ( +
    + {focus ? `Nothing in ${focus} to show ${span}.` : `Nothing changed ${span}.`} +
    + ) : ( + go(parentOf(focus)) : undefined} + label={`${focus || name}: ${listed.length} folders and files, sized by ${by === "churn" ? "commits" : "lines"}`} + centre={ + lit ? ( + <> + {lit.name} + {share(lit.value, whole)} + {amount(lit.value)} + + ) : ( + <> + {focus ? crumbs.at(-1)?.name : name} + {by === "churn" ? grouped(whole) : compact(whole)} + {by === "churn" ? (whole === 1 ? "commit" : "commits") : "lines"} + {focus && click to go up} + + ) + } + /> + )} +
    +
    + {listed.length === 0 ? null : ( +
      setHover(null)}> + {listed.slice(0, LISTED).map((n) => ( + + ))} + {rest.length > 0 && ( +
    1. + and {many(rest.length, "more", "more")} + {amount(rest.reduce((n, r) => n + r.value, 0))} +
    2. + )} +
    + )} +
    +
    + + {by === "churn" + ? "Each slice is a folder or file, as wide as its share of the commits in the Window that touched it, merges and bulk commits left out; a commit touching two folders counts in both, so a level's slices are shares of each other. " + : "Each slice is a folder or file, as wide as its share of the lines of code at HEAD. "} + Paler slices are files. The Map shows the same folders sized by lines, coloured by how often, when and by whom they change. + + + ); +} + +function Row({ node, block, whole, amount, lit, dim, onHover, onPick }: { node: SunNode; block: MapBlock | null; whole: number; amount: (n: number) => string; lit: boolean; dim: boolean; onHover: (k: string | null) => void; onPick: (n: SunNode) => void }) { + const can = node.file || node.open; + const body = ( + <> + + {node.file ? : } + {node.name} + {block?.owner && } + {amount(node.value)} + {share(node.value, whole)} + + ); + const cls = `flex w-full items-center gap-2.5 rounded-[var(--radius-element)] px-2 py-1.5 text-start transition-[background-color,opacity] duration-200 ${lit ? "bg-[var(--color-overlay-hover)]" : ""} ${dim ? "opacity-55" : ""}`; + return ( +
  • onHover(node.key)}> + {can ? ( + + ) : ( +
    {body}
    + )} +
  • + ); +} + +function Owner({ p }: { p: NonNullable }) { + const login = useLogin(p); + return ( + + + + ); +} diff --git a/packages/ui/src/standings/view.tsx b/packages/ui/src/standings/view.tsx index b04befa..def9b91 100644 --- a/packages/ui/src/standings/view.tsx +++ b/packages/ui/src/standings/view.tsx @@ -115,6 +115,11 @@ export function Leaderboard({ standings, focus, view, onView, link = true }: { s const pinned = !all && you >= SHOWN ? sorted[you] : null; const { owner, name } = standings.repo; const meta = VIEWS.find((v) => v.id === by); + const twins = useMemo(() => { + const seen = new Map(); + for (const r of standings.people) seen.set(r.name, (seen.get(r.name) ?? 0) + 1); + return new Set([...seen].filter(([, n]) => n > 1).map(([n]) => n)); + }, [standings]); return (
      {rows.map((r) => ( - + ))} {pinned && ( <>
    1. ⋯
    2. - + )}
    @@ -151,7 +156,7 @@ export function Leaderboard({ standings, focus, view, onView, link = true }: { s ); } -function Entry({ r, place, value, top, view, you, owner, name, link }: { r: StandingRow; place: Place | undefined; value: number | null; top: number; view: View; you: boolean; owner: string; name: string; link: boolean }) { +function Entry({ r, place, value, top, view, you, owner, name, link, alias }: { r: StandingRow; place: Place | undefined; value: number | null; top: number; view: View; you: boolean; owner: string; name: string; link: boolean; alias: boolean }) { const medal = place && place.place <= 3 ? ["#e8b931", "#b8bec7", "#c98a54"][place.place - 1] : null; const body = ( <> @@ -159,8 +164,9 @@ function Entry({ r, place, value, top, view, you, owner, name, link }: { r: Stan - {r.name} - {you && you} + {r.name} + {alias && r.login && @{r.login}} + {r.you && you} @@ -170,7 +176,7 @@ function Entry({ r, place, value, top, view, you, owner, name, link }: { r: Stan ); return ( -
  • +
  • {link && r.login ? ( {body} diff --git a/packages/ui/src/versus/duel.tsx b/packages/ui/src/versus/duel.tsx new file mode 100644 index 0000000..25f2753 --- /dev/null +++ b/packages/ui/src/versus/duel.tsx @@ -0,0 +1,84 @@ +import { useRef, useSyncExternalStore } from "react"; +import { Crown } from "lucide-react"; +import { CountUp, DURATION, EASE, motion, useInView, useReducedMotion } from "../motion"; +import { toneOf, winnerOf, type Duel, type Side } from "./tone"; + +const never = () => () => {}; + +function useHydrated() { + return useSyncExternalStore( + never, + () => true, + () => false, + ); +} + +/** A bar that grows out from the middle the first time it is seen; drawn full on the server so nothing waits for scripts. */ +export function GrowBar({ value, side, lead }: { value: number; side: Side; lead: boolean }) { + const ref = useRef(null); + const seen = useInView(ref, { once: true, amount: 0.6 }); + const reduce = useReducedMotion(); + const hydrated = useHydrated(); + const scale = !hydrated || reduce || seen ? 1 : 0; + return ( + + 0 ? Math.max(3, value * 100) : 0}%`, originX: side === "a" ? 1 : 0, opacity: lead ? 1 : 0.38 }} + /> + + ); +} + +function Value({ d, side, lead }: { d: Duel; side: Side; lead: boolean }) { + const n = side === "a" ? d.a : d.b; + return ( + + {lead && } + {n === null ? — : } + + ); +} + +/** One view between two people: what it counts in the middle, each side's number at the edge, and a bar each growing outward. */ +export function DuelRow({ d }: { d: Duel }) { + const win = winnerOf(d); + const most = Math.max(d.a ?? 0, d.b ?? 0); + const least = Math.min(d.a ?? Infinity, d.b ?? Infinity); + const width = (n: number | null) => (n === null || most === 0 ? 0 : d.lowerWins ? least / Math.max(n, 0.01) : n / most); + return ( +
  • +
    + + {d.label} + {win === "tie" && a tie} + + + + + + + +
    +
    + + +
    + {d.note &&

    {d.note}

    } +
  • + ); +} + +/** Views between two people, one row each, in a single surface. */ +export function DuelRows({ rows, label }: { rows: Duel[]; label?: string }) { + return ( +
      + {rows.map((d) => ( + + ))} +
    + ); +} diff --git a/packages/ui/src/versus/full.tsx b/packages/ui/src/versus/full.tsx new file mode 100644 index 0000000..0673d20 --- /dev/null +++ b/packages/ui/src/versus/full.tsx @@ -0,0 +1,556 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { Button } from "@astryxdesign/core/Button"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; +import { Award, Flame, Lock, MessagesSquare } from "lucide-react"; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import type { Achievement, SharedRepo, SharedWork, VersusFull, VersusPerson } from "@commitscape/data"; +import { useTip } from "../charts/tip"; +import { Face } from "../components/Face"; +import { compact, day, grouped, many } from "../format"; +import { A } from "../kit/A"; +import { LangDot, Panel } from "../kit/layout"; +import { Nothing, Reveal } from "../motion"; +import { DuelRows, GrowBar } from "./duel"; +import { toneOf, TONE, type Duel, type Side } from "./tone"; + +const hours = (h: number) => (h < 1 ? `${Math.max(1, Math.round(h * 60))} min` : h < 48 ? `${Math.round(h)} h` : `${Math.round(h / 24)} days`); +const days = (n: number) => `${grouped(n)} ${Math.round(n) === 1 ? "day" : "days"}`; +const pct = (n: number) => `${Math.round(n)}%`; +const at = (h: number) => `${String(h).padStart(2, "0")}:00`; + +function Who({ p, side, size = 24 }: { p: VersusPerson; side: Side; size?: 20 | 24 | 32 }) { + return ( + + + + + @{p.identity.login} + + ); +} + +function Pair({ a, b, children }: { a: ReactNode; b: ReactNode; children?: ReactNode }) { + return ( +
    + {children} +
    +
    {a}
    +
    {b}
    +
    +
    + ); +} + +function Fact({ value, label }: { value: ReactNode; label: string }) { + return ( + + {value} + {label} + + ); +} + +function Calendar({ p, side }: { p: VersusPerson; side: Side }) { + const tip = useTip(); + const { firstDay, days: list } = p.lastYear; + const offset = (firstDay + 3) % 7; + const cells = [...Array.from({ length: offset }, () => -1), ...list]; + const sorted = list.filter((n) => n > 0).sort((x, y) => x - y); + const q = (f: number) => sorted[Math.min(sorted.length - 1, Math.floor(f * sorted.length))] ?? 1; + const steps = [q(0.25), q(0.5), q(0.75)]; + const level = (n: number) => (n <= 0 ? 0 : n <= (steps[0] ?? 1) ? 0.3 : n <= (steps[1] ?? 1) ? 0.5 : n <= (steps[2] ?? 1) ? 0.75 : 1); + const total = list.reduce((s, n) => s + n, 0); + const best = list.reduce((b, n, i) => (n > b.n ? { n, i } : b), { n: 0, i: -1 }); + return ( +
    +
    + +
    + + {best.i >= 0 && } + + {p.totals.currentStreak > 0 && } + {days(p.totals.currentStreak)} + + } + label="streak now" + /> +
    +
    +
    + {cells.map((n, i) => + n < 0 ? ( + + ) : ( + 0 ? `color-mix(in oklab, var(--side) ${level(n) * 100}%, var(--empty))` : "var(--empty)" }} + {...tip( + <> + {many(n, "contribution", "contributions")} +
    {day(firstDay + i - offset)}
    + , + )} + /> + ), + )} +
    +
    + ); +} + +const METRICS = [ + ["contributions", "All", "Contributions"], + ["commits", "Commits", "Commits"], + ["prs", "PRs", "Pull requests opened"], + ["reviews", "Reviews", "Reviews"], +] as const; +type Metric = (typeof METRICS)[number][0]; + +function Years({ v }: { v: VersusFull }) { + const [metric, setMetric] = useState("contributions"); + const { a, b } = v.people; + const points = useMemo(() => { + const all = [...a.years, ...b.years].filter((y) => y.contributions > 0).map((y) => y.year); + if (all.length === 0) return []; + const from = Math.min(...all); + const to = Math.max(...all); + const value = (p: VersusPerson, year: number) => { + const joined = new Date(p.identity.createdAt).getUTCFullYear(); + if (year < joined) return null; + return p.years.find((y) => y.year === year)?.[metric] ?? 0; + }; + return Array.from({ length: to - from + 1 }, (_, i) => ({ year: String(from + i), a: value(a, from + i), b: value(b, from + i) })); + }, [a, b, metric]); + if (points.length === 0) return null; + const label = METRICS.find(([m]) => m === metric)?.[2] ?? ""; + return ( + setMetric(m as Metric)}> + {METRICS.map(([m, l]) => ( + + ))} + + } + > +
    + + +
    +
    + + + + + compact(n)} tick={{ fontSize: 11, fill: "var(--color-text-secondary)" }} tickLine={false} axisLine={false} width={40} allowDecimals={false} /> + } cursor={{ stroke: "var(--color-border-emphasized)" }} /> + + + + +
    +
    + ); +} + +function YearTip({ active, payload, label, a, b, unit }: { active?: boolean; payload?: { dataKey?: string; value?: number | null }[]; label?: string; a: string; b: string; unit: string }) { + if (!active || !payload?.length) return null; + const of = (k: string) => payload.find((p) => p.dataKey === k)?.value; + return ( +
    +
    + {label} · {unit} +
    + {( + [ + ["a", a], + ["b", b], + ] as const + ).map(([k, login]) => ( +
    + + @{login} + + {of(k) === null || of(k) === undefined ? "not on GitHub yet" : grouped(of(k) ?? 0)} +
    + ))} +
    + ); +} + +function pullDuels(v: VersusFull): Duel[] { + const { a, b } = v.people; + const t = (p: VersusPerson) => p.totals; + const rate = (p: VersusPerson) => (t(p).prsOpened > 0 ? (t(p).prsMerged * 100) / t(p).prsOpened : null); + const capped = [a, b].filter((p) => p.read.prs < p.read.prsTotal); + return [ + { key: "opened", label: "Opened", a: t(a).prsOpened, b: t(b).prsOpened, format: grouped }, + { key: "merged", label: "Merged", a: t(a).prsMerged, b: t(b).prsMerged, format: grouped }, + { key: "rate", label: "Share merged", a: rate(a), b: rate(b), format: pct }, + { key: "open", label: "Open now", a: t(a).prsOpen, b: t(b).prsOpen, format: grouped, winner: null }, + { key: "closed", label: "Closed unmerged", a: t(a).prsClosed, b: t(b).prsClosed, format: grouped, winner: null }, + { key: "lines", label: "Lines merged", a: t(a).linesAdded === null ? null : (t(a).linesAdded ?? 0) + (t(a).linesRemoved ?? 0), b: t(b).linesAdded === null ? null : (t(b).linesAdded ?? 0) + (t(b).linesRemoved ?? 0), format: compact }, + { key: "merge", label: "Time to merge", a: t(a).hoursToMerge, b: t(b).hoursToMerge, lowerWins: true, format: hours, note: capped.length > 0 ? `From the newest ${capped.map((p) => `${grouped(p.read.prs)} of @${p.identity.login}'s`).join(" and ")} pull requests.` : undefined }, + ]; +} + +function rhythmDuels(v: VersusFull): Duel[] { + const { a, b } = v.people; + const year = (p: VersusPerson) => p.lastYear.days.reduce((s, n) => s + n, 0); + return [ + { key: "now", label: "Streak now", a: a.totals.currentStreak, b: b.totals.currentStreak, format: days }, + { key: "longest", label: "Longest streak", a: a.totals.longestStreak, b: b.totals.longestStreak, format: days }, + { key: "active", label: "Active days", a: a.totals.activeDays, b: b.totals.activeDays, format: days }, + { key: "year", label: "Contributions, last year", a: year(a), b: year(b), format: grouped }, + { key: "all", label: "Contributions, all time", a: a.totals.contributions, b: b.totals.contributions, format: grouped }, + { key: "issues", label: "Issues opened", a: a.totals.issues, b: b.totals.issues, format: grouped }, + { key: "repos", label: "Public repositories worked in", a: a.repositories, b: b.repositories, format: grouped }, + ]; +} + +const LANGUAGES = 5; + +function Languages({ p, side, shared }: { p: VersusPerson; side: Side; shared: Set }) { + const tip = useTip(); + const sum = p.languages.reduce((s, l) => s + l.commits, 0); + const top = p.languages.slice(0, LANGUAGES).filter((l) => l.commits * 100 >= sum); + const rest = sum - top.reduce((s, l) => s + l.commits, 0); + const parts = [...top, ...(rest > 0 ? [{ name: "Other", colour: null, commits: rest }] : [])]; + return ( + <> + + {sum === 0 ? ( +

    No commits on GitHub to tell their languages from.

    + ) : ( + <> + + {parts.map((l) => ( + + {l.name} +
    {`${Math.round((l.commits * 100) / sum)}%, ${many(l.commits, "commit", "commits")}`}
    + , + )} + /> + ))} +
    +
      + {parts.map((l) => ( +
    • + + + {l.name} + {shared.has(l.name) && both} + + {(l.commits * 100) / sum < 1 ? "<1%" : `${Math.round((l.commits * 100) / sum)}%`} +
    • + ))} +
    + + )} + + ); +} + +function Hours({ p, side }: { p: VersusPerson; side: Side }) { + const tip = useTip(); + const clock = p.clock; + if (!clock) + return ( +
    + +

    GitHub gave no commit times to read for @{p.identity.login}.

    +
    + ); + const most = Math.max(1, ...clock.hours); + const peak = clock.hours.indexOf(Math.max(...clock.hours)); + const night = clock.hours.reduce((s, n, h) => (h >= 22 || h < 5 ? s + n : s), 0); + return ( +
    +
    + + + busiest at {at(peak)} · {Math.round((night * 100) / clock.sampled)}% at night + +
    +
    + {clock.hours.map((n, h) => ( + 0 ? `color-mix(in oklab, var(--side) ${Math.round(18 + (n / most) * 82)}%, var(--empty))` : "var(--empty)" }} + {...tip( + <> + + {at(h)} to {at((h + 1) % 24)} + +
    {`${many(n, "commit", "commits")} of their newest ${grouped(clock.sampled)}`}
    + , + )} + /> + ))} +
    +
    + ); +} + +function HourScale() { + return ( +
    + {[0, 6, 12, 18].map((h) => ( + {at(h)} + ))} +
    + ); +} + +function workWords(w: SharedWork): string { + const n = (v: number, one: string, more: string) => `${v >= 10_000 ? compact(v) : grouped(v)} ${v === 1 ? one : more}`; + return [w.prsMerged > 0 && n(w.prsMerged, "merged", "merged"), w.commits > 0 && n(w.commits, "commit", "commits"), w.reviews > 0 && n(w.reviews, "review", "reviews")].filter(Boolean).join(" · ") || "—"; +} + +const weight = (w: SharedWork) => w.prsMerged * 4 + w.commits + w.reviews; + +function SharedRow({ r, a, b }: { r: SharedRepo; a: string; b: string }) { + const most = Math.max(1, weight(r.a), weight(r.b)); + const side = (s: Side) => { + const w = s === "a" ? r.a : r.b; + const login = s === "a" ? a : b; + return ( +
    + + = weight(s === "a" ? r.b : r.a)} /> +
    + ); + }; + return ( +
  • + + + + + {r.owner}/ + {r.name} + + + {r.language && ( + + + {r.language} + + )} + {r.stars > 0 && ★ {compact(r.stars)}} + + + + {side("a")} + {side("b")} +
  • + ); +} + +const SHARED_SHOWN = 6; + +function Shared({ v }: { v: VersusFull }) { + const [all, setAll] = useState(false); + const { a, b } = v.people; + const la = a.identity.login; + const lb = b.identity.login; + return ( + + {v.between && ( +

    + + + {[v.between.aReviewedB > 0 && `@${la} reviewed @${lb}'s pull requests ${many(v.between.aReviewedB, "time", "times")}`, v.between.bReviewedA > 0 && `@${lb} reviewed @${la}'s ${many(v.between.bReviewedA, "time", "times")}`].filter(Boolean).join("; ")} + , as far as their newest reviews show. + +

    + )} + {v.shared.length === 0 ? ( + + ) : ( + <> +
    + {many(v.shared.length, "repository", "repositories")} + @{la} + @{lb} +
    +
      + {(all ? v.shared : v.shared.slice(0, SHARED_SHOWN)).map((r) => ( + + ))} +
    + {v.shared.length > SHARED_SHOWN && ( +
    +
    + )} + + )} +
    + ); +} + +function Medal({ a }: { a: Achievement }) { + return ( +
  • + + + + + {a.title} + {[a.detail, a.at ? `reached ${a.at}` : null].filter(Boolean).join(" · ") || a.rule} + +
  • + ); +} + +function Archetype({ p, side }: { p: VersusPerson; side: Side }) { + const main = p.archetypes[0]; + return ( + <> + +
    + {main ? "Archetype" : "No Archetype yet"} + {main ? main.title : "Not decided"} + {main ? main.rule : p.totals.contributions < 50 ? "Rules start at 50 contributions." : "None of the written rules fits their numbers."} + {p.archetypes.length > 1 && Also {p.archetypes.slice(1).map((x) => x.title).join(", ")}} +
    + + ); +} + +function Only({ p, side, other }: { p: VersusPerson; side: Side; other: VersusPerson }) { + const theirs = new Set(other.achievements.filter((x) => x.earned).map((x) => x.id)); + const only = p.achievements.filter((x) => x.earned && !theirs.has(x.id)); + return ( +
    + Only @{p.identity.login} + {only.length === 0 ? ( +

    + {p.achievements.some((x) => x.earned) ? "Nothing the other has not reached too." : `No Achievement reached yet, of ${p.achievements.length}.`} +

    + ) : ( +
      + {only.map((x) => ( + + ))} +
    + )} +
    + ); +} + +function Traits({ a, b }: { a: VersusPerson; b: VersusPerson }) { + const inB = new Set(b.achievements.filter((x) => x.earned).map((x) => x.id)); + const both = a.achievements.filter((x) => x.earned && inB.has(x.id)); + return ( + + } b={} /> + {both.length > 0 && ( +
    + Reached by both +
      + {both.map((x) => ( +
    • + + + + {x.title} +
    • + ))} +
    +
    + )} + } b={} /> +
    + ); +} + +/** The full side-by-side of two people: the views, their last years, careers, pull requests, rhythm, languages, hours, shared repositories, Archetypes and Achievements. Sections with nothing in them are left out. */ +export function VersusSections({ v }: { v: VersusFull }) { + const { a, b } = v.people; + const langA = new Set(a.languages.slice(0, LANGUAGES).map((l) => l.name)); + const shared = new Set(b.languages.slice(0, LANGUAGES).map((l) => l.name).filter((n) => langA.has(n))); + const anyLanguages = a.languages.length + b.languages.length > 0; + const anyClock = !!a.clock || !!b.clock; + return ( +
    + + + } b={} /> + + + + + +
    + + + + + + + + +
    + {anyLanguages && ( + + + } b={}> + {shared.size > 0 &&

    Both write {[...shared].join(", ")}.

    } +
    +
    +
    + )} + {anyClock && ( + + +
    + + + +
    +
    +
    + )} + + + + + + +

    + Numbers come from GitHub's public record and the repositories commitscape has read. +

    +
    + ); +} + +function SectionHead({ title, words }: { title: string; words: string }) { + return ( +
    +

    {title}

    +

    {words}

    +
    + ); +} diff --git a/packages/ui/src/versus/tone.test.ts b/packages/ui/src/versus/tone.test.ts new file mode 100644 index 0000000..67b3506 --- /dev/null +++ b/packages/ui/src/versus/tone.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "vitest"; +import { winnerOf } from "./tone"; + +test("who leads a view: the higher number, the lower where lower is better, nobody at nought each or unknown", () => { + expect(winnerOf({ a: 3, b: 5 })).toBe("b"); + expect(winnerOf({ a: 0.7, b: 6, lowerWins: true })).toBe("a"); + expect(winnerOf({ a: 4, b: 4 })).toBe("tie"); + expect(winnerOf({ a: 0, b: 0, winner: "tie" })).toBeNull(); + expect(winnerOf({ a: null, b: 9 })).toBeNull(); + expect(winnerOf({ a: 1, b: 2, winner: null })).toBeNull(); +}); diff --git a/packages/ui/src/versus/tone.ts b/packages/ui/src/versus/tone.ts new file mode 100644 index 0000000..757d8b4 --- /dev/null +++ b/packages/ui/src/versus/tone.ts @@ -0,0 +1,21 @@ +import type { CSSProperties, ReactNode } from "react"; + +export type Side = "a" | "b"; + +export const TONE: Record = { a: "var(--s1)", b: "var(--s2)" }; + +/** The CSS variables that colour one person's side of a Versus. */ +export function toneOf(side: Side): CSSProperties { + return { "--side": TONE[side], "--side-soft": `color-mix(in oklab, ${TONE[side]} 16%, transparent)` } as CSSProperties; +} + +export type Duel = { key: string; label: ReactNode; a: number | null; b: number | null; lowerWins?: boolean; format: (n: number) => string; winner?: Side | "tie" | null; note?: ReactNode }; + +/** Who leads one view: the higher number, or the lower where lower is better; a tie when equal; nobody when either is unknown. */ +export function winnerOf(d: Pick): Side | "tie" | null { + if (d.a === 0 && d.b === 0) return null; + if (d.winner !== undefined) return d.winner; + if (d.a === null || d.b === null) return null; + if (d.a === d.b) return "tie"; + return (d.a > d.b) !== !!d.lowerWins ? "a" : "b"; +} diff --git a/packages/ui/src/work/PeriodPicker.tsx b/packages/ui/src/work/PeriodPicker.tsx new file mode 100644 index 0000000..57feba6 --- /dev/null +++ b/packages/ui/src/work/PeriodPicker.tsx @@ -0,0 +1,99 @@ +import { useState, useSyncExternalStore } from "react"; +import { Button } from "@astryxdesign/core/Button"; +import { Calendar, type DateRange, type ISODateString } from "@astryxdesign/core/Calendar"; +import { Popover } from "@astryxdesign/core/Popover"; +import { CalendarDays, CalendarRange, Check, ChevronDown, ChevronLeft } from "lucide-react"; +import { periodDates, PERIODS, type Period } from "@commitscape/data"; +import { presetOf, shortRange } from "./helpers"; + +const LABELS: Record = { "last-month": "Last month", "this-month": "This month", "last-3-months": "Last 3 months", "this-year": "This year", "last-year": "Last year" }; + +const length = (from: string, to: string) => Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1; + +const wide = () => typeof window !== "undefined" && window.matchMedia("(min-width: 720px)").matches; +const listen = (on: () => void) => { + const q = window.matchMedia("(min-width: 720px)"); + q.addEventListener("change", on); + return () => q.removeEventListener("change", on); +}; + +/** One switcher for a period: the named periods, and a custom range picked on a calendar, at most a year long. */ +export function PeriodPicker({ from, to, onChange }: { from: string; to: string; onChange: (period: { from: string; to: string }) => void }) { + const [open, setOpen] = useState(false); + const [custom, setCustom] = useState(false); + const [draft, setDraft] = useState({ start: from as ISODateString, end: to as ISODateString }); + const twoMonths = useSyncExternalStore(listen, wide, () => false); + const preset = presetOf(from, to); + const today = new Date().toISOString().slice(0, 10) as ISODateString; + const pick = (next: { from: string; to: string }) => { + setOpen(false); + if (next.from !== from || next.to !== to) onChange(next); + }; + const toggle = (on: boolean) => { + setOpen(on); + if (on) { + setCustom(!preset); + setDraft({ start: from as ISODateString, end: to as ISODateString }); + } + }; + const content = ( +
    +
      + {PERIODS.map(([p]) => { + const d = periodDates(p); + const on = p === preset; + return ( +
    • + +
    • + ); + })} +
    • +
    • + +
    • +
    + {custom && ( +
    + + +
    + + {shortRange(draft.start, draft.end)} + · {length(draft.start, draft.end)} days + +
    +
    +
    +
    + )} +
    + ); + return ( + + + + ); +} diff --git a/packages/ui/src/work/RepoPicker.tsx b/packages/ui/src/work/RepoPicker.tsx new file mode 100644 index 0000000..dab6b72 --- /dev/null +++ b/packages/ui/src/work/RepoPicker.tsx @@ -0,0 +1,38 @@ +import { Selector } from "@astryxdesign/core/Selector"; +import type { SelectorOptionType } from "@astryxdesign/core/Selector"; +import { FolderGit2 } from "lucide-react"; +import type { WorkItem } from "@commitscape/data"; +import { many } from "../format"; +import { placesOf } from "./helpers"; + +const ALL = "*"; + +/** A dropdown of the organisations and repositories in a Proof of Work, and "Everything". */ +export function RepoPicker({ items, value, onChange, disabled = false }: { items: WorkItem[]; value: string | null; onChange: (filter: string | null) => void; disabled?: boolean }) { + const { organisations, repositories } = placesOf(items); + const known = !value || organisations.some((o) => o.name.toLowerCase() === value.toLowerCase()) || repositories.some((r) => r.name.toLowerCase() === value.toLowerCase()); + const options: SelectorOptionType[] = [ + { value: ALL, label: "Everything", description: many(items.length, "item", "items") }, + ...(!known && value ? [{ value, label: value, description: "nothing in this period" }] : []), + ...(organisations.length > 1 ? [{ type: "section" as const, title: "Organisations and people", options: organisations.map((o) => ({ value: o.name, label: o.name, description: `${many(o.count, "item", "items")} in ${many(o.repositories, "repository", "repositories")}` })) }] : []), + { type: "section" as const, title: "Repositories", options: repositories.map((r) => ({ value: r.name, label: r.name, description: many(r.count, "item", "items") })) }, + ]; + const match = value ? ([...organisations, ...repositories].find((p) => p.name.toLowerCase() === value.toLowerCase())?.name ?? value) : ALL; + return ( +
    + onChange(v === ALL ? null : v)} + startIcon={FolderGit2} + hasSearch={repositories.length + organisations.length > 8} + searchPlaceholder="Find a repository" + isDisabled={disabled} + width="100%" + renderValue={(o) => {o.value === ALL ? "Every repository" : o.label}} + /> +
    + ); +} diff --git a/packages/ui/src/work/WorkView.tsx b/packages/ui/src/work/WorkView.tsx index b7a681b..24af3d2 100644 --- a/packages/ui/src/work/WorkView.tsx +++ b/packages/ui/src/work/WorkView.tsx @@ -1,58 +1,50 @@ -import type { ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Badge } from "@astryxdesign/core/Badge"; import { Banner } from "@astryxdesign/core/Banner"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; import { Skeleton } from "@astryxdesign/core/Skeleton"; -import { GitCommitHorizontal, GitMerge, Lock } from "lucide-react"; -import { groupWork, monthName, workTotals, type Work, type WorkItem } from "@commitscape/data"; +import { CircleCheck, GitCommitHorizontal, GitMerge, Lock, X } from "lucide-react"; +import { groupWork, kindOf, monthName, percent, workSummary, type Work, type WorkItem, type WorkKind } from "@commitscape/data"; import { Face } from "../components/Face"; -import { Cell } from "../profile/view"; +import { Nothing } from "../motion"; import { Stat } from "../kit/layout"; import { compact, grouped, many } from "../format"; +import { KIND_COLOURS, periodWords } from "./helpers"; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const LONG = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }); +const FIRST = 40; +const STEP = 60; +const STICK = "sticky top-[53px] z-[2]"; const day = (iso: string) => `${Number(iso.slice(8, 10))} ${MONTHS[Number(iso.slice(5, 7)) - 1] ?? ""}`; const longDay = (iso: string) => LONG.format(new Date(`${iso.slice(0, 10)}T00:00:00Z`)); +const span = (from: string, to: string) => Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1; -/** A period as words: "1 September to 30 September 2026". */ -export function periodWords(from: string, to: string): string { - const a = new Date(`${from}T00:00:00Z`); - const b = new Date(`${to}T00:00:00Z`); - const sameYear = a.getUTCFullYear() === b.getUTCFullYear(); - const first = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" }).format(a); - return `${first} to ${LONG.format(b)}`; -} +export type WorkGroup = "month" | "repository"; +export type WorkLook = { group: WorkGroup; kind: WorkKind | null }; -/** A Proof of Work as a document: who and when, its numbers, then every merged pull request and commit by month and repository, with links. */ -export function WorkView({ work, footer }: { work: Work; footer?: ReactNode }) { - const t = workTotals(work.items); - const months = groupWork(work.items); +/** A Proof of Work as a document: its numbers, what kind of work it was, then every merged pull request and commit, grouped by month or repository, drawn as you scroll. */ +export function WorkView({ work, look, onLook, footer }: { work: Work; look?: WorkLook; onLook?: (look: WorkLook) => void; footer?: ReactNode }) { + const [own, setOwn] = useState({ group: "month", kind: null }); + const view = look ?? own; + const change = (next: Partial) => (onLook ?? setOwn)({ ...view, ...next }); + const summary = useMemo(() => workSummary(work.items), [work.items]); + const t = summary.totals; const name = work.name ?? work.login; + const kind = view.kind && summary.kinds.some((k) => k.kind === view.kind) ? view.kind : null; + const items = useMemo(() => (kind ? work.items.filter((i) => kindOf(i.title) === kind) : work.items), [work.items, kind]); + const days = span(work.from, work.to); return ( -
    -
    -
    - -
    - {name} - @{work.login} -
    -
    -
    - Proof of Work - {periodWords(work.from, work.to)} - {work.filter ? `Only in ${work.filter}` : "Everywhere on GitHub"} -
    -
    -
    +
    +
    + +{compact(t.additions)} / −{compact(t.deletions)} } @@ -61,78 +53,216 @@ export function WorkView({ work, footer }: { work: Work; footer?: ReactNode }) { /> - + - +
    + {summary.kinds.length > 0 && change({ kind: k === kind ? null : k })} />} {work.truncated && (
    )} - {months.length === 0 ? ( -
    - Nothing merged or committed in this period - - {work.scope === "public" ? "Only public repositories count here. " : ""}Try a longer period{work.filter ? `, or look beyond ${work.filter}` : ""}. - + {work.items.length === 0 ? ( +
    +
    ) : ( - months.map((m) => ) + <> +
    +
    + {kind ? `${summary.kinds.find((k) => k.kind === kind)?.label}` : "Everything"} + {many(items.length, "item", "items")} + {kind && ( + + )} +
    + change({ group: g as WorkGroup })}> + + + +
    + + )} {footer &&
    {footer}
    }
    ); } -function Month({ month, repositories, work }: { month: string; repositories: { repo: string; private: boolean; items: WorkItem[] }[]; work: Work }) { - const items = repositories.flatMap((r) => r.items); - const prs = items.filter((i) => i.kind === "pr").length; +function Cell({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +function Kinds({ kinds, active, onPick }: { kinds: ReturnType["kinds"]; active: WorkKind | null; onPick: (k: WorkKind) => void }) { return ( -
    -
    -

    - {monthName(month)} -

    - {[prs > 0 && many(prs, "pull request", "pull requests"), items.length - prs > 0 && many(items.length - prs, "commit", "commits"), many(repositories.length, "repository", "repositories")].filter(Boolean).join(" · ")} +
    +
    +

    Kind of work

    + From conventional prefixes and the words of each title. Pick one to list only it.
    -
    - {repositories.map((r) => ( - +
    + {kinds.map((k) => ( + ))}
    +
      + {kinds.map((k) => ( +
    • + +
    • + ))} +
    ); } -function Repository({ repo, isPrivate, items, work }: { repo: string; isPrivate: boolean; items: WorkItem[]; work: Work }) { - const [owner = "", name = repo] = repo.split("/"); +type Block = { key: string; title: ReactNode; meta: string; repo?: { repo: string; private: boolean }; rows: { repo: string; private: boolean; items: WorkItem[]; meta: string }[] }; + +function counts(items: WorkItem[]) { const prs = items.filter((i) => i.kind === "pr").length; - const commits = items.length - prs; + return [prs > 0 && many(prs, "pull request", "pull requests"), items.length - prs > 0 && many(items.length - prs, "commit", "commits")].filter(Boolean).join(" · "); +} + +function blocksOf(items: WorkItem[], group: WorkGroup): Block[] { + if (group === "month") + return groupWork(items).map((m) => { + const all = m.repositories.flatMap((r) => r.items); + return { key: m.month, title: monthName(m.month), meta: `${counts(all)} · ${many(m.repositories.length, "repository", "repositories")}`, rows: m.repositories.map((r) => ({ ...r, meta: counts(r.items) })) }; + }); + const repos = new Map(); + for (const i of items) repos.set(i.repo, [...(repos.get(i.repo) ?? []), i]); + return [...repos.entries()] + .sort((a, b) => b[1].length - a[1].length || (a[0] < b[0] ? -1 : 1)) + .map(([repo, list]) => { + const sorted = [...list].sort((a, b) => (a.at < b.at ? 1 : -1)); + const isPrivate = list.some((i) => i.private); + return { key: repo, title: repo, meta: counts(list), repo: { repo, private: isPrivate }, rows: [{ repo, private: isPrivate, items: sorted, meta: "" }] }; + }); +} + +function trim(blocks: Block[], limit: number): Block[] { + const out: Block[] = []; + let left = limit; + for (const b of blocks) { + if (left <= 0) break; + const rows: Block["rows"] = []; + for (const r of b.rows) { + if (left <= 0) break; + rows.push({ ...r, items: r.items.slice(0, left) }); + left -= r.items.length; + } + out.push({ ...b, rows }); + } + return out; +} + +function List({ items, group, work }: { items: WorkItem[]; group: WorkGroup; work: Work }) { + const [limit, setLimit] = useState(FIRST); + const end = useRef(null); + const blocks = useMemo(() => blocksOf(items, group), [items, group]); + const shown = useMemo(() => trim(blocks, limit), [blocks, limit]); + const more = limit < items.length; + useEffect(() => { + const el = end.current; + if (!el || !more) return; + const seen = new IntersectionObserver((e) => e.some((x) => x.isIntersecting) && setLimit((l) => l + STEP), { rootMargin: "0px 0px 900px 0px" }); + seen.observe(el); + return () => seen.disconnect(); + }, [more, limit]); return ( -
    -
    - -

    - - {owner}/ - {name} - -

    - {isPrivate && } variant="neutral" />} - {[prs > 0 && many(prs, "pull request", "pull requests"), commits > 0 && many(commits, "commit", "commits")].filter(Boolean).join(" · ")} +
    + {shown.map((b) => ( +
    +
    + {b.repo ? ( + + ) : ( +

    + {b.title} +

    + )} + {b.meta} +
    +
    + {b.rows.map((r) => ( +
    + {!b.repo && ( +
    + + {r.meta} +
    + )} +
      + {r.items.map((i) => ( + + ))} +
    +
    + ))} +
    +
    + ))} +
    + {more ? ( +
    + + Showing {grouped(Math.min(limit, items.length))} of {grouped(items.length)}. More appear as you scroll. + + {[0, 1, 2].map((i) => ( + + ))} +
    + ) : ( +
    + + + + {items.length === 1 ? "That is the one item" : `That is all ${grouped(items.length)}`}, {periodWords(work.from, work.to)} + + +
    + )}
    -
      - {items.map((i) => ( - - ))} -
    ); } +function RepoTitle({ id, repo, isPrivate, work, level }: { id?: string; repo: string; isPrivate: boolean; work: Work; level: 2 | 3 }) { + const [owner = "", name = repo] = repo.split("/"); + const H = level === 2 ? "h2" : "h3"; + return ( + + + + + {owner}/ + {name} + + + {isPrivate && } variant="neutral" />} + + ); +} + function Row({ item }: { item: WorkItem }) { const pr = item.kind === "pr"; const ref = pr ? `#${item.number}` : (item.sha ?? "").slice(0, 7); @@ -142,7 +272,7 @@ function Row({ item }: { item: WorkItem }) { ); return ( -
  • +
  • {pr ? : } @@ -169,21 +299,7 @@ function Row({ item }: { item: WorkItem }) { export function WorkSkeleton() { return (
    -
    -
    - -
    - - -
    -
    -
    - - - -
    -
    -
    +
    {[0, 1, 2, 3].map((i) => (
    @@ -194,6 +310,19 @@ export function WorkSkeleton() { ))}
    +
    + + +
    + {[90, 70, 80, 60, 64].map((w, i) => ( + + ))} +
    +
    +
    + + +
    diff --git a/packages/ui/src/work/helpers.ts b/packages/ui/src/work/helpers.ts new file mode 100644 index 0000000..dfb5c0e --- /dev/null +++ b/packages/ui/src/work/helpers.ts @@ -0,0 +1,55 @@ +import { periodDates, PERIODS, type Period, type WorkItem, type WorkKind } from "@commitscape/data"; + +export { periodWords } from "@commitscape/data"; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const parts = (iso: string) => ({ y: Number(iso.slice(0, 4)), m: Number(iso.slice(5, 7)), d: Number(iso.slice(8, 10)) }); + +/** The colour each kind of work wears, the same as in the PDF. */ +export const KIND_COLOURS: Record = { + feature: "var(--s1)", + fix: "var(--s2)", + refactor: "var(--s3)", + docs: "var(--s4)", + test: "var(--s5)", + perf: "var(--s7)", + chore: "light-dark(#8d8c86, #85847d)", + other: "light-dark(#c9c8c0, #4d4c48)", +}; + +/** Two days as a short range: "1–30 Sep 2026", "28 Sep – 4 Oct 2026". */ +export function shortRange(from: string, to: string): string { + const a = parts(from); + const b = parts(to); + const end = `${b.d} ${MONTHS[b.m - 1]} ${b.y}`; + if (from === to) return end; + if (a.y !== b.y) return `${a.d} ${MONTHS[a.m - 1]} ${a.y} – ${end}`; + if (a.m !== b.m) return `${a.d} ${MONTHS[a.m - 1]} – ${end}`; + return `${a.d}–${end}`; +} + +/** The named period two dates make, if they make one. */ +export function presetOf(from: string, to: string, today: Date = new Date()): Period | null { + return PERIODS.find(([p]) => { + const d = periodDates(p, today); + return d.from === from && d.to === to; + })?.[0] ?? null; +} + +/** The organisations and repositories a person's work is in, busiest first, with how many items each holds. */ +export function placesOf(items: WorkItem[]) { + const repos = new Map(); + const owners = new Map>(); + const counts = new Map(); + for (const i of items) { + repos.set(i.repo, (repos.get(i.repo) ?? 0) + 1); + const owner = i.repo.split("/")[0] ?? i.repo; + owners.set(owner, (owners.get(owner) ?? new Set()).add(i.repo)); + counts.set(owner, (counts.get(owner) ?? 0) + 1); + } + const busiest = (a: [string, T, number], b: [string, T, number]) => b[2] - a[2] || (a[0] < b[0] ? -1 : 1); + return { + organisations: [...owners.entries()].map(([o, set]) => [o, set.size, counts.get(o) ?? 0] as [string, number, number]).sort(busiest).map(([name, repositories, count]) => ({ name, repositories, count })), + repositories: [...repos.entries()].map(([r, n]) => [r, 0, n] as [string, number, number]).sort(busiest).map(([name, , count]) => ({ name, count })), + }; +} diff --git a/packages/ui/src/wrapped/charts.tsx b/packages/ui/src/wrapped/charts.tsx new file mode 100644 index 0000000..b6a87d8 --- /dev/null +++ b/packages/ui/src/wrapped/charts.tsx @@ -0,0 +1,235 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Lock } from "lucide-react"; +import { squarify } from "../charts/treemap"; +import { useTip } from "../charts/tip"; +import { CountUp } from "../motion"; +import { grouped, many } from "../format"; + +export type Slice = { key: string; label: string; value: number; colour: string }; + +const TAU = Math.PI * 2; +const pct = (part: number, whole: number) => (whole > 0 ? Math.round((part * 100) / whole) : 0); +const pctText = (part: number, whole: number) => (part > 0 && whole > 0 && (part * 100) / whole < 0.5 ? "<1%" : `${pct(part, whole)}%`); + +function arc(cx: number, cy: number, r: number, from: number, to: number) { + const f = (n: number) => n.toFixed(2); + const a = [f(cx + r * Math.sin(from)), f(cy - r * Math.cos(from))]; + const b = [f(cx + r * Math.sin(to)), f(cy - r * Math.cos(to))]; + return `M ${a[0]} ${a[1]} A ${r} ${r} 0 ${to - from > Math.PI ? 1 : 0} 1 ${b[0]} ${b[1]}`; +} + +/** A ring cut into slices, the biggest share in its middle and a legend beside it with each slice's number and share. */ +export function Donut({ slices, unit, centre }: { slices: Slice[]; unit: string; centre?: ReactNode }) { + const tip = useTip(); + const [on, setOn] = useState(null); + const list = slices.filter((s) => s.value > 0); + const total = list.reduce((n, s) => n + s.value, 0); + const size = 176; + const r = 70; + const gap = list.length > 1 ? 0.025 : 0; + const starts = list.reduce((acc, s) => [...acc, (acc.at(-1) ?? 0) + (s.value / total) * TAU], [0]); + const top = list[0]; + return ( +
    +
    + `${s.label} ${pct(s.value, total)}%`).join(", ")}> + + {list.map((s, i) => { + const from = starts[i] ?? 0; + const to = starts[i + 1] ?? TAU; + const d = list.length === 1 ? null : arc(size / 2, size / 2, r, from + gap / 2, Math.max(from + gap / 2 + 0.001, to - gap / 2)); + const style = { opacity: on && on !== s.key ? 0.35 : 1, transition: "opacity 160ms, stroke-width 160ms" }; + const hover = { + ...tip( + <> + {s.label} +
    + {grouped(s.value)} {unit} · {pctText(s.value, total)} +
    + , + ), + onMouseEnter: () => setOn(s.key), + onMouseOut: () => setOn(null), + }; + return d ? ( + + ) : ( + + ); + })} + + {top && ( +
    + {centre ?? ( + <> + + `${Math.round(n)}%`} /> + + {top.label} + + )} +
    + )} +
    +
      + {list.map((s) => ( +
    • setOn(s.key)} onMouseLeave={() => setOn(null)}> + + {s.label} + {grouped(s.value)} + {pctText(s.value, total)} +
    • + ))} +
    +
    + ); +} + +const WEEK = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +const WEEK_LONG = ["Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays", "Sundays"]; + +/** Contributions on each day of the week, Monday first, the busiest day marked. */ +export function WeekBars({ days, unit = "contributions" }: { days: number[]; unit?: string }) { + const tip = useTip(); + const most = Math.max(1, ...days); + const total = days.reduce((a, b) => a + b, 0); + const best = days.indexOf(Math.max(...days)); + return ( +
    +

    + Most on {WEEK_LONG[best]}: {pct(days[best] ?? 0, total)}% of the year. Weekends hold {pct((days[5] ?? 0) + (days[6] ?? 0), total)}%. +

    +
    `${d} ${grouped(days[i] ?? 0)}`).join(", ")}> + {days.map((n, i) => ( +
    + + {grouped(n)} {unit} + +
    on {WEEK_LONG[i]}
    + , + )}> + {grouped(n)} + +
    + ))} +
    +
    + {WEEK.map((d, i) => ( + + {d} + + ))} +
    +
    + ); +} + +/** What became of the pull requests opened in a year: merged, closed without merging, or still open. */ +export function Outcomes({ merged, closed, open }: { merged: number; closed: number; open: number }) { + const tip = useTip(); + const total = merged + closed + open; + const parts = [ + { key: "merged", label: "Merged", value: merged, colour: "var(--s7)" }, + { key: "open", label: "Still open", value: open, colour: "var(--s3)" }, + { key: "closed", label: "Closed unmerged", value: closed, colour: "var(--s8)" }, + ]; + return ( +
    +
    + + `${Math.round(n)}%`} /> + + of the {many(total, "pull request", "pull requests")} opened were merged +
    +
    `${p.label} ${p.value}`).join(", ")}> + {parts + .filter((p) => p.value > 0) + .map((p) => ( + + {p.label} +
    + {grouped(p.value)} · {pctText(p.value, total)} +
    + , + )} /> + ))} +
    +
    + {parts.map((p) => ( +
    +
    + + {p.label} +
    +
    {grouped(p.value)}
    +
    + ))} +
    +
    + ); +} + +const TILES = ["var(--s1)", "var(--s2)", "var(--s3)", "var(--s4)", "var(--s5)", "var(--s7)", "var(--s6)", "var(--s8)"]; + +/** Repositories as tiles sized by their commits (or another count), the biggest first, each named when it has room. */ +export function RepoTiles({ repositories, link, unit = ["commit", "commits"] }: { repositories: { repo: string; commits: number; private: boolean }[]; link: (repo: string) => string; unit?: [string, string] }) { + const tip = useTip(); + const box = useRef(null); + const [width, setWidth] = useState(1000); + useEffect(() => { + const el = box.current; + if (!el) return; + const seen = new ResizeObserver(([e]) => e && setWidth(Math.round(e.contentRect.width))); + seen.observe(el); + return () => seen.disconnect(); + }, []); + const top = repositories.slice(0, 8); + const rest = repositories.slice(8).reduce((n, r) => n + r.commits, 0); + const items = [...top.map((r, i) => ({ ...r, colour: TILES[i] ?? "var(--s1)" })), ...(rest > 0 ? [{ repo: "", commits: rest, private: false, colour: "light-dark(#c9c8c0, #4d4c48)" }] : [])]; + const height = width < 640 ? 360 : 280; + const total = items.reduce((n, r) => n + r.commits, 0); + const tiles = squarify(items, (r) => r.commits, { x: 0, y: 0, w: width, h: height }); + return ( +
    + {tiles.map((t) => { + const r = t.item; + const label = r.repo ? (r.private ? "A private repository" : r.repo) : `${many(repositories.length - 8, "more repository", "more repositories")}`; + const roomy = t.w > 90 && t.h > 46; + const body = ( + <> + {roomy && ( + + + {r.private && } + {r.repo ? (r.private ? "Private" : (r.repo.split("/")[1] ?? r.repo)) : "Others"} + + {many(r.commits, unit[0], unit[1])} + + )} + + ); + const style = { left: t.x + 1, top: t.y + 1, width: Math.max(0, t.w - 2), height: Math.max(0, t.h - 2), background: r.colour }; + const hover = tip( + <> + {label} +
    + {many(r.commits, unit[0], unit[1])} · {pctText(r.commits, total)} +
    + , + ); + const cls = "absolute flex items-start overflow-hidden rounded-[6px] p-2.5 no-underline transition-[filter] duration-150 hover:brightness-110"; + return r.repo && !r.private ? ( + + {body} + + ) : ( +
    + {body} +
    + ); + })} +
    + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 231c1d3..4672e53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,9 @@ importers: '@fontsource-variable/inter': specifier: 5.3.0 version: 5.3.0 + '@gsap/react': + specifier: 2.1.2 + version: 2.1.2(gsap@3.15.0)(react@19.3.0) '@modelcontextprotocol/sdk': specifier: 1.30.1 version: 1.30.1(supports-color@8.1.1)(zod@4.6.5) @@ -352,9 +355,15 @@ importers: drizzle-orm: specifier: 'catalog:' version: 0.45.3(@cloudflare/workers-types@5.20260925.2)(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0) + gsap: + specifier: 3.15.0 + version: 3.15.0 lucide-react: specifier: 1.48.0 version: 1.48.0(react@19.3.0) + motion: + specifier: 14.0.0 + version: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pdfkit: specifier: 0.20.2 version: 0.20.2 @@ -513,12 +522,21 @@ importers: '@commitscape/data': specifier: workspace:* version: link:../data + '@gsap/react': + specifier: 2.1.2 + version: 2.1.2(gsap@3.15.0)(react@19.3.0) '@stylexjs/stylex': specifier: 'catalog:' version: 0.19.1 + gsap: + specifier: 3.15.0 + version: 3.15.0 lucide-react: specifier: 1.48.0 version: 1.48.0(react@19.3.0) + motion: + specifier: 14.0.0 + version: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) recharts: specifier: 3.10.1 version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1) @@ -1292,6 +1310,12 @@ packages: '@formatjs/icu-skeleton-parser@2.1.11': resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + '@gsap/react@2.1.2': + resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==} + peerDependencies: + gsap: ^3.12.5 + react: '>=17' + '@hono/node-server@2.1.1': resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} engines: {node: '>=20'} @@ -3247,6 +3271,17 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@14.0.0: + resolution: {integrity: sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3290,6 +3325,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + h3@2.0.1-rc.20: resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} engines: {node: '>=20.11.1'} @@ -3632,6 +3670,23 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + motion-dom@14.0.0: + resolution: {integrity: sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==} + + motion-utils@14.0.0: + resolution: {integrity: sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==} + + motion@14.0.0: + resolution: {integrity: sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5017,6 +5072,11 @@ snapshots: '@formatjs/icu-skeleton-parser@2.1.11': {} + '@gsap/react@2.1.2(gsap@3.15.0)(react@19.3.0)': + dependencies: + gsap: 3.15.0 + react: 19.3.0 + '@hono/node-server@2.1.1(hono@4.13.9)': dependencies: hono: 4.13.9 @@ -6748,6 +6808,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + motion-dom: 14.0.0 + motion-utils: 14.0.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + fresh@2.0.0: {} fsevents@2.3.3: @@ -6793,6 +6862,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.15.0: {} + h3@2.0.1-rc.20(crossws@0.4.12(srvx@1.0.5)): dependencies: rou3: 0.8.1 @@ -7041,6 +7112,20 @@ snapshots: minipass@7.1.3: {} + motion-dom@14.0.0: + dependencies: + motion-utils: 14.0.0 + + motion-utils@14.0.0: {} + + motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + framer-motion: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + tslib: 2.8.1 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + ms@2.1.3: {} nanoid@3.3.19: {} From 7193486f4684761cb13c42182085dfc011c4b13e Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:42:35 +0530 Subject: [PATCH 05/10] feat(phase-35): establish a design system across the Site - Apply shared tokens and UI patterns across every screen - Improve profile survival reporting, cards, and repository views - Speed up full-year Proof of Work loading --- STATE.md | 11 + apps/site/src/components/CardStudio.tsx | 54 +-- apps/site/src/components/Facts.tsx | 50 +-- apps/site/src/components/Footer.tsx | 16 +- apps/site/src/components/Frame.tsx | 15 +- apps/site/src/components/Membership.tsx | 16 +- apps/site/src/components/Rivals.tsx | 6 +- apps/site/src/components/Start.tsx | 50 +-- apps/site/src/components/ThemedCard.tsx | 4 +- apps/site/src/components/WindowTable.tsx | 22 +- apps/site/src/functions/profiles.ts | 9 +- apps/site/src/home/Closing.tsx | 8 +- apps/site/src/home/Features.tsx | 21 +- apps/site/src/home/Hero.tsx | 21 +- apps/site/src/home/Install.tsx | 17 +- apps/site/src/home/layout.tsx | 22 +- apps/site/src/home/scenes/CardsScene.tsx | 214 +++++---- apps/site/src/home/scenes/HeroCards.tsx | 74 +++- apps/site/src/home/scenes/ProfileScene.tsx | 62 ++- apps/site/src/home/scenes/RaceScene.tsx | 42 +- apps/site/src/home/scenes/StandingScene.tsx | 41 +- apps/site/src/home/scenes/TerminalScene.tsx | 40 +- apps/site/src/home/scenes/VersusScene.tsx | 50 ++- apps/site/src/home/scenes/WorkScene.tsx | 48 +- apps/site/src/home/scenes/WrappedScene.tsx | 54 ++- apps/site/src/home/scenes/parts.tsx | 6 +- apps/site/src/lib/lookup.tsx | 2 +- apps/site/src/lib/queries.ts | 2 +- apps/site/src/routeTree.gen.ts | 21 + apps/site/src/routes/crews.$id.tsx | 26 +- apps/site/src/routes/design.tsx | 404 +++++++++++++++++ apps/site/src/routes/gh.$owner.$repo.tsx | 22 +- apps/site/src/routes/leaderboards.tsx | 74 ++-- apps/site/src/routes/me.tsx | 62 +-- apps/site/src/routes/privacy.tsx | 26 +- apps/site/src/routes/races.$id.tsx | 26 +- apps/site/src/routes/s.$id.tsx | 4 +- apps/site/src/routes/u.$login.tsx | 44 +- .../src/routes/u.$login_.$owner.$repo.tsx | 58 +-- apps/site/src/routes/u.$login_.cards.tsx | 17 +- apps/site/src/routes/u.$login_.work.tsx | 41 +- apps/site/src/routes/u.$login_.work_.$id.tsx | 2 +- .../src/routes/u.$login_.wrapped.$year.tsx | 49 ++- apps/site/src/routes/vs.$a.$b.tsx | 43 +- apps/site/src/routes/vs.index.tsx | 37 +- apps/site/src/server/survival.test.ts | 52 +++ apps/site/src/server/survival.ts | 38 ++ apps/site/src/server/work.test.ts | 13 +- apps/site/src/server/work.ts | 86 +++- apps/site/src/styles/app.css | 10 +- apps/site/src/styles/look.css | 110 +++-- packages/data/src/profile.ts | 14 +- packages/data/src/traits.test.ts | 15 + packages/data/src/traits.ts | 39 +- packages/ui/DESIGN.md | 107 +++++ packages/ui/package.json | 1 + packages/ui/src/App.tsx | 9 +- packages/ui/src/charts/Grid.tsx | 16 +- packages/ui/src/charts/Lines.tsx | 11 +- packages/ui/src/charts/Sunburst.tsx | 11 +- packages/ui/src/charts/Tip.tsx | 2 +- packages/ui/src/charts/Trend.tsx | 22 +- packages/ui/src/charts/Year.tsx | 13 +- packages/ui/src/charts/common.tsx | 15 +- packages/ui/src/components/Face.tsx | 14 +- packages/ui/src/components/Key.tsx | 2 +- packages/ui/src/components/Loading.tsx | 6 +- packages/ui/src/design/theme.css | 190 ++++++++ packages/ui/src/design/tokens.css | 163 +++++++ packages/ui/src/design/tokens.test.ts | 35 ++ packages/ui/src/design/tokens.ts | 90 ++++ packages/ui/src/explain.tsx | 2 +- packages/ui/src/index.css | 38 +- packages/ui/src/index.ts | 3 +- packages/ui/src/kit/layout.tsx | 47 +- packages/ui/src/motion/Nothing.tsx | 6 +- packages/ui/src/profile/cell.tsx | 2 +- packages/ui/src/profile/clock.tsx | 28 +- packages/ui/src/profile/days.ts | 15 +- packages/ui/src/profile/icons.ts | 14 + packages/ui/src/profile/languages.tsx | 97 ++-- packages/ui/src/profile/streak.tsx | 31 +- packages/ui/src/profile/survival.test.ts | 30 +- packages/ui/src/profile/survival.ts | 30 ++ packages/ui/src/profile/traits.tsx | 111 +++-- packages/ui/src/profile/view.tsx | 413 +++++++++++++----- packages/ui/src/profile/year.tsx | 38 +- packages/ui/src/profile/years.tsx | 34 +- packages/ui/src/screens/Activity.tsx | 19 +- packages/ui/src/screens/Commits.tsx | 42 +- packages/ui/src/screens/Map.tsx | 61 +-- packages/ui/src/screens/Overview.tsx | 76 ++-- packages/ui/src/screens/People.tsx | 270 ++++++++---- packages/ui/src/screens/WhereWork.tsx | 38 +- packages/ui/src/screens/kit.tsx | 26 +- packages/ui/src/standings/view.tsx | 42 +- packages/ui/src/versus/duel.tsx | 15 +- packages/ui/src/versus/full.tsx | 95 ++-- packages/ui/src/work/PeriodPicker.tsx | 34 +- packages/ui/src/work/RepoPicker.tsx | 2 +- packages/ui/src/work/WorkView.tsx | 85 ++-- packages/ui/src/work/helpers.ts | 4 +- packages/ui/src/worker.d.ts | 5 + packages/ui/src/wrapped/charts.tsx | 37 +- packages/ui/vitest.config.ts | 2 +- 105 files changed, 3324 insertions(+), 1485 deletions(-) create mode 100644 apps/site/src/routes/design.tsx create mode 100644 apps/site/src/server/survival.test.ts create mode 100644 apps/site/src/server/survival.ts create mode 100644 packages/ui/DESIGN.md create mode 100644 packages/ui/src/design/theme.css create mode 100644 packages/ui/src/design/tokens.css create mode 100644 packages/ui/src/design/tokens.test.ts create mode 100644 packages/ui/src/design/tokens.ts create mode 100644 packages/ui/src/profile/icons.ts diff --git a/STATE.md b/STATE.md index 7520b96..7a24990 100644 --- a/STATE.md +++ b/STATE.md @@ -2716,3 +2716,14 @@ The owner reviewed the rebuilt Site and listed gaps; this round fixed them and t On this machine the dev stack was `pnpm services`, `pnpm dev:builder` and `pnpm dev:site` with `apps/site/.env` and `apps/builder/.env` (not in the repository; `GITHUB_TOKEN` was the owner's `gh` token, so the dev Site's "public" numbers include what that token can see, as finding 7 of Phase 33 explains). Seams signed off by the user and not open for revision (from earlier runs) still hold: `RepoSource` (fake and real), `Index`, the `Analysis` methods, `--json` golden files, the TUI render via `insta`, the forge, the HTTP API, generated types, the problem-solvers, the browser screens through Playwright. This run added two of its own: `BlameSource` and `BlameThreads` beside `RepoSource` (with scripted fakes), and the Card pipeline (`renderCard`: a component, Satori, the paint's animation, resvg). +### The Site's design system (2026-10-06, uncommitted) + +The owner asked for one design system applied to every screen, plus a list of fixes. + +- **Design system:** `packages/ui/src/design/` holds the tokens (`tokens.css`: type scale, tracking, spacing rhythm, radii, page widths, z layers, motion, ink/surface/line, brand, data series, heat, flame, stage, medal colours, gradients, shadows, each colour light and dark), the Tailwind bridge (`theme.css`: `text-2xs`…`text-5xl`, `rounded-cell`…`rounded-2xl`, `gap-gutter`/`p-panel`/`gap-section`…, and text styles `type-hero`…`type-code`) and a TypeScript mirror (`tokens.ts`, `@commitscape/ui/design`: `CHART`, `heat()`, `ICON`, `MEDAL`…). `tokens.test.ts` keeps CSS and TypeScript in step. Rules in `packages/ui/DESIGN.md`; a live, unlinked style guide at `/design`. New kit primitives `Chip` and `Eyebrow`. Every page's raw sizes, radii and colours moved onto it (Tailwind's `text-xs` is now 12px, `text-sm` 13px). +- **Home:** the hero Cards shuffle like a deck (slide clear, change layer, settle); the Profile, Wrapped and Standing tiles never show holes or zeros; a new Card studio scene; one stage height and rhythm for all tiles; a new Wrapped gradient shared with the Wrapped page. +- **Profile:** Code that survived is honest by construction: the total is the sum of the counted repositories listed, it says how many of the repositories they committed to it covers, and every other one is listed as not read, reading, failed or not in it, with Read buttons (`server/survival.ts`, `profile/survival.ts`, tested). A redesigned Archetype panel with per-rule meters (rule evidence in `packages/data/src/traits.ts`), a streak cell aligned with its neighbours, and a Languages panel that fills its row. +- **Repository page:** `Face` keeps a square box and draws its own ring (`ring` prop), fixing the stretched ring; a redesigned What they work on. +- **Proof of Work:** "Last year" (the previous calendar year) is the default period; the period list's spacing is fixed; a whole year loads about three times faster (`server/work.ts` reads search pages and months in parallel and shares identical requests in flight). +- The dev Builder needs `target/release/commitscape`; it was missing in this worktree and was rebuilt. + diff --git a/apps/site/src/components/CardStudio.tsx b/apps/site/src/components/CardStudio.tsx index 53c0c61..4d23ef6 100644 --- a/apps/site/src/components/CardStudio.tsx +++ b/apps/site/src/components/CardStudio.tsx @@ -41,7 +41,7 @@ export function SteadyImage({ src, alt, width, height, className = "", eager = f return ( {alt} - + @@ -57,8 +57,8 @@ export function CardStudio({ choices, state, onChange, origin, pinned = true }: return (
    -
    -
    +
    +
    onChange({ ...state, mode: m as "light" | "dark" })}> } /> } /> @@ -86,12 +86,12 @@ function Picker({ choices, value, query, mode, onPick }: { choices: CardChoice[] }, [choices]); if (choices.length < 2) return null; return ( -
    -

    Card

    -
    +
    +

    Card

    +
    {groups.map(([group, list]) => (
    - {groups.length > 1 && {group}} + {groups.length > 1 && {group}}
    {list.map((c) => ( ))}
    @@ -119,9 +119,9 @@ function Styler({ style, mode, set, reset }: { style: CardStyle; mode: "light" | const accent = style.accent ?? PRESETS[style.preset][mode].accent; const changed = styleQuery(style) !== ""; return ( -
    +
    -

    Style

    +

    Style

    @@ -135,17 +135,17 @@ function Styler({ style, mode, set, reset }: { style: CardStyle; mode: "light" | type="button" aria-pressed={style.preset === id} onClick={() => set({ preset: id, accent: null, background: null })} - className={`flex cursor-pointer flex-col gap-1 rounded-[var(--radius-element)] border bg-transparent p-1 text-start ${style.preset === id ? "border-[var(--color-accent)] ring-2 ring-[var(--brand-soft)]" : "border-line hover:border-strong"}`} + className={`flex cursor-pointer flex-col gap-1 rounded-md border bg-transparent p-1 text-start ${style.preset === id ? "border-brand ring-2 ring-brand-soft" : "border-line hover:border-line-strong"}`} > {[0.5, 0.9, 0.7].map((h) => ( - + ))} - {PRESETS[id].label} + {PRESETS[id].label} ); })} @@ -158,12 +158,12 @@ function Styler({ style, mode, set, reset }: { style: CardStyle; mode: "light" | {style.accent === s && } ))} -
    - # + # set({ accent: hex })} /> {style.accent &&
    @@ -174,9 +174,9 @@ function Styler({ style, mode, set, reset }: { style: CardStyle; mode: "light" | const look = lookOf(mode, { ...style, background: b.id }); const on = (style.background ?? PRESETS[style.preset].background) === b.id; return ( - ); })} @@ -207,7 +207,7 @@ function HexInput({ value, placeholder, onHex }: { value: string | null; placeho const hex = hexOf(e.target.value); if (hex) onHex(hex); }} - className="h-7 w-24 rounded-md border border-line bg-[var(--color-background-body)] px-2 font-mono text-xs text-primary uppercase outline-none focus:border-[var(--color-accent)]" + className="h-7 w-24 rounded-md border border-line bg-body px-2 font-mono text-2xs text-primary uppercase outline-none focus:border-brand" /> ); } @@ -215,7 +215,7 @@ function HexInput({ value, placeholder, onHex }: { value: string | null; placeho function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
    - {label} + {label} {children}
    ); @@ -230,7 +230,7 @@ function Outputs({ choice, query, origin }: { choice: CardChoice; query: string; const link = absolute(choice.link); const markdown = markdownOf(url, choice.alt, link, query); return ( -
    +
    @@ -245,7 +245,7 @@ function Outputs({ choice, query, origin }: { choice: CardChoice; query: string;
    {tab === "readme" && (
    -

    Paste into a README. GitHub shows the dark Card to readers in dark mode, animated, and it refreshes every six hours.

    +

    Paste into a README. GitHub shows the dark Card to readers in dark mode, animated, and it refreshes every six hours.

    toast("Markdown copied")} />
    )} @@ -258,8 +258,8 @@ function Outputs({ choice, query, origin }: { choice: CardChoice; query: string; ["As a PNG", absolute(cardSrc(choice.url, "png", "light", query))], ].map(([label, link]) => (
    - {label} - {link} + {label} + {link}
    ))} @@ -267,7 +267,7 @@ function Outputs({ choice, query, origin }: { choice: CardChoice; query: string; )} {tab === "post" && (
    -

    The post links to the page; X and LinkedIn show its preview image. Download the PNG to attach the Card itself.

    +

    The post links to the page; X and LinkedIn show its preview image. Download the PNG to attach the Card itself.

    -
    @@ -137,7 +137,7 @@ export function People({ kind, id, members, you, signedIn, createdBy }: { kind: )} {!signedIn && (
    - Invited? Sign in with GitHub to answer. + Invited? Sign in with GitHub to answer.
    )} diff --git a/apps/site/src/components/Rivals.tsx b/apps/site/src/components/Rivals.tsx index 30c50b7..5251f3e 100644 --- a/apps/site/src/components/Rivals.tsx +++ b/apps/site/src/components/Rivals.tsx @@ -55,7 +55,7 @@ export function RivalGaps() { {"hidden" in r ? ( - + has chosen to stay out of comparisons ) : ( @@ -64,8 +64,8 @@ export function RivalGaps() { const d = g.mine - g.theirs; const Glyph = d > 0 ? ArrowUpRight : d < 0 ? ArrowDownRight : Minus; return ( - 0 ? "border-[color-mix(in_srgb,var(--added)_35%,transparent)] text-added" : d < 0 ? "border-[color-mix(in_srgb,var(--removed)_35%,transparent)] text-removed" : "border-line text-secondary"}`}> - + 0 ? "border-added/35 text-added" : d < 0 ? "border-removed/35 text-removed" : "border-line text-secondary"}`}> + {d === 0 ? `level on ${g.label}` : <>{grouped(Math.abs(d))} {d > 0 ? "ahead" : "behind"} on {g.label}} ); diff --git a/apps/site/src/components/Start.tsx b/apps/site/src/components/Start.tsx index 4badfed..0876959 100644 --- a/apps/site/src/components/Start.tsx +++ b/apps/site/src/components/Start.tsx @@ -46,9 +46,9 @@ export function Start({ kind }: { kind: Kind }) { if (!mine) return ; const invitations = mine.invitations.filter((i) => i.kind === kind); return ( -
    +
    {invitations.length > 0 && } -
    +
    {kind === "race" ? : }
    @@ -61,25 +61,25 @@ export function Start({ kind }: { kind: Kind }) { function SignedOut({ kind }: { kind: Kind }) { return ( -
    -
    +
    +
    {STEPS[kind].map((s, n) => ( -
    +
    - + - Step {n + 1} + Step {n + 1} {s.title} - {s.words} + {s.words}
    ))}
    -
    +
    - Sign in to start a {WORD[kind]}, or to answer an invitation - With GitHub. Read-only, and you can stay out of every comparison in Settings. + Sign in to start a {WORD[kind]}, or to answer an invitation + With GitHub. Read-only, and you can stay out of every comparison in Settings.
    @@ -99,15 +99,15 @@ function Invitations({ kind, invitations }: { kind: Kind; invitations: { id: str }, }); return ( -
    +
    -

    {invitations.length === 1 ? "An invitation is waiting" : `${invitations.length} invitations are waiting`}

    -

    You appear in a {WORD[kind]} only once you accept.

    +

    {invitations.length === 1 ? "An invitation is waiting" : `${invitations.length} invitations are waiting`}

    +

    You appear in a {WORD[kind]} only once you accept.

    - {reply.error &&

    {reply.error.message}

    } + {reply.error &&

    {reply.error.message}

    }
      {invitations.map((i) => ( -
    • +
    • @@ -139,7 +139,7 @@ function RaceList({ races }: { races: { id: string; name: string; from: string; {sorted.map((r) => { const state = raceState(r.from, r.to); return ( - {windowClock(r.from, r.to).words}} badge={} /> + {windowClock(r.from, r.to).words}} badge={} /> ); })}
    @@ -161,7 +161,7 @@ function CrewList({ crews }: { crews: { id: string; name: string }[] }) { ) : (
      {crews.map((c) => ( - {windowClock(`${season}-01`, lastDay(season)).words}} /> + {windowClock(`${season}-01`, lastDay(season)).words}} /> ))}
    )} @@ -177,13 +177,13 @@ const lastDay = (season: string) => { function Row({ to, id, title, sub, end, badge }: { to: "/races/$id" | "/crews/$id"; id: string; title: string; sub: string; end: ReactNode; badge?: ReactNode }) { return (
  • - + {title} {badge} - {sub} + {sub} {end} @@ -195,11 +195,11 @@ function Row({ to, id, title, sub, end, badge }: { to: "/races/$id" | "/crews/$i function Empty({ icon: Glyph, title, words }: { icon: typeof Flag; title: string; words: string }) { return (
    - + {title} - {words} + {words}
    ); } @@ -249,7 +249,7 @@ function StartForm({ kind }: { kind: Kind }) { type="button" aria-pressed={on} onClick={() => setTo(plus(from, n - 1))} - className={`cursor-pointer rounded-full border px-2.5 py-0.5 text-xs transition-colors ${on ? "border-transparent bg-brand-soft text-brand" : "border-line bg-transparent text-secondary hover:border-strong hover:text-primary"}`} + className={`cursor-pointer rounded-full border px-2.5 py-0.5 text-2xs transition-colors ${on ? "border-transparent bg-brand-soft text-brand" : "border-line bg-transparent text-secondary hover:border-strong hover:text-primary"}`} > {label} @@ -259,7 +259,7 @@ function StartForm({ kind }: { kind: Kind }) {
  • )} - {start.error &&

    {start.error.message}

    } + {start.error &&

    {start.error.message}

    }
  • @@ -16,11 +16,12 @@