diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c0ce3..0a5a5f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,8 @@ jobs: docker compose -f compose.dev.yaml up -d --wait - name: Typecheck, lint, test and build + env: + CARD_BUDGET_MS: "500" run: | pnpm install --frozen-lockfile pnpm check @@ -186,6 +188,8 @@ jobs: cargo xtask fixtures --force cargo llvm-cov --workspace --exclude xtask --lcov --output-path rust.lcov - name: TypeScript + env: + CARD_BUDGET_MS: "1000" run: | pnpm install --frozen-lockfile pnpm -r --filter "./packages/*" --filter "./apps/*" exec vitest run --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage diff --git a/CONTEXT.md b/CONTEXT.md index 5f2cdc9..9f17239 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,74 @@ 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, 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 (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**: +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, 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, 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**: +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/DEPLOY.md b/DEPLOY.md index 7db9039..7823e22 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="..." ``` @@ -107,10 +107,11 @@ Optional limits, with their defaults: | Variable | Default | What | |---|---|---| | `CONCURRENCY` | 1 | Builds at a time | -| `FULL_CLONE_UP_TO_MB` | 100 | Larger repositories are cloned without old file contents, and their lines are not counted | -| `MAX_REPOSITORY_MB` | 3000 | Larger repositories are refused | -| `TIME_LIMIT_SECONDS` | 900 | A Build stops after this long | +| `MAX_REPOSITORY_MB` | none | Unset, every repository is cloned in full and its lines counted, however big; set, larger repositories are refused | +| `TIME_LIMIT_SECONDS` | 900 | How long a Build's first attempt may take. One that runs out is queued again and goes on from the lines it counted, each attempt given twice the time, four attempts in all | | `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 at first. A count past it is queued again with twice the budget, up to four hours, and goes on from the files it counted; cloning and loading the repository have `TIME_LIMIT_SECONDS` of their own | +| `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 +142,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/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/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 e21bc95..9ba799d 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 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..44cffee --- /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..9cce77f --- /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..5116819 --- /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..62eb890 --- /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..c6fd6b4 --- /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..ad81713 --- /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..49eb188 --- /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..592b38b --- /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..b296b9b --- /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..f4b6ec1 --- /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..fd3420c --- /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..d0b39ce --- /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..f6af97c --- /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..d946ea3 --- /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..cd088b8 --- /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..d0b39ce --- /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..f6af97c --- /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..48c07df --- /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..5d85fc2 --- /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..864a449 --- /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..260b074 --- /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..adfe3a7 --- /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..95c017e --- /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..b5f8ac4 --- /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..e4c166f --- /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..c3e2a56 --- /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..0b4ce97 --- /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..9f5395d --- /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..29fa7d5 --- /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/backdrop.ts b/packages/ui/src/cards/backdrop.ts new file mode 100644 index 0000000..a05450a --- /dev/null +++ b/packages/ui/src/cards/backdrop.ts @@ -0,0 +1,292 @@ +import type { Background } from "./style"; +import type { CardTheme } from "./tokens"; + +export type Backdrop = { backgroundImage: string | undefined; backgroundSize: string | undefined }; + +export type BackdropInput = { theme: CardTheme; background: Background; bg: string; text: string; accent: string; hues: [string, string, string]; seed: number; foil?: boolean }; + +const GRAINY: Background[] = ["mesh", "aurora", "holo", "grain"]; +const MOVING: Background[] = ["mesh", "aurora", "glow"]; + +const n = (v: number) => String(Math.round(v * 10) / 10); + +function random(seed: number): () => number { + let a = seed || 1; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function soft(hex: string, toward: string, amount: number): string { + const p = (h: string) => [1, 3, 5].map((i) => Number.parseInt(h.slice(i, i + 2), 16)); + const [a, b] = [p(hex), p(toward)]; + return `#${a.map((v, i) => Math.round(v + ((b[i] ?? v) - v) * amount).toString(16).padStart(2, "0")).join("")}`; +} + +class Svg { + defs: string[] = []; + body: string[] = []; + private next = 0; + id() { + return `k${this.next++}`; + } + radial(colour: string, opacity: number): string { + const id = this.id(); + this.defs.push(``); + return id; + } + linear(stops: [number, string, number][], x1 = 0, y1 = 0, x2 = 1, y2 = 1, user?: boolean): string { + const id = this.id(); + this.defs.push(`${stops.map(([o, c, a]) => ``).join("")}`); + return id; + } + blob(cx: number, cy: number, rx: number, ry: number, colour: string, opacity: number, rotate = 0, cls = "") { + const id = this.radial(colour, opacity); + this.body.push(``); + } + done(width: number, height: number, style = ""): string { + return `${style ? `` : ""}${this.defs.join("")}${this.body.join("")}`; + } +} + +function closed(points: [number, number][]): string { + const p = (i: number) => points[(i + points.length) % points.length] as [number, number]; + const r = Math.round; + let d = `M${r(p(0)[0])} ${r(p(0)[1])}`; + for (let i = 0; i < points.length; i++) { + const [a, b, c, e] = [p(i - 1), p(i), p(i + 1), p(i + 2)]; + d += `C${r(b[0] + (c[0] - a[0]) / 6)} ${r(b[1] + (c[1] - a[1]) / 6)} ${r(c[0] - (e[0] - b[0]) / 6)} ${r(c[1] - (e[1] - b[1]) / 6)} ${r(c[0])} ${r(c[1])}`; + } + return `${d}Z`; +} + +function draw(input: BackdropInput, w: number, h: number): string | null { + const { theme, background, bg, text, accent, hues, seed } = input; + const dark = theme === "dark"; + const r = random(seed); + const s = new Svg(); + const big = Math.max(w, h); + const line = dark ? "#ffffff" : text; + let style = ""; + switch (background) { + case "plain": + return null; + case "glow": + s.blob(w * 0.94, -h * 0.12, big * 0.72, h * 1.15, accent, dark ? 0.46 : 0.3, 0, "a"); + s.blob(w * 0.04, h * 1.08, big * 0.48, h * 0.75, hues[0], dark ? 0.2 : 0.16, 0, "b"); + break; + case "gradient": { + const g = s.linear([[0, bg, 0], [1, soft(bg, accent, dark ? 0.3 : 0.2), 1]], 0, 0, 1, 1); + s.body.push(``); + s.blob(w * 0.88, h * 0.95, big * 0.5, h * 0.8, hues[0], dark ? 0.2 : 0.14); + break; + } + case "aurora": { + const colours = [accent, hues[0], hues[1]]; + for (let i = 0; i < 3; i++) s.blob(w * (0.18 + 0.32 * i + (r() - 0.5) * 0.12), h * (0.08 + r() * 0.22), w * (0.34 + r() * 0.12), h * (0.2 + r() * 0.1), colours[i] ?? accent, dark ? 0.55 : 0.36, -22 + r() * 14, i % 2 ? "b" : "a"); + s.blob(w * 0.6, h * 1.05, w * 0.6, h * 0.45, hues[2], dark ? 0.22 : 0.14); + break; + } + case "mesh": { + s.body.push(``); + const anchors: [number, number][] = [[0, 0], [1, 0], [0, 1], [1, 1], [0.5, 0.5]]; + const colours = [accent, hues[0], hues[1], hues[2], soft(accent, dark ? "#ffffff" : bg, 0.4)]; + const order = anchors.map((a, i) => ({ a, k: r(), i })).sort((x, y) => x.k - y.k); + order.forEach(({ a }, i) => { + const cx = (a[0] + (r() - 0.5) * 0.4) * w; + const cy = (a[1] + (r() - 0.5) * 0.4) * h; + s.blob(cx, cy, big * (0.42 + r() * 0.24), big * (0.32 + r() * 0.2), colours[i] ?? accent, dark ? 0.5 : 0.36, r() * 180, i % 2 ? "b" : "a"); + }); + break; + } + case "holo": { + const spectrum = input.foil ? [hues[0], soft(hues[2], "#ffffff", 0.3), hues[1], accent, soft(hues[0], "#ffffff", 0.4)].map((c) => (dark ? c : soft(c, "#ffffff", 0.35))) : dark ? ["#7c3aed", "#0891b2", "#ca8a04", "#db2777", "#059669"] : ["#f9a8d4", "#a5f3fc", "#fde68a", "#c4b5fd", "#a7f3d0"]; + const at = Math.floor(r() * spectrum.length); + const turned = [...spectrum.slice(at), ...spectrum.slice(0, at)]; + const bands = s.linear([...turned.map((c, i) => [i / (turned.length + 1), c, 1] as [number, string, number]), [1, soft(accent, dark ? bg : "#ffffff", 0.3), 1]], 0, 0.1, 1, 0.9); + s.body.push(``); + const sheen = s.linear([[0, "#ffffff", 0], [0.36, "#ffffff", 0], [0.47, "#ffffff", dark ? 0.14 : 0.75], [0.53, "#ffffff", dark ? 0.06 : 0.35], [0.62, "#ffffff", 0], [1, "#ffffff", 0]], 0, 0, 1, 1); + s.body.push(``); + const step = 6; + const lines: string[] = []; + for (let x = -h; x < w; x += step) lines.push(`M${n(x)} ${h}L${n(x + h)} 0`); + s.body.push(``); + s.blob(w * 0.12, h * 1.0, big * 0.4, h * 0.6, accent, dark ? 0.22 : 0.16); + break; + } + case "grain": { + s.blob(w * 0.12, h * 0.05, big * 0.6, h * 0.9, dark ? hues[0] : "#ffffff", dark ? 0.12 : 0.7); + s.blob(w * 0.95, h * 1.05, big * 0.5, h * 0.7, accent, dark ? 0.12 : 0.1); + break; + } + case "topo": { + const cx = w * (0.72 + r() * 0.3); + const cy = h * (-0.1 + r() * 0.45); + const phase = [r() * 6.28, r() * 6.28, r() * 6.28]; + const step = Math.max(w, h * 2) * 0.052; + s.blob(cx, cy, step * 4, step * 3, accent, dark ? 0.3 : 0.18); + const rings: string[] = []; + const strong: string[] = []; + for (let k = 1; k <= 13; k++) { + const pts: [number, number][] = []; + for (let i = 0; i < 28; i++) { + const a = (i / 28) * Math.PI * 2; + const wob = 1 + 0.11 * Math.sin(3 * a + (phase[0] ?? 0) + k * 0.18) + 0.06 * Math.sin(5 * a + (phase[1] ?? 0) - k * 0.12) + 0.03 * Math.sin(9 * a + (phase[2] ?? 0)); + const rad = step * k * wob; + pts.push([cx + Math.cos(a) * rad * 1.25, cy + Math.sin(a) * rad]); + } + (k % 4 === 0 ? strong : rings).push(closed(pts)); + } + s.body.push(``); + s.body.push(``); + break; + } + case "rings": { + const cx = w * (0.82 + r() * 0.2); + const cy = h * (0.85 + r() * 0.3); + s.blob(cx, cy, big * 0.3, big * 0.3, accent, dark ? 0.4 : 0.24); + const circles: string[] = []; + let rad = 18; + for (let k = 0; k < 26 && rad < big * 1.3; k++) { + circles.push(``); + rad += 14 + k * 1.6; + } + s.body.push(`${circles.join("")}`); + break; + } + case "horizon": { + const y0 = h * 0.46; + const vx = w * (0.38 + r() * 0.24); + const sky = s.linear([[0, soft(bg, hues[0], dark ? 0.12 : 0.08), 1], [1, bg, 0]], 0, 0, 0, 1); + s.body.push(``); + const floor = s.linear([[0, accent, 0], [1, accent, dark ? 0.14 : 0.08]], 0, y0, 0, h, true); + s.body.push(``); + const fade = s.linear([[0, k0(dark, accent, text), 0], [0.35, k0(dark, accent, text), dark ? 0.12 : 0.1], [1, k0(dark, accent, text), dark ? 0.42 : 0.3]], 0, y0, 0, h, true); + const lines: string[] = []; + const spread = w / 9; + for (let i = -16; i <= 16; i++) lines.push(`M${n(vx)} ${n(y0)}L${n(vx + i * spread * 2.4)} ${n(h + 40)}`); + for (let k = 1; k <= 11; k++) { + const y = y0 + (h + 40 - y0) * (1 / (k * 0.85 + 0.15)) * 0.95; + if (y < h + 2) lines.push(`M0 ${n(y)}L${w} ${n(y)}`); + } + s.body.push(``); + s.blob(vx, y0, w * 0.55, h * 0.2, accent, dark ? 0.5 : 0.3); + const edge = s.linear([[0, accent, 0], [0.5, accent, dark ? 0.7 : 0.5], [1, accent, 0]], 0, 0, 1, 0); + s.body.push(``); + break; + } + case "grid": + case "dots": { + const id = s.id(); + const size = background === "grid" ? 24 : 14; + const mark = background === "grid" ? `` : ``; + s.defs.push(`${mark}`); + s.body.push(``); + const veil = s.id(); + s.defs.push(``); + s.body.push(``); + s.blob(background === "grid" ? w * 0.85 : w * 0.15, background === "grid" ? h * 0.1 : h * 0.9, big * 0.35, h * 0.6, accent, dark ? 0.22 : 0.12); + break; + } + } + const bevel = s.linear([[0, "#ffffff", 0], [0.5, "#ffffff", dark ? 0.16 : 0.9], [1, "#ffffff", 0]], 0, 0, 1, 0); + s.body.push(``); + if (MOVING.includes(background)) style = `@keyframes a{50%{transform:translate(${n(w * 0.04)}px,${n(h * 0.06)}px)}}@keyframes b{50%{transform:translate(${n(-w * 0.05)}px,${n(-h * 0.05)}px)}}.a{animation:a 18s ease-in-out infinite}.b{animation:b 23s ease-in-out infinite}@media (prefers-reduced-motion:reduce){.a,.b{animation:none}}`; + return s.done(w, h, style); +} + +function k0(dark: boolean, accent: string, text: string): string { + return dark ? accent : soft(accent, text, 0.3); +} + +const CRC = Array.from({ length: 256 }, (_, i) => { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (const b of bytes) c = (CRC[(c ^ b) & 255] ?? 0) ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function u32(v: number): number[] { + return [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255]; +} + +function chunk(type: string, data: number[]): number[] { + const body = new Uint8Array([...type].map((c) => c.charCodeAt(0)).concat(data)); + return [...u32(data.length), ...body, ...u32(crc32(body))]; +} + +function stored(raw: number[]): number[] { + const out = [0x78, 0x01]; + for (let i = 0; i < raw.length || i === 0; i += 65535) { + const part = raw.slice(i, i + 65535); + const last = i + 65535 >= raw.length ? 1 : 0; + out.push(last, part.length & 255, part.length >> 8, ~part.length & 255, (~part.length >> 8) & 255, ...part); + } + let a = 1; + let b = 0; + for (const v of raw) { + a = (a + v) % 65521; + b = (b + a) % 65521; + } + return [...out, ...u32(((b << 16) | a) >>> 0)]; +} + +const GRAIN = 96; + +function grainPng(dark: number, light: number, seed: number): string { + const r = random(seed); + const raw: number[] = []; + for (let y = 0; y < GRAIN; y++) { + raw.push(0); + for (let x = 0; x < GRAIN; x += 4) { + let byte = 0; + for (let k = 0; k < 4; k++) { + const v = r(); + byte |= (v < 0.07 ? 0 : v > 0.93 ? 3 : 1) << (6 - 2 * k); + } + raw.push(byte); + } + } + const png = [137, 80, 78, 71, 13, 10, 26, 10, ...chunk("IHDR", [...u32(GRAIN), ...u32(GRAIN), 2, 3, 0, 0, 0]), ...chunk("PLTE", [0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255]), ...chunk("tRNS", [dark, 0, 0, light]), ...chunk("IDAT", stored(raw)), ...chunk("IEND", [])]; + let text = ""; + for (const b of png) text += String.fromCharCode(b); + return `data:image/png;base64,${btoa(text)}`; +} + +const tiles = new Map(); + +function grainOf(theme: CardTheme, heavy: boolean): string { + const key = `${theme}${heavy}`; + let tile = tiles.get(key); + if (!tile) { + const k = heavy ? 1.8 : 1; + tile = theme === "dark" ? grainPng(Math.round(70 * k), Math.round(26 * k), 7) : grainPng(Math.round(30 * k), Math.round(90 * k), 7); + tiles.set(key, tile); + } + return tile; +} + +const kept = new Map(); + +/** The decoration behind a Card, for its style and size: one SVG drawn from the style's colours and seed, under a fine grain where the background has one; kept, since every Card of a style shares it. */ +export function backdrop(input: BackdropInput, width: number, height: number): Backdrop { + const key = `${input.theme}|${input.foil ? "f" : ""}|${input.background}|${input.bg}|${input.text}|${input.accent}|${input.hues.join()}|${input.seed}|${width}x${height}`; + const found = kept.get(key); + if (found) return found; + const svg = draw(input, width, height); + const layers: [string, string][] = []; + if (GRAINY.includes(input.background)) layers.push([`url(${grainOf(input.theme, input.background === "grain")})`, `${GRAIN / 2}px ${GRAIN / 2}px`]); + if (svg) layers.push([`url(data:image/svg+xml;base64,${btoa(svg)})`, "100% 100%"]); + const out: Backdrop = layers.length ? { backgroundImage: layers.map((l) => l[0]).join(", "), backgroundSize: layers.map((l) => l[1]).join(", ") } : { backgroundImage: undefined, backgroundSize: undefined }; + if (kept.size > 400) kept.delete(kept.keys().next().value as string); + kept.set(key, out); + return out; +} 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, of: 7 }, + spread: { commits: { repositories: 9, total: 640 }, prsMerged: { repositories: 4, total: 44 } }, + 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, forks: 2_412, description: "ripgrep recursively searches directories for a regex pattern while respecting your gitignore", language: "Rust" }, + 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, colour: "#3178c6" }], + repositories: [{ repo: "acme/rocket", commits: 900, private: false }], + mergedIn: [{ repo: "acme/rocket", prs: 581, private: false }], + months: [100, 200, 300, 400, 500, 600, 700, 800, 900, 417, 0, 0], + weekdays: [900, 880, 860, 840, 800, 330, 307], + merges: [20, 30, 40, 50, 60, 70, 80, 90, 100, 41, 0, 0], + opened: { merged: 560, closed: 30, open: 10 }, + biggest: { repo: "acme/rocket", number: 42, title: "Ship the engine", additions: 12_000, deletions: 3_000, private: false }, + 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 0000000..2f21ed4 Binary files /dev/null and b/packages/ui/src/cards/fonts/inter-400.woff differ 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 0000000..323fa67 Binary files /dev/null and b/packages/ui/src/cards/fonts/inter-600.woff differ diff --git a/packages/ui/src/cards/fonts/inter-700.woff b/packages/ui/src/cards/fonts/inter-700.woff new file mode 100644 index 0000000..161b01e Binary files /dev/null and b/packages/ui/src/cards/fonts/inter-700.woff differ 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(/^
-
    - {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 255fbb2..0000000 --- a/packages/ui/src/charts/Columns.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { day, grouped } from "../format"; -import { Legend, TableView, YAxis, type Swatch } from "./common"; -import { ticks, useWidth } from "./scale"; -import { useTip } from "./tip"; - -export type Series = { label: string; colour: string; values: number[] }; -export type Mark = { day: number; label: string }; - -const HEIGHT = 180; -const LEFT = 36; -const BOTTOM = 22; -const TOP = 18; - -function binDays(days: number, width: number): number { - const room = Math.max(1, (width - LEFT) / 4); - if (days <= room) return 1; - if (days / 7 <= room) return 7; - return 28; -} - -export function Columns({ - firstDay, - series, - marks = [], - unit = "commits", -}: { - firstDay: number; - series: Series[]; - marks?: Mark[]; - unit?: string; -}) { - const [ref, width] = useWidth(); - const tip = useTip(); - const days = Math.max(0, ...series.map((s) => s.values.length)); - const per = binDays(days, width); - const bins = 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 most = Math.max(1, ...totals); - const scale = ticks(most); - const top = scale.at(-1) ?? most; - const inner = Math.max(1, width - LEFT); - const bw = inner / Math.max(1, bins); - const gap = bw >= 4 ? 2 : bw >= 2 ? 1 : 0; - const y = (v: number) => TOP + (HEIGHT - TOP - BOTTOM) * (1 - v / top); - const xOfDay = (d: number) => LEFT + ((d - firstDay) / per) * bw; - 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)}`; - }; - let lastLabel = -Infinity; - const labelled = new Set(); - for (const [i, m] of marks.entries()) { - const x = xOfDay(m.day); - if (x - lastLabel > 70) { - labelled.add(i); - lastLabel = x; - } - } - const years: number[] = []; - const perYear = new Set(); - for (let d = firstDay; d < firstDay + days; d++) { - const date = new Date(d * 86_400_000); - const yr = date.getUTCFullYear(); - if (!perYear.has(yr) && date.getUTCMonth() === 0 && date.getUTCDate() === 1) { - perYear.add(yr); - years.push(d); - } - } - const legend: Swatch[] = series.map((s) => ({ label: s.label, colour: s.colour })); - if (marks.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" : "a column every four weeks"; - return ( -
- -

{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 ( - - {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) => { - 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)} - - ))} - - - )} - 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 e31eb04..b1a3409 100644 --- a/packages/ui/src/charts/Grid.tsx +++ b/packages/ui/src/charts/Grid.tsx @@ -1,137 +1,130 @@ -import { day, grouped } from "../format"; -import { ramp } from "../theme"; +import { heat } from "../design/tokens"; +import { grouped, many, share } from "../format"; import { TableView } from "./common"; -import { ranges, useWidth } from "./scale"; import { useTip } from "./tip"; -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)); -} +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; -function step(n: number, bounds: number[]): number { - if (n <= 0) return 0; - const i = bounds.findIndex((b) => n <= b); - return i === -1 ? 4 : i + 1; +const hh = (h: number) => `${String(h % 24).padStart(2, "0")}:00`; + +function step(n: number, max: number): number { + if (n <= 0 || max <= 0) return 0; + return Math.min(4, Math.max(1, Math.ceil(Math.sqrt(n / max) * 4))); } -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}
  • -
+ +export type Peak = { day: number; hour: number; commits: number; times: number }; + +/** The busiest weekday and hour of a week of commits, and how many times a typical hour it is. */ +export function peakOf(week: number[][]): Peak | null { + let best: Peak | null = null; + const all = week.flat().reduce((a, b) => a + b, 0); + week.forEach((hours, day) => + hours.forEach((commits, hour) => { + if (commits > 0 && (!best || commits > best.commits)) best = { day, hour, commits, times: 0 }; + }), ); + if (!best) return null; + const found: Peak = best; + return { ...found, times: all > 0 ? found.commits / (all / 168) : 0 }; } -const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; - -export function WeekGrid({ week }: { week: number[][] }) { +/** Commits by weekday and hour as a heat map of cells on the authors' own clocks, with each hour's and each day's totals beside it and the busiest hour ringed. */ +export function WeekGrid({ week, unit = "commits", totals = true }: { week: number[][]; unit?: string; totals?: boolean }) { const tip = useTip(); - const [ref, width] = useWidth(); 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 all = week.flat().reduce((a, b) => a + b, 0); + const peak = peakOf(week); + const byHour = Array.from({ length: 24 }, (_, h) => week.reduce((n, hours) => n + (hours[h] ?? 0), 0)); + const byDay = week.map((hours) => hours.reduce((a, b) => a + b, 0)); + const hourMost = Math.max(1, ...byHour); + const dayMost = Math.max(1, ...byDay); + const average = all / 168; + const columns = totals ? "grid-cols-[2.1rem_repeat(24,minmax(0,1fr))] sm:grid-cols-[2.4rem_repeat(24,minmax(0,1fr))_4rem]" : "grid-cols-[2.1rem_repeat(24,minmax(0,1fr))]"; return ( -
- - - {week.map((hours, d) => ( - - - {WEEKDAYS[d]?.slice(0, 3)} - - {hours.map((n, h) => ( - +
+ {totals && ( + <> + + {byHour.map((n, h) => ( + - {WEEKDAYS[d]}s, {String(h).padStart(2, "0")}:00–{String(h + 1).padStart(2, "0")}:00 + {hh(h)} to {hh(h + 1)} -
{grouped(n)} commits
+
+ {many(n, unit === "commits" ? "commit" : unit, unit)} across the week · {share(n, all)} +
, )} - /> + > + 0 ? 6 : 0, (n * 100) / hourMost)}%` }} /> + ))} - + + + )} + {week.map((hours, d) => ( +
+ {WEEKDAYS[d]?.slice(0, 3)} + {hours.map((n, h) => { + const top = peak !== null && peak.day === d && peak.hour === h; + return ( + + + {WEEKDAYS[d]}s, {hh(h)} to {hh(h + 1)} + +
{many(n, unit === "commits" ? "commit" : unit, unit)}
+ {n > 0 && ( +
+ {share(n, all)} of all{average > 0 ? ` · ${(n / average).toFixed(n / average >= 10 ? 0 : 1)}× a typical hour` : ""} + {top ? " · the busiest hour" : ""} +
+ )} + , + )} + /> + ); + })} + {totals && ( + + + 0 ? 12 : 0, ((byDay[d] ?? 0) * 100) / dayMost)}%` }} /> + + {share(byDay[d] ?? 0, all)} + + )} +
))} - {[0, 6, 12, 18].map((h) => ( - - {String(h).padStart(2, "0")}:00 - + + {Array.from({ length: 24 }, (_, h) => ( + + {h % 6 === 0 && {hh(h)}} + ))} - - `${h}`)]} - rows={week.map((hours, d) => [WEEKDAYS[d] ?? "", ...hours])} - /> -
- ); -} - -export function Calendar({ firstDay, days }: { firstDay: number; days: number[] }) { - const tip = useTip(); - const [ref, width] = useWidth(); - const max = Math.max(0, ...days); - const bounds = 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))); - return ( -
- - - {days.map((n, i) => { - const d = firstDay + i; - const col = Math.floor((i + offset) / 7); - return ( - - {day(d)} -
{grouped(n)} commits
- , - )} - /> - ); - })} - - (n > 0 ? [[day(firstDay + i), n]] : []))} - /> + {totals && } +
+
+ `${h}`)]} rows={week.map((hours, d) => [WEEKDAYS[d] ?? "", ...hours])} /> + + 0 + {[0, 1, 2, 3, 4].map((s) => ( + + ))} + + {grouped(max)} {unit} in an hour + + +
); } diff --git a/packages/ui/src/charts/Hours.tsx b/packages/ui/src/charts/Hours.tsx deleted file mode 100644 index 67cb24a..0000000 --- a/packages/ui/src/charts/Hours.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { grouped } from "../format"; -import { TableView, YAxis } from "./common"; -import { ticks, useWidth } 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 [ref, width] = useWidth(); - 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 ( -
- {width > 0 && ( - - - - - 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 e5731f5..3d99fd5 100644 --- a/packages/ui/src/charts/Lines.tsx +++ b/packages/ui/src/charts/Lines.tsx @@ -1,17 +1,19 @@ import { useState } from "react"; +import { CHART, COLOR } from "../design/tokens"; 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 }; -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 [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)); @@ -20,67 +22,63 @@ 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.

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

+ None in this Window. +

+ ) : ( +
{ 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)} > {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/Sunburst.tsx b/packages/ui/src/charts/Sunburst.tsx new file mode 100644 index 0000000..843fd29 --- /dev/null +++ b/packages/ui/src/charts/Sunburst.tsx @@ -0,0 +1,140 @@ +import { type ReactNode } from "react"; +import { AnimatePresence, motion } from "../motion"; +import { COLOR } from "../design/tokens"; +import { DURATION, EASE } from "../motion/constants"; +import { useTip } from "./tip"; + +export type SunNode = { + key: string; + name: string; + value: number; + colour: string; + file: boolean; + open: boolean; + tip: ReactNode; + inside: SunNode[] | null; +}; + +export type Arc = { node: SunNode; parent: SunNode | null; ring: 0 | 1; from: number; to: number; fill: string }; + +export const SUN = { size: 320, hole: 64, ring: 112, gap: 3, outer: 156 } as const; + +const MIN = 0.022; + +/** The rings of a sunburst for one level: its children around the hole and their own children outside them, the slivers (and children past the limit) folded into one grey "more" arc each. */ +export function arcsOf(children: SunNode[], more: (count: number, value: number, parent: SunNode | null) => SunNode, limit = Infinity): Arc[] { + const out: Arc[] = []; + const place = (list: SunNode[], from: number, span: number, ring: 0 | 1, parent: SunNode | null) => { + const total = list.reduce((n, c) => n + c.value, 0); + if (total <= 0 || span <= 0) return; + let at = from; + let rest = 0; + let restCount = 0; + let shown = 0; + for (const c of list) { + if (c.value <= 0) continue; + const angle = (span * c.value) / total; + if (angle < MIN || (ring === 0 && shown >= limit)) { + rest += c.value; + restCount += 1; + continue; + } + const fill = ring === 0 ? (c.file ? `color-mix(in srgb, ${c.colour} 62%, var(--surface))` : c.colour) : `color-mix(in srgb, ${parent?.colour ?? c.colour} ${c.file ? 42 : 72}%, var(--surface))`; + out.push({ node: c, parent, ring, from: at, to: at + angle, fill }); + shown += 1; + if (ring === 0 && c.inside && c.inside.length > 0) place(c.inside, at, angle, 1, c); + at += angle; + } + if (restCount > 0) { + const node = more(restCount, rest, parent); + out.push({ node, parent, ring, from: at, to: from + span, fill: ring === 0 ? "var(--other)" : "color-mix(in srgb, var(--other) 55%, var(--surface))" }); + } + }; + place(children, 0, Math.PI * 2, 0, null); + return out; +} + +function point(r: number, a: number): string { + return `${(Math.sin(a) * r).toFixed(2)} ${(-Math.cos(a) * r).toFixed(2)}`; +} + +function sector(r0: number, r1: number, a0: number, a1: number): string { + const sweep = Math.min(a1 - a0, Math.PI * 2 - 0.0001); + const end = a0 + sweep; + const large = sweep > Math.PI ? 1 : 0; + return `M${point(r1, a0)}A${r1} ${r1} 0 ${large} 1 ${point(r1, end)}L${point(r0, end)}A${r0} ${r0} 0 ${large} 0 ${point(r0, a0)}Z`; +} + +/** A Filelight-style sunburst: a level's children as a ring around a hole, their children as an outer ring; the level zooms in and out as it changes. */ +export function Sunburst({ + level, + arcs, + direction, + hover, + onHover, + onPick, + onUp, + centre, + label, +}: { + level: string; + arcs: Arc[]; + direction: 1 | -1; + hover: string | null; + onHover: (key: string | null) => void; + onPick: (node: SunNode) => void; + onUp?: () => void; + centre: ReactNode; + label: string; +}) { + const tip = useTip(); + const lit = (a: Arc) => hover === null || a.node.key === hover || a.parent?.key === hover || hover.startsWith(a.node.key); + const half = SUN.size / 2; + return ( +
+ + ({ opacity: 0, scale: d > 0 ? 0.8 : 1.14 }), + shown: { opacity: 1, scale: 1 }, + leave: (d: number) => ({ opacity: 0, scale: d > 0 ? 1.14 : 0.8 }), + }} + initial="enter" + animate="shown" + exit="leave" + transition={{ duration: DURATION.base, ease: EASE.out }} + > + onHover(null)}> + {arcs.map((a) => { + const r0 = a.ring === 0 ? SUN.hole : SUN.ring + SUN.gap; + const r1 = a.ring === 0 ? SUN.ring : SUN.outer; + const can = a.node.open || a.node.file; + return ( + onPick(a.node) : undefined} + onMouseEnter={() => onHover(a.node.key)} + {...tip(a.node.tip)} + /> + ); + })} + onHover(null)} /> + + + +
+
{centre}
+
+
+ ); +} diff --git a/packages/ui/src/charts/Tip.tsx b/packages/ui/src/charts/Tip.tsx index 5b78568..5854211 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 }; + + +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={CHART.tick} tickLine={false} axisLine={false} minTickGap={14} /> + compact(v)} tick={CHART.tick} 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={CHART.tick} 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..1ea722d --- /dev/null +++ b/packages/ui/src/charts/Year.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; +import { day, grouped } from "../format"; +import { TableView } from "./common"; +import { useTip } from "./tip"; +import { heat, RADIUS } from "../design/tokens"; + +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) => heat(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, table = true }: { firstDay: number; days: number[]; unit?: string; label?: string; table?: boolean }) { + 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)}
+ , + )} + /> + ); + })} + +
+
+ {table ? (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/common.tsx b/packages/ui/src/charts/common.tsx index b28a154..3399553 100644 --- a/packages/ui/src/charts/common.tsx +++ b/packages/ui/src/charts/common.tsx @@ -1,6 +1,4 @@ -import type { ReactNode } from "react"; -import { Card } from "@astryxdesign/core/Card"; -import { Heading } from "@astryxdesign/core/Heading"; +import { CHART } from "../design/tokens"; import { compact, grouped } from "../format"; export type Swatch = { label: string; colour: string; mark?: "line" | "dash" | "block" }; @@ -8,22 +6,14 @@ 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" ? ( - + ) : ( - + )} {s.label} @@ -33,32 +23,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 +56,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 +70,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/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..074adf7 --- /dev/null +++ b/packages/ui/src/components/Face.tsx @@ -0,0 +1,51 @@ +import type { CSSProperties } from "react"; +import { Avatar } from "@astryxdesign/core/Avatar"; +import { avatarUrl } from "./avatar"; + +export type FaceSize = 16 | 20 | 24 | 32 | 36 | 40 | 48 | 56 | 60 | 64 | 72 | 80 | 96 | 112 | 128 | 144; + +export type FaceProps = { + login: string | null | undefined; + name: string; + size?: FaceSize; + wide?: FaceSize; + shape?: "circle" | "rounded"; + ring?: string; + glow?: boolean; + edge?: "surface" | "body" | "stage"; + lift?: "md" | "float"; + className?: string; +}; + +const AVATAR = [16, 20, 24, 32, 36, 40, 48, 60, 64, 72, 96, 128, 144] as const; +const FILL = { width: "100%", height: "100%" }; + +const EDGES = { surface: "var(--color-background-surface)", body: "var(--color-background-body)", stage: "var(--stage-cell-1)" } as const; + +/** A person's GitHub avatar, or their initials when no GitHub account is known: always a square box the image is bound to, with an optional coloured ring and glow, a cut-out edge against what is behind it, and a lift. */ +export function Face({ login, name, size = 24, wide, shape = "circle", ring, glow = false, edge, lift, className = "" }: FaceProps) { + const big = AVATAR.find((n) => n >= Math.max(size, wide ?? 0)) ?? 144; + const round = shape === "circle" ? "rounded-full" : size >= 64 ? "rounded-2xl" : "rounded-md"; + const align = /(^|\s)self-/.test(className) ? "" : "self-center"; + const gap = size >= 64 ? 3 : 2; + const line = size >= 96 ? 3 : 2; + const cut = size >= 64 ? 4 : 2; + const shadows = [ + edge && !ring ? `0 0 0 ${cut}px ${EDGES[edge]}` : null, + ring && glow ? `0 14px 36px -12px color-mix(in oklab, ${ring} 70%, transparent)` : null, + lift ? `var(--shadow-${lift})` : null, + ].filter(Boolean); + const box = { + "--face": `${size}px`, + "--face-wide": `${wide ?? size}px`, + margin: ring ? gap + line : undefined, + outline: ring ? `${line}px solid ${ring}` : undefined, + outlineOffset: ring ? gap : undefined, + boxShadow: shadows.length > 0 ? shadows.join(", ") : undefined, + } as CSSProperties; + return ( + + + + ); +} diff --git a/packages/ui/src/components/Key.tsx b/packages/ui/src/components/Key.tsx index 89ce330..c1f8b9c 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 23c23d4..6c4e050 100644 --- a/packages/ui/src/components/Loading.tsx +++ b/packages/ui/src/components/Loading.tsx @@ -1,22 +1,174 @@ import { Skeleton } from "@astryxdesign/core/Skeleton"; +import { Panel } from "../kit/layout"; +import { CommitsLoading } from "../screens/Commits"; +import { NARROW, WIDE } from "../screens/Map"; +import { CONTRIBUTORS_SHOWN } from "../screens/Overview"; +import { NumberCell, NumberStrip, ScreenFrame } from "../screens/kit"; -export function ScreenSkeleton() { +function line(width: string | number, index: number, height = 14) { + return ; +} + +function Cells({ labels }: { labels: string[] }) { return ( -
      -
      - {[0, 1, 2, 3].map((i) => ( - - ))} + <> + {labels.map((l, i) => ( + } label={l} note={{line("70%", i, 12)}} /> + ))} + + ); +} + +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)} +
      + ))} +
      +
      +
      +
      -
      - - + +
      {line(120, 0, 14)}
      +
      +
      + +
      + +
      +
      + +
      + +
      +
      + + + ); +} + +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 ; +} + +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..8401a4a 100644 --- a/packages/ui/src/components/Name.tsx +++ b/packages/ui/src/components/Name.tsx @@ -1,41 +1,46 @@ -import { useContext } from "react"; -import { Avatar } from "@astryxdesign/core/Avatar"; +import { createContext, useContext } from "react"; import type { PersonRef } from "@commitscape/data"; -import { AvatarsContext } from "../help"; -import { avatarUrl } from "./avatar"; -import { personColour } from "../theme"; +import { useLogin } from "./login"; +import { Face, type FaceSize } from "./Face"; -export function Name({ p, onOpen }: { p: PersonRef | null | undefined; onOpen?: (id: number) => void }) { - const avatars = useContext(AvatarsContext); - if (!p) return someone unknown; - const mark = - avatars && p.login ? ( - - - - ) : ( - - ); - if (!onOpen) { - return ( - - {mark} - {p.name} +/** 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 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/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/design/theme.css b/packages/ui/src/design/theme.css new file mode 100644 index 0000000..f3e7fc7 --- /dev/null +++ b/packages/ui/src/design/theme.css @@ -0,0 +1,190 @@ +@theme reference inline { + --text-2xs: var(--type-2xs); + --text-2xs--line-height: var(--type-2xs-leading); + --text-xs: var(--type-xs); + --text-xs--line-height: var(--type-xs-leading); + --text-sm: var(--type-sm); + --text-sm--line-height: var(--type-sm-leading); + --text-base: var(--type-base); + --text-base--line-height: var(--type-base-leading); + --text-md: var(--type-md); + --text-md--line-height: var(--type-md-leading); + --text-lg: var(--type-lg); + --text-lg--line-height: var(--type-lg-leading); + --text-xl: var(--type-xl); + --text-xl--line-height: var(--type-xl-leading); + --text-2xl: var(--type-2xl); + --text-2xl--line-height: var(--type-2xl-leading); + --text-3xl: var(--type-3xl); + --text-3xl--line-height: var(--type-3xl-leading); + --text-4xl: var(--type-4xl); + --text-4xl--line-height: var(--type-4xl-leading); + --text-5xl: var(--type-5xl); + --text-5xl--line-height: var(--type-5xl-leading); + + --tracking-tight: var(--tracking-tight); + --tracking-snug: var(--tracking-snug); + --tracking-heading: var(--tracking-heading); + --tracking-display: var(--tracking-display); + --tracking-number: var(--tracking-number); + --tracking-wide: var(--tracking-wide); + + --spacing-cluster: var(--space-cluster); + --spacing-stack: var(--space-stack); + --spacing-gutter: var(--space-gutter); + --spacing-panel: var(--space-panel); + --spacing-page-top: var(--space-page-top); + --spacing-section: var(--space-section); + --spacing-band: var(--space-band); + --spacing-header: var(--header-height); + + --radius-cell: var(--radius-cell); + --radius-xs: var(--radius-xs); + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); + --radius-2xl: var(--radius-2xl); + + --color-brand: var(--brand); + --color-brand-strong: var(--brand-strong); + --color-brand-soft: var(--brand-soft); + --color-brand-line: var(--brand-line); + --color-on-brand: var(--on-brand); + --color-line: var(--line); + --color-line-strong: var(--line-strong); + --color-tertiary: var(--ink-3); + --color-quiet: var(--ink-quiet); + --color-raised: var(--surface-raised); + --color-sunken: var(--surface-sunken); + --color-hover: var(--surface-hover); + --color-added: var(--added); + --color-removed: var(--removed); + --color-other: var(--other); + --color-side-a: var(--side-a); + --color-side-b: var(--side-b); + --color-heat-0: var(--heat-0); + --color-heat-1: var(--heat-1); + --color-heat-2: var(--heat-2); + --color-heat-3: var(--heat-3); + --color-heat-4: var(--heat-4); + --color-medal-1: var(--medal-1); + --color-medal-2: var(--medal-2); + --color-medal-3: var(--medal-3); + --color-on-medal: var(--on-medal); + --color-on-stage: var(--on-stage); + --color-on-stage-2: var(--on-stage-2); + + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-float: var(--shadow-float); + + --ease-out: var(--ease-out); + --ease-in-out: var(--ease-in-out); + --ease-in: var(--ease-in); + + --font-sans: var(--font-family-body); + --font-mono: var(--font-family-code); +} + +@utility type-hero { + font-size: var(--type-5xl); + line-height: var(--type-5xl-leading); + font-weight: 600; + letter-spacing: -0.045em; + text-wrap: balance; +} +@utility type-display { + font-size: var(--type-4xl); + line-height: var(--type-4xl-leading); + font-weight: 600; + letter-spacing: var(--tracking-display); + text-wrap: balance; +} +@utility type-title { + font-size: var(--type-3xl); + line-height: var(--type-3xl-leading); + font-weight: 600; + letter-spacing: var(--tracking-heading); + text-wrap: balance; +} +@utility type-heading { + font-size: var(--type-xl); + line-height: var(--type-xl-leading); + font-weight: 600; + letter-spacing: var(--tracking-snug); +} +@utility type-panel { + font-size: var(--type-lg); + line-height: var(--type-lg-leading); + font-weight: 600; + letter-spacing: var(--tracking-tight); +} +@utility type-lead { + font-size: var(--type-md); + line-height: var(--type-md-leading); + color: var(--ink-2); + text-wrap: pretty; +} +@utility type-body { + font-size: var(--type-base); + line-height: var(--type-base-leading); +} +@utility type-description { + font-size: var(--type-sm); + line-height: var(--type-sm-leading); + color: var(--ink-2); + text-wrap: pretty; +} +@utility type-label { + font-size: var(--type-sm); + line-height: var(--type-sm-leading); + font-weight: 500; + color: var(--ink-1); +} +@utility type-caption { + font-size: var(--type-xs); + line-height: var(--type-xs-leading); + color: var(--ink-2); +} +@utility type-micro { + font-size: var(--type-2xs); + line-height: var(--type-2xs-leading); + color: var(--ink-2); +} +@utility type-eyebrow { + font-size: var(--type-2xs); + line-height: var(--type-2xs-leading); + font-weight: 600; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--ink-2); +} +@utility type-stat-lg { + font-size: var(--type-4xl); + line-height: 1; + font-weight: 600; + letter-spacing: var(--tracking-number); + font-variant-numeric: tabular-nums; +} +@utility type-stat { + font-size: var(--type-2xl); + line-height: 1; + font-weight: 600; + letter-spacing: var(--tracking-number); + font-variant-numeric: tabular-nums; +} +@utility type-stat-sm { + font-size: var(--type-xl); + line-height: 1; + font-weight: 600; + letter-spacing: var(--tracking-number); + font-variant-numeric: tabular-nums; +} +@utility type-code { + font-family: var(--font-family-code); + font-size: var(--type-xs); + line-height: var(--type-xs-leading); +} diff --git a/packages/ui/src/design/tokens.css b/packages/ui/src/design/tokens.css new file mode 100644 index 0000000..8bb621d --- /dev/null +++ b/packages/ui/src/design/tokens.css @@ -0,0 +1,163 @@ +:root { + --type-2xs: 0.6875rem; + --type-2xs-leading: 1rem; + --type-xs: 0.75rem; + --type-xs-leading: 1rem; + --type-sm: 0.8125rem; + --type-sm-leading: 1.25rem; + --type-base: 0.875rem; + --type-base-leading: 1.375rem; + --type-md: 0.9375rem; + --type-md-leading: 1.5rem; + --type-lg: 1rem; + --type-lg-leading: 1.5rem; + --type-xl: 1.25rem; + --type-xl-leading: 1.75rem; + --type-2xl: 1.625rem; + --type-2xl-leading: 2rem; + --type-3xl: clamp(1.75rem, 1.25rem + 1.5vw, 2.25rem); + --type-3xl-leading: 1.1; + --type-4xl: clamp(2.125rem, 1.45rem + 2.2vw, 2.75rem); + --type-4xl-leading: 1.05; + --type-5xl: clamp(2.75rem, 1.6rem + 4.2vw, 4.75rem); + --type-5xl-leading: 0.98; + + --tracking-tight: -0.01em; + --tracking-snug: -0.02em; + --tracking-heading: -0.025em; + --tracking-display: -0.035em; + --tracking-number: -0.03em; + --tracking-wide: 0.06em; + + --space-cluster: 0.5rem; + --space-stack: 1rem; + --space-gutter: 1rem; + --space-panel: 1.25rem; + --space-page-top: 2.5rem; + --space-section: 3rem; + --space-band: 6rem; + + --radius-cell: 3px; + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 20px; + + --page: 76rem; + --page-narrow: 48rem; + --page-wide: 88rem; + --header-height: 53px; + + --z-raised: 1; + --z-sticky: 20; + --z-header: 30; + --z-overlay: 40; + --z-toast: 50; + --z-progress: 100; + + --duration-instant: 120ms; + --duration-fast: 200ms; + --duration-base: 360ms; + --duration-slow: 600ms; + --duration-reveal: 800ms; + --duration-scene: 1200ms; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --ease-in: cubic-bezier(0.55, 0, 1, 0.45); + --ease-emphasized: cubic-bezier(0.2, 0, 0, 1); + + --ink-1: var(--color-text-primary); + --ink-2: var(--color-text-secondary); + --ink-3: light-dark(#868990, #6c6f77); + --ink-quiet: color-mix(in oklab, var(--color-text-primary) 50%, var(--color-background-body)); + --surface-body: var(--color-background-body); + --surface: var(--color-background-surface); + --surface-raised: var(--color-background-popover); + --surface-sunken: var(--color-background-muted); + --surface-hover: color-mix(in oklab, var(--color-background-body) 96%, var(--color-text-primary)); + --line: var(--color-border); + --line-strong: var(--color-border-emphasized); + + --brand: light-dark(#0f7a37, #3ccf74); + --brand-strong: light-dark(#0b5f2a, #6ff2a1); + --brand-soft: light-dark(#0f7a371a, #3ccf741f); + --brand-line: light-dark(#0f7a3759, #3ccf7459); + --on-brand: light-dark(#ffffff, #04210f); + + --added: light-dark(#11733a, #4ac776); + --removed: light-dark(#c9303a, #f07178); + --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); + --s3: light-dark(#1baf7a, #199e70); + --s4: light-dark(#eda100, #c98500); + --s5: light-dark(#e87ba4, #d55181); + --s6: light-dark(#008300, #008300); + --s7: light-dark(#4a3aa7, #9085e9); + --s8: light-dark(#e34948, #e66767); + --side-a: var(--s1); + --side-b: var(--s2); + + --blue-1: light-dark(#86b6ef, #1c5cab); + --blue-2: light-dark(#3987e5, #3987e5); + --blue-3: light-dark(#1c5cab, #86b6ef); + --blue-4: light-dark(#0d366b, #cfe2fa); + --orange-1: light-dark(#fc7856, #a42602); + --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); + + --heat-0: light-dark(#0c0d0f0d, #ffffff0f); + --heat-1: var(--green-1); + --heat-2: var(--green-2); + --heat-3: var(--green-3); + --heat-4: var(--green-4); + --empty: var(--heat-0); + + --flame-1: #4f8cff; + --flame-2: #22b8cf; + --flame-3: #f5b400; + --flame-4: #ff6a1a; + --flame-5: #e83e8c; + + --medal-1: light-dark(#d9a514, #e8b931); + --medal-2: light-dark(#a3aab4, #b8bec7); + --medal-3: light-dark(#b9773f, #c98a54); + --on-medal: #141416; + + --on-stage: #ffffff; + --on-stage-2: #ffffffb3; + --stage-cell-0: #ffffff1f; + --stage-cell-1: #ffffff59; + --stage-cell-2: #ffffff8c; + --stage-cell-3: #ffffffbf; + --stage-cell-4: #ffffff; + + --gradient-wrapped: + radial-gradient(100% 85% at 0% 0%, #22c55eb3, transparent 65%), + radial-gradient(95% 85% at 100% 100%, #6366f1cc, transparent 65%), + radial-gradient(70% 60% at 100% 0%, #0ea5e980, transparent 62%), + linear-gradient(140deg, #0c3b2a, #13254a 55%, #1e1b4b); + --gradient-archetype: + radial-gradient(90% 75% at 100% 0%, #5b6cffcc, transparent 62%), + radial-gradient(95% 85% at 0% 100%, #12893fd9, transparent 62%), + radial-gradient(55% 45% at 85% 100%, #db277766, transparent 70%), + linear-gradient(165deg, #11141c, #0b0d10); + --glow-brand: radial-gradient(40rem 30rem at 50% 50%, light-dark(#0f7a3712, #3ccf7417), transparent 70%); + --dots: radial-gradient(circle, var(--color-border-emphasized) 1px, transparent 1.3px); + + --shadow-xs: 0 1px 2px light-dark(#0c0d0f0d, #0000004d); + --shadow-sm: 0 1px 2px light-dark(#0c0d0f0f, #00000059), 0 2px 8px light-dark(#0c0d0f0d, #00000040); + --shadow-md: 0 1px 2px light-dark(#0c0d0f0d, #0000004d), 0 16px 36px -20px light-dark(#0c0d0f33, #000000b3); + --shadow-lg: 0 2px 4px light-dark(#0c0d0f0f, #00000059), 0 24px 48px -16px light-dark(#0c0d0f33, #000000cc); + --shadow-float: 0 18px 40px light-dark(#0c0d0f24, #00000080); + --ring-brand: 0 0 0 3px var(--brand-soft); +} diff --git a/packages/ui/src/design/tokens.test.ts b/packages/ui/src/design/tokens.test.ts new file mode 100644 index 0000000..72a4707 --- /dev/null +++ b/packages/ui/src/design/tokens.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { DURATION, EASE, RADIUS, SPACE, TYPE, Z } from "./tokens"; +import css from "./tokens.css?raw"; + +const value = (name: string) => css.match(new RegExp(`--${name}:\\s*([^;]+);`))?.[1]?.trim(); +const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + +describe("design tokens", () => { + it("keep the type scale the same in CSS and TypeScript", () => { + for (const [name, t] of Object.entries(TYPE)) { + expect(value(`type-${name}`)).toBe(`${t.size / 16}rem`); + expect(value(`type-${name}-leading`)).toBe(`${t.leading / 16}rem`); + } + }); + + it("keep spacing, radii and layers the same in CSS and TypeScript", () => { + for (const [name, px] of Object.entries(SPACE)) expect(value(`space-${kebab(name)}`)).toBe(`${px / 16}rem`); + for (const [name, px] of Object.entries(RADIUS)) expect(value(`radius-${name}`)).toBe(`${px}px`); + for (const [name, z] of Object.entries(Z)) expect(value(`z-${name}`)).toBe(`${z}`); + }); + + it("keep motion timings the same in CSS and TypeScript", () => { + for (const name of ["instant", "fast", "base", "slow", "reveal", "scene"] as const) expect(value(`duration-${name}`)).toBe(`${Math.round(DURATION[name] * 1000)}ms`); + expect(value("ease-out")).toBe(`cubic-bezier(${EASE.out.join(", ")})`); + expect(value("ease-in-out")).toBe(`cubic-bezier(${EASE.inOut.join(", ")})`); + expect(value("ease-in")).toBe(`cubic-bezier(${EASE.in.join(", ")})`); + expect(value("ease-emphasized")).toBe(`cubic-bezier(${EASE.emphasized.join(", ")})`); + }); + + it("give every colour token a light and a dark value or a reference", () => { + const colours = [...css.matchAll(/--((?:s|heat|green|blue|orange)-?\d|brand[a-z-]*|added|removed|ink-3|on-brand):\s*([^;]+);/g)]; + expect(colours.length).toBeGreaterThan(20); + for (const [, , v] of colours) expect(v).toMatch(/^(light-dark\(|var\()/); + }); +}); diff --git a/packages/ui/src/design/tokens.ts b/packages/ui/src/design/tokens.ts new file mode 100644 index 0000000..ef04796 --- /dev/null +++ b/packages/ui/src/design/tokens.ts @@ -0,0 +1,90 @@ +import { DURATION, EASE } from "../motion/constants"; + +export { DURATION, EASE }; + +export const TYPE = { + "2xs": { size: 11, leading: 16 }, + xs: { size: 12, leading: 16 }, + sm: { size: 13, leading: 20 }, + base: { size: 14, leading: 22 }, + md: { size: 15, leading: 24 }, + lg: { size: 16, leading: 24 }, + xl: { size: 20, leading: 28 }, + "2xl": { size: 26, leading: 32 }, +} as const; + +export const SPACE = { + cluster: 8, + stack: 16, + gutter: 16, + panel: 20, + pageTop: 40, + section: 48, + band: 96, +} as const; + +export const RADIUS = { + cell: 3, + xs: 4, + sm: 6, + md: 8, + lg: 12, + xl: 16, + "2xl": 20, +} as const; + +export const ICON = { + xs: 12, + sm: 14, + md: 16, + lg: 20, +} as const; + +export const Z = { + raised: 1, + sticky: 20, + header: 30, + overlay: 40, + toast: 50, + progress: 100, +} as const; + +export const COLOR = { + ink: "var(--ink-1)", + ink2: "var(--ink-2)", + ink3: "var(--ink-3)", + surface: "var(--surface)", + line: "var(--line)", + lineStrong: "var(--line-strong)", + brand: "var(--brand)", + brandSoft: "var(--brand-soft)", + added: "var(--added)", + removed: "var(--removed)", + other: "var(--other)", + sideA: "var(--side-a)", + sideB: "var(--side-b)", +} as const; + +export const SERIES = ["var(--s1)", "var(--s2)", "var(--s3)", "var(--s4)", "var(--s5)", "var(--s6)", "var(--s7)", "var(--s8)"] as const; + +export const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"] as const; + +export const STAGE_HEAT = ["var(--stage-cell-0)", "var(--stage-cell-1)", "var(--stage-cell-2)", "var(--stage-cell-3)", "var(--stage-cell-4)"] as const; + +export const MEDAL = ["var(--medal-1)", "var(--medal-2)", "var(--medal-3)"] as const; + +export const FLAME = ["var(--flame-1)", "var(--flame-2)", "var(--flame-3)", "var(--flame-4)", "var(--flame-5)"] as const; + +/** The colour of a contribution cell for a level from 0 (nothing) to 4 (the most). */ +export function heat(level: number, on: "page" | "stage" = "page"): string { + const scale = on === "stage" ? STAGE_HEAT : HEAT; + return scale[Math.max(0, Math.min(4, Math.round(level)))] ?? scale[0]; +} + +export const CHART = { + tick: { fontSize: TYPE["2xs"].size, fill: "var(--ink-2)" }, + grid: "var(--line)", + cursor: "var(--surface-sunken)", + barRadius: RADIUS.cell, + height: { sm: 160, md: 220, lg: 280 }, +} as const; diff --git a/packages/ui/src/explain.tsx b/packages/ui/src/explain.tsx index a9bb66d..95ee526 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/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..a733d82 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1,218 +1,33 @@ @import "@astryxdesign/core/reset.css"; @import "@astryxdesign/core/astryx.css"; +@import "./design/tokens.css"; :root { - --surface: var(--color-background-surface); --surface-2: var(--color-background-muted); --text: var(--color-text-primary); --text-2: var(--color-text-secondary); --muted: var(--color-text-secondary); - --line: var(--color-border); --radius: var(--radius-element); - --empty: light-dark(#e8e6e0, #2c2c2a); - --other: light-dark(#a3a19b, #6b6a65); - --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); - --s3: light-dark(#1baf7a, #199e70); - --s4: light-dark(#eda100, #c98500); - --s5: light-dark(#e87ba4, #d55181); - --s6: light-dark(#008300, #008300); - --s7: light-dark(#4a3aa7, #9085e9); - --s8: light-dark(#e34948, #e66767); - --blue-1: light-dark(#86b6ef, #1c5cab); - --blue-2: light-dark(#3987e5, #3987e5); - --blue-3: light-dark(#1c5cab, #86b6ef); - --blue-4: light-dark(#0d366b, #cfe2fa); - --orange-1: light-dark(#fc7856, #a42602); - --orange-2: light-dark(#ce4e2f, #ce4e2f); - --orange-3: light-dark(#a42602, #fc7856); - --orange-4: light-dark(#6e1a00, #ffd2c2); --error: var(--color-error); --mono: var(--font-family-code); } -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 { max-width: 84rem; margin: 0 auto; padding: 0.75rem 1.5rem 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 { margin: 0 0 0.6rem; font-size: 0.88rem; } -.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; } -.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; } -.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); } -.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; } -.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; } -.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; } -.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; } - -.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; } -.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; } +.small { font-size: var(--type-sm); line-height: var(--type-sm-leading); } +.added { color: var(--added); } +.removed { color: var(--removed); } + +@keyframes rise { from { opacity: 0; transform: translateY(6px); } } +@keyframes fade { from { opacity: 0; } } +.rise { animation: rise var(--duration-slow) var(--ease-out) backwards; } +.fade { animation: fade var(--duration-base) var(--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 e1bfb68..f0fbbc0 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,8 +1,38 @@ -export { default as App } from "./App"; +export { default as App, ScreenBar } 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, MODES, useMode, type Mode } from "./theme"; +export { commitscapeTheme, MODE_COOKIE, MODES, ModeContext, modeOf, themeCss, useMode, type Mode } from "./theme"; +export { Face, type FaceProps, type FaceSize } from "./components/Face"; +export { avatarUrl } from "./components/avatar"; +export { SCREEN_SKELETONS } from "./components/skeletons"; +export { TipLayer } from "./charts/Tip"; +export { compact, date, day, duration, grouped, many, share } from "./format"; +export { survival } from "./profile/survival"; +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 { hallOfFameCardSize } from "./cards/sizes"; +export { CARD_DESIGN, TOKENS, type CardTheme } from "./cards/tokens"; +export { WorkView, type WorkGroup, type WorkLook } from "./work/WorkView"; +export { PeriodPicker } from "./work/PeriodPicker"; +export { RepoPicker } from "./work/RepoPicker"; +export { KIND_COLOURS, placesOf, presetOf, shortRange } from "./work/helpers"; +export { Donut, Outcomes, RepoTiles, WeekBars, type Slice } from "./wrapped/charts"; +export { AddedRemoved, Chip, Eyebrow, LangDot, Meter, Page, PageHead, Panel, PanelSkeleton, Stat } from "./kit/layout"; +export { CHART, COLOR, FLAME, HEAT, heat, ICON, MEDAL, RADIUS, SERIES, SPACE, STAGE_HEAT, TYPE, Z } from "./design/tokens"; +export { A } from "./kit/A"; +export { FaceStack, Person } from "./kit/people"; +export { AchievementsPanel, ArchetypePanel, ArchetypePill, Cell, CommitClock, HeadlineNumbers, hours, LanguagesOverTime, LastYear, OverTheYears, PeoplePanel, ProfileHeader, PullRequestNumbers, RepositoryTable, StreakCell, SurvivalPanel, SurvivingNumber, TraitsPanel } from "./profile/view"; +export { clockState, streakHeat } from "./profile/days"; +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, randomStyle, styleKey, styleQuery, SWATCHES, type Background, type CardStyle, type Corner, type PresetId } from "./cards/style"; +export { Leaderboard, NextUp, PlaceCards, viewsOf } from "./standings/view"; +export { WorkSkeleton } from "./work/WorkView"; +export { periodWords } from "./work/helpers"; +export { DuelRows } from "./versus/duel"; +export { TONE, toneOf, winnerOf, type Duel, type Side } from "./versus/tone"; +export { VersusSections } from "./versus/full"; 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..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,18 +38,19 @@ 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); }, []); } 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/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..ad69d80 --- /dev/null +++ b/packages/ui/src/kit/layout.tsx @@ -0,0 +1,133 @@ +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-[var(--page-narrow)]", wide: "max-w-[var(--page-wide)]" } 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" ? "type-stat-lg" : size === "md" ? "type-stat" : "type-stat-sm"; + 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 ; +} + +const CHIP_TONES = { + neutral: "border-line bg-surface text-primary", + brand: "border-brand-line bg-brand-soft text-brand", + quiet: "border-transparent bg-sunken text-secondary", +} as const; + +/** A small rounded label: a status, a tag, or where something goes. */ +export function Chip({ children, icon, tone = "neutral", className = "" }: { children: ReactNode; icon?: ReactNode; tone?: keyof typeof CHIP_TONES; className?: string }) { + return ( + + {icon} + {children} + + ); +} + +/** The short uppercase line above a heading that says what kind of thing follows. */ +export function Eyebrow({ children, className = "" }: { children: ReactNode; className?: string }) { + return {children}; +} diff --git a/packages/ui/src/kit/people.tsx b/packages/ui/src/kit/people.tsx new file mode 100644 index 0000000..492d9fb --- /dev/null +++ b/packages/ui/src/kit/people.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { Face, type FaceProps, type FaceSize } from "../components/Face"; + +type Who = { login: string; name?: string | null }; + +/** A person in a row: their face, their name, and their @login beneath it when the name says something else. */ +export function Person({ login, name, size = 32, ring, glow, note, className = "" }: Who & { size?: FaceSize; ring?: string; glow?: boolean; note?: ReactNode; className?: string }) { + const shown = name ?? login; + const sub = note ?? (name && name !== login ? `@${login}` : null); + return ( + + + + {shown} + {sub && {sub}} + + + ); +} + +/** A few faces overlapping, each cut out of the surface behind it. */ +export function FaceStack({ people, size = 32, edge = "surface", label }: { people: Who[]; size?: FaceSize; edge?: FaceProps["edge"]; label?: string }) { + return ( + + {people.map((p) => ( + + ))} + + ); +} 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/motion/MeshGradient.tsx b/packages/ui/src/motion/MeshGradient.tsx new file mode 100644 index 0000000..6955faa --- /dev/null +++ b/packages/ui/src/motion/MeshGradient.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef } from "react"; + +import type { MeshPalette } from "./mesh"; + +const VERTEX = `attribute vec2 p; +void main() { gl_Position = vec4(p, 0.0, 1.0); }`; + +const FRAGMENT = `precision mediump float; +uniform vec2 size; +uniform float time; +uniform float seed; +uniform vec4 corners; +uniform vec3 c0; +uniform vec3 c1; +uniform vec3 c2; +uniform vec3 c3; + +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + mat2 turn = mat2(0.8, 0.6, -0.6, 0.8); + for (int i = 0; i < 5; i++) { + v += a * noise(p); + p = turn * p * 2.0 + 17.0; + a *= 0.5; + } + return v; +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + vec2 p = uv * vec2(size.x / size.y, 1.0) * 1.6 + seed; + float t = time * 0.045; + vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, 1.3) - t)); + vec2 w = vec2(fbm(p + 2.2 * q + vec2(1.7, 9.2) + t * 1.4), fbm(p + 2.2 * q + vec2(8.3, 2.8) - t * 1.1)); + float f = fbm(p + 2.6 * w); + float a = smoothstep(0.32, 0.72, f); + float b = smoothstep(0.42, 0.68, q.y); + float c = smoothstep(0.5, 0.78, w.x); + vec3 colour = mix(c0, c1, a * 0.9); + colour = mix(colour, c2, b * 0.8); + colour = mix(colour, c3, c * a * 0.85); + float light = smoothstep(-0.1, 1.5, uv.x * 0.9 + (1.0 - uv.y) * 0.5); + colour = mix(c0, colour, mix(0.45, 1.0, light)); + colour += (hash(gl_FragCoord.xy + fract(time)) - 0.5) * 0.04; + vec2 at = gl_FragCoord.xy - size * 0.5; + float r = at.x > 0.0 ? (at.y > 0.0 ? corners.y : corners.z) : (at.y > 0.0 ? corners.x : corners.w); + vec2 d = abs(at) - size * 0.5 + r; + float edge = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - r; + float alpha = clamp(0.5 - edge, 0.0, 1.0); + gl_FragColor = vec4(colour * alpha, alpha); +}`; + +function rgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.replace("#", "").slice(0, 6), 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} + +function compile(gl: WebGLRenderingContext, type: number, source: string) { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null; +} + +type Colours = [number, number, number][]; + +/** A slowly flowing, domain-warped gradient drawn by a WebGL fragment shader behind its parent; still when motion is reduced, paused off screen. A new palette blends in over the old one. It draws its parent's rounded corners itself, so no browser shows it square. */ +export function MeshGradient({ palette, seed = 0, className = "" }: { palette: MeshPalette; seed?: number; className?: string }) { + const ref = useRef(null); + const key = palette.join(); + const wanted = useRef(key); + const shift = useRef<((key: string) => void) | null>(null); + useEffect(() => { + const canvas = ref.current; + const gl = canvas?.getContext("webgl", { antialias: false, alpha: true, premultipliedAlpha: true, preserveDrawingBuffer: false }); + if (!canvas || !gl) return; + const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX); + const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT); + const program = gl.createProgram(); + if (!vertex || !fragment || !program) return; + gl.attachShader(program, vertex); + gl.attachShader(program, fragment); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return; + gl.useProgram(program); + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + const at = gl.getAttribLocation(program, "p"); + gl.enableVertexAttribArray(at); + gl.vertexAttribPointer(at, 2, gl.FLOAT, false, 0, 0); + const u = (name: string) => gl.getUniformLocation(program, name); + const [size, time, corners] = [u("size"), u("time"), u("corners")]; + const slots = [0, 1, 2, 3].map((i) => u(`c${i}`)); + gl.uniform1f(u("seed"), seed); + const parse = (k: string): Colours => k.split(",").map(rgb); + let from = parse(wanted.current); + let to = from; + let now = from; + let since = 0; + const paint = (colours: Colours) => colours.forEach((c, i) => gl.uniform3fv(slots[i] ?? null, c)); + paint(now); + + const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const started = performance.now() - seed * 7000; + let frame = 0; + let onScreen = true; + const blend = () => { + if (now === to) return; + const t = Math.min(1, (performance.now() - since) / 900); + const e = 1 - (1 - t) ** 3; + now = t >= 1 ? to : from.map((c, i) => c.map((v, j) => v + ((to[i]?.[j] ?? v) - v) * e) as [number, number, number]); + paint(now); + }; + const draw = () => { + blend(); + gl.uniform1f(time, still ? 40 + seed * 10 : (performance.now() - started) / 1000); + gl.drawArrays(gl.TRIANGLES, 0, 3); + canvas.dataset.ready = ""; + }; + const loop = () => { + draw(); + frame = !still && onScreen && !document.hidden ? requestAnimationFrame(loop) : 0; + }; + const resize = () => { + const scale = Math.min(window.devicePixelRatio || 1, 1.5) * 0.75; + canvas.width = Math.max(1, Math.round(canvas.clientWidth * scale)); + canvas.height = Math.max(1, Math.round(canvas.clientHeight * scale)); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.uniform2f(size, canvas.width, canvas.height); + const css = getComputedStyle(canvas); + const r = (v: string) => (Number.parseFloat(v) || 0) * scale; + gl.uniform4f(corners, r(css.borderTopLeftRadius), r(css.borderTopRightRadius), r(css.borderBottomRightRadius), r(css.borderBottomLeftRadius)); + if (!frame) draw(); + }; + const wake = () => { + if (!frame && !still && onScreen && !document.hidden) frame = requestAnimationFrame(loop); + }; + shift.current = (k) => { + from = now; + to = parse(k); + since = performance.now(); + if (frame) return; + if (still || !onScreen || document.hidden) { + now = to; + paint(now); + draw(); + } else wake(); + }; + const sized = new ResizeObserver(resize); + sized.observe(canvas); + const seen = new IntersectionObserver(([e]) => { + onScreen = !!e?.isIntersecting; + wake(); + }); + seen.observe(canvas); + document.addEventListener("visibilitychange", wake); + resize(); + wake(); + return () => { + shift.current = null; + cancelAnimationFrame(frame); + sized.disconnect(); + seen.disconnect(); + document.removeEventListener("visibilitychange", wake); + gl.deleteBuffer(buffer); + gl.deleteProgram(program); + gl.deleteShader(vertex); + gl.deleteShader(fragment); + }; + }, [seed]); + useEffect(() => { + if (wanted.current === key) return; + wanted.current = key; + shift.current?.(key); + }, [key]); + return ; +} diff --git a/packages/ui/src/motion/Nothing.tsx b/packages/ui/src/motion/Nothing.tsx new file mode 100644 index 0000000..8349ce5 --- /dev/null +++ b/packages/ui/src/motion/Nothing.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"; +import { SPRING } from "./constants"; + +const LINES = ["Nothing to count here yet.", "Still nothing. Poke me again?", "I checked twice. Empty.", "Okay, you win: here is a jump."]; + +function Blob({ onPoke, mood }: { onPoke: () => void; mood: number }) { + const ref = useRef(null); + const reduce = useReducedMotion(); + const x = useMotionValue(0); + const y = useMotionValue(0); + const eyeX = useSpring(x, SPRING.snappy); + const eyeY = useSpring(y, SPRING.snappy); + const [blink, setBlink] = useState(false); + useEffect(() => { + if (reduce) return; + const move = (e: PointerEvent) => { + const box = ref.current?.getBoundingClientRect(); + if (!box) return; + const dx = e.clientX - (box.left + box.width / 2); + const dy = e.clientY - (box.top + box.height / 2); + const d = Math.max(1, Math.hypot(dx, dy)); + x.set((dx / d) * Math.min(4, d / 40)); + y.set((dy / d) * Math.min(3, d / 40)); + }; + const timer = window.setInterval(() => { + setBlink(true); + window.setTimeout(() => setBlink(false), 140); + }, 3800); + window.addEventListener("pointermove", move); + return () => { + window.removeEventListener("pointermove", move); + window.clearInterval(timer); + }; + }, [reduce, x, y]); + const eye = ( + + ); + return ( + + + {eye} + {eye} + + 0 ? "w-2.5" : "w-4"}`} /> + + ); +} + +/** A section with nothing to show: a small mascot that follows the pointer and answers pokes, with what is missing and why. */ +export function Nothing({ title, words, action, compact = false }: { title?: ReactNode; words?: ReactNode; action?: ReactNode; compact?: boolean }) { + const [mood, setMood] = useState(0); + return ( +
      + setMood((m) => (m + 1) % LINES.length)} /> +
      + + {mood === 0 ? (title ?? LINES[0]) : LINES[mood]} + + {words && {words}} +
      + {action} +
      + ); +} diff --git a/packages/ui/src/motion/constants.ts b/packages/ui/src/motion/constants.ts new file mode 100644 index 0000000..28a9b9d --- /dev/null +++ b/packages/ui/src/motion/constants.ts @@ -0,0 +1,59 @@ +export const DURATION = { + instant: 0.12, + fast: 0.2, + base: 0.36, + slow: 0.6, + reveal: 0.8, + scene: 1.2, +} as const; + +export const EASE = { + out: [0.22, 1, 0.36, 1], + inOut: [0.65, 0, 0.35, 1], + in: [0.55, 0, 1, 0.45], + emphasized: [0.2, 0, 0, 1], +} as const; + +export const GSAP_EASE = { + out: "power3.out", + inOut: "power2.inOut", + in: "power2.in", + soft: "sine.inOut", + pop: "back.out(1.7)", + snap: "expo.out", +} as const; + +export const SPRING = { + gentle: { type: "spring", stiffness: 170, damping: 26, mass: 1 }, + snappy: { type: "spring", stiffness: 420, damping: 32, mass: 0.8 }, + bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 0.9 }, +} as const; + +export const STAGGER = { + tight: 0.04, + base: 0.07, + loose: 0.12, +} as const; + +export const DISTANCE = { + nudge: 6, + rise: 16, + travel: 40, +} as const; + +export const BLUR = { + reveal: 8, +} as const; + +export const BEAT = { + short: 0.5, + base: 1, + long: 1.8, + hold: 2.8, +} as const; + +export const TYPING = { + char: 0.045, +} as const; + +export const VIEWPORT = { once: true, amount: 0.3, margin: "0px 0px -10% 0px" } as const; diff --git a/packages/ui/src/motion/index.ts b/packages/ui/src/motion/index.ts new file mode 100644 index 0000000..430c732 --- /dev/null +++ b/packages/ui/src/motion/index.ts @@ -0,0 +1,7 @@ +export { BEAT, BLUR, DISTANCE, DURATION, EASE, GSAP_EASE, SPRING, STAGGER, TYPING, VIEWPORT } from "./constants"; +export { AnimatePresence, CountUp, Lift, motion, MotionProvider, Reveal, Stagger, StaggerItem, useInView, useReducedMotion, useSpring, useTransform, type MotionValue } from "./primitives"; +export { animate, useMotionTemplate, useMotionValue } from "motion/react"; +export { gsap, useGSAP, useScene, type SceneBuilder, type SceneOptions } from "./scene"; +export { Nothing } from "./Nothing"; +export { MeshGradient } from "./MeshGradient"; +export { meshFallback, type MeshPalette } from "./mesh"; diff --git a/packages/ui/src/motion/mesh.ts b/packages/ui/src/motion/mesh.ts new file mode 100644 index 0000000..abefc08 --- /dev/null +++ b/packages/ui/src/motion/mesh.ts @@ -0,0 +1,6 @@ +export type MeshPalette = readonly [base: string, first: string, second: string, third: string]; + +/** The CSS stand-in for a palette, shown until the shader draws and wherever WebGL is missing. */ +export function meshFallback([base, first, second, third]: MeshPalette): string { + return `radial-gradient(90% 75% at 100% 0%, ${first}cc, transparent 62%), radial-gradient(95% 85% at 0% 100%, ${second}b3, transparent 62%), radial-gradient(55% 45% at 85% 100%, ${third}80, transparent 70%), ${base}`; +} diff --git a/packages/ui/src/motion/primitives.tsx b/packages/ui/src/motion/primitives.tsx new file mode 100644 index 0000000..c6e6a18 --- /dev/null +++ b/packages/ui/src/motion/primitives.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react"; +import { animate, motion, MotionConfig, useInView, useReducedMotion, type Variants } from "motion/react"; +import { BLUR, DISTANCE, DURATION, EASE, SPRING, STAGGER, VIEWPORT } from "./constants"; + +/** Sets the Site's motion defaults and turns animation off for people who ask their system for less motion. */ +export function MotionProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const revealVariants = (y: number, blur: boolean, delay = 0): Variants => ({ + hidden: { opacity: 0, y, filter: blur ? `blur(${BLUR.reveal}px)` : "blur(0px)" }, + shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: DURATION.reveal, ease: EASE.out, delay } }, +}); + +/** Fades, lifts and unblurs its children the first time they scroll into view. For content below the fold. */ +export function Reveal({ children, as = "div", y = DISTANCE.rise, blur = true, delay = 0, className }: { children: ReactNode; as?: "div" | "section" | "li" | "span" | "p" | "h2"; y?: number; blur?: boolean; delay?: number; className?: string }) { + const Tag = motion[as] as ElementType; + return ( + + {children} + + ); +} + +/** Reveals its StaggerItem children one after another when the group scrolls into view. */ +export function Stagger({ children, as = "div", gap = STAGGER.base, delay = 0, className }: { children: ReactNode; as?: "div" | "ul" | "ol" | "section"; gap?: number; delay?: number; className?: string }) { + const Tag = motion[as] as ElementType; + return ( + + {children} + + ); +} + +/** One member of a Stagger group. */ +export function StaggerItem({ children, as = "div", y = DISTANCE.rise, blur = false, className }: { children: ReactNode; as?: "div" | "li" | "span" | "article"; y?: number; blur?: boolean; className?: string }) { + const Tag = motion[as] as ElementType; + return ( + + {children} + + ); +} + +/** A number that counts up to its value the first time it is seen, keeping its width so nothing shifts. */ +export function CountUp({ value, format = (n) => Math.round(n).toLocaleString("en-US"), duration = DURATION.scene, className }: { value: number; format?: (n: number) => string; duration?: number; className?: string }) { + const ref = useRef(null); + const seen = useInView(ref, { once: true, amount: 0.6 }); + const reduce = useReducedMotion(); + const [shown, setShown] = useState(value); + useEffect(() => { + if (!seen || reduce) return void setShown(value); + const controls = animate(0, value, { duration, ease: EASE.out, onUpdate: setShown }); + return () => controls.stop(); + }, [seen, reduce, value, duration]); + return ( + + + {format(value)} + + + {format(shown)} + + + ); +} + +/** Lifts a card slightly under the pointer and presses it on click. */ +export function Lift({ children, className, as = "div" }: { children: ReactNode; className?: string; as?: "div" | "li" | "article" }) { + const Tag = motion[as] as ElementType; + return ( + + {children} + + ); +} + +export { AnimatePresence, motion, useInView, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react"; diff --git a/packages/ui/src/motion/scene.ts b/packages/ui/src/motion/scene.ts new file mode 100644 index 0000000..d48073b --- /dev/null +++ b/packages/ui/src/motion/scene.ts @@ -0,0 +1,50 @@ +import { useRef, type RefObject } from "react"; +import { gsap } from "gsap"; +import { useGSAP } from "@gsap/react"; +import { GSAP_EASE } from "./constants"; + +gsap.registerPlugin(useGSAP); +gsap.defaults({ ease: GSAP_EASE.out, duration: 0.5 }); + +export type SceneOptions = { + loop?: boolean; + repeatDelay?: number; + amount?: number; + still?: number | string; +}; + +export type SceneBuilder = (tl: gsap.core.Timeline, q: (selector: string) => Element[], root: HTMLElement) => void; + +function reduced() { + return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** + * Runs a GSAP timeline scoped to a container: built once, played while the container is on screen and paused + * when it leaves, looped if asked, killed on unmount. With reduced motion it shows one still frame: `still` (a time or + * label) when given, else the last frame. + */ +export function useScene(build: SceneBuilder, { loop = true, repeatDelay = 1.6, amount = 0.35, still }: SceneOptions = {}, deps: unknown[] = []): RefObject { + const ref = useRef(null); + useGSAP( + () => { + const root = ref.current; + if (!root) return; + const q = gsap.utils.selector(root) as (selector: string) => Element[]; + const tl = gsap.timeline({ paused: true, repeat: loop ? -1 : 0, repeatDelay }); + build(tl, q, root); + if (reduced()) { + if (still === undefined) tl.progress(1).pause(); + else tl.seek(still, false).pause(); + return; + } + const watch = new IntersectionObserver(([entry]) => (entry?.isIntersecting ? tl.play() : tl.pause()), { threshold: amount }); + watch.observe(root); + return () => watch.disconnect(); + }, + { scope: ref, dependencies: deps }, + ); + return ref; +} + +export { gsap, useGSAP }; diff --git a/packages/ui/src/profile/cell.tsx b/packages/ui/src/profile/cell.tsx new file mode 100644 index 0000000..bd043d3 --- /dev/null +++ b/packages/ui/src/profile/cell.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from "react"; + +/** One slot of the headline numbers. */ +export function Cell({ children, small = false }: { children: ReactNode; small?: boolean }) { + return
      {children}
      ; +} diff --git a/packages/ui/src/profile/clock.tsx b/packages/ui/src/profile/clock.tsx new file mode 100644 index 0000000..58cb003 --- /dev/null +++ b/packages/ui/src/profile/clock.tsx @@ -0,0 +1,122 @@ +import type { Profile } from "@commitscape/data"; +import { useTip } from "../charts/tip"; +import { heat } from "../design/tokens"; +import { grouped, many } from "../format"; +import { Panel } from "../kit/layout"; +import { Nothing } from "../motion"; +import { clockState, WEEKDAYS } from "./days"; + +const NIGHT = new Set([22, 23, 0, 1, 2, 3, 4]); +const pad = (h: number) => `${String(h).padStart(2, "0")}:00`; + +function weekOf(clock: NonNullable): number[][] | null { + return clock.week && clock.week.length === 7 ? clock.week : null; +} + +function shade(n: number, most: number): string { + return heat(n <= 0 ? 0 : Math.min(4, Math.max(1, Math.ceil((n / most) * 4)))); +} + +/** When their newest commits were made, on each commit's own clock: a weekday by hour heat map, with the busiest hour and day, and how much falls at night and at weekends. */ +export function CommitClock({ profile, wide = false }: { profile: Profile; wide?: boolean }) { + const state = clockState(profile); + if (state === "failed") + return ( + + + + ); + if (state === "none" || !profile.clock) + return ( + + + + ); + return ; +} + +function Heat({ clock, wide }: { clock: NonNullable; wide: boolean }) { + const tip = useTip(); + const week = weekOf(clock); + const peak = clock.hours.indexOf(Math.max(...clock.hours)); + const night = clock.hours.reduce((n, v, h) => (NIGHT.has(h) ? n + v : n), 0); + const days = week ? week.map((r) => r.reduce((a, b) => a + b, 0)) : null; + const topDay = days ? days.indexOf(Math.max(...days)) : -1; + const weekend = days ? (days[5] ?? 0) + (days[6] ?? 0) : 0; + const most = Math.max(1, ...(week ? week.flat() : clock.hours)); + const facts = [ + { label: "Busiest hour", value: `${pad(peak)}`, note: many(clock.hours[peak] ?? 0, "commit", "commits") }, + ...(days ? [{ label: "Busiest day", value: WEEKDAYS[topDay] ?? "—", note: many(days[topDay] ?? 0, "commit", "commits") }] : []), + { label: "At night", value: `${Math.round((night * 100) / clock.sampled)}%`, note: "22:00 to 05:00" }, + ...(days ? [{ label: "At weekends", value: `${Math.round((weekend * 100) / clock.sampled)}%`, note: "Saturday and Sunday" }] : []), + ]; + return ( + +
      + {week ? ( +
      = 0 ? ` and on ${WEEKDAYS[topDay]}s` : ""}`}> +
      + {week.map((row, d) => ( +
      + {WEEKDAYS[d]?.slice(0, 3)} + {row.map((n, h) => ( + + + {WEEKDAYS[d]}, {pad(h)} to {pad((h + 1) % 24)} + +
      {many(n, "commit", "commits")}
      + , + )} + /> + ))} +
      + ))} +
      +
      + + {[0, 6, 12, 18].map((h) => ( + {pad(h)} + ))} +
      + + Fewer + {[0, 1, 2, 3, 4].map((s) => ( + + ))} + More + +
      + ) : ( +
      +
      + {clock.hours.map((n, h) => ( + {pad(h)} to {pad((h + 1) % 24)}
      {many(n, "commit", "commits")}
      )}> + 0 ? 3 : 1, (n * 100) / most)}%`, opacity: n > 0 ? 1 : 0.25 }} /> + + ))} +
      +
      + {[0, 6, 12, 18, 23].map((h) => ( + {pad(h)} + ))} +
      +
      + )} +
      + {facts.map((f) => ( +
      +
      {f.label}
      +
      {f.value}
      +
      {f.note}
      +
      + ))} +
      +
      +
      + ); +} diff --git a/packages/ui/src/profile/days.ts b/packages/ui/src/profile/days.ts new file mode 100644 index 0000000..d0d6369 --- /dev/null +++ b/packages/ui/src/profile/days.ts @@ -0,0 +1,69 @@ +import type { Profile } from "@commitscape/data"; +import { COLOR, FLAME } from "../design/tokens"; + +export const DAY = 86_400; +export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +export const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +export const weekdayOf = (d: number) => (d + 3) % 7; + +export const monthKey = (d: number) => new Date(d * DAY * 1000).toISOString().slice(0, 7); + +export function monthLabel(key: string): string { + const [y = "", m = "01"] = key.split("-"); + return `${MONTHS[Number(m) - 1]} ${y}`; +} + +export function monthsBetween(from: string, to: string): string[] { + const out: string[] = []; + let [y, m] = from.split("-").map(Number) as [number, number]; + const [ty, tm] = to.split("-").map(Number) as [number, number]; + while (y < ty || (y === ty && m <= tm)) { + out.push(`${y}-${String(m).padStart(2, "0")}`); + m++; + if (m > 12) { + m = 1; + y++; + } + } + return out; +} + +/** The last 52 weeks and this one, from the Monday that starts them. */ +export 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 longest run of days with a contribution in a list of days, and where it starts. */ +export function longestRun(days: number[]): { length: number; start: number } { + let best = { length: 0, start: -1 }; + let run = 0; + days.forEach((n, i) => { + run = n > 0 ? run + 1 : 0; + if (run > best.length) best = { length: run, start: i - run + 1 }; + }); + return best; +} + +const HEAT = [ + { from: 100, colour: FLAME[4], word: "Legendary" }, + { from: 30, colour: FLAME[4], word: "On fire" }, + { from: 14, colour: FLAME[3], word: "Blazing" }, + { from: 7, colour: FLAME[2], word: "Heating up" }, + { from: 3, colour: FLAME[1], word: "Warming up" }, + { from: 1, colour: FLAME[0], word: "Just lit" }, +]; + +/** The colour and word for a streak of this many days, cool for short ones and hot for long ones. */ +export function streakHeat(days: number): { colour: string; word: string } { + return HEAT.find((h) => days >= h.from) ?? { colour: COLOR.ink2, word: "Not lit" }; +} + +/** What a person's commit clock can show: their commit times, nothing because they have no commits, or nothing because GitHub did not answer. */ +export function clockState(profile: Profile): "shown" | "none" | "failed" { + if (profile.clock && profile.clock.sampled > 0) return "shown"; + if (profile.clock) return "none"; + return profile.read.complete ? "failed" : "none"; +} diff --git a/packages/ui/src/profile/icons.ts b/packages/ui/src/profile/icons.ts new file mode 100644 index 0000000..d0bbf30 --- /dev/null +++ b/packages/ui/src/profile/icons.ts @@ -0,0 +1,28 @@ +import { CalendarDays, Eraser, Footprints, Hammer, Languages, MessageSquareText, Moon, Siren, type LucideIcon } from "lucide-react"; +import type { ArchetypeId } from "@commitscape/data"; +import type { MeshPalette } from "../motion/mesh"; + +/** The icon that stands for each Archetype. */ +export const ARCHETYPE_ICONS: Record = { + reviewer: MessageSquareText, + janitor: Eraser, + firefighter: Siren, + "night-owl": Moon, + polyglot: Languages, + weekend: CalendarDays, + marathoner: Footprints, + builder: Hammer, +}; + +/** The colours each Archetype's stage flows through: a dark base and three lights. */ +export const ARCHETYPE_PALETTES: Record = { + reviewer: ["#0b0816", "#6d28d9", "#9d174d", "#4f46e5"], + janitor: ["#06130f", "#0f766e", "#10b981", "#65a30d"], + firefighter: ["#150907", "#b91c1c", "#ea580c", "#ca8a04"], + "night-owl": ["#050816", "#1d4ed8", "#0e7490", "#ca8a04"], + polyglot: ["#0b0912", "#be185d", "#2563eb", "#059669"], + weekend: ["#13090f", "#be123c", "#d97706", "#7c3aed"], + marathoner: ["#05110a", "#15803d", "#16a34a", "#0369a1"], + builder: ["#0e0c07", "#a16207", "#15803d", "#0e7490"], + none: ["#0b0d10", "#334155", "#1f6f43", "#475569"], +}; diff --git a/packages/ui/src/profile/languages.tsx b/packages/ui/src/profile/languages.tsx new file mode 100644 index 0000000..c5ae75d --- /dev/null +++ b/packages/ui/src/profile/languages.tsx @@ -0,0 +1,195 @@ +import type { Profile } from "@commitscape/data"; +import { useTip } from "../charts/tip"; +import { grouped, many, share as percent } from "../format"; +import { Panel } from "../kit/layout"; +import { Nothing } from "../motion"; + +const LANGUAGE_COLOURS = 6; +const SHORT = 4; + +function languagesOf(profile: Profile) { + 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]); + const named = ranked.slice(0, LANGUAGE_COLOURS).map(([n]) => n); + 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 { totals, ranked, named, colour, years }; +} + +/** The languages of the repositories a person committed to, year by year, weighted by their commits: a row a year for long careers, a ring and a year-by-year trend for short ones. */ +export function LanguagesOverTime({ profile }: { profile: Profile }) { + const data = languagesOf(profile); + return ( + + {data.years.length === 0 ? : data.years.length < SHORT ? : } + + ); +} + +type Data = ReturnType; + +function Legend({ data }: { data: Data }) { + return ( +
        + {[...data.named, ...(data.ranked.length > data.named.length ? ["Other"] : [])].map((n) => ( +
      • + + {n} +
      • + ))} +
      + ); +} + +function partsOf(data: Data, y: Data["years"][number]) { + const sum = y.languages.reduce((n, l) => n + l.commits, 0); + const other = y.languages.filter((l) => !data.named.includes(l.name)).reduce((n, l) => n + l.commits, 0); + return { sum, parts: [...y.languages.filter((l) => data.named.includes(l.name)).map((l) => ({ name: l.name, commits: l.commits })), ...(other > 0 ? [{ name: "Other", commits: other }] : [])] }; +} + +function Long({ data }: { data: Data }) { + const tip = useTip(); + const years = data.years.slice(-16); + return ( +
      + +
        + {years.map((y) => { + const { sum, parts } = partsOf(data, y); + return ( +
      1. + {y.year} + + {parts.map((p, i) => ( + + {p.name} +
        + {y.year}: {percent(p.commits, sum)}, {many(p.commits, "commit", "commits")} +
        + , + )} + /> + ))} +
        +
      2. + ); + })} +
      +
      + ); +} + +function Lead({ name, years }: { name: string; years: Data["years"] }) { + const led = years.filter((y) => [...y.languages].sort((a, b) => b.commits - a.commits)[0]?.name === name).length; + return ( +

      + {name} {led === years.length ? (years.length === 1 ? "leads their only year so far." : `leads every one of their ${years.length} years.`) : `leads ${led} of their ${years.length} years.`} +

      + ); +} + +const R = 52; +const STROKE = 16; +const CIRC = 2 * Math.PI * R; + +function Short({ data }: { data: Data }) { + const tip = useTip(); + const all = [...data.totals.values()].reduce((a, b) => a + b, 0); + const rows = [...data.named.map((n) => ({ name: n, commits: data.totals.get(n) ?? 0 })), ...(data.ranked.length > data.named.length ? [{ name: "Other", commits: data.ranked.slice(data.named.length).reduce((s, [, v]) => s + v, 0) }] : [])]; + const colour = (n: string) => (n === "Other" ? "var(--other)" : data.colour(n)); + const top = rows[0]; + const share = (y: Data["years"][number], name: string) => { + const { sum, parts } = partsOf(data, y); + return (parts.find((p) => p.name === name)?.commits ?? 0) / Math.max(1, sum); + }; + const cols = `minmax(0,1fr) repeat(${data.years.length}, minmax(2.5rem, 3.5rem)) 2.5rem`; + let at = 0; + return ( +
      +
      +
      + `${r.name} ${Math.round((r.commits * 100) / Math.max(1, all))}%`).join(", ")}> + + {rows.map((r) => { + const len = (r.commits / Math.max(1, all)) * CIRC; + const gap = rows.length > 1 ? Math.min(2, len / 2) : 0; + const seg = ( + + {r.name} +
      + {percent(r.commits, all)}, {many(r.commits, "commit", "commits")} +
      + , + )} + /> + ); + at += len; + return seg; + })} + + {top && ( + + {Math.round((top.commits * 100) / Math.max(1, all))}% + {top.name} + + )} +
      +
      + {top && } +

      + {grouped(all)} commits in repositories with a main language, {many(data.ranked.length, "language", "languages")} in all. +

      +
      +
      +
      +
      + Language + {data.years.map((y) => ( + + {y.year} + + ))} + All +
      + {rows.map((r) => ( +
      + + + {r.name} + + {data.years.map((y) => { + const s = share(y, r.name); + return ( + + + 0 ? Math.max(8, s * 100) : 0}%`, background: colour(r.name) }} /> + + + ); + })} + + {percent(r.commits, all)} + +
      + ))} +
      +
      + ); +} diff --git a/packages/ui/src/profile/streak.tsx b/packages/ui/src/profile/streak.tsx new file mode 100644 index 0000000..4571872 --- /dev/null +++ b/packages/ui/src/profile/streak.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties } from "react"; +import { Flame } from "lucide-react"; +import type { Profile } from "@commitscape/data"; +import { ICON } from "../design/tokens"; +import { grouped, many } from "../format"; +import { Stat } from "../kit/layout"; +import { BEAT, motion } from "../motion"; +import { DAY, streakHeat, WEEKDAYS, weekdayOf } from "./days"; + +/** The streak running now, laid out like the numbers beside it, with a flame by the number whose colour climbs with its length; the longest streak beneath, and the last seven days as dots. */ +export function StreakCell({ profile }: { profile: Profile }) { + const { currentStreak: now, longestStreak: longest } = profile.totals; + const heat = streakHeat(now); + const lit = now > 0; + const { firstDay, days } = profile.calendar; + const today = Math.floor(profile.fetchedAt / DAY); + const week = Array.from({ length: 7 }, (_, i) => { + const d = today - 6 + i; + return { d, n: days[d - firstDay] ?? 0 }; + }); + const value = ( + + {grouped(now)} + {now === 1 ? "day" : "days"} + = 7 ? "drop-shadow(0 0 6px var(--heat))" : undefined }} animate={lit ? { scale: [1, 1.08, 0.97, 1.04, 1], rotate: [0, -3, 2, -1, 0] } : undefined} transition={lit ? { duration: BEAT.hold, repeat: Infinity, ease: "easeInOut" } : undefined}> + + + + ); + return ( +
      + 0 ? `longest ${many(longest, "day", "days")}` : "one contribution starts one"}> + w.n > 0).length} of the last 7 days with a contribution`} role="img"> + {week.map((w) => ( + 0 ? "" : "bg-[var(--color-track)]"}`} style={w.n > 0 ? { background: lit ? "var(--heat)" : "var(--brand)" } : undefined} /> + ))} + + +
      + ); +} diff --git a/packages/ui/src/profile/survival.test.ts b/packages/ui/src/profile/survival.test.ts new file mode 100644 index 0000000..45192a8 --- /dev/null +++ b/packages/ui/src/profile/survival.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "vitest"; +import type { EngineRepo, EngineView } from "@commitscape/data"; +import { coverage, 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(); +}); + +const repo = (name: string, status: EngineRepo["surviving"]["status"], lines: number | null, added: number | null): EngineRepo => ({ owner: "acme", name, private: false, builtAt: 1, commits: 3, linesAdded: 10, linesRemoved: 1, first: 1, last: 2, surviving: { status, lines, added } }); + +test("the total equals the sum of the counted repositories shown, and says how many of all it covers", () => { + const engine: EngineView = { + repos: [repo("a", "counted", 100, 400), repo("b", "counted", 20, 50), repo("c", "counting", null, null), repo("d", "failed", null, null)], + surviving: 999, + added: 999, + counting: 1, + unread: [{ owner: "acme", name: "e", private: false, commits: 9, state: "not_read", reason: null, canRead: true }], + }; + const c = coverage(engine); + expect(c.counted.map((r) => r.name)).toEqual(["a", "b"]); + expect(c.total).toBe(c.counted.reduce((n, r) => n + (r.surviving.lines ?? 0), 0)); + expect(c.total).toBe(120); + expect(c.added).toBe(450); + expect(c.waiting.map((r) => r.name)).toEqual(["c", "d"]); + expect(c.repositories).toBe(5); + expect(c.busy).toBe(true); +}); + +test("nothing counted is no total, and a list never checked against GitHub covers an unknown number", () => { + const c = coverage({ repos: [repo("a", "counting", null, null)], surviving: null, added: null, counting: 1 }); + expect(c.total).toBeNull(); + expect(c.repositories).toBeNull(); + expect(coverage({ repos: [repo("a", "counted", 5, null)], surviving: 5, added: null, counting: 0, unread: [] })).toMatchObject({ total: 5, added: null, repositories: 1, busy: false }); +}); diff --git a/packages/ui/src/profile/survival.ts b/packages/ui/src/profile/survival.ts new file mode 100644 index 0000000..0f3be5c --- /dev/null +++ b/packages/ui/src/profile/survival.ts @@ -0,0 +1,36 @@ +import type { EngineRepo, EngineView, UnreadRepo } from "@commitscape/data"; + +/** 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)}%`; +} + +export type Coverage = { + counted: EngineRepo[]; + waiting: EngineRepo[]; + unread: UnreadRepo[]; + total: number | null; + added: number | null; + repositories: number | null; + busy: boolean; +}; + +/** What a Surviving Lines total covers: the counted repositories it is the sum of, the read ones still waiting for a count, and the ones not read yet; the total is only ever the sum of the counted list. */ +export function coverage(engine: EngineView): Coverage { + const counted = engine.repos.filter((r) => r.surviving.status === "counted" && r.surviving.lines !== null); + const waiting = engine.repos.filter((r) => !counted.includes(r)); + const unread = engine.unread ?? []; + const total = counted.length > 0 ? counted.reduce((n, r) => n + (r.surviving.lines ?? 0), 0) : null; + const added = counted.length > 0 && counted.every((r) => r.surviving.added !== null) ? counted.reduce((n, r) => n + (r.surviving.added ?? 0), 0) : null; + return { + counted, + waiting, + unread, + total, + added, + repositories: engine.unread ? engine.repos.length + unread.length : null, + busy: waiting.some((r) => r.surviving.status === "counting") || unread.some((r) => r.state === "reading"), + }; +} diff --git a/packages/ui/src/profile/traits.tsx b/packages/ui/src/profile/traits.tsx new file mode 100644 index 0000000..4c6c364 --- /dev/null +++ b/packages/ui/src/profile/traits.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { Dialog, DialogHeader } from "@astryxdesign/core/Dialog"; +import { Icon } from "@astryxdesign/core/Icon"; +import { IconButton } from "@astryxdesign/core/IconButton"; +import { Award, Check, Eye, Lock, Minus, Share2 } from "lucide-react"; +import { ARCHETYPE_MINIMUM, ARCHETYPES, type Achievement, type Archetype, type ArchetypeCheck } from "@commitscape/data"; +import { ICON } from "../design/tokens"; +import { Chip, Panel } from "../kit/layout"; +import { meshFallback } from "../motion/mesh"; +import { MeshGradient } from "../motion/MeshGradient"; +import { ARCHETYPE_ICONS, ARCHETYPE_PALETTES } from "./icons"; + +/** Their Archetype as the hero of its own stage: its name and what it means, the numbers that met its rule as meters, and a dialog with every rule. */ +export function ArchetypePanel({ archetypes, complete, checks }: { archetypes: Archetype[]; complete: boolean; checks?: ArchetypeCheck[] }) { + const [open, setOpen] = useState(false); + const main = archetypes[0]; + const closest = main ? null : [...(checks ?? [])].sort((a, b) => mean(b) - mean(a))[0]; + const shown = main ? checks?.find((c) => c.id === main.id) : closest; + const Glyph = main ? ARCHETYPE_ICONS[main.id] : Award; + const palette = ARCHETYPE_PALETTES[main?.id ?? "none"]; + return ( +
      + + +
      + Archetype + +
      +
      + + + +
      + {main ? "They are a" : "Not decided yet"} + {main ? main.title : "No Archetype"} +
      +
      +

      {main ? main.rule : `None of the rules fits yet. A rule is tried once they have ${ARCHETYPE_MINIMUM} contributions, each against its own threshold.`}

      + {shown && shown.evidence.length > 0 && ( +
      + {main ? "What made it" : `Closest: ${shown.title}`} +
        + {shown.evidence.map((e) => ( +
      • + + {e.label} + {e.value} + + + + + + {e.met ? : } + needs {e.goal} + +
      • + ))} +
      +
      + )} + {(archetypes.length > 1 || !complete) && ( +
      + {archetypes.length > 1 && ( +
      + Also + {archetypes.slice(1).map((a) => { + const Also = ARCHETYPE_ICONS[a.id]; + return ( + + + {a.title} + + ); + })} +
      + )} + {!complete &&

      Some rules wait for their pull requests to be read.

      } +
      + )} + {open && } +
      + ); +} + +function mean(c: ArchetypeCheck): number { + return c.evidence.length === 0 ? 0 : c.evidence.reduce((n, e) => n + e.share, 0) / c.evidence.length; +} + +function RulesDialog({ open, onOpenChange, archetypes, checks }: { open: boolean; onOpenChange: (open: boolean) => void; archetypes: Archetype[]; checks?: ArchetypeCheck[] }) { + const met = new Set(archetypes.map((a) => a.id)); + const main = archetypes[0]?.id; + const byId = new Map((checks ?? []).map((c) => [c.id, c])); + return ( + + +
      + {ARCHETYPES.map((a, i) => { + const yes = met.has(a.id); + const check = byId.get(a.id); + return ( +
      + + {yes ? : } + +
      + {i + 1}. + {a.title} + {a.id === main && theirs} + {yes && a.id !== main && also met} +
      +
      {a.rule}
      + {check &&
      {check.numbers}
      } +
      + ); + })} +
      +
      + ); +} + +/** Every Achievement: reached ones in colour, each a Card away, the rest with what reaches them. */ +export function AchievementsPanel({ achievements, onCard }: { achievements: Achievement[]; onCard?: (id: string) => void }) { + const earned = achievements.filter((a) => a.earned); + return ( + +
        + {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 && } onClick={() => onCard(a.id)} />} +
      • + ))} +
      +
      + ); +} + +/** Their Archetype beside every Achievement, the two the same height. */ +export function TraitsPanel({ archetypes, achievements, complete, onCard, checks }: { archetypes: Archetype[]; achievements: Achievement[]; complete: boolean; onCard?: (id: string) => void; checks?: ArchetypeCheck[] }) { + return ( +
      + + +
      + ); +} diff --git a/packages/ui/src/profile/view.tsx b/packages/ui/src/profile/view.tsx new file mode 100644 index 0000000..12fe654 --- /dev/null +++ b/packages/ui/src/profile/view.tsx @@ -0,0 +1,630 @@ +import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { HoverCard } from "@astryxdesign/core/HoverCard"; +import { Pagination } from "@astryxdesign/core/Pagination"; +import { Spinner } from "@astryxdesign/core/Spinner"; +import { Award, Building2, CalendarDays, Lock, MapPin, Play, Users } from "lucide-react"; +import type { Archetype, EngineRepo, EngineView, Identity, Profile, ProfileRepo, UnreadRepo } from "@commitscape/data"; +import { avatarUrl } from "../components/avatar"; +import { Face } from "../components/Face"; +import { compact, grouped, many } from "../format"; +import { A } from "../kit/A"; +import { ICON } from "../design/tokens"; +import { AddedRemoved, Chip, Eyebrow, LangDot, Meter, Page, Panel, Stat } from "../kit/layout"; +import { CountUp, Nothing } from "../motion"; +import { Cell } from "./cell"; +import { StreakCell } from "./streak"; +import { coverage, survival, type Coverage } from "./survival"; +import { ARCHETYPE_ICONS } from "./icons"; + +export { Cell } from "./cell"; +export { CommitClock } from "./clock"; +export { LanguagesOverTime } from "./languages"; +export { StreakCell } from "./streak"; +export { AchievementsPanel, ArchetypePanel, TraitsPanel } from "./traits"; +export { LastYear } from "./year"; +export { OverTheYears } from "./years"; + +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[] }) { + const Glyph = ARCHETYPE_ICONS[archetype.id] ?? Award; + return ( + + Archetype, by a written rule + {archetype.title} + {archetype.rule} + {also.length > 0 && Also: {also.join(", ")}} +
      + } + > + + + ); +} + +const counted = (n: number) => ; + +/** The headline numbers: four large, each saying what it counts, and four smaller beneath ending with the streak; 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} + + + + + + +
      +
      + ); +} + +/** 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; + const none = t.linesAdded !== null && t.linesAdded + (t.linesRemoved ?? 0) === 0; + return ( + <> + + +{compact(t.linesAdded)} / −{compact(t.linesRemoved ?? 0)}} label="Lines merged" note={none ? "no merged pull requests yet" : capped ? `in the newest ${grouped(profile.read.prs)} pull requests` : "added and removed in merged pull requests"}> + {t.linesAdded !== null && !none && } + + + + + + + ); +} + +function coveredWords(c: Coverage): string { + const of = c.repositories === null ? many(c.counted.length, "repository", "repositories") : `${grouped(c.counted.length)} of ${many(c.repositories, "repository", "repositories")}`; + return `in ${of} they committed to`; +} + +/** The headline Surviving Lines: only ever the sum of the repositories counted so far, saying how many of theirs that is. */ +export function SurvivingNumber({ engine, suggest }: { engine: EngineView; suggest?: string | null }) { + const c = coverage(engine); + const label = "Lines that still run"; + const more = ( + + {c.total === null ? "See which repositories" : "See what it covers"} → + + ); + if (c.total === null && c.busy) + return ( + Counting} label={label} note={`${many(c.waiting.length + c.unread.filter((r) => r.state === "reading").length, "repository", "repositories")} being read or counted`}> + {more} + + ); + if (c.total === null) + return ( + —} label={label} note={c.repositories ? `none of their ${many(c.repositories, "repository", "repositories")} read yet` : "no repository of theirs read here yet"}> + {c.unread.length > 0 ? more : suggest ? ( + + Read {suggest} to count them → + + ) : null} + + ); + return ( + + {more} + + ); +} + +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; unread: UnreadRepo | null }; + +const PAGE = 12; +const ROW = "h-14"; + +function rowsOf(repos: ProfileRepo[], engine: EngineView | null): Row[] { + const byName = new Map((engine?.repos ?? []).map((e) => [`${e.owner}/${e.name}`.toLowerCase(), e])); + const unread = new Map((engine?.unread ?? []).map((u) => [`${u.owner}/${u.name}`.toLowerCase(), u])); + 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, unread: unread.get(key) ?? null }; + }); + 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, unread: null }); + return rows; +} + +const quiet = —; + +/** 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 [page, setPage] = useState(1); + 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 pages = Math.max(1, Math.ceil(filtered.length / PAGE)); + const at = Math.min(page, pages); + const shown = filtered.slice((at - 1) * PAGE, at * PAGE); + const fill = rows.length > PAGE ? PAGE - shown.length : 0; + const most = Math.max(1, ...rows.map((r) => r.prsMerged * 4 + r.commits + r.reviews)); + const login = profile.identity.login; + return ( + PAGE ? ( + { + setQuery(e.target.value); + setPage(1); + }} + placeholder="Find a repository" + aria-label="Find a repository" + className="h-8 w-48 max-w-full rounded-md border border-line bg-body px-2.5 text-xs text-primary outline-none placeholder:text-secondary focus:border-brand-line" + /> + ) : null + } + > + {rows.length === 0 ? ( +
      + +
      + ) : ( +
      +
      {h} + {h} +
      + {typeof c === "number" ? grouped(c) : c}
      + + + + + + + + + + + + {shown.map((r) => ( + + ))} + {shown.length === 0 && ( + + + + )} + {Array.from({ length: Math.max(0, fill - (shown.length === 0 ? 1 : 0)) }, (_, i) => ( + + + ))} + +
      RepositoryMerged PRsCommitsReviewsLines mergedStill running
      + No repository of theirs has “{query}” in its name. +
      +
      +
      + )} + {pages > 1 || profile.totals.hidden > 0 ? ( +
      + {pages > 1 ? ( +
      + + + {grouped((at - 1) * PAGE + 1)}–{grouped(Math.min(filtered.length, at * PAGE))} of {grouped(filtered.length)} + +
      + ) : ( + + )} + {profile.totals.hidden > 0 && ( + + {many(profile.totals.hidden, "private contribution", "private contributions")} counted in the totals, never named + + )} +
      + ) : null} + + ); +} + +function RepoRow({ r, login, weight }: { r: Row; login: string; weight: number }) { + const n = (v: number) => (v > 0 ? grouped(v) : quiet); + return ( + + + + + + + {r.owner}/ + {r.name} + + + {r.owner} + {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)} + + ) : ( + quiet + )} + + + + + + ); +} + +const UNREAD_WORDS: Record = { + not_read: "not read yet", + reading: "reading", + failed: "could not be read", + not_in_it: "none of theirs found", +}; + +const COUNT_WORDS: Partial> = { + failed: "could not count", + over_budget: "too big to count here", + not_counted: "read without lines", + stale: "read again soon", +}; + +function SurvivingCell({ repo, unread }: { repo: EngineRepo | null; unread?: UnreadRepo | null }) { + if (!repo) { + if (unread?.state === "reading") + return ( + + reading + + ); + return {unread ? UNREAD_WORDS[unread.state] : "—"}; + } + const s = repo.surviving; + if (s.status === "counted" && s.lines !== null) return {compact(s.lines)}; + if (s.status !== "counting") return {COUNT_WORDS[s.status] ?? "not counted"}; + return ( + + counting + + ); +} + +const UNREAD_PAGE = 6; + +/** Lines that still run, repository by repository: the total is the sum of the counted rows listed, and every other repository they committed to is shown as waiting, being read, or not read, with a way to read it. */ +export function SurvivalPanel({ engine, login, onRead }: { engine: EngineView; login: string; onRead?: (repos: { owner: string; name: string }[]) => Promise }) { + const c = coverage(engine); + const [shown, setShown] = useState(UNREAD_PAGE); + if (engine.repos.length === 0 && c.unread.length === 0) return null; + const listed = [...c.counted, ...c.waiting]; + const most = Math.max(1, ...c.counted.map((r) => r.surviving.added ?? r.surviving.lines ?? 0)); + const share = survival(c.total, c.added); + const readable = c.unread.filter((r) => r.canRead); + const reading = c.unread.filter((r) => r.state === "reading").length; + const counting = c.waiting.filter((r) => r.surviving.status === "counting").length; + const next = readable.slice(0, 5); + return ( + }> + {[counting > 0 && `counting ${grouped(counting)}`, reading > 0 && `reading ${grouped(reading)}`].filter(Boolean).join(", ")}; updates by itself + + ) : null + } + > +
      +
      +
      + + {c.total === null ? "—" : compact(c.total)} + lines still running + + + {c.total === null ? (c.busy ? "None counted yet; the first arrive in a few minutes." : "No repository of theirs counted yet.") : `The sum of the ${many(c.counted.length, "repository", "repositories")} listed${share ? `: ${share} of the ${compact(c.added ?? 0)} lines they added there` : ""}.`} + +
      + +
      +
      + {listed.length > 0 && ( +
        + {listed.map((r) => ( + + ))} +
      + )} + {c.unread.length > 0 && ( +
      0 ? "border-t border-line pt-stack" : ""}`}> +
      +
      + Not in the total yet + {many(c.unread.length, "repository", "repositories")} they committed to that commitscape has not counted. Each joins the total once it is read. +
      + {onRead && next.length > 0 && onRead(next.map((r) => ({ owner: r.owner, name: r.name })))} name={next.map((r) => `${r.owner}/${r.name}`).join(", ")} label={next.length === 1 ? `Read ${next[0]?.name}` : `Read the next ${next.length}`} />} +
      +
        + {c.unread.slice(0, shown).map((r) => ( + + ))} +
      + {c.unread.length > shown && ( + + )} +
      + )} +
      +
      +
      + ); +} + +function CoverageBar({ c }: { c: Coverage }) { + if (c.repositories === null || c.repositories === 0) return null; + const parts = [ + { n: c.counted.length, colour: "bg-brand", word: "counted" }, + { n: c.waiting.length, colour: "bg-brand-line", word: "read, counting or not countable" }, + { n: c.unread.length, colour: "bg-[var(--color-track)]", word: "not read" }, + ].filter((p) => p.n > 0); + return ( +
      + `${p.n} ${p.word}`).join(", ")} className="flex h-1.5 w-full gap-0.5 overflow-hidden rounded-full"> + {parts.map((p) => ( + + ))} + +
        + {parts.map((p) => ( +
      • + + {grouped(p.n)} {p.word} +
      • + ))} +
      +
      + ); +} + +function SurvivalRow({ r, login, most }: { r: EngineRepo; login: string; most: number }) { + 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) ? ` · ${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 && " · not in the total yet"} + + + {counted && r.surviving.added ? : null} + {counted ? : r.surviving.status === "counting" ? : null} + +
    • + ); +} + +function UnreadRow({ r, login, onRead }: { r: UnreadRepo; login: string; onRead?: (repos: { owner: string; name: string }[]) => Promise }) { + const words = r.state === "failed" && r.reason ? `${UNREAD_WORDS.failed}: ${FAILURE_SHORT[r.reason]}` : r.private && r.state === "not_read" ? "private: open it to connect" : UNREAD_WORDS[r.state]; + return ( +
    • + + + + + {r.owner}/ + {r.name} + + + {many(r.commits, "commit", "commits")} · {words} + + + + {r.state === "reading" ? ( + + ) : r.canRead && onRead ? ( + onRead([{ owner: r.owner, name: r.name }])} name={`${r.owner}/${r.name}`} /> + ) : null} +
    • + ); +} + +function ReadButton({ onClick, name, label = "Read" }: { onClick: () => Promise; name: string; label?: string }) { + const [busy, setBusy] = useState(false); + return ( + + ); +} + +const FAILURE_SHORT: Record, string> = { + not_found: "GitHub has no such repository", + private: "private", + too_big: "too big for the Site", + timed_out: "took too long", + error: "something went wrong", + paused: "Builds are paused", +}; + +/** The people who review their pull requests, and whose they review, each a Versus away. */ +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 ? ( + + ) : ( + + )} + {people.length > 0 && ( +
      + + reviews of their pull requests + + + their reviews of others + +
      + )} +
      + ); +} diff --git a/packages/ui/src/profile/year.tsx b/packages/ui/src/profile/year.tsx new file mode 100644 index 0000000..8cbc42a --- /dev/null +++ b/packages/ui/src/profile/year.tsx @@ -0,0 +1,195 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { Dialog, DialogHeader } from "@astryxdesign/core/Dialog"; +import { Icon } from "@astryxdesign/core/Icon"; +import { IconButton } from "@astryxdesign/core/IconButton"; +import { Eye } from "lucide-react"; +import type { Profile } from "@commitscape/data"; +import { YearGrid } from "../charts/Year"; +import { day, grouped, many } from "../format"; +import { Panel } from "../kit/layout"; +import { DAY, lastYear, longestRun, monthKey, MONTHS, streakHeat, WEEKDAYS, weekdayOf } from "./days"; + +const KINDS = [ + { key: "commits", label: "Commits", colour: "var(--s1)" }, + { key: "prs", label: "Pull requests", colour: "var(--s7)" }, + { key: "reviews", label: "Reviews", colour: "var(--s3)" }, + { key: "issues", label: "Issues", colour: "var(--s4)" }, + { key: "hidden", label: "Private", colour: "var(--other)" }, +] as const; + +function breakdown(profile: Profile) { + const year = lastYear(profile); + const { firstDay, days } = year; + const total = days.reduce((a, b) => a + b, 0); + const active = days.filter((n) => n > 0).length; + const best = days.reduce((b, n, i) => (n > b.n ? { n, d: firstDay + i } : b), { n: 0, d: firstDay }); + const weeks: { n: number; d: number }[] = []; + for (let i = 0; i < days.length; i += 7) weeks.push({ n: days.slice(i, i + 7).reduce((a, b) => a + b, 0), d: firstDay + i }); + const bestWeek = weeks.reduce((b, w) => (w.n > b.n ? w : b), { n: 0, d: firstDay }); + const months = new Map(); + days.forEach((n, i) => months.set(monthKey(firstDay + i), (months.get(monthKey(firstDay + i)) ?? 0) + n)); + const monthList = [...months.entries()].map(([key, n]) => ({ key, n })); + const bestMonth = monthList.reduce((b, m) => (m.n > b.n ? m : b), { key: "", n: 0 }); + const weekdays = Array.from({ length: 7 }, () => ({ n: 0, active: 0, days: 0 })); + days.forEach((n, i) => { + const w = weekdays[weekdayOf(firstDay + i)]; + if (!w) return; + w.n += n; + w.days++; + if (n > 0) w.active++; + }); + const run = longestRun(days); + const firstYear = new Date(firstDay * DAY * 1000).getUTCFullYear(); + const lastDay = firstDay + days.length - 1; + const thisYear = new Date(lastDay * DAY * 1000).getUTCFullYear(); + const kinds = profile.years.filter((y) => y.year >= firstYear && y.year <= thisYear).sort((a, b) => b.year - a.year); + return { firstDay, lastDay, total, active, length: days.length, best, bestWeek, monthList, bestMonth, weekdays, run, kinds, thisYear }; +} + +const monthName = (key: string) => { + const [y = "", m = "01"] = key.split("-"); + return `${MONTHS[Number(m) - 1]} ${y}`; +}; + +/** The last year, a square a day, its busiest day, and everything behind it in a dialog. */ +export function LastYear({ profile }: { profile: Profile }) { + const [open, setOpen] = useState(false); + 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)} + + )} + {total > 0 && } onClick={() => setOpen(true)} />} +
