Home leads with the daily signal; the workspace overview threads spend to the work
Home opens with the past week in four figures (agent-authored merges, spend per agent merge, stalled changes, agent rework rate), each against the week before and drawn over ten. Then the daily signal: the finding that matters most today as the page's headline, and the others under it. Each finding is a sentence with its evidence, a link to where to see it, and Ask g1t: - work items waiting on one unreviewed change - agents' drafts waiting days for review - a milestone shipped against its due date - where the quarter's agent spend is going - stalled changes and agent rework moving - who is moving into a project one person has carried The box that asks g1t sits under the findings. The workspace overview opens with the same week, then: - the quarter's spend threaded through milestone, work item, change set and pull request, sized by spend and shaded by agent share - each team's last two weeks, day by day, with what waits on a first review - quiet changes: knowledge silos, new ground and new habits Everything is worked out in lib/signals.ts from workspace_signals and usage, tested on its own, and compared only with the workspace's own last weeks. The Home and Workspaces guides say how each figure is counted. Part of #157
- G1t-Session:
- ses_01m57128wre9bvvj1y4z4b8dk0
| 1 | 1 | --- | |
| 2 | 2 | title: Home | |
| 3 | − | description: Your workspace's front page. Ask g1t what to do, where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out. | |
| 3 | + | description: Your workspace's front page. The past week in four figures, the daily signal (what your work says today, with the evidence), where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **Home** is the first page of your workspace, at `/<workspace>/-/home`. | |
| 7 | − | It opens with **What are we building today?** and a box that | |
| 8 | − | [asks g1t](#ask-from-home). Under it, the main column is read top to | |
| 7 | + | Across the top is [the past week](#the-past-week) in four figures, each | |
| 8 | + | against the week before. Under it, the main column is read top to | |
| 9 | 9 | bottom: | |
| 10 | 10 | ||
| 11 | − | 1. **Where you're needed**: everything waiting on you, however long ago | |
| 11 | + | 1. **[The daily signal](#the-daily-signal)**: what the workspace's work | |
| 12 | + | says today. Its headline is the finding that matters most, and the | |
| 13 | + | others follow, each a sentence with its evidence and where to see it. | |
| 14 | + | Under them is a box that [asks g1t](#ask-from-home) about any of it. | |
| 15 | + | 2. **Where you're needed**: everything waiting on you, however long ago | |
| 12 | 16 | it started, with the action beside each item. Its first row is | |
| 13 | 17 | [Start here](#start-here), the one item to do first, and why. | |
| 14 | − | 2. **Since you were last here**: one card for the span. It shows what people | |
| 18 | + | 3. **Since you were last here**: one card for the span. It shows what people | |
| 15 | 19 | did, what changed in plain English when you ask for it, and what landed, | |
| 16 | 20 | each change with whether it is live yet. | |
| 17 | 21 | ||
| ⋯ | |||
| 21 | 25 | - **[Spent](#spent)**: what the span cost, and the month so far. | |
| 22 | 26 | - **[Settled for you](#settled-for-you)**: what finished by itself. | |
| 23 | 27 | ||
| 24 | − | On a phone it is one column: Where you're needed, Running now, the span, | |
| 25 | − | then Spent and Settled for you. | |
| 28 | + | On a phone it is one column: the past week, the daily signal, Where | |
| 29 | + | you're needed, Running now, the span, then Spent and Settled for you. | |
| 26 | 30 | ||
| 27 | 31 | For a workspace's owners, Home also opens | |
| 28 | 32 | [Getting started](/guides/workspaces/#getting-started), the setup guide, | |
| ⋯ | |||
| 30 | 34 | of Home opens it again at any time, as `?setup=1` does. | |
| 31 | 35 | ||
| 32 | 36 | Every number on the page comes from what g1t has recorded. Nothing is | |
| 33 | − | estimated. When a part of g1t does not answer, the section it feeds says | |
| 37 | + | estimated, and nothing compares your workspace or its teams with anyone | |
| 38 | + | else's: each figure is set against your own last weeks. When a part of g1t does not answer, the section it feeds says | |
| 34 | 39 | so, and the rest of the page still shows. Only what you ask g1t is written | |
| 35 | 40 | by a model: [What changed](#what-changed) and the answers in Ask. | |
| 36 | 41 | ||
| 42 | + | ## The past week | |
| 43 | + | ||
| 44 | + | Four figures for the last seven UTC days. Each shows how it moved since the | |
| 45 | + | seven days before (green when that is good news, red when it is not) and a | |
| 46 | + | line over the last ten weeks. Point at the line, or focus it and use the | |
| 47 | + | arrow keys, to read any week. Each figure opens the page behind it. | |
| 48 | + | ||
| 49 | + | | Figure | What it counts | | |
| 50 | + | | --- | --- | | |
| 51 | + | | **Agent-authored merges** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents). | | |
| 52 | + | | **Spend per agent merge** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, divided by the agent pull requests that merged in it: an average, not what any one cost. A dash when usage can't be read or nothing an agent made merged. | | |
| 53 | + | | **Stalled changes** | Pull requests open more than seven days at the end of each week (now, for this one), neither merged nor closed. Drafts count. | | |
| 54 | + | | **Agent rework rate** | Of the agent pull requests that merged in the week, the share a person asked for changes on before they merged. | | |
| 55 | + | ||
| 56 | + | ### Whose work is an agent's | |
| 57 | + | ||
| 58 | + | A pull request is an agent's when g1t's agent opened it, it was opened as | |
| 59 | + | an agent's, or a [session](/guides/sessions/) is tied to it: linked to it, | |
| 60 | + | started for it, or pushing its branch. Its owner is the person who asked | |
| 61 | + | for it, else its author, so an agent's work counts toward the person it | |
| 62 | + | was for. | |
| 63 | + | ||
| 64 | + | ## The daily signal | |
| 65 | + | ||
| 66 | + | First the day, then one sentence for the finding that matters most today, | |
| 67 | + | then the others. Each finding names its evidence in a line of figures, | |
| 68 | + | links to where you can see it, and has **Ask g1t**, which asks | |
| 69 | + | [Ask](/guides/ask/) to look into it. A finding with a trend has its line | |
| 70 | + | beside it. When nothing stands out, the headline says what the week did. | |
| 71 | + | ||
| 72 | + | | Finding | When it shows | | |
| 73 | + | | --- | --- | | |
| 74 | + | | **Work items waiting on one unreviewed change** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. | | |
| 75 | + | | **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. | | |
| 76 | + | | **A milestone shipped** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 77 | + | | **Where agent spend is going** | More than a third of the quarter's agent usage by pull request went into one milestone (else one work item, else one project). | | |
| 78 | + | | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. | | |
| 79 | + | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 80 | + | | **Who works where** | Someone started merging to a project one person has carried (three quarters of its last 90 days' merges), or merged to a project for the first time this week after work elsewhere. | | |
| 81 | + | ||
| 82 | + | Findings are ordered by how much they hold up: blocked work first, then | |
| 83 | + | waits, then what shipped and where spend went. Up to five show, and the | |
| 84 | + | rest are one click away. The same reads, at more length, are on the | |
| 85 | + | [workspace overview](/guides/workspaces/#the-workspace-overview). | |
| 86 | + | ||
| 37 | 87 | ## Ask from Home | |
| 38 | 88 | ||
| 39 | − | The box at the top asks g1t anything about your work. Type a question and | |
| 89 | + | The box under the daily signal asks g1t anything about your work. Type a question and | |
| 40 | 90 | press **Enter** (**Shift+Enter** starts a new line): the top bar's | |
| 41 | 91 | [Ask](/guides/ask/) panel opens and sends it, and the answer streams in | |
| 42 | 92 | there. Under the box are questions to start with: | |
| ⋯ | |||
| 341 | 391 | A member whose membership does not include Code sees Home without anything | |
| 342 | 392 | from Code: | |
| 343 | 393 | ||
| 394 | + | - The past week and the daily signal are not shown. The page opens with | |
| 395 | + | **What are we building today?** and the box that asks g1t. | |
| 344 | 396 | - People shows what was said and the artifacts edited; nothing from | |
| 345 | 397 | repositories. | |
| 346 | 398 | - Where you're needed has no rows from Code, and no notifications about a | |
| ⋯ | |||
| 362 | 414 | deployments or sessions can't be read, Landed shows its rows without | |
| 363 | 415 | their live labels or agent markers. | |
| 364 | 416 | - Settled for you says when your notifications can't be read. | |
| 417 | + | - The daily signal says when the work couldn't be read, and the past week | |
| 418 | + | is left out. When some projects didn't answer, it says its findings | |
| 419 | + | leave them out. | |
| 365 | 420 | - What changed says when Ask can't be reached or isn't set up, or when | |
| 366 | 421 | the question could not be sent. | |
| 367 | 422 | - If your last visit can't be read, the span is the last 24 hours and the | |
| ⋯ | |||
| 409 | 464 | `until`) returns `messages`, `channels` (conversations with at least one) | |
| 410 | 465 | and `authors` (`key`, `messages`; most first), over text messages and | |
| 411 | 466 | thread replies not deleted in the conversations the viewer can read. | |
| 467 | + | ||
| 468 | + | **`workspace_signals`** on the work service takes `workspace`, | |
| 469 | + | `repo_ids` (at most 100), `viewer`, `until` (a UTC day), `weeks` (1 to | |
| 470 | + | 13), `days` (1 to 28) and `since` (a UTC day, at most 400 days before | |
| 471 | + | `until`). For the repositories the viewer can read, it returns: | |
| 472 | + | ||
| 473 | + | - `weeks`: per repository and week of seven days ending with `until`, | |
| 474 | + | `merged`, `agent_merged`, `agent_reworked`, `agent_drafted` and | |
| 475 | + | `agent_draft_seconds`, `reviewed` and `review_seconds`, and `stalled`. | |
| 476 | + | - `days`: per repository and day, `opened`, `merged`, `agent_merged`, | |
| 477 | + | `reworked`, `reviews` and `sessions`. | |
| 478 | + | - `waiting`: open pull requests with no first review, oldest first, at | |
| 479 | + | most 50, each with its work item and the open work items it `blocks`. | |
| 480 | + | - `pulls`: those opened or merged since `since`, at most 3,000 newest | |
| 481 | + | first, each with its owner, work item and milestone; `pulls_capped` | |
| 482 | + | when there were more. | |
| 483 | + | - `authors`: per repository and owner, their merges over its history and | |
| 484 | + | in the last 90 days. | |
| 485 | + | - The `items` and `milestones` those name. | |
| 486 | + | ||
| 487 | + | Rows are per repository, so a caller asking in batches adds them up. | |
| 671 | 671 | says so and shows a dash in place of numbers it cannot vouch for. **Explore | |
| 672 | 672 | costs** opens [Usage](/guides/usage-and-billing/#the-usage-page) with the same period and project. | |
| 673 | 673 | ||
| 674 | + | ### The workspace overview | |
| 675 | + | ||
| 676 | + | The workspace's **Overview**, `g1t.sh/<workspace>/-/workspace`, opens with | |
| 677 | + | what its work says, for members who can see Code. It is worked out the | |
| 678 | + | same way as [Home's daily signal](/guides/home/#the-daily-signal), and | |
| 679 | + | each person sees only the projects they can read. | |
| 680 | + | ||
| 681 | + | 1. **[The past week](/guides/home/#the-past-week)**: the same four | |
| 682 | + | figures as Home. | |
| 683 | + | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | + | each milestone, its work items, each work item's change set in each | |
| 685 | + | project, and that change set's pull requests. Each box is sized by | |
| 686 | + | what the agents' usage on its pull requests came to, and shaded by how | |
| 687 | + | many were an agent's (**Agent use**, less to more). Choose a box to | |
| 688 | + | open what is under it. Work with no milestone or work item has its own | |
| 689 | + | box. When usage can't be read, boxes are sized by pull requests. | |
| 690 | + | 3. **What's moving and what's waiting**: each team's last two weeks, day | |
| 691 | + | by day, by the projects the team works on (each project, when the | |
| 692 | + | workspace has no teams). Above the line are pull requests opened and | |
| 693 | + | merged, and agent sessions started; below it, reviews and comments and | |
| 694 | + | requests for changes. Turn each layer on or off above the rows, point | |
| 695 | + | at a day for its numbers, or open **The numbers, as a table**. Each | |
| 696 | + | row is drawn to its own scale. Beside it, **Waiting on a first | |
| 697 | + | review** lists the oldest open pull requests no one has reviewed. | |
| 698 | + | 4. **Quiet changes**: places to look, not verdicts. | |
| 699 | + | - **Knowledge silos**: projects where one person merged three quarters | |
| 700 | + | or more of the last 90 days' changes (six or more), and whoever has | |
| 701 | + | started there this month. | |
| 702 | + | - **New ground**: people who merged to a project for the first time in | |
| 703 | + | the last two weeks, after three or more merges elsewhere. | |
| 704 | + | - **New habits**: people whose first agent-made change merged in the | |
| 705 | + | last two weeks, after three or more of their own. | |
| 706 | + | ||
| 707 | + | Under it are the members and projects, the month's usage against the | |
| 708 | + | plan, and the workspace's policies. | |
| 709 | + | ||
| 674 | 710 | ## The rail, the sidebar and the page | |
| 675 | 711 | ||
| 676 | 712 | Signed in, g1t is always about one workspace: the one you are in. On a | |
| ⋯ | |||
| 820 | 856 | ||
| 821 | 857 | | Group | Page | Who | | | |
| 822 | 858 | | --- | --- | --- | --- | | |
| 823 | − | | | **Overview** | Members | The workspace at a glance: members, plan and spend. `g1t.sh/<workspace>/-/workspace` | | |
| 859 | + | | | **Overview** | Members | What the work says (the past week, spend threaded to the work, what is moving and waiting, quiet changes), then members, plan and spend. `g1t.sh/<workspace>/-/workspace` | | |
| 824 | 860 | | **General** | **General** | Owners | The icon, the display name, a one-line description, the address (the slug), [requiring two-factor authentication](#require-two-factor-authentication), [data residency](#data-residency), and deleting the workspace. `-/settings` | | |
| 825 | 861 | | **Access** | **Members** | Members | Who is in the workspace and their roles, with search and filters; owners [invite people](#add-people), change roles, and see **Outside collaborators**. `-/members` | | |
| 826 | 862 | | | **Invitations** | Owners | Every invitation to join, filtered by where it stands; send one again, revoke it, copy its link. `-/members/invitations` | | |
| 33 | 33 | return span.key === "last" && span.note == null ? "since I was last here" : `${span.words.charAt(0).toLowerCase()}${span.words.slice(1)}`; | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | − | /** The top of Home: what are we building today, a box that asks g1t, and a few things to ask first. */ | |
| 37 | − | export function HomeAsk({ span }: { span: AskSpan }) { | |
| 36 | + | /** | |
| 37 | + | * Home's box that asks g1t, with a few things to ask first: under the | |
| 38 | + | * daily signal, `compact`, it says to ask about any of it; on its own, it | |
| 39 | + | * leads the page with what are we building today. | |
| 40 | + | */ | |
| 41 | + | export function HomeAsk({ span, compact = false }: { span: AskSpan; compact?: boolean }) { | |
| 38 | 42 | const [draft, setDraft] = useState(""); | |
| 39 | 43 | const send = () => { | |
| 40 | 44 | if (!draft.trim()) return; | |
| ⋯ | |||
| 52 | 56 | } | |
| 53 | 57 | }; | |
| 54 | 58 | return ( | |
| 55 | − | <section aria-labelledby="home-ask" className="mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8"> | |
| 56 | − | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 57 | − | What are we building today? | |
| 58 | − | </h1> | |
| 59 | + | <section aria-labelledby="home-ask" className={compact ? "flex w-full flex-col gap-3" : "mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8"}> | |
| 60 | + | {compact ? ( | |
| 61 | + | <h2 id="home-ask" className="text-sm font-semibold text-fg"> | |
| 62 | + | Ask about any of this | |
| 63 | + | </h2> | |
| 64 | + | ) : ( | |
| 65 | + | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 66 | + | What are we building today? | |
| 67 | + | </h1> | |
| 68 | + | )} | |
| 59 | 69 | <form onSubmit={submit} className="w-full"> | |
| 60 | 70 | <div className="flex items-end gap-2 rounded-2xl border border-line bg-surface p-2 pl-4 shadow-sm transition-shadow focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25"> | |
| 61 | 71 | <MarkGlyph className="mb-2.5 h-4 shrink-0 max-sm:hidden" /> | |
| ⋯ | |||
| 79 | 89 | </Hint> | |
| 80 | 90 | </div> | |
| 81 | 91 | </form> | |
| 82 | − | <Suggestions className="justify-center"> | |
| 92 | + | <Suggestions className={compact ? "" : "justify-center"}> | |
| 83 | 93 | <Suggestion | |
| 84 | 94 | suggestion={`Summarize what changed ${spanLabel(span)}`} | |
| 85 | 95 | onPick={() => window.dispatchEvent(new CustomEvent(SUMMARIZE_EVENT))} | |
| 1 | + | /** | |
| 2 | + | * What is moving and what is waiting (lib/signals.ts rhythm): each team's | |
| 3 | + | * last two weeks, day by day. Above the line, pull requests opened and | |
| 4 | + | * merged (bars) and agent sessions started (a wash); below it, reviews and | |
| 5 | + | * comments (a wash) and requests for changes (bars). Each row is drawn to | |
| 6 | + | * its own scale, so a small team's week is as legible as a big one's; the | |
| 7 | + | * numbers are in the readout and the table. Layers turn on and off above | |
| 8 | + | * the rows, and pointing at a day says what happened on it. | |
| 9 | + | */ | |
| 10 | + | import { useState } from "react"; | |
| 11 | + | import { Link } from "react-router"; | |
| 12 | + | ||
| 13 | + | import { cn } from "../lib/cn"; | |
| 14 | + | import { type RhythmDay, type RhythmRow, rhythmLine, rhythmSum } from "../lib/signals"; | |
| 15 | + | ||
| 16 | + | type Layer = "prs" | "rework" | "agent" | "reviews"; | |
| 17 | + | ||
| 18 | + | const LAYERS: { key: Layer; label: string; swatch: string }[] = [ | |
| 19 | + | { key: "prs", label: "PR activity", swatch: "bg-fg-soft" }, | |
| 20 | + | { key: "rework", label: "Rework", swatch: "bg-faint" }, | |
| 21 | + | { key: "agent", label: "Agent activity", swatch: "bg-series-1" }, | |
| 22 | + | { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" }, | |
| 23 | + | ]; | |
| 24 | + | ||
| 25 | + | const WIDTH = 700; | |
| 26 | + | const HEIGHT = 112; | |
| 27 | + | const MID = HEIGHT / 2; | |
| 28 | + | const BAR = 5; | |
| 29 | + | ||
| 30 | + | /** A smooth path through points, as a closed wash from the baseline. */ | |
| 31 | + | function wash(points: [number, number][]): string { | |
| 32 | + | if (points.length === 0) return ""; | |
| 33 | + | let d = `M${points[0]![0]},${MID} L${points[0]![0]},${points[0]![1]}`; | |
| 34 | + | for (let i = 1; i < points.length; i++) { | |
| 35 | + | const [x0, y0] = points[i - 1]!; | |
| 36 | + | const [x1, y1] = points[i]!; | |
| 37 | + | const cx = (x0 + x1) / 2; | |
| 38 | + | d += ` C${cx},${y0} ${cx},${y1} ${x1},${y1}`; | |
| 39 | + | } | |
| 40 | + | return `${d} L${points[points.length - 1]![0]},${MID} Z`; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | function dayWords(day: string): string { | |
| 44 | + | return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | function Readout({ row, day }: { row: RhythmRow; day: RhythmDay | null }) { | |
| 48 | + | const sum = day ?? rhythmSum(row.days.slice(Math.floor(row.days.length / 2))); | |
| 49 | + | return ( | |
| 50 | + | <div className="min-w-48 rounded-xl border border-line bg-surface px-3.5 py-3 text-xs shadow-lg"> | |
| 51 | + | <p className="font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase"> | |
| 52 | + | {row.label} · {day ? dayWords(day.day) : "this week"} | |
| 53 | + | </p> | |
| 54 | + | <ul className="mt-2 space-y-1 text-fg"> | |
| 55 | + | <li className="flex items-center gap-2"> | |
| 56 | + | <span className="size-2 rounded-full bg-fg-soft" /> | |
| 57 | + | {sum.opened} opened · {sum.merged} merged | |
| 58 | + | </li> | |
| 59 | + | <li className="flex items-center gap-2"> | |
| 60 | + | <span className="size-2 rounded-full bg-faint" /> | |
| 61 | + | {sum.reworked} {sum.reworked === 1 ? "request" : "requests"} for changes | |
| 62 | + | </li> | |
| 63 | + | <li className="flex items-center gap-2"> | |
| 64 | + | <span className="size-2 rounded-full bg-series-4" /> | |
| 65 | + | {sum.reviews} reviews & comments | |
| 66 | + | </li> | |
| 67 | + | <li className="flex items-center gap-2"> | |
| 68 | + | <span className="size-2 rounded-full bg-series-1" /> | |
| 69 | + | {sum.sessions} agent {sum.sessions === 1 ? "session" : "sessions"} | |
| 70 | + | </li> | |
| 71 | + | </ul> | |
| 72 | + | </div> | |
| 73 | + | ); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | function Row({ | |
| 77 | + | row, | |
| 78 | + | on, | |
| 79 | + | active, | |
| 80 | + | faded, | |
| 81 | + | onDay, | |
| 82 | + | }: { | |
| 83 | + | row: RhythmRow; | |
| 84 | + | on: Set<Layer>; | |
| 85 | + | active: number | null; | |
| 86 | + | faded: boolean; | |
| 87 | + | onDay: (day: number | null) => void; | |
| 88 | + | }) { | |
| 89 | + | const n = row.days.length; | |
| 90 | + | const slot = WIDTH / n; | |
| 91 | + | const x = (i: number) => slot * i + slot / 2; | |
| 92 | + | const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged : 0, on.has("agent") ? day.sessions : 0))); | |
| 93 | + | const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0))); | |
| 94 | + | const above = (value: number) => MID - (value / up) * (MID - 6); | |
| 95 | + | const below = (value: number) => MID + (value / down) * (MID - 6); | |
| 96 | + | const day = active != null ? row.days[active]! : null; | |
| 97 | + | return ( | |
| 98 | + | <li className={cn("grid gap-3 py-5 transition-opacity md:grid-cols-[12rem_minmax(0,1fr)] md:gap-6", faded && "opacity-45")}> | |
| 99 | + | <div className="min-w-0"> | |
| 100 | + | <Link to={row.to} className="text-base font-semibold text-fg hover:text-accent"> | |
| 101 | + | {row.label} | |
| 102 | + | </Link> | |
| 103 | + | {row.note && <p className="text-xs text-muted">{row.note}</p>} | |
| 104 | + | <p className="mt-1.5 text-xs text-fg-soft">{rhythmLine(row)}</p> | |
| 105 | + | </div> | |
| 106 | + | <div className="relative"> | |
| 107 | + | <svg | |
| 108 | + | viewBox={`0 0 ${WIDTH} ${HEIGHT}`} | |
| 109 | + | preserveAspectRatio="none" | |
| 110 | + | className="block h-28 w-full overflow-visible" | |
| 111 | + | role="img" | |
| 112 | + | aria-label={`${row.label}: the last ${n} days`} | |
| 113 | + | onMouseLeave={() => onDay(null)} | |
| 114 | + | > | |
| 115 | + | {active != null && <rect x={slot * active} y={0} width={slot} height={HEIGHT} className="fill-accent/8" />} | |
| 116 | + | <line x1={WIDTH / 2} x2={WIDTH / 2} y1={0} y2={HEIGHT} className="stroke-line" strokeWidth={1} vectorEffect="non-scaling-stroke" /> | |
| 117 | + | {on.has("agent") && ( | |
| 118 | + | <path | |
| 119 | + | d={wash(row.days.map((d, i) => [x(i), above(d.sessions)]))} | |
| 120 | + | className="fill-series-1/15 stroke-series-1" | |
| 121 | + | strokeWidth={1.5} | |
| 122 | + | vectorEffect="non-scaling-stroke" | |
| 123 | + | /> | |
| 124 | + | )} | |
| 125 | + | {on.has("reviews") && ( | |
| 126 | + | <path | |
| 127 | + | d={wash(row.days.map((d, i) => [x(i), below(d.reviews)]))} | |
| 128 | + | className="fill-series-4/18 stroke-series-4" | |
| 129 | + | strokeWidth={1.5} | |
| 130 | + | vectorEffect="non-scaling-stroke" | |
| 131 | + | /> | |
| 132 | + | )} | |
| 133 | + | <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" /> | |
| 134 | + | {on.has("prs") && | |
| 135 | + | row.days.map((d, i) => | |
| 136 | + | d.opened + d.merged > 0 ? ( | |
| 137 | + | <rect | |
| 138 | + | key={`p${i}`} | |
| 139 | + | x={x(i) - BAR / 2} | |
| 140 | + | y={above(d.opened + d.merged)} | |
| 141 | + | width={BAR} | |
| 142 | + | height={MID - above(d.opened + d.merged)} | |
| 143 | + | rx={1.5} | |
| 144 | + | className="fill-fg-soft" | |
| 145 | + | /> | |
| 146 | + | ) : null, | |
| 147 | + | )} | |
| 148 | + | {on.has("rework") && | |
| 149 | + | row.days.map((d, i) => | |
| 150 | + | d.reworked > 0 ? ( | |
| 151 | + | <rect key={`r${i}`} x={x(i) - BAR / 2} y={MID} width={BAR} height={below(d.reworked) - MID} rx={1.5} className="fill-faint" /> | |
| 152 | + | ) : null, | |
| 153 | + | )} | |
| 154 | + | {row.days.map((_, i) => ( | |
| 155 | + | <rect key={`h${i}`} x={slot * i} y={0} width={slot} height={HEIGHT} fill="transparent" onMouseEnter={() => onDay(i)} /> | |
| 156 | + | ))} | |
| 157 | + | </svg> | |
| 158 | + | {day && ( | |
| 159 | + | <div | |
| 160 | + | className="pointer-events-none absolute top-0 z-10 -translate-y-1/3" | |
| 161 | + | style={active! < n / 2 ? { left: `${((active! + 1) / n) * 100}%` } : { right: `${((n - active!) / n) * 100}%` }} | |
| 162 | + | > | |
| 163 | + | <Readout row={row} day={day} /> | |
| 164 | + | </div> | |
| 165 | + | )} | |
| 166 | + | </div> | |
| 167 | + | </li> | |
| 168 | + | ); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | export function Rhythm({ rows, by }: { rows: RhythmRow[]; by: "team" | "project" }) { | |
| 172 | + | const [on, setOn] = useState<Set<Layer>>(new Set(["prs", "rework", "agent", "reviews"])); | |
| 173 | + | const [hover, setHover] = useState<{ row: string; day: number } | null>(null); | |
| 174 | + | const toggle = (layer: Layer) => { | |
| 175 | + | const next = new Set(on); | |
| 176 | + | if (next.has(layer)) next.delete(layer); | |
| 177 | + | else next.add(layer); | |
| 178 | + | setOn(next); | |
| 179 | + | }; | |
| 180 | + | if (rows.length === 0) return <p className="text-sm text-muted">Nothing has moved in the last two weeks.</p>; | |
| 181 | + | return ( | |
| 182 | + | <div> | |
| 183 | + | <div className="flex flex-wrap gap-2" role="group" aria-label="What the rows show"> | |
| 184 | + | {LAYERS.map((layer) => ( | |
| 185 | + | <button | |
| 186 | + | key={layer.key} | |
| 187 | + | type="button" | |
| 188 | + | aria-pressed={on.has(layer.key)} | |
| 189 | + | onClick={() => toggle(layer.key)} | |
| 190 | + | className={cn( | |
| 191 | + | "inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs transition-colors", | |
| 192 | + | on.has(layer.key) ? "border-line-strong bg-surface text-fg" : "border-line bg-transparent text-faint", | |
| 193 | + | )} | |
| 194 | + | > | |
| 195 | + | <span className={cn("h-2.5 w-2.5 rounded-sm", on.has(layer.key) ? layer.swatch : "bg-line")} /> | |
| 196 | + | {layer.label} | |
| 197 | + | </button> | |
| 198 | + | ))} | |
| 199 | + | </div> | |
| 200 | + | <div className="mt-5 hidden grid-cols-[12rem_minmax(0,1fr)] gap-6 md:grid"> | |
| 201 | + | <span /> | |
| 202 | + | <div className="grid grid-cols-2 font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase"> | |
| 203 | + | <span>Last week</span> | |
| 204 | + | <span className="pl-2">This week</span> | |
| 205 | + | </div> | |
| 206 | + | </div> | |
| 207 | + | <ul className="divide-y divide-line"> | |
| 208 | + | {rows.map((row) => ( | |
| 209 | + | <Row | |
| 210 | + | key={row.key} | |
| 211 | + | row={row} | |
| 212 | + | on={on} | |
| 213 | + | active={hover?.row === row.key ? hover.day : null} | |
| 214 | + | faded={hover != null && hover.row !== row.key} | |
| 215 | + | onDay={(day) => setHover(day == null ? null : { row: row.key, day })} | |
| 216 | + | /> | |
| 217 | + | ))} | |
| 218 | + | </ul> | |
| 219 | + | <details className="mt-2 text-xs text-muted"> | |
| 220 | + | <summary className="cursor-pointer hover:text-fg">The numbers, as a table</summary> | |
| 221 | + | <table className="mt-3 w-full text-left tabular-nums"> | |
| 222 | + | <thead className="text-faint"> | |
| 223 | + | <tr> | |
| 224 | + | <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th> | |
| 225 | + | <th className="py-1 font-normal">Opened</th> | |
| 226 | + | <th className="py-1 font-normal">Merged</th> | |
| 227 | + | <th className="py-1 font-normal">Rework</th> | |
| 228 | + | <th className="py-1 font-normal">Reviews & comments</th> | |
| 229 | + | <th className="py-1 font-normal">Agent sessions</th> | |
| 230 | + | </tr> | |
| 231 | + | </thead> | |
| 232 | + | <tbody> | |
| 233 | + | {rows.map((row) => { | |
| 234 | + | const half = Math.floor(row.days.length / 2); | |
| 235 | + | const last = rhythmSum(row.days.slice(0, half)); | |
| 236 | + | const now = rhythmSum(row.days.slice(half)); | |
| 237 | + | const cell = (a: number, b: number) => `${b} (${a} last week)`; | |
| 238 | + | return ( | |
| 239 | + | <tr key={row.key} className="border-t border-line text-fg-soft"> | |
| 240 | + | <td className="py-1.5 text-fg">{row.label}</td> | |
| 241 | + | <td>{cell(last.opened, now.opened)}</td> | |
| 242 | + | <td>{cell(last.merged, now.merged)}</td> | |
| 243 | + | <td>{cell(last.reworked, now.reworked)}</td> | |
| 244 | + | <td>{cell(last.reviews, now.reviews)}</td> | |
| 245 | + | <td>{cell(last.sessions, now.sessions)}</td> | |
| 246 | + | </tr> | |
| 247 | + | ); | |
| 248 | + | })} | |
| 249 | + | </tbody> | |
| 250 | + | </table> | |
| 251 | + | </details> | |
| 252 | + | </div> | |
| 253 | + | ); | |
| 254 | + | } |
| 1 | + | /** | |
| 2 | + | * The daily signal on Home (lib/signals.ts): the past week in four | |
| 3 | + | * figures, each against the week before and drawn over ten, and the | |
| 4 | + | * findings worth a look today, each a sentence with its evidence, where | |
| 5 | + | * to see it, and a way to ask g1t about it. | |
| 6 | + | */ | |
| 7 | + | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useId, useState } from "react"; | |
| 9 | + | import { Link } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { askG1t } from "../lib/ask"; | |
| 12 | + | import { cn } from "../lib/cn"; | |
| 13 | + | import type { DailySignal as DailySignalView, PulseTile, Signal } from "../lib/signals"; | |
| 14 | + | import { Skeleton } from "./ui/skeleton"; | |
| 15 | + | ||
| 16 | + | // --- A trend line ------------------------------------------------------------------- | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * A measure over the weeks as a thin line, the latest point marked. Weeks | |
| 20 | + | * with nothing to measure break the line. Pointing at a week (or moving | |
| 21 | + | * along with the arrow keys) says its value through `onPoint`; every | |
| 22 | + | * value is also in a list for screen readers. | |
| 23 | + | */ | |
| 24 | + | export function TrendLine({ | |
| 25 | + | values, | |
| 26 | + | points, | |
| 27 | + | label, | |
| 28 | + | width = 120, | |
| 29 | + | height = 28, | |
| 30 | + | onPoint, | |
| 31 | + | }: { | |
| 32 | + | values: (number | null)[]; | |
| 33 | + | points: string[]; | |
| 34 | + | label: string; | |
| 35 | + | width?: number; | |
| 36 | + | height?: number; | |
| 37 | + | onPoint?: (index: number | null) => void; | |
| 38 | + | }) { | |
| 39 | + | const [active, setActive] = useState<number | null>(null); | |
| 40 | + | const list = useId(); | |
| 41 | + | const known = values.filter((value): value is number => value != null); | |
| 42 | + | if (known.length === 0) return <div style={{ width, height }} aria-hidden />; | |
| 43 | + | const pad = 4; | |
| 44 | + | const min = Math.min(...known); | |
| 45 | + | const max = Math.max(...known); | |
| 46 | + | const step = values.length > 1 ? (width - 2 * pad) / (values.length - 1) : 0; | |
| 47 | + | const x = (i: number) => pad + i * step; | |
| 48 | + | const y = (value: number) => (max === min ? height / 2 : pad + (1 - (value - min) / (max - min)) * (height - 2 * pad)); | |
| 49 | + | // One path, lifted where a week has nothing. | |
| 50 | + | let d = ""; | |
| 51 | + | values.forEach((value, i) => { | |
| 52 | + | if (value == null) return; | |
| 53 | + | d += `${i > 0 && values[i - 1] != null ? "L" : "M"}${x(i).toFixed(1)},${y(value).toFixed(1)}`; | |
| 54 | + | }); | |
| 55 | + | const last = values.length - 1 - [...values].reverse().findIndex((value) => value != null); | |
| 56 | + | const point = (index: number | null) => { | |
| 57 | + | setActive(index); | |
| 58 | + | onPoint?.(index); | |
| 59 | + | }; | |
| 60 | + | const shown = active ?? last; | |
| 61 | + | return ( | |
| 62 | + | <> | |
| 63 | + | <svg | |
| 64 | + | width={width} | |
| 65 | + | height={height} | |
| 66 | + | viewBox={`0 0 ${width} ${height}`} | |
| 67 | + | className="block overflow-visible outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-accent/40" | |
| 68 | + | role="img" | |
| 69 | + | aria-label={label} | |
| 70 | + | aria-describedby={list} | |
| 71 | + | tabIndex={0} | |
| 72 | + | onMouseLeave={() => point(null)} | |
| 73 | + | onBlur={() => point(null)} | |
| 74 | + | onKeyDown={(event) => { | |
| 75 | + | if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; | |
| 76 | + | event.preventDefault(); | |
| 77 | + | const from = active ?? last; | |
| 78 | + | point(Math.max(0, Math.min(values.length - 1, from + (event.key === "ArrowLeft" ? -1 : 1)))); | |
| 79 | + | }} | |
| 80 | + | > | |
| 81 | + | {active != null && <line x1={x(active)} x2={x(active)} y1={0} y2={height} className="stroke-line-strong" strokeWidth={1} />} | |
| 82 | + | <path d={d} fill="none" className="stroke-muted" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> | |
| 83 | + | {values[shown] != null && <circle cx={x(shown)} cy={y(values[shown]!)} r={3.5} className="fill-accent" stroke="var(--color-surface)" strokeWidth={2} />} | |
| 84 | + | {values.map((_, i) => ( | |
| 85 | + | <rect key={i} x={x(i) - step / 2} y={-4} width={Math.max(step, 8)} height={height + 8} fill="transparent" onMouseEnter={() => point(i)} /> | |
| 86 | + | ))} | |
| 87 | + | </svg> | |
| 88 | + | <ul id={list} className="sr-only"> | |
| 89 | + | {points.map((text, i) => ( | |
| 90 | + | <li key={i}>{text}</li> | |
| 91 | + | ))} | |
| 92 | + | </ul> | |
| 93 | + | </> | |
| 94 | + | ); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // --- The pulse ---------------------------------------------------------------------- | |
| 98 | + | ||
| 99 | + | const TONE: Record<"good" | "bad" | "flat", string> = { | |
| 100 | + | good: "bg-success/12 text-success", | |
| 101 | + | bad: "bg-danger/10 text-danger", | |
| 102 | + | flat: "bg-line text-muted", | |
| 103 | + | }; | |
| 104 | + | ||
| 105 | + | const TONE_WORDS: Record<"good" | "bad" | "flat", string> = { good: "better than last week", bad: "worse than last week", flat: "the same as last week" }; | |
| 106 | + | ||
| 107 | + | function Tile({ tile }: { tile: PulseTile }) { | |
| 108 | + | const [point, setPoint] = useState<number | null>(null); | |
| 109 | + | return ( | |
| 110 | + | <div className="group relative flex min-w-0 flex-col gap-2 bg-surface px-4 py-3.5 transition-colors hover:bg-raised"> | |
| 111 | + | <Link to={tile.to} className="truncate text-xs text-muted after:absolute after:inset-0 group-hover:text-fg"> | |
| 112 | + | {tile.label} | |
| 113 | + | </Link> | |
| 114 | + | <div className="flex items-center gap-2"> | |
| 115 | + | <span className="text-2xl leading-none font-semibold tracking-tight text-fg">{tile.value}</span> | |
| 116 | + | {tile.delta && ( | |
| 117 | + | <span className={cn("rounded-full px-1.5 py-0.5 text-[0.6875rem] font-medium tabular-nums", TONE[tile.delta.tone])}> | |
| 118 | + | {tile.delta.text} | |
| 119 | + | <span className="sr-only">, {TONE_WORDS[tile.delta.tone]}</span> | |
| 120 | + | </span> | |
| 121 | + | )} | |
| 122 | + | </div> | |
| 123 | + | {/* Above the link's cover, so pointing at a week says it. */} | |
| 124 | + | <div className="relative"> | |
| 125 | + | <TrendLine values={tile.series} points={tile.points} label={`${tile.label} over the last ${tile.series.length} weeks`} width={132} onPoint={setPoint} /> | |
| 126 | + | </div> | |
| 127 | + | <p className="truncate text-[0.6875rem] text-faint">{point != null ? tile.points[point] : tile.hint}</p> | |
| 128 | + | </div> | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /** The past week in four figures, across the top of Home. */ | |
| 133 | + | export function PulseStrip({ tiles }: { tiles: PulseTile[] }) { | |
| 134 | + | if (tiles.length === 0) return null; | |
| 135 | + | return ( | |
| 136 | + | <section | |
| 137 | + | aria-label="The past week" | |
| 138 | + | className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-line bg-line lg:grid-cols-[6.5rem_repeat(4,minmax(0,1fr))]" | |
| 139 | + | > | |
| 140 | + | <div className="col-span-2 flex items-center bg-surface px-4 py-2 lg:col-span-1 lg:py-0"> | |
| 141 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">Past week</p> | |
| 142 | + | </div> | |
| 143 | + | {tiles.map((tile) => ( | |
| 144 | + | <Tile key={tile.key} tile={tile} /> | |
| 145 | + | ))} | |
| 146 | + | </section> | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | export function PulseSkeleton() { | |
| 151 | + | return ( | |
| 152 | + | <div className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-line bg-line lg:grid-cols-[6.5rem_repeat(4,minmax(0,1fr))]"> | |
| 153 | + | <div className="col-span-2 bg-surface px-4 py-3 lg:col-span-1" /> | |
| 154 | + | {[0, 1, 2, 3].map((i) => ( | |
| 155 | + | <div key={i} className="space-y-2 bg-surface px-4 py-3.5"> | |
| 156 | + | <Skeleton className="h-3 w-28" /> | |
| 157 | + | <Skeleton className="h-6 w-16" /> | |
| 158 | + | <Skeleton className="h-5 w-32" /> | |
| 159 | + | </div> | |
| 160 | + | ))} | |
| 161 | + | </div> | |
| 162 | + | ); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // --- The findings ------------------------------------------------------------------- | |
| 166 | + | ||
| 167 | + | function Facts({ facts }: { facts: string[] }) { | |
| 168 | + | return ( | |
| 169 | + | <p className="font-mono text-[0.75rem] leading-relaxed text-muted"> | |
| 170 | + | {facts.map((fact, i) => ( | |
| 171 | + | <span key={i}> | |
| 172 | + | {i > 0 && <span className="px-1.5 text-faint">·</span>} | |
| 173 | + | {fact} | |
| 174 | + | </span> | |
| 175 | + | ))} | |
| 176 | + | </p> | |
| 177 | + | ); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | function Actions({ signal }: { signal: Signal }) { | |
| 181 | + | return ( | |
| 182 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> | |
| 183 | + | {signal.link && ( | |
| 184 | + | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-sm font-medium text-accent hover:underline"> | |
| 185 | + | {signal.link.label} | |
| 186 | + | <ChevronRight size={14} /> | |
| 187 | + | </Link> | |
| 188 | + | )} | |
| 189 | + | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 190 | + | <MessageSquare size={12} /> | |
| 191 | + | Ask g1t | |
| 192 | + | </button> | |
| 193 | + | </div> | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | function Trend({ trend }: { trend: NonNullable<Signal["trend"]> }) { | |
| 198 | + | const [point, setPoint] = useState<number | null>(null); | |
| 199 | + | return ( | |
| 200 | + | <figure className="space-y-1.5"> | |
| 201 | + | <TrendLine values={trend.values} points={trend.points} label={`${trend.caption}, ${trend.range}`} width={196} height={40} onPoint={setPoint} /> | |
| 202 | + | <figcaption> | |
| 203 | + | <p className="text-xs text-fg-soft">{trend.caption}</p> | |
| 204 | + | <p className="font-mono text-[0.6875rem] text-faint">{point != null ? trend.points[point] : trend.range}</p> | |
| 205 | + | </figcaption> | |
| 206 | + | </figure> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | function Finding({ signal }: { signal: Signal }) { | |
| 211 | + | return ( | |
| 212 | + | <li className={cn("grid gap-4 py-5 first:pt-0 last:pb-0", signal.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 213 | + | <div className="min-w-0 space-y-2"> | |
| 214 | + | <h3 className="font-display text-lg leading-snug text-fg md:text-xl">{signal.headline}</h3> | |
| 215 | + | <Facts facts={signal.facts} /> | |
| 216 | + | <Actions signal={signal} /> | |
| 217 | + | </div> | |
| 218 | + | {signal.trend && ( | |
| 219 | + | <div className="md:pt-1"> | |
| 220 | + | <Trend trend={signal.trend} /> | |
| 221 | + | </div> | |
| 222 | + | )} | |
| 223 | + | </li> | |
| 224 | + | ); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /** Findings under the lead, before "and N more". */ | |
| 228 | + | const SHOWN = 4; | |
| 229 | + | ||
| 230 | + | /** | |
| 231 | + | * The daily signal: the day, the finding that matters most as the page's | |
| 232 | + | * headline, and the others under it. A quiet week still gets a sentence. | |
| 233 | + | * Null `view`: the work service did not answer. | |
| 234 | + | */ | |
| 235 | + | export function DailySignal({ date, view, line, footer }: { date: string; view: DailySignalView | null; line?: ReactNode; footer?: ReactNode }) { | |
| 236 | + | const [more, setMore] = useState(false); | |
| 237 | + | const lead = view?.findings[0] ?? null; | |
| 238 | + | const rest = view?.findings.slice(1) ?? []; | |
| 239 | + | const shown = more ? rest : rest.slice(0, SHOWN); | |
| 240 | + | return ( | |
| 241 | + | <section aria-labelledby="daily-signal" className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 242 | + | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 243 | + | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 244 | + | {view == null ? ( | |
| 245 | + | <> | |
| 246 | + | <h1 id="daily-signal" className="mt-4 font-display text-3xl leading-tight tracking-tight text-fg md:text-[2.5rem]"> | |
| 247 | + | Here's your day. | |
| 248 | + | </h1> | |
| 249 | + | <p className="mt-3 flex items-center gap-1.5 text-sm text-muted"> | |
| 250 | + | <TriangleAlert size={13} className="shrink-0 text-warn" /> | |
| 251 | + | What the work says couldn't be read just now, so today's findings aren't here. | |
| 252 | + | </p> | |
| 253 | + | </> | |
| 254 | + | ) : ( | |
| 255 | + | <> | |
| 256 | + | <h1 | |
| 257 | + | id="daily-signal" | |
| 258 | + | className="mt-4 max-w-3xl font-display text-3xl leading-[1.12] tracking-tight text-balance text-fg md:text-[2.25rem] xl:text-[2.5rem]" | |
| 259 | + | > | |
| 260 | + | {lead?.headline ?? view.quiet} | |
| 261 | + | </h1> | |
| 262 | + | {lead && ( | |
| 263 | + | <div className={cn("mt-4 grid gap-4", lead.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 264 | + | <div className="space-y-2"> | |
| 265 | + | <Facts facts={lead.facts} /> | |
| 266 | + | <Actions signal={lead} /> | |
| 267 | + | </div> | |
| 268 | + | {lead.trend && <Trend trend={lead.trend} />} | |
| 269 | + | </div> | |
| 270 | + | )} | |
| 271 | + | {!view.complete && ( | |
| 272 | + | <p className="mt-3 flex items-center gap-1.5 text-xs text-faint"> | |
| 273 | + | <TriangleAlert size={12} className="shrink-0" /> | |
| 274 | + | Some projects didn't answer, so these findings leave them out. | |
| 275 | + | </p> | |
| 276 | + | )} | |
| 277 | + | </> | |
| 278 | + | )} | |
| 279 | + | {line && <p className="mt-4 text-sm text-muted">{line}</p>} | |
| 280 | + | {shown.length > 0 && ( | |
| 281 | + | <> | |
| 282 | + | <hr className="my-7 border-line" /> | |
| 283 | + | <ul className="divide-y divide-line"> | |
| 284 | + | {shown.map((signal) => ( | |
| 285 | + | <Finding key={signal.key} signal={signal} /> | |
| 286 | + | ))} | |
| 287 | + | </ul> | |
| 288 | + | {rest.length > SHOWN && ( | |
| 289 | + | <button type="button" onClick={() => setMore(!more)} className="mt-5 text-xs text-muted hover:text-fg"> | |
| 290 | + | {more ? "Show fewer" : `${rest.length - SHOWN} more ${rest.length - SHOWN === 1 ? "finding" : "findings"}`} | |
| 291 | + | </button> | |
| 292 | + | )} | |
| 293 | + | </> | |
| 294 | + | )} | |
| 295 | + | {footer && <div className="mt-8 border-t border-line pt-6">{footer}</div>} | |
| 296 | + | </section> | |
| 297 | + | ); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | export function DailySignalSkeleton({ date }: { date: string }) { | |
| 301 | + | return ( | |
| 302 | + | <section className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 303 | + | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 304 | + | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 305 | + | <Skeleton className="mt-5 h-9 w-full max-w-2xl" /> | |
| 306 | + | <Skeleton className="mt-2 h-9 w-2/3 max-w-xl" /> | |
| 307 | + | <Skeleton className="mt-5 h-3 w-64" /> | |
| 308 | + | </section> | |
| 309 | + | ); | |
| 310 | + | } |
| 1 | + | /** | |
| 2 | + | * Spend threaded to the work it produced (lib/signals.ts spendFlow): the | |
| 3 | + | * quarter's milestones, the chosen one's work items, the chosen work | |
| 4 | + | * item's change set in each project, and that change set's pull requests. | |
| 5 | + | * Each box is sized by what the agents' usage on it came to (by its pull | |
| 6 | + | * requests when usage is not known) and shaded by how much of it was an | |
| 7 | + | * agent's. Choosing a box opens what is under it; on a phone the columns | |
| 8 | + | * stack. | |
| 9 | + | */ | |
| 10 | + | import { Bot } from "lucide-react"; | |
| 11 | + | import { type CSSProperties, useState } from "react"; | |
| 12 | + | import { Link } from "react-router"; | |
| 13 | + | ||
| 14 | + | import { cn } from "../lib/cn"; | |
| 15 | + | import { type FlowNode, type FlowPull, agentStep, dollars, percent } from "../lib/signals"; | |
| 16 | + | ||
| 17 | + | /** The five steps of agent use: the accent, from a wash to full. */ | |
| 18 | + | const STEP_FILL = [ | |
| 19 | + | "color-mix(in oklab, var(--color-accent) 10%, var(--color-surface))", | |
| 20 | + | "color-mix(in oklab, var(--color-accent) 24%, var(--color-surface))", | |
| 21 | + | "color-mix(in oklab, var(--color-accent) 42%, var(--color-surface))", | |
| 22 | + | "color-mix(in oklab, var(--color-accent) 68%, var(--color-surface))", | |
| 23 | + | "var(--color-accent)", | |
| 24 | + | ]; | |
| 25 | + | /** Steps deep enough that their text turns: white on the light theme's accent, near black on the dark theme's pale one. */ | |
| 26 | + | const DARK_STEP = 3; | |
| 27 | + | ||
| 28 | + | const HEIGHT = 480; | |
| 29 | + | const GAP = 12; | |
| 30 | + | const MIN_BOX = 76; | |
| 31 | + | /** Columns' left edges and width, in percent of the canvas: the pull requests' column is wider, for their titles. */ | |
| 32 | + | const COLUMN = 19; | |
| 33 | + | const left = (column: number) => column * 23.5; | |
| 34 | + | ||
| 35 | + | type Box = { top: number; height: number }; | |
| 36 | + | ||
| 37 | + | /** Boxes down a column, each at least MIN_BOX tall and the rest shared by weight. */ | |
| 38 | + | function stack(weights: number[]): Box[] { | |
| 39 | + | const n = weights.length; | |
| 40 | + | if (n === 0) return []; | |
| 41 | + | const free = HEIGHT - GAP * (n - 1) - MIN_BOX * n; | |
| 42 | + | const total = weights.reduce((sum, w) => sum + w, 0); | |
| 43 | + | let top = 0; | |
| 44 | + | return weights.map((w) => { | |
| 45 | + | const height = MIN_BOX + (total > 0 ? (w / total) * free : free / n); | |
| 46 | + | const box = { top, height }; | |
| 47 | + | top += height + GAP; | |
| 48 | + | return box; | |
| 49 | + | }); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | const weightOf = (sized: "spend" | "pulls") => (node: { micros: number; pulls: number }) => (sized === "spend" ? node.micros : node.pulls); | |
| 53 | + | ||
| 54 | + | /** A band from a slice of `from` (in column `a`) to all of `to` (in column `a + 1`), as an SVG path in percent across. */ | |
| 55 | + | function band(a: number, from: Box, to: Box): string { | |
| 56 | + | const x0 = left(a) + COLUMN; | |
| 57 | + | const x1 = left(a + 1); | |
| 58 | + | const mid = (x0 + x1) / 2; | |
| 59 | + | return `M${x0},${from.top} C${mid},${from.top} ${mid},${to.top} ${x1},${to.top} L${x1},${to.top + to.height} C${mid},${to.top + to.height} ${mid},${from.top + from.height} ${x0},${from.top + from.height} Z`; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** Slices of a box, one per child, by weight. */ | |
| 63 | + | function slices(box: Box, weights: number[]): Box[] { | |
| 64 | + | const total = weights.reduce((sum, w) => sum + w, 0); | |
| 65 | + | let top = box.top; | |
| 66 | + | return weights.map((w) => { | |
| 67 | + | const height = total > 0 ? (w / total) * box.height : box.height / weights.length; | |
| 68 | + | const slice = { top, height }; | |
| 69 | + | top += height; | |
| 70 | + | return slice; | |
| 71 | + | }); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | function Legend() { | |
| 75 | + | return ( | |
| 76 | + | <div className="flex items-center gap-2 text-xs text-muted"> | |
| 77 | + | <span className="font-medium text-fg">Agent use</span> | |
| 78 | + | <span>less</span> | |
| 79 | + | <span className="flex gap-0.5" aria-hidden> | |
| 80 | + | {STEP_FILL.map((fill, i) => ( | |
| 81 | + | <span key={i} className="h-2.5 w-5 rounded-sm" style={{ background: fill }} /> | |
| 82 | + | ))} | |
| 83 | + | </span> | |
| 84 | + | <span>more</span> | |
| 85 | + | </div> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | function figures(node: FlowNode, sized: "spend" | "pulls"): string { | |
| 90 | + | const share = node.pulls > 0 ? node.agent / node.pulls : 0; | |
| 91 | + | return `${node.pulls} ${node.pulls === 1 ? "PR" : "PRs"} · ${percent(share)} agent${sized === "spend" && node.micros > 0 ? ` · ${dollars(node.micros)}` : ""}`; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | function NodeBox({ | |
| 95 | + | node, | |
| 96 | + | sized, | |
| 97 | + | chosen, | |
| 98 | + | onChoose, | |
| 99 | + | style, | |
| 100 | + | }: { | |
| 101 | + | node: FlowNode; | |
| 102 | + | sized: "spend" | "pulls"; | |
| 103 | + | chosen: boolean; | |
| 104 | + | onChoose?: () => void; | |
| 105 | + | style?: CSSProperties; | |
| 106 | + | }) { | |
| 107 | + | const step = agentStep(node.pulls > 0 ? node.agent / node.pulls : 0); | |
| 108 | + | const dark = step >= DARK_STEP; | |
| 109 | + | return ( | |
| 110 | + | <button | |
| 111 | + | type="button" | |
| 112 | + | onClick={onChoose} | |
| 113 | + | aria-pressed={chosen} | |
| 114 | + | className={cn( | |
| 115 | + | "flex flex-col items-start overflow-hidden rounded-xl px-3.5 py-3 text-left transition-shadow outline-none focus-visible:ring-2 focus-visible:ring-accent/50", | |
| 116 | + | chosen ? "ring-[1.5px] ring-fg/80" : "hover:ring-1 hover:ring-line-strong", | |
| 117 | + | dark ? "text-[light-dark(#ffffff,#141018)]" : "text-fg", | |
| 118 | + | )} | |
| 119 | + | style={{ background: STEP_FILL[step], ...style }} | |
| 120 | + | > | |
| 121 | + | <span className="line-clamp-2 text-sm leading-snug font-semibold">{node.label}</span> | |
| 122 | + | <span className={cn("mt-1 font-mono text-[0.6875rem]", dark ? "text-[light-dark(rgb(255_255_255/0.82),rgb(20_16_24/0.75))]" : "text-fg-soft")}> | |
| 123 | + | {figures(node, sized)} | |
| 124 | + | </span> | |
| 125 | + | </button> | |
| 126 | + | ); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | function PullRow({ pull, style }: { pull: FlowPull; style?: CSSProperties }) { | |
| 130 | + | return ( | |
| 131 | + | <Link to={pull.to} className="flex items-center gap-3 border-b border-line px-3 text-sm last:border-b-0 hover:bg-raised" style={style}> | |
| 132 | + | <span className="shrink-0 font-mono text-xs text-muted">#{pull.number}</span> | |
| 133 | + | <span className="min-w-0 grow truncate font-medium text-fg">{pull.title}</span> | |
| 134 | + | {pull.agent && <Bot size={13} className="shrink-0 text-accent" aria-label="An agent's" />} | |
| 135 | + | </Link> | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | const HEADS = ["Milestone", "Work item", "Change set", "Pull requests"]; | |
| 140 | + | ||
| 141 | + | export function SpendFlow({ flow, sized }: { flow: FlowNode[]; sized: "spend" | "pulls" }) { | |
| 142 | + | const [path, setPath] = useState<string[]>([]); | |
| 143 | + | const weight = weightOf(sized); | |
| 144 | + | // The chosen node at each level: the one asked for, else the biggest. | |
| 145 | + | const columns: FlowNode[][] = [flow]; | |
| 146 | + | const chosen: FlowNode[] = []; | |
| 147 | + | for (let level = 0; level < 3; level++) { | |
| 148 | + | const nodes = columns[level] ?? []; | |
| 149 | + | const pick = nodes.find((node) => node.key === path[level]) ?? nodes[0]; | |
| 150 | + | if (!pick) break; | |
| 151 | + | chosen.push(pick); | |
| 152 | + | if (level < 2) columns.push(pick.children); | |
| 153 | + | } | |
| 154 | + | const pulls = chosen[2]?.list ?? []; | |
| 155 | + | const choose = (level: number, key: string) => setPath([...chosen.slice(0, level).map((node) => node.key), key]); | |
| 156 | + | const boxes = columns.map((nodes) => stack(nodes.map(weight))); | |
| 157 | + | const rowHeight = pulls.length > 0 ? HEIGHT / Math.max(pulls.length, 5) : 0; | |
| 158 | + | const rows: Box[] = pulls.map((_, i) => ({ top: i * rowHeight, height: rowHeight })); | |
| 159 | + | ||
| 160 | + | if (flow.length === 0) return <p className="text-sm text-muted">No pull requests were opened or merged this quarter yet.</p>; | |
| 161 | + | ||
| 162 | + | return ( | |
| 163 | + | <div className="space-y-4"> | |
| 164 | + | <Legend /> | |
| 165 | + | {/* Wide: the four columns, joined. */} | |
| 166 | + | <div className="hidden md:block"> | |
| 167 | + | <div className="relative h-7"> | |
| 168 | + | {HEADS.map((head, column) => ( | |
| 169 | + | <p key={head} className="absolute top-0 font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase" style={{ left: `${left(column)}%` }}> | |
| 170 | + | {head} | |
| 171 | + | </p> | |
| 172 | + | ))} | |
| 173 | + | </div> | |
| 174 | + | <div className="relative" style={{ height: HEIGHT }}> | |
| 175 | + | <svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox={`0 0 100 ${HEIGHT}`} preserveAspectRatio="none" aria-hidden> | |
| 176 | + | {chosen.map((node, level) => { | |
| 177 | + | const index = columns[level]!.indexOf(node); | |
| 178 | + | const from = boxes[level]![index]!; | |
| 179 | + | if (level < 2) { | |
| 180 | + | const children = columns[level + 1]!; | |
| 181 | + | return slices(from, children.map(weight)).map((slice, i) => ( | |
| 182 | + | <path key={`${level}-${i}`} d={band(level, slice, boxes[level + 1]![i]!)} className="fill-line-strong" opacity={0.45} /> | |
| 183 | + | )); | |
| 184 | + | } | |
| 185 | + | return slices( | |
| 186 | + | from, | |
| 187 | + | rows.map(() => 1), | |
| 188 | + | ).map((slice, i) => ( | |
| 189 | + | <path | |
| 190 | + | key={`p-${i}`} | |
| 191 | + | d={band(level, slice, { top: rows[i]!.top + 6, height: rows[i]!.height - 12 })} | |
| 192 | + | className="fill-line-strong" | |
| 193 | + | opacity={0.35} | |
| 194 | + | /> | |
| 195 | + | )); | |
| 196 | + | })} | |
| 197 | + | </svg> | |
| 198 | + | {columns.map((nodes, level) => | |
| 199 | + | nodes.map((node, i) => ( | |
| 200 | + | <NodeBox | |
| 201 | + | key={node.key} | |
| 202 | + | node={node} | |
| 203 | + | sized={sized} | |
| 204 | + | chosen={chosen[level]?.key === node.key} | |
| 205 | + | onChoose={() => choose(level, node.key)} | |
| 206 | + | style={{ position: "absolute", left: `${left(level)}%`, width: `${COLUMN}%`, top: boxes[level]![i]!.top, height: boxes[level]![i]!.height }} | |
| 207 | + | /> | |
| 208 | + | )), | |
| 209 | + | )} | |
| 210 | + | <div | |
| 211 | + | className="absolute rounded-xl border border-line bg-surface" | |
| 212 | + | style={{ left: `${left(3)}%`, width: `${100 - left(3)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }} | |
| 213 | + | > | |
| 214 | + | {pulls.length === 0 ? ( | |
| 215 | + | <p className="px-3 py-4 text-sm text-muted">No pull requests here.</p> | |
| 216 | + | ) : ( | |
| 217 | + | pulls.map((pull, i) => <PullRow key={pull.key} pull={pull} style={{ height: rows[i]!.height }} />) | |
| 218 | + | )} | |
| 219 | + | </div> | |
| 220 | + | </div> | |
| 221 | + | </div> | |
| 222 | + | {/* A phone: each level as a list, the chosen one open. */} | |
| 223 | + | <div className="space-y-5 md:hidden"> | |
| 224 | + | {columns.map((nodes, level) => ( | |
| 225 | + | <div key={level} className="space-y-2"> | |
| 226 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[level]}</p> | |
| 227 | + | <div className="grid gap-2"> | |
| 228 | + | {nodes.map((node) => ( | |
| 229 | + | <NodeBox key={node.key} node={node} sized={sized} chosen={chosen[level]?.key === node.key} onChoose={() => choose(level, node.key)} /> | |
| 230 | + | ))} | |
| 231 | + | </div> | |
| 232 | + | </div> | |
| 233 | + | ))} | |
| 234 | + | <div className="space-y-2"> | |
| 235 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[3]}</p> | |
| 236 | + | <div className="rounded-xl border border-line bg-surface"> | |
| 237 | + | {pulls.map((pull) => ( | |
| 238 | + | <PullRow key={pull.key} pull={pull} style={{ height: 48 }} /> | |
| 239 | + | ))} | |
| 240 | + | </div> | |
| 241 | + | </div> | |
| 242 | + | </div> | |
| 243 | + | </div> | |
| 244 | + | ); | |
| 245 | + | } |
| 1 | + | /** | |
| 2 | + | * The daily signal's reads: the work service's `workspaceSignals` for the | |
| 3 | + | * workspace's repositories, a hundred at a time; the agents' usage for the | |
| 4 | + | * weeks and for the quarter; and, for the Workspace overview, its teams. | |
| 5 | + | * Each comes back on its own, so one that fails leaves only what needs it | |
| 6 | + | * saying so. The shaping is in ./signals.ts. | |
| 7 | + | */ | |
| 8 | + | import type { Repo, RepoPath, Usage, User, WorkspaceSignals } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | import { billing, identity, work } from "./services.server"; | |
| 11 | + | import { type Group, type WeekTotals, mergeSignals, signalsRange, weekStarts, weekTotals, weeklySpend } from "./signals"; | |
| 12 | + | ||
| 13 | + | /** Repositories asked of the work service at once: its limit. */ | |
| 14 | + | const BATCH = 100; | |
| 15 | + | /** Teams the rhythm reads the projects of. */ | |
| 16 | + | const MAX_TEAMS = 8; | |
| 17 | + | ||
| 18 | + | export type SignalData = { | |
| 19 | + | slug: string; | |
| 20 | + | now: number; | |
| 21 | + | range: ReturnType<typeof signalsRange>; | |
| 22 | + | starts: string[]; | |
| 23 | + | repos: Record<string, RepoPath>; | |
| 24 | + | signals: WorkspaceSignals; | |
| 25 | + | /** Every batch answered: otherwise the figures leave some projects out. */ | |
| 26 | + | complete: boolean; | |
| 27 | + | weeks: WeekTotals[]; | |
| 28 | + | /** Usage per week; null when billing did not answer. */ | |
| 29 | + | spend: number[] | null; | |
| 30 | + | quarterUsage: Usage | null; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | async function usageSince(viewer: User, slug: string, day: string): Promise<Usage | null> { | |
| 34 | + | const result = await billing.usage(slug, viewer, `${day}T00:00:00.000Z`).catch(() => null); | |
| 35 | + | return result?.ok ? result.value : null; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** | |
| 39 | + | * What the workspace's work says, for a page drawn at `now`. Null when | |
| 40 | + | * the work service answered none of the batches: the page then says so. | |
| 41 | + | */ | |
| 42 | + | export async function loadSignalData(viewer: User, slug: string, now: number, owned: Repo[] | null): Promise<SignalData | null> { | |
| 43 | + | if (!owned) return null; | |
| 44 | + | const range = signalsRange(now); | |
| 45 | + | const starts = weekStarts(range.until, range.weeks); | |
| 46 | + | const ids = owned.map((repo) => repo.id); | |
| 47 | + | const repos: Record<string, RepoPath> = {}; | |
| 48 | + | for (const repo of owned) repos[repo.id] = { namespace: repo.namespace, name: repo.name }; | |
| 49 | + | const [batches, weekly, quarterUsage] = await Promise.all([ | |
| 50 | + | Promise.allSettled( | |
| 51 | + | Array.from({ length: Math.ceil(ids.length / BATCH) }, (_, i) => | |
| 52 | + | work.workspaceSignals( | |
| 53 | + | { workspace: slug, repoIds: ids.slice(i * BATCH, (i + 1) * BATCH), until: range.until, weeks: range.weeks, days: range.days, since: range.since }, | |
| 54 | + | viewer, | |
| 55 | + | ), | |
| 56 | + | ), | |
| 57 | + | ), | |
| 58 | + | usageSince(viewer, slug, starts[0]!), | |
| 59 | + | usageSince(viewer, slug, range.quarter), | |
| 60 | + | ]); | |
| 61 | + | const answered = batches.flatMap((batch) => (batch.status === "fulfilled" ? [batch.value] : [])); | |
| 62 | + | for (const batch of batches) if (batch.status === "rejected") console.warn("g1t: workspace signals could not be read:", batch.reason); | |
| 63 | + | if (ids.length > 0 && answered.length === 0) return null; | |
| 64 | + | const signals = mergeSignals(answered); | |
| 65 | + | return { | |
| 66 | + | slug, | |
| 67 | + | now, | |
| 68 | + | range, | |
| 69 | + | starts, | |
| 70 | + | repos, | |
| 71 | + | signals, | |
| 72 | + | complete: answered.length === batches.length, | |
| 73 | + | weeks: weekTotals(signals.weeks, starts), | |
| 74 | + | spend: weeklySpend(weekly, starts), | |
| 75 | + | quarterUsage, | |
| 76 | + | }; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** | |
| 80 | + | * What the rhythm's rows are: the workspace's teams by the projects they | |
| 81 | + | * work on, with how many people each has; else, with no teams, the | |
| 82 | + | * projects themselves. Null when identity did not answer. | |
| 83 | + | */ | |
| 84 | + | export async function loadGroups(viewer: User, slug: string, owned: Repo[]): Promise<{ groups: Group[]; by: "team" | "project" } | null> { | |
| 85 | + | const projects = (): { groups: Group[]; by: "project" } => ({ | |
| 86 | + | by: "project", | |
| 87 | + | groups: owned.map((repo) => ({ key: repo.id, label: repo.name, note: repo.namespace, to: `/${repo.namespace}/${repo.name}`, repoIds: [repo.id] })), | |
| 88 | + | }); | |
| 89 | + | const listed = await identity.listTeams(viewer, slug).catch(() => null); | |
| 90 | + | if (!listed) return null; | |
| 91 | + | if (!listed.ok || listed.value.length === 0) return projects(); | |
| 92 | + | const teams = listed.value.slice(0, MAX_TEAMS); | |
| 93 | + | const [repos, memberships] = await Promise.all([ | |
| 94 | + | Promise.all(teams.map((team) => identity.teamRepos(viewer, slug, team.slug).catch(() => null))), | |
| 95 | + | identity.teamMemberships(viewer, slug).catch(() => null), | |
| 96 | + | ]); | |
| 97 | + | const people = new Map<string, number>(); | |
| 98 | + | if (memberships?.ok) for (const member of memberships.value) for (const team of member.teams) people.set(team.slug, (people.get(team.slug) ?? 0) + 1); | |
| 99 | + | const groups = teams.flatMap((team, i) => { | |
| 100 | + | const found = repos[i]; | |
| 101 | + | if (!found?.ok || found.value.length === 0) return []; | |
| 102 | + | const count = people.get(team.slug); | |
| 103 | + | return [ | |
| 104 | + | { | |
| 105 | + | key: team.slug, | |
| 106 | + | label: team.name, | |
| 107 | + | note: count == null ? "" : `${count} ${count === 1 ? "person" : "people"}`, | |
| 108 | + | to: `/${slug}/-/teams/${team.slug}`, | |
| 109 | + | repoIds: found.value.map((repo) => repo.repo_id), | |
| 110 | + | }, | |
| 111 | + | ]; | |
| 112 | + | }); | |
| 113 | + | return groups.length > 0 ? { by: "team", groups } : projects(); | |
| 114 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { SignalAuthor, SignalPull, SignalWeek, Usage, WorkspaceSignals } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | type SignalContext, | |
| 8 | + | type WeekTotals, | |
| 9 | + | agentStep, | |
| 10 | + | blockedSignal, | |
| 11 | + | dayList, | |
| 12 | + | emptySignals, | |
| 13 | + | findings, | |
| 14 | + | mergeSignals, | |
| 15 | + | newGround, | |
| 16 | + | newHabits, | |
| 17 | + | pulse, | |
| 18 | + | quarterStart, | |
| 19 | + | quietHeadline, | |
| 20 | + | reviewSignal, | |
| 21 | + | reworkSignal, | |
| 22 | + | rhythm, | |
| 23 | + | shippedSignal, | |
| 24 | + | short, | |
| 25 | + | signalsRange, | |
| 26 | + | silos, | |
| 27 | + | spendFlow, | |
| 28 | + | spendSignal, | |
| 29 | + | spoken, | |
| 30 | + | stalledSignal, | |
| 31 | + | weekStarts, | |
| 32 | + | weekTotals, | |
| 33 | + | weeklySpend, | |
| 34 | + | } from "./signals.ts"; | |
| 35 | + | ||
| 36 | + | const NOW = Date.parse("2026-10-18T15:00:00Z"); | |
| 37 | + | const DAY = 86_400_000; | |
| 38 | + | const repos = { r1: { namespace: "acme", name: "web" }, r2: { namespace: "acme", name: "tax" } }; | |
| 39 | + | ||
| 40 | + | function week(start: string, fields: Partial<WeekTotals> = {}): WeekTotals { | |
| 41 | + | return { start, merged: 0, agentMerged: 0, agentReworked: 0, agentDrafted: 0, agentDraftSeconds: 0, reviewed: 0, reviewSeconds: 0, stalled: 0, ...fields }; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function usage(fields: Partial<Usage> = {}): Usage { | |
| 45 | + | return { | |
| 46 | + | since: "", | |
| 47 | + | spentMicros: 0, | |
| 48 | + | costMicros: 0, | |
| 49 | + | providerMicros: 0, | |
| 50 | + | usedMicros: 0, | |
| 51 | + | free: false, | |
| 52 | + | runs: 0, | |
| 53 | + | byDay: [], | |
| 54 | + | byTask: [], | |
| 55 | + | byRepo: [], | |
| 56 | + | byPull: [], | |
| 57 | + | byModel: [], | |
| 58 | + | addedMicros: 0, | |
| 59 | + | ...fields, | |
| 60 | + | }; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | function ctx(fields: Partial<SignalContext> = {}): SignalContext { | |
| 64 | + | return { slug: "acme", now: NOW, repos, signals: emptySignals(), weeks: [], quarterUsage: null, quarter: "2026-10-01", ...fields }; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | function pull(fields: Partial<SignalPull>): SignalPull { | |
| 68 | + | return { | |
| 69 | + | repoId: "r1", | |
| 70 | + | number: 1, | |
| 71 | + | title: "A change", | |
| 72 | + | status: "merged", | |
| 73 | + | agent: false, | |
| 74 | + | ownerId: "u1", | |
| 75 | + | ownerName: "ana", | |
| 76 | + | createdAt: "2026-10-05T00:00:00Z", | |
| 77 | + | mergedAt: "2026-10-06T00:00:00Z", | |
| 78 | + | item: null, | |
| 79 | + | milestone: null, | |
| 80 | + | ...fields, | |
| 81 | + | }; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | function author(fields: Partial<SignalAuthor>): SignalAuthor { | |
| 85 | + | return { | |
| 86 | + | repoId: "r1", | |
| 87 | + | ownerId: "u1", | |
| 88 | + | ownerName: "ana", | |
| 89 | + | firstMergedAt: "2026-01-01T00:00:00Z", | |
| 90 | + | lastMergedAt: "2026-10-10T00:00:00Z", | |
| 91 | + | merged: 10, | |
| 92 | + | recent: 10, | |
| 93 | + | ...fields, | |
| 94 | + | }; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | test("weeks of seven days end with the last day, and the quarter starts on its first month", () => { | |
| 98 | + | assert.deepEqual(weekStarts("2026-10-18", 3), ["2026-09-28", "2026-10-05", "2026-10-12"]); | |
| 99 | + | assert.deepEqual(dayList("2026-10-18", 3), ["2026-10-16", "2026-10-17", "2026-10-18"]); | |
| 100 | + | assert.equal(quarterStart("2026-10-18"), "2026-10-01"); | |
| 101 | + | assert.equal(quarterStart("2026-03-31"), "2026-01-01"); | |
| 102 | + | assert.equal(quarterStart("2026-08-02"), "2026-07-01"); | |
| 103 | + | // The reach covers a milestone's build even early in a quarter. | |
| 104 | + | assert.equal(signalsRange(NOW).since, "2026-06-20"); | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | test("batches add up per repository, and quiet weeks stay in the line", () => { | |
| 108 | + | const rows: SignalWeek[] = [ | |
| 109 | + | { repoId: "r1", ...week("2026-10-12", { merged: 2, agentMerged: 1 }) }, | |
| 110 | + | { repoId: "r2", ...week("2026-10-12", { merged: 3, agentMerged: 3, stalled: 1 }) }, | |
| 111 | + | { repoId: "r9", ...week("2026-01-01", { merged: 9 }) }, | |
| 112 | + | ]; | |
| 113 | + | const totals = weekTotals(rows, ["2026-10-05", "2026-10-12"]); | |
| 114 | + | assert.deepEqual( | |
| 115 | + | totals.map((w) => [w.start, w.merged, w.agentMerged, w.stalled]), | |
| 116 | + | [ | |
| 117 | + | ["2026-10-05", 0, 0, 0], | |
| 118 | + | ["2026-10-12", 5, 4, 1], | |
| 119 | + | ], | |
| 120 | + | ); | |
| 121 | + | assert.equal(weekTotals(rows, ["2026-10-12"], new Set(["r1"]))[0]!.merged, 2); | |
| 122 | + | const a: WorkspaceSignals = { | |
| 123 | + | ...emptySignals(), | |
| 124 | + | items: [{ number: 4, title: "Old", state: "open", milestone: null }], | |
| 125 | + | waiting: [{ repoId: "r1", number: 2, title: "b", createdAt: "2026-10-02", agent: false, draftSeconds: null, item: null, blocks: [] }], | |
| 126 | + | }; | |
| 127 | + | const b: WorkspaceSignals = { | |
| 128 | + | ...emptySignals(), | |
| 129 | + | items: [{ number: 4, title: "Old", state: "open", milestone: null }], | |
| 130 | + | waiting: [{ repoId: "r2", number: 1, title: "a", createdAt: "2026-10-01", agent: false, draftSeconds: null, item: null, blocks: [] }], | |
| 131 | + | }; | |
| 132 | + | const merged = mergeSignals([a, b]); | |
| 133 | + | assert.equal(merged.items.length, 1, "an item named by two batches is one item"); | |
| 134 | + | assert.deepEqual( | |
| 135 | + | merged.waiting.map((w) => w.number), | |
| 136 | + | [1, 2], | |
| 137 | + | "the oldest waits first across batches", | |
| 138 | + | ); | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | test("usage by day falls into its week", () => { | |
| 142 | + | const spend = weeklySpend( | |
| 143 | + | usage({ | |
| 144 | + | byDay: [ | |
| 145 | + | { key: "2026-10-12/review", micros: 5, runs: 1 }, | |
| 146 | + | { key: "2026-10-18/run", micros: 7, runs: 1 }, | |
| 147 | + | { key: "2026-10-11/run", micros: 3, runs: 1 }, | |
| 148 | + | ], | |
| 149 | + | }), | |
| 150 | + | ["2026-10-05", "2026-10-12"], | |
| 151 | + | ); | |
| 152 | + | assert.deepEqual(spend, [3, 12]); | |
| 153 | + | assert.equal(weeklySpend(null, ["2026-10-12"]), null, "usage that did not answer is not zero"); | |
| 154 | + | }); | |
| 155 | + | ||
| 156 | + | test("the pulse says each figure against last week, and whether that is good news", () => { | |
| 157 | + | const weeks = [ | |
| 158 | + | week("2026-10-05", { merged: 60, agentMerged: 52, agentReworked: 11, stalled: 16 }), | |
| 159 | + | week("2026-10-12", { merged: 70, agentMerged: 61, agentReworked: 11, stalled: 23 }), | |
| 160 | + | ]; | |
| 161 | + | const tiles = pulse({ slug: "acme", weeks, spend: [52 * 72_000_000, 61 * 86_000_000] }); | |
| 162 | + | const by = Object.fromEntries(tiles.map((tile) => [tile.key, tile])); | |
| 163 | + | assert.equal(by.merges!.value, "61"); | |
| 164 | + | assert.deepEqual(by.merges!.delta, { text: "+9", tone: "good" }); | |
| 165 | + | assert.equal(by.spend!.value, "$86"); | |
| 166 | + | assert.deepEqual(by.spend!.delta, { text: "+$14", tone: "bad" }, "spend going up is not good news"); | |
| 167 | + | assert.deepEqual(by.stalled!.delta, { text: "+7", tone: "bad" }); | |
| 168 | + | assert.equal(by.rework!.value, "18%"); | |
| 169 | + | assert.equal(by.rework!.delta!.text, "−3.1 pts"); | |
| 170 | + | assert.equal(by.rework!.delta!.tone, "good"); | |
| 171 | + | const unknown = pulse({ slug: "acme", weeks, spend: null }); | |
| 172 | + | assert.equal(unknown.find((tile) => tile.key === "spend")!.value, "—", "no usage, no figure"); | |
| 173 | + | assert.equal(unknown.find((tile) => tile.key === "spend")!.delta, null); | |
| 174 | + | }); | |
| 175 | + | ||
| 176 | + | test("the change holding up the most work items leads, with what it holds up", () => { | |
| 177 | + | const signals = { | |
| 178 | + | ...emptySignals(), | |
| 179 | + | waiting: [ | |
| 180 | + | { repoId: "r1", number: 7, title: "Small", createdAt: "2026-10-10T00:00:00Z", agent: true, draftSeconds: 600, item: 3, blocks: [4] }, | |
| 181 | + | { | |
| 182 | + | repoId: "r2", | |
| 183 | + | number: 4821, | |
| 184 | + | title: "Nexus threshold configuration", | |
| 185 | + | createdAt: "2026-10-16T15:00:00Z", | |
| 186 | + | agent: true, | |
| 187 | + | draftSeconds: 600, | |
| 188 | + | item: 5, | |
| 189 | + | blocks: [8, 9, 10], | |
| 190 | + | }, | |
| 191 | + | { repoId: "gone", number: 1, title: "Unreadable", createdAt: "2026-10-01T00:00:00Z", agent: false, draftSeconds: null, item: 1, blocks: [2, 3, 4, 5] }, | |
| 192 | + | ], | |
| 193 | + | }; | |
| 194 | + | const signal = blockedSignal(ctx({ signals }))!; | |
| 195 | + | assert.equal(signal.headline, "Three work items are waiting on one unreviewed change."); | |
| 196 | + | assert.deepEqual(signal.facts, ["tax#4821 Nexus threshold configuration", "open 2 days", "holds up #8, #9, #10"]); | |
| 197 | + | assert.equal(signal.link!.to, "/acme/tax/pull/4821"); | |
| 198 | + | assert.equal(blockedSignal(ctx()), null); | |
| 199 | + | }); | |
| 200 | + | ||
| 201 | + | test("a quick draft waiting long for review is said with both times; a slowdown alone is said against the weeks before", () => { | |
| 202 | + | const weeks = [ | |
| 203 | + | week("2026-09-28", { reviewed: 4, reviewSeconds: 4 * 0.8 * 86400 }), | |
| 204 | + | week("2026-10-05", { reviewed: 4, reviewSeconds: 4 * 1.2 * 86400 }), | |
| 205 | + | week("2026-10-12", { reviewed: 10, reviewSeconds: 10 * 2.1 * 86400, agentDrafted: 5, agentDraftSeconds: 5 * 48 * 60 }), | |
| 206 | + | ]; | |
| 207 | + | const gap = reviewSignal(ctx({ weeks }))!; | |
| 208 | + | assert.equal(gap.headline, "Changes an agent drafts in under an hour are waiting 2.1 days for first review."); | |
| 209 | + | assert.deepEqual(gap.facts, ["48m to draft", "2.1d to first review", "this week"]); | |
| 210 | + | assert.equal(gap.trend!.range, "19h → 2.1d over 3 weeks"); | |
| 211 | + | const slower = reviewSignal(ctx({ weeks: weeks.map((w, i) => (i === 2 ? { ...w, agentDrafted: 0, agentDraftSeconds: 0 } : w)) }))!; | |
| 212 | + | assert.match(slower.headline, /^First reviews are taking 2\.1 days, up from a day in the weeks before\.$/); | |
| 213 | + | assert.equal(reviewSignal(ctx({ weeks: [week("2026-10-12")] })), null, "no reviews, nothing to say"); | |
| 214 | + | }); | |
| 215 | + | ||
| 216 | + | test("spend that concentrates in one milestone is a finding; spread-out spend is not", () => { | |
| 217 | + | const signals = { | |
| 218 | + | ...emptySignals(), | |
| 219 | + | milestones: [{ number: 1, title: "the Checkout replatform", state: "open", dueOn: null, createdAt: "2026-08-01T00:00:00Z", closedAt: null }], | |
| 220 | + | items: [{ number: 5, title: "Tax cutover", state: "open", milestone: 1 }], | |
| 221 | + | pulls: [pull({ number: 1, item: 5, milestone: 1 }), pull({ number: 2 }), pull({ repoId: "r2", number: 3 })], | |
| 222 | + | }; | |
| 223 | + | const quarterUsage = usage({ | |
| 224 | + | byPull: [ | |
| 225 | + | { key: "acme/web#1", micros: 28_300_000_000, runs: 1 }, | |
| 226 | + | { key: "acme/web#2", micros: 14_000_000_000, runs: 1 }, | |
| 227 | + | { key: "acme/tax#3", micros: 10_100_000_000, runs: 1 }, | |
| 228 | + | ], | |
| 229 | + | }); | |
| 230 | + | const signal = spendSignal(ctx({ signals, quarterUsage }))!; | |
| 231 | + | assert.equal(signal.headline, "More than half of this quarter's agent spend is going into the Checkout replatform."); | |
| 232 | + | assert.deepEqual(signal.facts, ["$28.3K of $52.4K", "quarter to date"]); | |
| 233 | + | const even = usage({ byPull: quarterUsage.byPull.map((slice) => ({ ...slice, micros: 1_000_000 })) }); | |
| 234 | + | assert.equal(spendSignal(ctx({ signals, quarterUsage: even })), null); | |
| 235 | + | assert.equal(spendSignal(ctx({ signals, quarterUsage: null })), null); | |
| 236 | + | }); | |
| 237 | + | ||
| 238 | + | test("a milestone closed this week says when against its due date, and what it took", () => { | |
| 239 | + | const closedAt = "2026-10-13T18:00:00Z"; | |
| 240 | + | const signals = { | |
| 241 | + | ...emptySignals(), | |
| 242 | + | milestones: [{ number: 2, title: "Single sign-on", state: "closed", dueOn: "2026-11-03", createdAt: "2026-07-28T00:00:00Z", closedAt }], | |
| 243 | + | pulls: [ | |
| 244 | + | pull({ milestone: 2, number: 1 }), | |
| 245 | + | pull({ milestone: 2, number: 2, repoId: "r2" }), | |
| 246 | + | pull({ milestone: 2, number: 3, status: "closed", mergedAt: null }), | |
| 247 | + | ], | |
| 248 | + | }; | |
| 249 | + | const signal = shippedSignal(ctx({ signals }))!; | |
| 250 | + | assert.equal(signal.headline, "Single sign-on shipped on Tuesday, three weeks ahead of schedule."); | |
| 251 | + | assert.deepEqual(signal.facts, ["11 weeks in build", "2 changes", "2 projects"]); | |
| 252 | + | const old = { ...signals, milestones: [{ ...signals.milestones[0]!, closedAt: "2026-09-01T00:00:00Z" }] }; | |
| 253 | + | assert.equal(shippedSignal(ctx({ signals: old })), null, "only the last week's"); | |
| 254 | + | }); | |
| 255 | + | ||
| 256 | + | test("stalled changes and rework are findings only when they moved", () => { | |
| 257 | + | assert.equal( | |
| 258 | + | stalledSignal(ctx({ weeks: [week("a", { stalled: 16 }), week("b", { stalled: 23 })] }))!.headline, | |
| 259 | + | "23 changes have been open over a week, 7 more than last week.", | |
| 260 | + | ); | |
| 261 | + | assert.equal(stalledSignal(ctx({ weeks: [week("a", { stalled: 16 }), week("b", { stalled: 17 })] })), null); | |
| 262 | + | const steady = [1, 2, 3, 4].map((i) => week(`w${i}`, { agentMerged: 10, agentReworked: 1 })); | |
| 263 | + | assert.match( | |
| 264 | + | reworkSignal(ctx({ weeks: [...steady, week("now", { agentMerged: 10, agentReworked: 4 })] }))!.headline, | |
| 265 | + | /^People asked for changes on 40% .* up from 10%\.$/, | |
| 266 | + | ); | |
| 267 | + | assert.equal(reworkSignal(ctx({ weeks: [...steady, week("now", { agentMerged: 10, agentReworked: 1 })] })), null); | |
| 268 | + | }); | |
| 269 | + | ||
| 270 | + | test("who works where: new ground, one person carrying a project, and a first agent merge", () => { | |
| 271 | + | const recent = new Date(NOW - 3 * DAY).toISOString(); | |
| 272 | + | const authors = [ | |
| 273 | + | author({ ownerId: "u1", ownerName: "ana", repoId: "r1", recent: 9, merged: 40 }), | |
| 274 | + | author({ ownerId: "u2", ownerName: "bo", repoId: "r1", firstMergedAt: recent, recent: 1, merged: 1 }), | |
| 275 | + | author({ ownerId: "u2", ownerName: "bo", repoId: "r2", firstMergedAt: "2026-02-01T00:00:00Z", merged: 12, recent: 2 }), | |
| 276 | + | ]; | |
| 277 | + | assert.deepEqual( | |
| 278 | + | newGround(authors, NOW).map((row) => [row.name, row.repoId, row.elsewhere]), | |
| 279 | + | [["bo", "r1", 12]], | |
| 280 | + | ); | |
| 281 | + | const [silo] = silos(authors, NOW); | |
| 282 | + | assert.equal(silo!.name, "ana"); | |
| 283 | + | assert.equal(silo!.share, 0.9); | |
| 284 | + | assert.deepEqual(silo!.joining, [{ ownerId: "u2", name: "bo" }]); | |
| 285 | + | const pulls = [1, 2, 3].map((n) => pull({ number: n, mergedAt: `2026-09-0${n}T00:00:00Z` })); | |
| 286 | + | const habits = newHabits([...pulls, pull({ number: 9, agent: true, mergedAt: recent })], NOW); | |
| 287 | + | assert.deepEqual( | |
| 288 | + | habits.map((row) => [row.name, row.byHand]), | |
| 289 | + | [["ana", 3]], | |
| 290 | + | ); | |
| 291 | + | const signal = findings(ctx({ signals: { ...emptySignals(), authors } })).find((s) => s.key === "ground")!; | |
| 292 | + | assert.equal(signal.headline, "bo is starting to work in web, which ana has carried almost alone."); | |
| 293 | + | }); | |
| 294 | + | ||
| 295 | + | test("findings come highest first, and a quiet week still says something", () => { | |
| 296 | + | const signals = { | |
| 297 | + | ...emptySignals(), | |
| 298 | + | waiting: [{ repoId: "r1", number: 2, title: "Fix", createdAt: "2026-10-16T00:00:00Z", agent: false, draftSeconds: null, item: 1, blocks: [3, 4] }], | |
| 299 | + | }; | |
| 300 | + | const found = findings(ctx({ signals, weeks: [week("a", { stalled: 1 }), week("b", { stalled: 10 })] })); | |
| 301 | + | assert.deepEqual( | |
| 302 | + | found.map((s) => s.key), | |
| 303 | + | ["blocked", "stalled"], | |
| 304 | + | ); | |
| 305 | + | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has merged yet, and nothing is stuck."); | |
| 306 | + | assert.equal( | |
| 307 | + | quietHeadline(week("x", { merged: 4, agentMerged: 2 })), | |
| 308 | + | "Four changes merged this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 309 | + | ); | |
| 310 | + | }); | |
| 311 | + | ||
| 312 | + | test("waits are said in words and in a few characters", () => { | |
| 313 | + | assert.equal(spoken(48 * 60), "48 minutes"); | |
| 314 | + | assert.equal(spoken(5 * 3600), "5 hours"); | |
| 315 | + | assert.equal(spoken(2.1 * 86400), "2.1 days"); | |
| 316 | + | assert.equal(short(48 * 60), "48m"); | |
| 317 | + | assert.equal(short(5.5 * 3600), "5.5h"); | |
| 318 | + | assert.equal(short(null), "—"); | |
| 319 | + | }); | |
| 320 | + | ||
| 321 | + | test("the spend flow threads each pull request through its milestone, work item and project", () => { | |
| 322 | + | const signals = { | |
| 323 | + | ...emptySignals(), | |
| 324 | + | milestones: [{ number: 1, title: "Checkout replatform", state: "open", dueOn: null, createdAt: "2026-08-01T00:00:00Z", closedAt: null }], | |
| 325 | + | items: [{ number: 5, title: "Tax service cutover", state: "open", milestone: 1 }], | |
| 326 | + | pulls: [ | |
| 327 | + | pull({ number: 1, item: 5, milestone: 1, agent: true, createdAt: "2026-10-02T00:00:00Z" }), | |
| 328 | + | pull({ number: 2, item: 5, milestone: 1, repoId: "r2", createdAt: "2026-10-03T00:00:00Z" }), | |
| 329 | + | pull({ number: 3, createdAt: "2026-10-04T00:00:00Z" }), | |
| 330 | + | pull({ number: 4, createdAt: "2026-05-01T00:00:00Z", mergedAt: "2026-05-02T00:00:00Z" }), | |
| 331 | + | ], | |
| 332 | + | }; | |
| 333 | + | const flow = spendFlow({ | |
| 334 | + | slug: "acme", | |
| 335 | + | repos, | |
| 336 | + | signals, | |
| 337 | + | quarter: "2026-10-01", | |
| 338 | + | quarterUsage: usage({ byPull: [{ key: "acme/web#1", micros: 900, runs: 1 }] }), | |
| 339 | + | }); | |
| 340 | + | assert.deepEqual( | |
| 341 | + | flow.map((node) => [node.label, node.pulls, node.agent, node.micros]), | |
| 342 | + | [ | |
| 343 | + | ["Checkout replatform", 2, 1, 900], | |
| 344 | + | ["No milestone", 1, 0, 0], | |
| 345 | + | ], | |
| 346 | + | "last quarter's work is left out; the costliest comes first", | |
| 347 | + | ); | |
| 348 | + | const item = flow[0]!.children[0]!; | |
| 349 | + | assert.equal(item.label, "Tax service cutover"); | |
| 350 | + | assert.deepEqual( | |
| 351 | + | item.children.map((set) => [set.label, set.list!.map((p) => p.number)]), | |
| 352 | + | [ | |
| 353 | + | ["web", [1]], | |
| 354 | + | ["tax", [2]], | |
| 355 | + | ], | |
| 356 | + | ); | |
| 357 | + | assert.equal(agentStep(0.71), 3); | |
| 358 | + | assert.equal(agentStep(0.1), 0); | |
| 359 | + | }); | |
| 360 | + | ||
| 361 | + | test("the rhythm adds each group's days and leaves out groups with nothing", () => { | |
| 362 | + | const days = [ | |
| 363 | + | { repoId: "r1", day: "2026-10-17", opened: 2, merged: 1, agentMerged: 1, reworked: 0, reviews: 3, sessions: 4 }, | |
| 364 | + | { repoId: "r2", day: "2026-10-18", opened: 1, merged: 0, agentMerged: 0, reworked: 1, reviews: 0, sessions: 0 }, | |
| 365 | + | ]; | |
| 366 | + | const rows = rhythm( | |
| 367 | + | days, | |
| 368 | + | ["2026-10-17", "2026-10-18"], | |
| 369 | + | [ | |
| 370 | + | { key: "pay", label: "Payments", note: "3 people", to: "/t/pay", repoIds: ["r1", "r2"] }, | |
| 371 | + | { key: "idle", label: "Idle", note: "", to: "/t/idle", repoIds: ["r9"] }, | |
| 372 | + | ], | |
| 373 | + | ); | |
| 374 | + | assert.equal(rows.length, 1); | |
| 375 | + | assert.deepEqual( | |
| 376 | + | rows[0]!.days.map((day) => [day.opened, day.reviews, day.sessions, day.reworked]), | |
| 377 | + | [ | |
| 378 | + | [2, 3, 4, 0], | |
| 379 | + | [1, 0, 0, 1], | |
| 380 | + | ], | |
| 381 | + | ); | |
| 382 | + | }); |
Comments
Sign in to comment.