+ } + > + + {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..826b55a --- /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 { CHART } from "../design/tokens"; +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 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={CHART.tick} 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={CHART.tick} tickLine={false} axisLine={false} minTickGap={14} /> + compact(v)} tick={CHART.tick} tickLine={false} axisLine={false} width={Y_WIDTH} allowDecimals={false} tickCount={3} /> + } cursor={{ fill: CHART.cursor }} /> + {shown.map((o, i) => ( + + ))} + + +
+ ) : ( + + )} + {(!full || opened > 0) && + {coverage(full, given, since)} + } +
+
+ ) : ( + + )} +
+ ); +} 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..7141017 100644 --- a/packages/ui/src/screens/Activity.tsx +++ b/packages/ui/src/screens/Activity.tsx @@ -1,117 +1,173 @@ 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 { 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 { githubWhy, grouped, many, share, WINDOW_WORDS } from "../format"; +import { grouped, many, share } from "../format"; +import { Panel } from "../kit/layout"; +import { COLOR, ICON, SERIES } from "../design/tokens"; import { personColour } from "../theme"; +import { Failed, Quiet, ScreenFrame, StacksOverTime, type Stack, within } from "./kit"; import type { ScreenProps } from "./props"; -import { ScreenSkeleton } from "../components/Loading"; -export function Activity({ meta, params }: ScreenProps) { +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); - if (error) return

{error}

; - if (!a) return ; - 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 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 peak = peakOf(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 ? ( -
+ {a.github && ( + +
- ) : ( -

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

+ From GitHub's history of the repository, read a page at a time and kept, so later Builds read only what changed. +
+ )} + +
+ = 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. 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 && ( + + + + 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. + + )} - - 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. -

- ) : ( - ({ - key: k.kind, - label: k.kind, - value: k.commits, - 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. - -
+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 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(office, all), "in office hours, 09:00 to 18:00 on weekdays"], + [share(weekend, all), "on weekends"], + [share(night, all), "at night, 22:00 to 05:00"], + [WEEKDAYS[top] ?? "", "the busiest day"], + ]; + return ( +
+ {facts.map(([value, label]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +function KindsBar({ kinds, all }: { kinds: Data["kinds"]; all: number }) { + const tip = useTip(); + const parts = kinds.map((k, i) => ({ ...k, colour: SERIES[i] ?? COLOR.other })); + 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/Commits.tsx b/packages/ui/src/screens/Commits.tsx index 11cf351..6742be6 100644 --- a/packages/ui/src/screens/Commits.tsx +++ b/packages/ui/src/screens/Commits.tsx @@ -1,26 +1,41 @@ 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 { 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 { Failed, ScreenFrame } from "./kit"; import type { ScreenProps } from "./props"; -import { ScreenSkeleton } from "../components/Loading"; -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 } = 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, { @@ -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 (!list) return ; - const span = route.from !== undefined || route.to !== undefined ? "the dates chosen" : WINDOW_WORDS[String(params.window)] ?? "all time"; + 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 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)} - - - {list.merge[i] && } - {kind && kind !== "other" && } +
+ {date(when)} + {who && who.id !== 0xffffffff ? : } + + + {list.merge[i] && } + {kind && kind !== "other" && ( + {kind} + )} + + {subject || (no subject)} + + + + {who?.name ?? "someone unknown"} · {date(when)} + - - {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)} )}
); }); + +const KIND_TONE: Record = { + green: "bg-green-subtle text-green-vivid", + red: "bg-red-subtle text-red-vivid", + blue: "bg-blue-subtle text-blue-vivid", + purple: "bg-purple-subtle text-purple-vivid", + teal: "bg-teal-subtle text-teal-vivid", + orange: "bg-orange-subtle text-orange-vivid", + yellow: "bg-yellow-subtle text-yellow-vivid", + pink: "bg-pink-subtle text-pink-vivid", + gray: "bg-sunken 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 9328bfd..859818d 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 { useData } from "../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 { ranges, useWidth } 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 { RADIUS } from "../design/tokens"; +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 = useData(route.file ? "/api/file" : null, { ...params, path: route.file }); - const [colour, setColour] = useState("activity"); - const [ref, width] = useWidth(); - 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,304 @@ 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 ( -
-
-
+ )} + ); } -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); +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 ( - <> - ({ - key: String(year), - label: String(year), - value: lines, - shown: `${compact(lines)} · ${share(lines, all)}`, - }))} - /> - [`${q.year} Q${q.quarter}`, q.lines])} - /> - + +
+
+
+ {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 0283d38..ff0c625 100644 --- a/packages/ui/src/screens/People.tsx +++ b/packages/ui/src/screens/People.tsx @@ -1,28 +1,32 @@ -import { useContext, useState } from "react"; -import { Avatar } from "@astryxdesign/core/Avatar"; +import { useState, type CSSProperties, type ReactNode } 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"; -import type { People as Data, Person, PersonRow } from "@commitscape/data"; +import { Icon } from "@astryxdesign/core/Icon"; +import { ArrowDown, ArrowLeft, ArrowUp, Bot, Check, Copy, Folder, Trophy } from "lucide-react"; +import { NOT_IN_REPORT, type PathCount, type People as Data, type Person, type PersonRow } from "@commitscape/data"; 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 { Name, Path } from "../components/Name"; -import { Tile } from "../components/Tile"; -import { AvatarsContext } from "../help"; +import { WeekGrid } from "../charts/Grid"; +import { useTip } from "../charts/tip"; +import { COLOR, SERIES } from "../design/tokens"; +import { YearGrid } from "../charts/Year"; +import { Face } from "../components/Face"; +import { useLogin } from "../components/login"; +import { Name, Path, useStanding } from "../components/Name"; 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"; +import { compact, date, githubWhy, 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 { folderOf, openers, type ScreenProps } from "./props"; type Column = { key: string; label: string; value: (r: PersonRow) => number | null; - shown: (r: PersonRow) => string; + shown: (r: PersonRow) => ReactNode; why: string; + wide?: boolean; }; function hoursWords(h: number): string { @@ -35,136 +39,197 @@ function known(n: number | null, words: (n: number) => string = grouped): string return n === null ? "—" : words(n); } +const dash = —; + const COLUMNS: Column[] = [ { key: "commits", label: "Commits", value: (r) => r.commits, shown: (r) => grouped(r.commits), why: "Commits that are not merges." }, { 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: "added", + label: "Lines", + value: (r) => r.lines_added, + shown: (r) => + r.lines_added === null ? ( + dash + ) : ( + <> + +{compact(r.lines_added)} −{compact(r.lines_removed ?? 0)} + + ), + why: "Lines their commits added and removed, lockfiles and generated files left out.", + wide: true, + }, + { key: "prs", label: "PRs merged", value: (r) => r.prs_merged, shown: (r) => known(r.prs_merged), why: "Their pull requests merged in the Window. — when GitHub knows no account for them." }, { 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." }, ]; -export function People(props: ScreenProps) { +/** The people of a repository: the page's Standings first when it has them, everyone in the Window, and one person in full. */ +export function People(props: ScreenProps & { extra?: ReactNode }) { if (props.route.id !== undefined) return ; - return ; + return ; } -type Row = PersonRow & Record; - -function PeopleTable({ meta, params, go }: ScreenProps) { +function Everyone({ meta, params, go, extra }: ScreenProps & { extra?: ReactNode }) { const { data, error, stale } = useData("/api/people", params); - const [sort, setSort] = useState([{ sortKey: "commits", direction: "descending" as "ascending" | "descending" }]); - const sortable = useTableSortable({ sort, onSortChange: (next) => setSort(next.length > 0 ? next : sort) }); + const [sort, setSort] = useState<{ key: string; down: boolean }>({ key: "commits", down: true }); + const [shown, setShown] = useState(50); + const [suspects, setSuspects] = useState(8); const open = openers(go); - if (error) return

{error}

; - if (!data) return ; - const by = sort[0] ?? { sortKey: "commits", direction: "descending" }; - const column = COLUMNS.find((c) => c.key === by.sortKey) ?? COLUMNS[0]; + if (error || !data) return ; + const span = within(data.window); + const columns = COLUMNS.filter((c) => data.people.some((r) => c.value(r) !== null)); + const column = COLUMNS.find((c) => c.key === sort.key) ?? COLUMNS[0]; const rows = [...data.people].sort((a, b) => (column?.value(b) ?? -1) - (column?.value(a) ?? -1)); - if (by.direction === "ascending") rows.reverse(); - const columns: TableColumn[] = [ - { - key: "person", - header: "Person", - width: proportional(3), - renderCell: (r) => ( - <> - - {r.identities > 1 && {r.identities} addresses} - - ), - }, - ...COLUMNS.map( - (c): TableColumn => ({ - key: c.key, - header: {c.label}, - align: "end", - sortable: true, - width: pixel(118), - renderCell: (r) => {c.shown(r)}, - }), - ), - { - key: "span", - header: "First and last commit", - width: pixel(210), - renderCell: (r) => ( - - {date(r.first)} – {date(r.last)} - - ), - }, - ]; + if (!sort.down) rows.reverse(); + const most = Math.max(1, ...data.people.map((r) => r.commits)); + const commits = data.people.reduce((n, r) => n + r.commits, 0); + const by = (key: string) => setSort((s) => (s.key === key ? { key, down: !s.down } : { key, down: true })); return ( -
-
+ {extra} + = 1000 ? `The ${grouped(data.people.length)} with most commits` : many(data.people.length, "person", "people")}. No single score: each column is its own measure. Click a heading to sort, a name for what they did here.`} > -
- - className="people-table" - data={rows.slice(0, 300) as Row[]} - columns={columns} - idKey={(r: Row) => r.person.id} - density="compact" - hasHover - plugins={{ sortable }} - /> + {data.people.length === 0 ? ( +
+ Nobody committed in this Window. Choose a longer one above. +
+ ) : ( +
+ + + + + + {columns.map((c) => ( + + ))} + + + + + {rows.slice(0, shown).map((r) => ( + + ))} + +
People
+ Person + + + + First and last commit +
+
+ )} + {rows.length > shown && ( +
+
+ )} +
+ +
    + {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)}

+
- {rows.length > 300 &&

The 300 with the most in this column are shown.

} - -
    - {COLUMNS.map((c) => ( -
  • - {c.label}: {c.why} -
  • - ))} -
- {meta.lines !== "counted" &&

Lines are {meta.lines === "counting" ? "being counted" : "not counted here"}.

} - {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.slice(0, suspects).map((g) => ( +
    • p.id).join("-")} className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md 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. -
+ + {data.suspects.length > suspects && ( +
+
+ )} + 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)} + + ); } @@ -191,110 +256,255 @@ const TRAITS: Record = { function Profile({ params, go, id }: ScreenProps & { id: number }) { const { data: p, error, stale } = useData("/api/person", { ...params, id }); - const avatars = useContext(AvatarsContext); const [copied, setCopied] = useState(false); + const login = useLogin(p?.person); + const standing = useStanding(login); const open = openers(go); - if (error) return

{error}

; - if (!p) return ; + 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 ( -
-
-
-
- {avatars && p.person.login && } -
- - {p.person.name} - -

- {p.person.login && <>@{p.person.login} · } - {p.email} -

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

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

+
-
+ {standing &&
+ + + {(p.areas.length > 0 || p.addresses.length > 0 || p.traits.length > 0 || p.mailmap) && ( +
0 && (p.addresses.length > 0 || p.traits.length > 0 || p.mailmap) ? "lg:grid-cols-2" : ""}`}> + {p.areas.length > 0 && ( + + ({ + key: a.folder, + label: {a.folder}, + lead: , + value: a.theirs / Math.max(1, a.all), + shown: `${grouped(a.theirs)} of ${many(a.all, "commit", "commits")}`, + colour: personColour(p.person.colour), + onClick: () => open.folder(a.folder === "(root)" ? "" : a.folder), + }))} + /> + + )} + {(p.addresses.length > 0 || p.traits.length > 0 || p.mailmap) && ( + 0 ? "The addresses joined into this person, and why" : "How their commits were joined into one person"} className="h-full [&>*]:h-full"> + {p.addresses.length > 0 && ( +
    + {p.addresses.map((a) => ( +
  • + + {many(a.commits, "commit", "commits")} +
  • + ))} +
+ )} + {p.traits.map((t) => ( +

+ {TRAITS[t] ?? t} +

+ ))} + {p.mailmap && ( +
+

To make this merge permanent for everyone, add these lines to the repository's .mailmap:

+
{p.mailmap}
+
+
+
+ )} + Addresses are joined only on strong evidence: a .mailmap, the same GitHub account, or the same full name. +
+ )} +
+ )} + + ); +} + +const WORK_SHOWN = 10; +const FOLDERS_COLOURED = 5; + +type WorkFolder = { path: string; files: number; commits: number; colour: string }; + +function foldersOf(work: PathCount[]): WorkFolder[] { + const by = new Map(); + for (const w of work) { + const f = folderOf(w.path); + const seen = by.get(f) ?? { files: 0, commits: 0 }; + seen.files += 1; + seen.commits += w.commits; + by.set(f, seen); + } + return [...by.entries()] + .sort((a, b) => b[1].commits - a[1].commits || b[1].files - a[1].files) + .map(([path, v], i) => ({ path, ...v, colour: i < FOLDERS_COLOURED ? (SERIES[i] ?? COLOR.other) : COLOR.other })); +} + +function WorkOn({ work, commits, span, onFile }: { work: PathCount[]; commits: number; span: string; onFile: (path: string) => void }) { + const tip = useTip(); + const [all, setAll] = useState(false); + const folders = foldersOf(work); + const colourOf = new Map(folders.map((f) => [f.path, f.colour])); + const most = Math.max(1, ...work.map((w) => w.commits)); + const whole = Math.max(1, folders.reduce((n, f) => n + f.commits, 0)); + const named = folders.slice(0, FOLDERS_COLOURED); + const rest = folders.slice(FOLDERS_COLOURED); + const legend = [...named, ...(rest.length > 0 ? [{ path: `${many(rest.length, "other folder", "other folders")}`, files: rest.reduce((n, f) => n + f.files, 0), commits: rest.reduce((n, f) => n + f.commits, 0), colour: COLOR.other }] : [])]; + const shown = all ? work : work.slice(0, WORK_SHOWN); + return ( + + {work.length === 0 ? ( + No files changed in this Window. Choose a longer one above. + ) : ( +
+ {folders.length > 1 && ( +
+
+ {legend.map((f) => ( + + {f.path || "the top folder"} +
+ {many(f.files, "file", "files")} of these · {share(f.commits, whole)} of their changes to them +
+ , + )} + /> + ))} +
+
    + {legend.map((f) => ( +
  • + + + {f.path || "(root)"} + + {many(f.files, "file", "files")} +
  • + ))} +
+
+ )} +
    + {shown.map((w, i) => ( + ))} - - +
+ {work.length > WORK_SHOWN && ( +
+
+ )} +
)} + + Each file's bar compares it with the file they changed most; beneath its commits is the share of all their commits {span} that changed it. The bar above sizes each folder by their commits to these files, so a commit that changed two of them counts twice. Lockfiles and generated files are left out. + +
+ ); +} -
-
    - {p.addresses.map((a) => ( -
  • - {a.email} {many(a.commits, "commit", "commits")} -
  • - ))} -
- {p.traits.map((t) => ( -

- {TRAITS[t] ?? t} -

- ))} - {p.mailmap && ( - <> -

To make this merge permanent for everyone, add these lines to the repository's .mailmap:

-
{p.mailmap}
-
-
+function WorkRow({ top, place, path, value, commits, most, colour, onOpen }: { top: boolean; place: number; path: string; value: number; commits: number; most: number; colour: string; onOpen: (path: string) => void }) { + const folder = folderOf(path); + const name = path.slice(folder.length); + const part = value / most; + return ( +
  • + +
  • + ); +} + +function PersonNumbers({ r, streak }: { r: PersonRow; streak: number | null }) { + const cells: { id: string; value: string; label: string; note?: string }[] = [ + { id: "commits", value: grouped(r.commits), label: "commits", note: `${date(r.first)} – ${date(r.last)}` }, + { id: "days", value: grouped(r.active_days), label: "active days", note: `${share(r.active_days, Math.max(1, Math.round((r.last - r.first) / 86_400) + 1))} of the days in that span` }, + ...(streak !== null ? [{ id: "streak", value: many(streak, "day", "days"), label: "longest streak", note: "days in a row with a commit" }] : []), + ...(r.lines_added !== null ? [{ id: "added", value: compact(r.lines_added), label: "lines added", note: `${compact(r.lines_removed ?? 0)} removed` }] : []), + ...(r.prs_merged !== null ? [{ id: "prs", value: grouped(r.prs_merged), label: "PRs merged", note: r.hours_to_merge !== null ? `${hoursWords(r.hours_to_merge)} to merge, typically` : undefined }] : []), + ...(r.reviews !== null ? [{ id: "reviews", value: grouped(r.reviews), label: "reviews given" }] : []), + ]; + const columns = Math.min(7, Math.max(4, cells.length)) as 4 | 5 | 6 | 7; + return ( + + {cells.map((c) => ( + + ))} + ); } 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/screens/WhereWork.tsx b/packages/ui/src/screens/WhereWork.tsx new file mode 100644 index 0000000..95f0ae3 --- /dev/null +++ b/packages/ui/src/screens/WhereWork.tsx @@ -0,0 +1,280 @@ +import { useMemo, useState } from "react"; +import { SegmentedControl, SegmentedControlItem } from "@astryxdesign/core/SegmentedControl"; +import { Spinner } from "@astryxdesign/core/Spinner"; +import { ChevronRight, File, Folder, House, Map as MapIcon } from "lucide-react"; +import type { MapBlock, MapLevel, Params } from "@commitscape/data"; +import { arcsOf, Sunburst, type SunNode } from "../charts/Sunburst"; +import { Face } from "../components/Face"; +import { useLogin } from "../components/login"; +import { useLazyData } from "../data"; +import { Explain } from "../explain"; +import { compact, date, grouped, many, share } from "../format"; +import { Panel } from "../kit/layout"; +import { Quiet } from "./kit"; + +type Size = "churn" | "lines"; + +const LISTED = 8; +const DEEPEST = 2; + +const depthOf = (path: string) => path.split("/").filter(Boolean).length; +const topOf = (path: string) => (path ? `${path.split("/")[0]}/` : null); +const parentOf = (path: string) => { + const parts = path.split("/").filter(Boolean); + return parts.length <= 1 ? "" : `${parts.slice(0, -1).join("/")}/`; +}; + +function learn(into: Map, blocks: Map, level: MapLevel | null) { + if (!level) return; + into.set(level.path, level.children); + for (const b of level.children) { + blocks.set(b.path, b); + if (!b.file && b.inside.length > 0) { + into.set(b.path, b.inside); + for (const c of b.inside) blocks.set(c.path, c); + } + } +} + +/** Where the work is, as a sunburst of folders sized by their commits in the Window (or their lines), entered a ring at a time, with a list of the level beside it. */ +export function WhereWork({ root, name, params, span, commits, onFile, onFolder }: { root: MapLevel | null; name: string; params: Params; span: string; commits: number; onFile: (path: string) => void; onFolder: (path: string) => void }) { + const paramsKey = JSON.stringify(params); + const [chosen, setChosen] = useState({ at: paramsKey, path: "" }); + const focus = chosen.at === paramsKey ? chosen.path : ""; + const [direction, setDirection] = useState<1 | -1>(1); + const [hover, setHover] = useState(null); + const changed = (root?.children ?? []).some((b) => b.churn > 0); + const [size, setSize] = useState("churn"); + const by: Size = changed ? size : "lines"; + const top = topOf(focus); + const deeper = useLazyData(top ? "/api/map" : null, { ...params, path: top ?? "" }); + const { lists, blocks } = useMemo(() => { + const lists = new Map(); + const blocks = new Map(); + learn(lists, blocks, root); + if (deeper.data && deeper.data.path === top) learn(lists, blocks, deeper.data); + return { lists, blocks }; + }, [root, deeper.data, top]); + + if (!root) return null; + const value = (b: MapBlock) => (by === "churn" ? b.churn : b.lines); + const here = focus ? blocks.get(focus) : null; + const whole = here ? value(here) : by === "churn" ? commits : root.children.reduce((n, b) => n + b.lines, 0); + const unit = by === "churn" ? ["commit", "commits"] : ["line", "lines"]; + const amount = (n: number) => (by === "churn" ? many(n, unit[0] ?? "", unit[1] ?? "") : `${compact(n)} ${n === 1 ? "line" : "lines"}`); + const inside = focus ? `of ${focus}` : "of all"; + + const nodesOf = (path: string, rings: number, colourOf: (i: number) => string): SunNode[] | null => { + const list = lists.get(path); + if (!list) return null; + return [...list] + .filter((b) => value(b) > 0) + .sort((a, b) => value(b) - value(a)) + .map((b, i) => { + const colour = colourOf(i); + const open = !b.file && depthOf(b.path) <= DEEPEST; + return { + key: b.path, + name: b.file ? b.name : `${b.name}/`, + value: value(b), + colour, + file: b.file, + open, + inside: rings > 1 && !b.file ? nodesOf(b.path, rings - 1, () => colour) : null, + tip: ( + <> + {b.path} +
    + {amount(value(b))} · {share(value(b), whole)} {inside} +
    +
    + {by === "churn" ? `${compact(b.lines)} lines` : many(b.churn, "commit", "commits")} + {b.file ? "" : ` in ${many(b.files, "file", "files")}`} + {b.last_touched > 0 ? `, last changed ${date(b.last_touched)}` : ""} +
    + {b.owner &&
    most commits by {b.owner.name}
    } +
    {b.file ? "click to open it on the Map" : open ? "click to go inside" : "the Report keeps no deeper folders"}
    + + ), + }; + }); + }; + + const nodes = nodesOf(focus, 2, (i) => `var(--s${(i % 8) + 1})`); + const more = (count: number, v: number, parent: SunNode | null): SunNode => ({ + key: `${parent?.key ?? focus}…`, + name: `${count} more`, + value: v, + colour: "var(--other)", + file: false, + open: false, + inside: null, + tip: ( + <> + + {count} smaller {count === 1 ? "one" : "ones"} + +
    {amount(v)} between them
    + + ), + }); + const arcs = nodes ? arcsOf(nodes, more, LISTED) : []; + const go = (path: string) => { + setDirection(path.length >= focus.length ? 1 : -1); + setHover(null); + setChosen({ at: paramsKey, path }); + }; + const pick = (n: SunNode) => { + if (n.file) onFile(n.key); + else if (n.open) go(n.key); + }; + const lit = hover ? arcs.find((a) => a.node.key === hover)?.node : null; + const block = lit ? blocks.get(lit.key) : null; + const crumbs = [{ name, path: "" }]; + let at = ""; + for (const part of focus.split("/").filter(Boolean)) { + at += `${part}/`; + crumbs.push({ name: part, path: at }); + } + const listed = nodes ?? []; + const rest = listed.slice(LISTED); + const loading = nodes === null && deeper.data?.path !== top; + + return ( + setSize(v as Size)}> + + + + ) : undefined + } + > +
    + + {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-md px-2 py-1.5 text-start transition-[background-color,opacity] duration-(--duration-fast) ${lit ? "bg-sunken" : ""} ${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/screens/kit.tsx b/packages/ui/src/screens/kit.tsx new file mode 100644 index 0000000..fe4fa09 --- /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-md 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/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/standings/view.tsx b/packages/ui/src/standings/view.tsx new file mode 100644 index 0000000..6735657 --- /dev/null +++ b/packages/ui/src/standings/view.tsx @@ -0,0 +1,191 @@ +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 { ICON, MEDAL } from "../design/tokens"; +import { Panel } from "../kit/layout"; +import { STAGGER } from "../motion"; + +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); + 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 ( + 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, 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 ? MEDAL[place.place - 1] : null; + const body = ( + <> + {medal ? {place?.place} : (place?.place ?? "—")} + + + + {r.name} + {alias && r.login && @{r.login}} + {r.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 f62bc7c..b544859 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,59 +1,70 @@ -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 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 }, + }, + }, }); -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 }; -function subscribe(onChange: () => void): () => void { - listeners.add(onChange); - window.addEventListener("storage", onChange); - return () => { - listeners.delete(onChange); - window.removeEventListener("storage", onChange); - }; +/** The theme's CSS, in the layers Astryx would inject it into, for a `

    @@ -16,11 +16,12 @@