Home reads like a day's start (#54): what needs you first with the one to start on at the top, then one card for what happened, and what is running, what it cost and what settled by itself in a side column that stays in view.
The span was summarized five times (the sentence under Ask, What changed, While you were away, People, Landed's line) and production builds were listed three times (their own Landed row, the merge's Live label, Deploys). Now: - Where you're needed opens with Start here, set apart with why it was picked and its button; the separate Start here card is gone. - Since you were last here is one card: People (two columns of lines, the faces), What changed as a strip with Summarize, and the Landed feed, whose line counts what is live, deploying, waiting and failed (what While you were away's chips said). - A production build is a Landed row only where no merge or push on the list already says it (its live label links to the build, or its commit is the build's): a redeploy, a rollback, or a failed build, shown red (landedDeploys). The Deploys card is gone; previews stay on the project's Deployments. - Running now (wrapping titles, state and time under them), Spent and Settled for you sit in a sticky side column on wide screens; on a phone it is Where you're needed, Running now, the span, then Spent and Settled. - Done links to /notifications/done, the path #52 moves it to.
- Checked:
- rendered the route with fixture data in a Vite harness at 1440 and 390 (dark), no console errors, no sideways scroll at 390; tsc -b clean; the web tests (963) pass, including the new landedDeploys case; the docs build passes.
- G1t-Session:
- ses_01m4ypk0jhex5t0j2c38w7aq7p
| 5 | 5 | ||
| 6 | 6 | **Home** is the first page of your workspace, at `/<workspace>/-/home`. | |
| 7 | 7 | It opens with **What are we building today?** and a box that | |
| 8 | − | [asks g1t](#ask-from-home), then answers three questions, in this order: | |
| 8 | + | [asks g1t](#ask-from-home). Under it, the main column is read top to | |
| 9 | + | bottom: | |
| 9 | 10 | ||
| 10 | 11 | 1. **Where you're needed**: everything waiting on you, however long ago | |
| 11 | − | it started, with the action beside each item. | |
| 12 | − | 2. **What happened while you were away**: what changed, in plain | |
| 13 | − | English when you ask for it; what settled by itself and what went | |
| 14 | − | live; what people did; what landed; deploys and spend, since you were | |
| 15 | − | last here. | |
| 16 | − | 3. **What is running now**: workflow runs and builds still going. | |
| 12 | + | it started, with the action beside each item. Its first row is | |
| 13 | + | [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 | |
| 15 | + | did, what changed in plain English when you ask for it, and what landed, | |
| 16 | + | each change with whether it is live yet. | |
| 17 | + | ||
| 18 | + | The side column stays in view as you scroll: | |
| 19 | + | ||
| 20 | + | - **[Running now](#running-now)**: workflow runs and builds still going. | |
| 21 | + | - **[Spent](#spent)**: what the span cost, and the month so far. | |
| 22 | + | - **[Settled for you](#settled-for-you)**: what finished by itself. | |
| 17 | 23 | ||
| 18 | − | **Start here** picks the one item to do first. | |
| 24 | + | On a phone it is one column: Where you're needed, Running now, the span, | |
| 25 | + | then Spent and Settled for you. | |
| 19 | 26 | ||
| 20 | 27 | For a workspace's owners, Home also opens | |
| 21 | 28 | [Getting started](/guides/workspaces/#getting-started), the setup guide, | |
| ⋯ | |||
| 88 | 95 | ||
| 89 | 96 | ## Start here | |
| 90 | 97 | ||
| 91 | − | **Start here** is the first row of Where you're needed: the most pressing | |
| 92 | − | tier, and within it the row that has waited longest. g1t picks it with | |
| 93 | − | that rule, so the same rows always give the same pick. | |
| 94 | − | The card shows who is on it (**Owner**), the step it waits for (**Next**, | |
| 95 | − | such as **Merge** or **Review**), how long it has waited (**Waiting**), | |
| 96 | − | what is at stake (**At stake**), and **Why this one**, such as | |
| 97 | − | "Everything it needs is met; it lands when you merge it." When others in | |
| 98 | − | its tier are waiting too, it adds that this one has waited longest. | |
| 98 | + | **Start here** is the first row of Where you're needed, set apart at the | |
| 99 | + | top: the most pressing tier, and within it the row that has waited | |
| 100 | + | longest. g1t picks it with that rule, so the same rows always give the | |
| 101 | + | same pick. It says why, such as "Everything it needs is met; it lands when | |
| 102 | + | you merge it." When others in its tier are waiting too, it adds that this | |
| 103 | + | one has waited longest. Beside its button are what is at stake, how long | |
| 104 | + | it has waited, and who is on it. | |
| 99 | 105 | ||
| 100 | 106 | Its title links to what it picked: the pull request, issue or build. The | |
| 101 | − | button under it opens the same place. For a pull request (one ready to | |
| 107 | + | button opens the same place. For a pull request (one ready to | |
| 102 | 108 | merge, a review asked of you, or yours that is stuck) it says **Open pull | |
| 103 | 109 | request**; you merge or review there. For anything else it names the | |
| 104 | 110 | action, such as **See the build** or **Respond**. | |
| ⋯ | |||
| 106 | 112 | ## Since you were last here | |
| 107 | 113 | ||
| 108 | 114 | Home remembers when you were last on it, for each workspace, with your | |
| 109 | − | account, so it is the same on your laptop and your phone. The second half | |
| 110 | − | of the page covers what happened since then, and its heading says the span | |
| 115 | + | account, so it is the same on your laptop and your phone. The span's card | |
| 116 | + | covers what happened since then, and its heading says the span | |
| 111 | 117 | in words, such as **Since Tuesday evening · 2 days** or **Since 40 | |
| 112 | 118 | minutes ago**. | |
| 113 | 119 | ||
| ⋯ | |||
| 149 | 155 | ||
| 150 | 156 | ### What changed | |
| 151 | 157 | ||
| 152 | − | The first card, **What changed**, has g1t tell you what happened in the | |
| 158 | + | **What changed**, under People in the span's card, has g1t tell you what happened in the | |
| 153 | 159 | span in plain English: what changed, why, and what is live. It is not | |
| 154 | 160 | written until you ask, since each summary is a call to your workspace's | |
| 155 | 161 | AI provider: | |
| ⋯ | |||
| 169 | 175 | ||
| 170 | 176 | The question g1t is sent reads as you would ask it, with the span's start | |
| 171 | 177 | in your time zone: "Summarize what changed since I was last here (2:12 | |
| 172 | − | PM) in plain English." Choosing another span with the switch starts the | |
| 173 | − | card over. When Ask is not set up, the card says so: owners get a link to | |
| 178 | + | PM) in plain English." Choosing another span with the switch starts it | |
| 179 | + | over. When Ask is not set up, the card says so: owners get a link to | |
| 174 | 180 | connect a provider and choose it for Ask, and members are told to ask an | |
| 175 | 181 | owner. | |
| 176 | − | ||
| 177 | − | ### While you were away | |
| 178 | 182 | ||
| 179 | − | The next card says what finished by itself in the span, and what is | |
| 180 | − | live. (Over **Last 24 hours** and **Last 7 days** it is called **Settled | |
| 181 | − | for you**.) Its first line counts: | |
| 182 | − | ||
| 183 | − | | Count | What it is | | |
| 184 | − | | --- | --- | | |
| 185 | − | | *4 notifications settled* | Your notifications in this workspace that [settled themselves](/guides/notifications/#threads-that-settle-themselves) in the span: a later run passed, the pull request merged, the deployment went out. One you marked done yourself is not counted | | |
| 186 | − | | *3 changes live*, *1 deploying*, *1 waiting to deploy*, *1 deploy failed* | The merges and pushes under [Landed](#landed), by whether each is [live](#live-after-merge) | | |
| 187 | − | | *2 things still need you* | The rows of [Where you're needed](#where-youre-needed); it takes you there | | |
| 188 | − | ||
| 189 | − | Under it, each notification that settled, newest first, with why it | |
| 190 | − | settled and who did it, such as **CI passed in run 330** or **Merged by | |
| 191 | − | @ana**. **Done** opens the notifications that are done. | |
| 192 | − | ||
| 193 | 183 | ### People | |
| 194 | 184 | ||
| 195 | − | The second card, **People**, lists what people did in the span, one line | |
| 185 | + | The span's card opens with **People**: what people did in the span, one line | |
| 196 | 186 | per kind of thing with its count, and each line opens the place that lists | |
| 197 | 187 | them: a project's Activity tab for pushes, its pull requests or issues, | |
| 198 | 188 | Artifacts, Chat. Beside the heading are the faces of the five most active, | |
| ⋯ | |||
| 212 | 202 | ||
| 213 | 203 | A push an import made names nobody, so it is counted without a face. | |
| 214 | 204 | ||
| 215 | − | The card says when it has nothing to show, and names the part that didn't | |
| 216 | − | answer (Code or Chat) so an empty card never reads as a quiet day. | |
| 205 | + | It says when it has nothing to show, and names the part that didn't | |
| 206 | + | answer (Code or Chat) so an empty list never reads as a quiet day. | |
| 217 | 207 | ||
| 218 | 208 | ### Landed | |
| 219 | 209 | ||
| 220 | − | Everything that landed in the span, newest first: | |
| 210 | + | The rest of the span's card is everything that landed in the span, | |
| 211 | + | newest first: | |
| 221 | 212 | ||
| 222 | 213 | | Row | What it is | | |
| 223 | 214 | | --- | --- | | |
| 224 | 215 | | A pull request merged | With who wrote it, and who merged it when that was someone else | | |
| 225 | 216 | | A push to a default branch | One row per push a person made straight to a default branch: its commit's first line when it brought one, else how many commits, and who pushed. For the ten projects pushed to most in the span; past those, one row per project with how many commits and pushes | | |
| 226 | − | | A production build that went live | A project's production build that built and was served (previews stay under Deploys) | | |
| 217 | + | | A production build | Only one that no merge or push on the list already speaks for: a redeploy, a rollback, a build of a project whose pushes weren't read, or one that failed. A build that shipped a merge is that merge's [live label](#live-after-merge), not a row of its own. Previews stay on the project's Deployments | | |
| 227 | 218 | | A release published | Its tag and name, and who released it | | |
| 228 | 219 | | A package version published | Its name and version, and who published it | | |
| 229 | 220 | ||
| 230 | − | The card's line counts them by kind. The first | |
| 231 | − | five show; **Show more** opens the rest. Over **Last 7 days** the rows are | |
| 221 | + | Its line counts them by kind, and how many of the merges and pushes are | |
| 222 | + | live, deploying, waiting to deploy, or failed to deploy. The first five | |
| 223 | + | show; **Show more** opens the rest. Over **Last 7 days** the rows are | |
| 232 | 224 | grouped by day (**Today**, **Yesterday**, **Sun, Oct 11**), five of each | |
| 233 | 225 | day showing. | |
| 234 | 226 | ||
| ⋯ | |||
| 281 | 273 | requests, pushes and releases, deployments) didn't answer, the card says | |
| 282 | 274 | so under the list. | |
| 283 | 275 | ||
| 284 | − | ### Deploys | |
| 276 | + | ## Running now | |
| 285 | 277 | ||
| 286 | − | Every build that finished in the span, production and previews: **Went | |
| 287 | − | live** when it built and was served (it may have been replaced since), or | |
| 288 | − | **Failed**. Skipped builds are left out. A workspace whose projects did not | |
| 289 | − | build in the span has no Deploys card. | |
| 278 | + | At the top of the side column, what is going right now, longest-going | |
| 279 | + | first, each with its state and how long it has gone: | |
| 290 | 280 | ||
| 291 | − | ### Spent | |
| 281 | + | | Kind | What it is | | |
| 282 | + | | --- | --- | | |
| 283 | + | | Workflow run | A [workflow](/guides/actions/) run that is queued, waiting, running, or waiting for approval, in the ten projects pushed to most in the span (their newest 20 runs each) | | |
| 284 | + | | Build | A project's newest build, queued or building | | |
| 285 | + | ||
| 286 | + | ## Spent | |
| 292 | 287 | ||
| 293 | − | What the workspace spent over the span, from its | |
| 288 | + | In the side column: what the workspace spent over the span, from its | |
| 294 | 289 | [statement](/guides/usage-and-billing/), with each line at its price: what | |
| 295 | 290 | was charged plus what your plan's included usage, a trial, a pool or a | |
| 296 | 291 | discount paid of it. The lines are the statement's kinds of charge, such | |
| ⋯ | |||
| 304 | 299 | yesterday counts all of yesterday. Usage that g1t measures through the | |
| 305 | 300 | month, such as hosting requests and storage, shows on the day it is added | |
| 306 | 301 | to the statement. | |
| 307 | − | ||
| 308 | − | ## Running now | |
| 309 | 302 | ||
| 310 | − | What is going right now, longest-going first: | |
| 303 | + | ## Settled for you | |
| 311 | 304 | ||
| 312 | − | | Kind | What it is | | |
| 313 | − | | --- | --- | | |
| 314 | − | | Workflow run | A [workflow](/guides/actions/) run that is queued, waiting, running, or waiting for approval, in the ten projects pushed to most in the span (their newest 20 runs each) | | |
| 315 | − | | Build | A project's newest build, queued or building | | |
| 305 | + | At the bottom of the side column: your notifications in this workspace | |
| 306 | + | that [settled themselves](/guides/notifications/#threads-that-settle-themselves) | |
| 307 | + | in the span, newest first, each with why, such as **CI passed in run 330** | |
| 308 | + | or **Merged by @ana**: a later run passed, the pull request merged, the | |
| 309 | + | deployment went out. One you marked done yourself is not counted. **Done** | |
| 310 | + | opens the notifications that are done. | |
| 316 | 311 | ||
| 317 | 312 | ## While the page is open | |
| 318 | 313 | ||
| ⋯ | |||
| 349 | 344 | repositories. | |
| 350 | 345 | - Where you're needed has no rows from Code, and no notifications about a | |
| 351 | 346 | repository. | |
| 352 | − | - Landed, Deploys and Running now are not shown. | |
| 347 | + | - Landed and Running now are not shown. | |
| 353 | 348 | - Spent is the same for every member. | |
| 354 | 349 | ||
| 355 | 350 | ## When something doesn't answer | |
| ⋯ | |||
| 362 | 357 | - People counts what could be read, and names the part that is missing | |
| 363 | 358 | (Code or Chat). If nothing could be read, the card says so, and the | |
| 364 | 359 | sentence under the heading says what people did couldn't be read. | |
| 365 | − | - Landed, Deploys and Spent each say their part didn't answer. When | |
| 360 | + | - Landed and Spent each say their part didn't answer. When | |
| 366 | 361 | deployments or sessions can't be read, Landed shows its rows without | |
| 367 | 362 | their live labels or agent markers. | |
| 368 | − | - While you were away says when your notifications can't be read. | |
| 363 | + | - Settled for you says when your notifications can't be read. | |
| 369 | 364 | - What changed says when Ask can't be reached or isn't set up, or when | |
| 370 | 365 | the question could not be sent. | |
| 371 | 366 | - If your last visit can't be read, the span is the last 24 hours and the | |
| 122 | 122 | a few words, and who did it when someone did: **CI passed in run 330**, | |
| 123 | 123 | **Merged by @ana**, **Production of site deployed at 8e30fb4**, | |
| 124 | 124 | **Review no longer asked of you**. A thread you marked done yourself says | |
| 125 | − | nothing there. [Home](/guides/home/#while-you-were-away) lists what | |
| 125 | + | nothing there. [Home](/guides/home/#settled-for-you) lists what | |
| 126 | 126 | settled for you since your last visit. | |
| 127 | 127 | ||
| 128 | 128 | A notification that settled is no longer shown again: tabs you open later |
| 14 | 14 | import { askG1t, openAsk } from "../lib/ask"; | |
| 15 | 15 | import { HOME_STARTERS, type Span, summarizeQuestion } from "../lib/home"; | |
| 16 | 16 | import { Suggestion, Suggestions } from "./ai-elements/suggestion"; | |
| 17 | − | import { Card } from "./home"; | |
| 18 | 17 | import { MarkGlyph } from "./logo"; | |
| 19 | 18 | import { Button } from "./ui/button"; | |
| 20 | 19 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 96 | 95 | } | |
| 97 | 96 | ||
| 98 | 97 | /** | |
| 99 | − | * What changed: g1t's summary of the span in plain English, on the page. | |
| 100 | − | * Asked only when the viewer presses Summarize (each answer is a call to | |
| 101 | − | * the workspace's own AI provider); asked again for another span. | |
| 98 | + | * What changed: g1t's summary of the span in plain English, on the page, | |
| 99 | + | * a strip in the span's card. Asked only when the viewer presses | |
| 100 | + | * Summarize (each answer is a call to the workspace's own AI provider); | |
| 101 | + | * asked again for another span. | |
| 102 | 102 | */ | |
| 103 | 103 | export function WhatChangedCard({ slug, span, tz }: { slug: string; span: AskSpan; tz: string | null }) { | |
| 104 | 104 | // Each ask is its own: Summarize again asks anew. | |
| ⋯ | |||
| 125 | 125 | const onDone = useCallback((channelId: string | null) => setDone({ channelId }), []); | |
| 126 | 126 | ||
| 127 | 127 | return ( | |
| 128 | − | <div ref={card} className="scroll-mt-4"> | |
| 129 | − | <Card title="What changed" hint={`In plain English, ${label}`}> | |
| 130 | − | <div className="px-5 pb-5"> | |
| 128 | + | <div ref={card} className="scroll-mt-4 border-t border-line"> | |
| 129 | + | <div className="px-5 py-3.5"> | |
| 131 | 130 | {!asked ? ( | |
| 132 | 131 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 133 | − | <p className="grow text-sm text-muted">g1t reads the commits, pull requests, deploys and conversations {label} and tells you what changed, why, and what is live.</p> | |
| 132 | + | <p className="grow text-sm text-muted"> | |
| 133 | + | <span className="font-medium text-fg">What changed, in plain English.</span> g1t reads the commits, pull requests, deploys and conversations {label} and | |
| 134 | + | tells you what changed, why, and what is live. | |
| 135 | + | </p> | |
| 134 | 136 | <Button type="button" variant="accent" size="sm" onClick={summarize} onPointerEnter={() => void loadSummary()} className="shrink-0 self-start sm:self-auto"> | |
| 135 | 137 | <Sparkles size={14} /> | |
| 136 | 138 | Summarize | |
| ⋯ | |||
| 171 | 173 | </> | |
| 172 | 174 | )} | |
| 173 | 175 | </div> | |
| 174 | − | </Card> | |
| 175 | 176 | </div> | |
| 176 | 177 | ); | |
| 177 | 178 | } | |
| 33 | 33 | type AttentionKind, | |
| 34 | 34 | type AttentionRow, | |
| 35 | 35 | type Away, | |
| 36 | − | type DeployRow, | |
| 37 | 36 | type Digest, | |
| 38 | 37 | type DigestActor, | |
| 39 | 38 | type DigestColumn, | |
| ⋯ | |||
| 204 | 203 | ); | |
| 205 | 204 | } | |
| 206 | 205 | ||
| 207 | − | /** Everything waiting on you, most pressing and longest waiting first; past the first few, the rest fold away. */ | |
| 206 | + | /** | |
| 207 | + | * Everything waiting on you, most pressing and longest waiting first, with | |
| 208 | + | * the one g1t picked to start with on top and why; past the first few, the | |
| 209 | + | * rest fold away. | |
| 210 | + | */ | |
| 208 | 211 | export function NeedsList({ attention, now }: { attention: Attention; now: number }) { | |
| 209 | − | const { rows, missing } = attention; | |
| 210 | − | const arrived = useArrived(rows.map((row) => row.key)); | |
| 212 | + | const { start, missing } = attention; | |
| 213 | + | const rows = start ? attention.rows.filter((row) => row.key !== start.row.key) : attention.rows; | |
| 214 | + | const arrived = useArrived(attention.rows.map((row) => row.key)); | |
| 211 | 215 | return ( | |
| 212 | 216 | <> | |
| 213 | − | {rows.length === 0 ? ( | |
| 214 | − | <Quiet>{missing.length > 0 ? "Nothing needs you in what could be read." : "Nothing needs you. Reviews, invitations and messages for you show here as soon as they arrive."}</Quiet> | |
| 217 | + | {start ? ( | |
| 218 | + | <StartRow start={start} now={now} arrived={arrived.has(start.row.key)} /> | |
| 215 | 219 | ) : ( | |
| 216 | − | <ul> | |
| 220 | + | attention.rows.length === 0 && ( | |
| 221 | + | <Quiet>{missing.length > 0 ? "Nothing needs you in what could be read." : "Nothing needs you. Reviews, invitations and messages for you show here as soon as they arrive."}</Quiet> | |
| 222 | + | ) | |
| 223 | + | )} | |
| 224 | + | {rows.length > 0 && ( | |
| 225 | + | <ul className={cn(start && "border-t border-line")}> | |
| 217 | 226 | {rows.slice(0, ROWS).map((row) => ( | |
| 218 | 227 | <NeedItem key={row.key} row={row} now={now} arrived={arrived.has(row.key)} /> | |
| 219 | 228 | ))} | |
| ⋯ | |||
| 259 | 268 | /** Rows about a pull request: Start here's button opens it, and says so, rather than naming the step (Merge, Review) it can't take there. */ | |
| 260 | 269 | const PULL_KINDS = new Set<AttentionKind>(["ready", "review", "pull_blocked"]); | |
| 261 | 270 | ||
| 262 | − | export function StartHere({ attention, now }: { attention: Attention; now: number }) { | |
| 263 | − | const start = attention.start; | |
| 271 | + | /** The one g1t picked to start with: the first of Where you're needed, a step bigger, with why. */ | |
| 272 | + | function StartRow({ start, now, arrived }: { start: NonNullable<Attention["start"]>; now: number; arrived?: boolean }) { | |
| 273 | + | const { row } = start; | |
| 274 | + | const owner = row.owner ? (row.owner.agent ? `@${row.owner.name}` : row.owner.name) : null; | |
| 264 | 275 | return ( | |
| 265 | − | <Card | |
| 266 | − | title={ | |
| 267 | − | <> | |
| 268 | − | Start here <span className="font-normal text-faint">· picked by g1t</span> | |
| 269 | − | </> | |
| 270 | − | } | |
| 271 | − | > | |
| 272 | − | {!start ? ( | |
| 273 | − | <Quiet>{attention.missing.length > 0 ? "Nothing to pick from what could be read." : "Nothing needs you, so there's nothing to pick."}</Quiet> | |
| 274 | − | ) : ( | |
| 275 | − | <div className="px-5 pb-5"> | |
| 276 | − | <div className="flex items-start gap-3"> | |
| 277 | − | <span className="mt-0.5 shrink-0">{ICON[start.row.kind]}</span> | |
| 278 | − | <div className="min-w-0"> | |
| 279 | − | <Link to={start.row.action.to} className="text-[0.9375rem] leading-snug font-semibold text-fg hover:underline"> | |
| 280 | − | {start.row.title} | |
| 281 | − | </Link> | |
| 282 | − | <p className="mt-1 line-clamp-2 text-xs text-muted">{start.row.detail}</p> | |
| 283 | − | </div> | |
| 276 | + | <div className={cn("mx-3 mb-3 rounded-xl border border-accent/25 bg-accent/5 px-4 py-3.5", arrived && ARRIVED)}> | |
| 277 | + | <p className="text-[0.6875rem] font-medium tracking-wide text-accent uppercase">Start here · picked by g1t</p> | |
| 278 | + | <div className="mt-2 flex items-start gap-3"> | |
| 279 | + | <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span> | |
| 280 | + | <div className="min-w-0 grow"> | |
| 281 | + | <Link to={row.action.to} className="text-[0.9375rem] leading-snug font-semibold text-fg hover:underline"> | |
| 282 | + | {row.title} | |
| 283 | + | </Link> | |
| 284 | + | <p className="mt-0.5 line-clamp-2 text-xs text-muted">{row.detail}</p> | |
| 285 | + | <p className="mt-2 text-xs leading-relaxed text-fg-soft">{start.why}</p> | |
| 286 | + | <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 287 | + | <ButtonLink to={row.action.to} variant="accent" size="sm"> | |
| 288 | + | {PULL_KINDS.has(row.kind) ? "Open pull request" : row.action.label} | |
| 289 | + | <ArrowRight size={14} /> | |
| 290 | + | </ButtonLink> | |
| 291 | + | <span className="flex flex-wrap items-center gap-x-2 text-xs text-faint"> | |
| 292 | + | {row.stake && <StakeText stake={row.stake} />} | |
| 293 | + | {row.at > 0 && <span suppressHydrationWarning>waiting {waited(row.at, now)}</span>} | |
| 294 | + | {owner && ( | |
| 295 | + | <span className="inline-flex items-center gap-1"> | |
| 296 | + | <User size={11} /> | |
| 297 | + | {owner} | |
| 298 | + | </span> | |
| 299 | + | )} | |
| 300 | + | </span> | |
| 284 | 301 | </div> | |
| 285 | − | <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs"> | |
| 286 | − | {start.row.owner && ( | |
| 287 | − | <> | |
| 288 | − | <dt className="text-muted">Owner</dt> | |
| 289 | − | <dd className="flex min-w-0 items-center gap-1.5 text-fg-soft"> | |
| 290 | − | <User size={12} className="shrink-0 text-muted" /> | |
| 291 | − | <span className="truncate">{start.row.owner.agent ? `@${start.row.owner.name}` : start.row.owner.name}</span> | |
| 292 | − | </dd> | |
| 293 | − | </> | |
| 294 | − | )} | |
| 295 | − | <dt className="text-muted">Next</dt> | |
| 296 | − | <dd className="text-fg-soft">{start.row.action.label}</dd> | |
| 297 | − | {start.row.at > 0 && ( | |
| 298 | − | <> | |
| 299 | − | <dt className="text-muted">Waiting</dt> | |
| 300 | − | <dd className="text-fg-soft" suppressHydrationWarning> | |
| 301 | − | {waited(start.row.at, now)} | |
| 302 | − | </dd> | |
| 303 | − | </> | |
| 304 | − | )} | |
| 305 | − | {start.row.stake && ( | |
| 306 | − | <> | |
| 307 | − | <dt className="text-muted">At stake</dt> | |
| 308 | − | <dd> | |
| 309 | − | <StakeText stake={start.row.stake} /> | |
| 310 | − | </dd> | |
| 311 | − | </> | |
| 312 | − | )} | |
| 313 | − | </dl> | |
| 314 | − | <p className="mt-4 rounded-lg bg-raised/60 px-3 py-2.5 text-xs leading-relaxed text-fg-soft"> | |
| 315 | − | <span className="font-medium text-fg">Why this one: </span> | |
| 316 | − | {start.why} | |
| 317 | − | </p> | |
| 318 | − | <Link | |
| 319 | − | to={start.row.action.to} | |
| 320 | − | className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover" | |
| 321 | − | > | |
| 322 | − | {PULL_KINDS.has(start.row.kind) ? "Open pull request" : start.row.action.label} | |
| 323 | − | <ArrowRight size={14} /> | |
| 324 | − | </Link> | |
| 325 | 302 | </div> | |
| 326 | − | )} | |
| 327 | − | </Card> | |
| 303 | + | </div> | |
| 304 | + | </div> | |
| 328 | 305 | ); | |
| 329 | 306 | } | |
| 330 | 307 | ||
| ⋯ | |||
| 368 | 345 | /** The lines: each what was done, with its count in the words, linked to where it is listed. */ | |
| 369 | 346 | function Lines({ column }: { column: DigestColumn }) { | |
| 370 | 347 | return ( | |
| 371 | − | <ul className="mt-3 space-y-1.5"> | |
| 348 | + | <ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2"> | |
| 372 | 349 | {column.lines.map((line) => ( | |
| 373 | 350 | <li key={line.key} className="flex items-start gap-2 text-sm text-fg"> | |
| 374 | 351 | <span className="mt-1.75 size-1.5 shrink-0 rounded-full bg-fg-soft/60" aria-hidden /> | |
| ⋯ | |||
| 386 | 363 | } | |
| 387 | 364 | ||
| 388 | 365 | /** | |
| 389 | − | * What people did in the span: lines with counts linked to where each | |
| 390 | − | * thing is listed, and the most active faces. It says when it is empty, | |
| 391 | − | * and which part didn't answer. | |
| 366 | + | * What people did in the span, the top of the span's card: lines with | |
| 367 | + | * counts linked to where each thing is listed, and the most active faces. | |
| 368 | + | * It says when it is empty, and which part didn't answer. | |
| 392 | 369 | */ | |
| 393 | − | export function PeopleCard({ digest, span, code }: { digest: Digest | null; span: Pick<Span, "key" | "note" | "words">; code: boolean }) { | |
| 370 | + | export function PeopleSummary({ digest, span, code }: { digest: Digest | null; span: Pick<Span, "key" | "note" | "words">; code: boolean }) { | |
| 394 | 371 | const when = inSpan(span); | |
| 395 | 372 | if (!digest) { | |
| 396 | 373 | return ( | |
| 397 | − | <section className="rounded-2xl border border-line bg-surface p-5"> | |
| 374 | + | <div className="px-5 py-4"> | |
| 398 | 375 | <p className="text-sm text-muted">What happened couldn't be read right now. It shows here once Code and Chat answer.</p> | |
| 399 | − | </section> | |
| 376 | + | </div> | |
| 400 | 377 | ); | |
| 401 | 378 | } | |
| 402 | 379 | const silent = digest.missing.filter((name) => name === "Code" || name === "Chat"); | |
| 403 | 380 | const col = digest.people; | |
| 404 | 381 | return ( | |
| 405 | − | <section className="rounded-2xl border border-line bg-surface p-5" aria-label="What people did"> | |
| 382 | + | <div className="px-5 py-4" aria-label="What people did"> | |
| 406 | 383 | <div className="flex flex-wrap items-center gap-3"> | |
| 407 | 384 | <h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-fg"> | |
| 408 | 385 | <Users size={15} className="text-muted" /> | |
| ⋯ | |||
| 435 | 412 | </p> | |
| 436 | 413 | )} | |
| 437 | 414 | {digest.partial && <p className="mt-3 text-xs text-faint">More happened than Home reads at once, so some counts are low.</p>} | |
| 438 | − | </section> | |
| 415 | + | </div> | |
| 439 | 416 | ); | |
| 440 | 417 | } | |
| 441 | 418 | ||
| 442 | 419 | export function SpanSkeleton() { | |
| 443 | 420 | return ( | |
| 444 | − | <section className="space-y-3 rounded-2xl border border-line bg-surface p-5" aria-busy="true"> | |
| 421 | + | <div className="space-y-3 px-5 py-4" aria-busy="true"> | |
| 445 | 422 | <div className="flex items-center justify-between"> | |
| 446 | 423 | <Skeleton className="h-3.5 w-20" /> | |
| 447 | 424 | <Skeleton className="h-5 w-24 rounded-full" /> | |
| ⋯ | |||
| 449 | 426 | <Skeleton className="h-3.5 w-3/4" /> | |
| 450 | 427 | <Skeleton className="h-3.5 w-1/2" /> | |
| 451 | 428 | <Skeleton className="h-3.5 w-2/3" /> | |
| 452 | − | </section> | |
| 429 | + | </div> | |
| 453 | 430 | ); | |
| 454 | 431 | } | |
| 455 | 432 | ||
| ⋯ | |||
| 574 | 551 | function LandedItem({ change, now, tz, timeOnly, arrived }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean; arrived?: boolean }) { | |
| 575 | 552 | return ( | |
| 576 | 553 | <li className={cn(rowClass, "items-start", arrived && ARRIVED)}> | |
| 577 | − | <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span> | |
| 554 | + | <span className="mt-0.5 shrink-0">{change.failed ? <Rocket size={15} className="shrink-0 text-danger" /> : LANDED_ICON[change.kind]}</span> | |
| 578 | 555 | <div className="min-w-0 grow"> | |
| 579 | 556 | <div className="flex min-w-0 items-center gap-1.5"> | |
| 580 | 557 | {change.refsIn ? ( | |
| ⋯ | |||
| 600 | 577 | ); | |
| 601 | 578 | } | |
| 602 | 579 | ||
| 580 | + | /** How many of the span's merges and pushes are live, deploying, waiting or failed: the feed's line. */ | |
| 581 | + | function shipFacts(landed: LandedChange[]): { key: string; text: string; tone: BadgeTone }[] { | |
| 582 | + | const count = (state: ShipInfo["state"]) => landed.filter((change) => change.shipped?.state === state).length; | |
| 583 | + | const failed = count("failed") + landed.filter((change) => change.failed).length; | |
| 584 | + | const facts: { key: string; text: string; tone: BadgeTone }[] = []; | |
| 585 | + | if (count("live") > 0) facts.push({ key: "live", text: `${count("live").toLocaleString("en-US")} live`, tone: "success" }); | |
| 586 | + | if (count("deploying") > 0) facts.push({ key: "deploying", text: `${count("deploying").toLocaleString("en-US")} deploying`, tone: "info" }); | |
| 587 | + | if (count("waiting") > 0) facts.push({ key: "waiting", text: `${count("waiting").toLocaleString("en-US")} waiting to deploy`, tone: "neutral" }); | |
| 588 | + | if (failed > 0) facts.push({ key: "failed", text: `${failed.toLocaleString("en-US")} deploy failed`, tone: "danger" }); | |
| 589 | + | return facts; | |
| 590 | + | } | |
| 591 | + | ||
| 603 | 592 | /** | |
| 604 | − | * Everything that landed in the span, newest first: pull requests merged, | |
| 605 | − | * commits pushed straight to a default branch, production builds that went | |
| 606 | − | * live, releases and package versions published. Each merge and push says | |
| 593 | + | * Everything that landed in the span, newest first, in the span's card: | |
| 594 | + | * pull requests merged, commits pushed straight to a default branch, | |
| 595 | + | * releases and package versions published, and production builds no merge | |
| 596 | + | * or push already speaks for (`landedDeploys`). Each merge and push says | |
| 607 | 597 | * whether it is live yet, and one made in a harness session carries the | |
| 608 | 598 | * agent marker. Over the last 7 days, day by day. | |
| 609 | 599 | */ | |
| 610 | − | export function LandedCard({ | |
| 600 | + | export function LandedFeed({ | |
| 611 | 601 | landed, | |
| 612 | 602 | missing, | |
| 613 | 603 | code, | |
| ⋯ | |||
| 628 | 618 | const arrived = useArrived(landed?.map((change) => change.key) ?? [], scope); | |
| 629 | 619 | if (!code) return null; | |
| 630 | 620 | const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} arrived={arrived.has(change.key)} />; | |
| 621 | + | const facts = landed ? shipFacts(landed) : []; | |
| 631 | 622 | return ( | |
| 632 | − | <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}> | |
| 623 | + | <div className="border-t border-line"> | |
| 624 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-5 pt-3.5 pb-2"> | |
| 625 | + | <h3 className="text-sm font-semibold text-fg">Landed</h3> | |
| 626 | + | {landed && landed.length > 0 && <span className="text-xs text-muted">{landedHint(landed)}</span>} | |
| 627 | + | {facts.length > 0 && ( | |
| 628 | + | <ul className="flex flex-wrap gap-1.5 sm:ml-auto" aria-label="Deployed"> | |
| 629 | + | {facts.map((fact) => ( | |
| 630 | + | <li key={fact.key}> | |
| 631 | + | <Badge tone={fact.tone}>{fact.text}</Badge> | |
| 632 | + | </li> | |
| 633 | + | ))} | |
| 634 | + | </ul> | |
| 635 | + | )} | |
| 636 | + | </div> | |
| 633 | 637 | {!landed ? ( | |
| 634 | 638 | <Quiet>Code didn't answer, so what landed can't be listed right now.</Quiet> | |
| 635 | 639 | ) : landed.length === 0 ? ( | |
| 636 | 640 | <Quiet>{missing.length > 0 ? "Nothing landed in what could be read." : "Nothing merged, pushed to a default branch, deployed to production or published in this span."}</Quiet> | |
| 637 | 641 | ) : byDay ? ( | |
| 638 | 642 | landedByDay(landed, now, tz).map((day) => ( | |
| 639 | − | <section key={day.key} aria-label={day.label} className="border-t border-line first-of-type:border-t-0"> | |
| 640 | − | <h3 className="flex items-baseline gap-2 bg-raised/40 px-5 py-1.5 text-xs font-medium text-fg-soft"> | |
| 643 | + | <section key={day.key} aria-label={day.label} className="border-t border-line"> | |
| 644 | + | <h4 className="flex items-baseline gap-2 bg-raised/40 px-5 py-1.5 text-xs font-medium text-fg-soft"> | |
| 641 | 645 | {day.label} | |
| 642 | 646 | <span className="font-normal text-faint tabular-nums">{day.rows.length.toLocaleString("en-US")}</span> | |
| 643 | − | </h3> | |
| 647 | + | </h4> | |
| 644 | 648 | <Folded items={day.rows} render={render} /> | |
| 645 | 649 | </section> | |
| 646 | 650 | )) | |
| 647 | 651 | ) : ( | |
| 648 | − | <Folded items={landed} render={render} /> | |
| 652 | + | <div className="border-t border-line"> | |
| 653 | + | <Folded items={landed} render={render} /> | |
| 654 | + | </div> | |
| 649 | 655 | )} | |
| 650 | 656 | {landed && <Missing names={missing} what="everything that landed" />} | |
| 651 | − | </Card> | |
| 657 | + | </div> | |
| 652 | 658 | ); | |
| 653 | 659 | } | |
| 654 | 660 | ||
| 655 | − | // --- While you were away ----------------------------------------------------------- | |
| 661 | + | // --- Settled for you --------------------------------------------------------------- | |
| 656 | 662 | ||
| 657 | 663 | /** | |
| 658 | − | * What g1t settled for you since you were last here (a later run passed, | |
| 659 | − | * it merged, it deployed), what shipped, and what still needs you: the | |
| 660 | − | * counts on one line, then each thing that settled and why. | |
| 664 | + | * Your notifications that settled by themselves in the span (a later run | |
| 665 | + | * passed, it merged, it deployed), each with why: in the side column. | |
| 666 | + | * What shipped is the Landed feed's; what still needs you, Where you're | |
| 667 | + | * needed. | |
| 661 | 668 | */ | |
| 662 | − | export function AwayCard({ away, span, now }: { away: Away | null; span: Pick<Span, "key" | "note" | "words">; now: number }) { | |
| 663 | − | const title = span.key === "last" && span.note == null ? "While you were away" : "Settled for you"; | |
| 664 | − | if (!away) { | |
| 665 | − | return ( | |
| 666 | − | <Card title={title}> | |
| 669 | + | export function SettledCard({ away, now }: { away: Away | null; now: number }) { | |
| 670 | + | const count = away?.settled.length ?? 0; | |
| 671 | + | return ( | |
| 672 | + | <Card title="Settled for you" hint={away ? (count > 0 ? `${count.toLocaleString("en-US")} finished by themselves` : "Nothing finished by itself") : undefined} link={{ label: "Done", to: "/notifications/done" }}> | |
| 673 | + | {!away ? ( | |
| 667 | 674 | <Quiet>Your notifications couldn't be read, so what settled can't be listed right now.</Quiet> | |
| 668 | − | </Card> | |
| 669 | − | ); | |
| 670 | − | } | |
| 671 | − | const facts: { key: string; text: string; tone: BadgeTone; to: string | null }[] = []; | |
| 672 | − | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 673 | − | facts.push({ key: "settled", text: `${plural(away.settled.length, "notification")} settled`, tone: away.settled.length > 0 ? "success" : "neutral", to: "/notifications?view=done" }); | |
| 674 | − | if (away.shipped > 0) facts.push({ key: "shipped", text: `${plural(away.shipped, "change")} live`, tone: "success", to: null }); | |
| 675 | − | if (away.deploying > 0) facts.push({ key: "deploying", text: `${away.deploying.toLocaleString("en-US")} deploying`, tone: "info", to: null }); | |
| 676 | − | if (away.waiting > 0) facts.push({ key: "waiting", text: `${away.waiting.toLocaleString("en-US")} waiting to deploy`, tone: "neutral", to: null }); | |
| 677 | − | if (away.failed > 0) facts.push({ key: "failed", text: `${away.failed.toLocaleString("en-US")} deploy failed`, tone: "danger", to: null }); | |
| 678 | − | facts.push({ key: "needs", text: away.needs > 0 ? `${plural(away.needs, "thing")} still ${away.needs === 1 ? "needs" : "need"} you` : "Nothing needs you", tone: away.needs > 0 ? "warn" : "neutral", to: "#needed" }); | |
| 679 | − | return ( | |
| 680 | − | <Card title={title} hint="What finished by itself, and what is live" link={{ label: "Done", to: "/notifications?view=done" }}> | |
| 681 | − | <ul className="flex flex-wrap gap-1.5 px-5 pb-3" aria-label="In short"> | |
| 682 | − | {facts.map((fact) => ( | |
| 683 | − | <li key={fact.key}> | |
| 684 | − | {fact.to ? ( | |
| 685 | − | <Link to={fact.to} className="rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"> | |
| 686 | − | <Badge tone={fact.tone} size="md"> | |
| 687 | − | {fact.text} | |
| 688 | − | </Badge> | |
| 689 | − | </Link> | |
| 690 | − | ) : ( | |
| 691 | − | <Badge tone={fact.tone} size="md"> | |
| 692 | − | {fact.text} | |
| 693 | − | </Badge> | |
| 694 | − | )} | |
| 695 | − | </li> | |
| 696 | − | ))} | |
| 697 | − | </ul> | |
| 698 | − | {away.settled.length > 0 && ( | |
| 675 | + | ) : count === 0 ? null : ( | |
| 699 | 676 | <div className="border-t border-line"> | |
| 700 | 677 | <Folded | |
| 701 | 678 | items={away.settled} | |
| 702 | 679 | render={(row) => ( | |
| 703 | − | <li key={row.key} className={rowClass}> | |
| 704 | − | <CheckCheck size={15} className="shrink-0 text-success" aria-hidden="true" /> | |
| 680 | + | <li key={row.key} className={cn(rowClass, "items-start px-4")}> | |
| 681 | + | <CheckCheck size={14} className="mt-0.5 shrink-0 text-success" aria-hidden="true" /> | |
| 705 | 682 | <div className="min-w-0 grow"> | |
| 706 | 683 | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| 707 | 684 | {row.title} | |
| ⋯ | |||
| 712 | 689 | </p> | |
| 713 | 690 | </div> | |
| 714 | 691 | <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning> | |
| 715 | − | {waited(row.at, now)} ago | |
| 692 | + | {waited(row.at, now)} | |
| 716 | 693 | </span> | |
| 717 | 694 | </li> | |
| 718 | 695 | )} | |
| ⋯ | |||
| 723 | 700 | ); | |
| 724 | 701 | } | |
| 725 | 702 | ||
| 726 | − | export function DeploysCard({ | |
| 727 | − | deploys, | |
| 728 | − | complete, | |
| 729 | − | code, | |
| 730 | − | now, | |
| 731 | − | tz, | |
| 732 | − | scope, | |
| 733 | − | }: { | |
| 734 | − | deploys: DeployRow[] | null; | |
| 735 | − | complete: boolean; | |
| 736 | − | code: boolean; | |
| 737 | − | now: number; | |
| 738 | − | tz: string | null; | |
| 739 | − | scope?: string; | |
| 740 | − | }) { | |
| 741 | − | const arrived = useArrived(deploys?.map((row) => row.key) ?? [], scope); | |
| 742 | − | // A workspace that doesn't deploy has nothing to say here. | |
| 743 | − | if (!code || (deploys && deploys.length === 0)) return null; | |
| 744 | − | const failed = deploys?.filter((row) => row.outcome === "failed").length ?? 0; | |
| 745 | − | return ( | |
| 746 | − | <Card title="Deploys" hint={deploys ? `${deploys.length.toLocaleString("en-US")} finished${failed ? ` · ${failed} failed` : ""}` : undefined}> | |
| 747 | − | {!deploys ? ( | |
| 748 | − | <Quiet>Deployments didn't answer, so builds can't be listed right now.</Quiet> | |
| 749 | − | ) : ( | |
| 750 | − | <Folded | |
| 751 | − | items={deploys} | |
| 752 | − | render={(row) => ( | |
| 753 | − | <li key={row.key} className={cn(rowClass, arrived.has(row.key) && ARRIVED)}> | |
| 754 | − | <Rocket size={15} className={cn("shrink-0", row.outcome === "failed" ? "text-danger" : "text-success")} /> | |
| 755 | − | <div className="min-w-0 grow"> | |
| 756 | − | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| 757 | − | {row.kind === "production" ? "Production" : `Preview of ${row.branch ?? "a branch"}`} of {row.project} | |
| 758 | − | <span className={cn("ml-2 text-xs font-medium", row.outcome === "failed" ? "text-danger" : "text-success")}> | |
| 759 | − | {row.outcome === "failed" ? "Failed" : "Went live"} | |
| 760 | − | </span> | |
| 761 | − | </Link> | |
| 762 | − | <p className="truncate text-xs text-muted"> | |
| 763 | − | <span className="font-mono">{row.commit}</span> · by {row.by} | |
| 764 | − | </p> | |
| 765 | − | </div> | |
| 766 | − | <When at={row.at} now={now} tz={tz} /> | |
| 767 | − | </li> | |
| 768 | − | )} | |
| 769 | − | /> | |
| 770 | − | )} | |
| 771 | − | {deploys && !complete && <p className="border-t border-line px-5 py-2.5 text-xs text-faint">Some projects' builds couldn't be read.</p>} | |
| 772 | − | </Card> | |
| 773 | − | ); | |
| 774 | − | } | |
| 775 | − | ||
| 776 | 703 | // --- Spend in the span ------------------------------------------------------------ | |
| 777 | 704 | ||
| 778 | 705 | /** | |
| ⋯ | |||
| 795 | 722 | <Quiet>Billing didn't answer, so spend can't be shown right now.</Quiet> | |
| 796 | 723 | ) : ( | |
| 797 | 724 | <div className="px-5 pb-5"> | |
| 798 | − | <div className="text-3xl font-semibold tracking-tight text-fg tabular-nums">{money(spend.totalMicros)}</div> | |
| 725 | + | <div className="text-2xl font-semibold tracking-tight text-fg tabular-nums">{money(spend.totalMicros)}</div> | |
| 799 | 726 | <p className="mt-1 text-xs text-muted"> | |
| 800 | 727 | {spend.from === spend.to ? utcDay(spend.from) : `${utcDay(spend.from)} – ${utcDay(spend.to)}`} | |
| 801 | 728 | {spend.monthMicros != null && ` · ${money(spend.monthMicros)} this month so far`} | |
| ⋯ | |||
| 820 | 747 | </ul> | |
| 821 | 748 | </> | |
| 822 | 749 | )} | |
| 823 | − | <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint"> | |
| 824 | − | At price, as your statement shows it. The statement keeps whole days, midnight to midnight UTC, so this counts from the start of the day the span began. | |
| 825 | − | </p> | |
| 750 | + | <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">At price, by whole UTC days from the day the span began.</p> | |
| 826 | 751 | </div> | |
| 827 | 752 | )} | |
| 828 | 753 | </Card> | |
| ⋯ | |||
| 857 | 782 | <Folded | |
| 858 | 783 | items={rows} | |
| 859 | 784 | render={(row) => ( | |
| 860 | − | <li key={row.key} className={cn(rowClass, arrived.has(row.key) && ARRIVED)}> | |
| 861 | − | <span className="shrink-0">{RUNNING_ICON[row.kind]}</span> | |
| 785 | + | // In the side column: the title gets the width, its state and how long under it. | |
| 786 | + | <li key={row.key} className={cn(rowClass, "items-start px-4", arrived.has(row.key) && ARRIVED)}> | |
| 787 | + | <span className="mt-0.5 shrink-0">{RUNNING_ICON[row.kind]}</span> | |
| 862 | 788 | <div className="min-w-0 grow"> | |
| 863 | − | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| 789 | + | <Link to={row.to} className="line-clamp-2 text-sm text-fg hover:underline"> | |
| 864 | 790 | {row.title} | |
| 865 | 791 | </Link> | |
| 866 | 792 | <p className="truncate text-xs text-muted">{row.detail}</p> | |
| 793 | + | <p className="mt-1 flex items-center gap-1.5 text-xs text-fg-soft"> | |
| 794 | + | <Loader size={11} className="animate-spin text-accent motion-reduce:animate-none" aria-hidden /> | |
| 795 | + | {row.status} | |
| 796 | + | <span className="text-faint tabular-nums" suppressHydrationWarning> | |
| 797 | + | · {waited(row.at, now)} | |
| 798 | + | </span> | |
| 799 | + | </p> | |
| 867 | 800 | </div> | |
| 868 | − | <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-raised px-2 py-0.5 text-xs text-fg-soft"> | |
| 869 | − | <Loader size={11} className="animate-spin text-accent motion-reduce:animate-none" aria-hidden /> | |
| 870 | − | {row.status} | |
| 871 | − | </span> | |
| 872 | − | <span className="hidden w-14 shrink-0 text-right text-xs text-faint tabular-nums sm:inline" suppressHydrationWarning> | |
| 873 | − | {waited(row.at, now)} | |
| 874 | − | </span> | |
| 875 | 801 | </li> | |
| 876 | 802 | )} | |
| 877 | 803 | /> | |
| 7 | 7 | type AttentionRow, | |
| 8 | 8 | type CodeNeed, | |
| 9 | 9 | type CodePull, | |
| 10 | + | type DeployRow, | |
| 10 | 11 | type LandedChange, | |
| 11 | 12 | actorKind, | |
| 12 | 13 | agentLabel, | |
| ⋯ | |||
| 514 | 515 | { key: "deploy:site:d2", project: "site", kind: "preview", outcome: "live", commit: "abcdef2", branch: "fix", at: NOW - HOUR, to: "/acme/site/deployments/d2", by: "bo" }, | |
| 515 | 516 | { key: "deploy:site:d3", project: "site", kind: "production", outcome: "failed", commit: "abcdef3", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d3", by: "bo" }, | |
| 516 | 517 | ]); | |
| 517 | − | assert.deepEqual(builds.map((change) => [change.kind, change.title]), [["deploy", "Production of site went live"]]); | |
| 518 | − | const all = landed([fromActivity, builds]); | |
| 519 | 518 | assert.deepEqual( | |
| 519 | + | builds.map((change) => [change.kind, change.title, change.failed ?? false]), | |
| 520 | + | [ | |
| 521 | + | ["deploy", "Production of site went live", false], | |
| 522 | + | ["deploy", "Production of site failed to deploy", true], | |
| 523 | + | ], | |
| 524 | + | ); | |
| 525 | + | const all = landed([fromActivity, builds.slice(0, 1)]); | |
| 526 | + | assert.deepEqual( | |
| 520 | 527 | all.map((change) => change.key), | |
| 521 | 528 | ["push:rep_cli", "landed:deploy:site:d1", "package:rep_api:container:api:1.4.0", "push:rep_web", "release:rep_web:v1.4.0", "push:rep_api"], | |
| 522 | 529 | ); | |
| 523 | 530 | }); | |
| 524 | 531 | ||
| 532 | + | test("a production build is its own row only where no merge or push on the list says it", () => { | |
| 533 | + | const row = (key: string, commit: string): DeployRow => ({ key, project: "site", kind: "production", outcome: "live", commit, branch: null, at: NOW - HOUR, to: `/acme/site/deployments/${key}`, by: "bo" }); | |
| 534 | + | const merge: LandedChange = { key: "merge:1", kind: "merge", title: "Fix", detail: "", to: "/acme/site/pull/1", at: NOW - 2 * HOUR, sha: "aaaaaaa111", repoId: "r" }; | |
| 535 | + | const push: LandedChange = { key: "push:1", kind: "push", title: "Push", detail: "", to: "/acme/site/activity", at: NOW - 2 * HOUR, sha: "bbbbbbb222", repoId: "r", shipped: { state: "live", at: NOW - HOUR, to: "/acme/site/deployments/d2" } }; | |
| 536 | + | const builds = landedDeploys([row("d1", "aaaaaaa"), row("d2", "ccccccc"), row("d3", "ddddddd")], [merge, push]); | |
| 537 | + | assert.deepEqual(builds.map((change) => change.key), ["landed:d3"]); | |
| 538 | + | }); | |
| 539 | + | ||
| 525 | 540 | const deploy = (over: Partial<Deployment>): Deployment => ({ | |
| 526 | 541 | id: "d1", | |
| 527 | 542 | kind: "production", | |
| 525 | 525 | agent?: AgentMark | null; | |
| 526 | 526 | /** A title that is a commit's subject: its repository (`owner/name`), whose issues its `#12`s are. */ | |
| 527 | 527 | refsIn?: string; | |
| 528 | + | /** A production build that failed (`landedDeploys`). */ | |
| 529 | + | failed?: boolean; | |
| 528 | 530 | }; | |
| 529 | 531 | ||
| 530 | 532 | /** Whether a change is live: shipped by a production deployment, on its way, waiting, or the deployment after it failed. */ | |
| ⋯ | |||
| 617 | 619 | return rows; | |
| 618 | 620 | } | |
| 619 | 621 | ||
| 620 | − | /** Production builds that went live in the span, as landed changes; previews stay in Deploys. */ | |
| 621 | − | export function landedDeploys(deploys: DeployRow[]): LandedChange[] { | |
| 622 | + | /** | |
| 623 | + | * Production builds of the span as landed changes, only where nothing on | |
| 624 | + | * the list already says so: a merge or push whose live label links to the | |
| 625 | + | * build (`shipped.to`), or whose commit it built. So a build that shipped | |
| 626 | + | * a merge is that merge's "Live" label, not a row of its own; a redeploy, | |
| 627 | + | * a rollback or a build of a project whose pushes weren't read is a row, | |
| 628 | + | * and so is one that failed (`failed`). Previews stay on their project's | |
| 629 | + | * Deployments. | |
| 630 | + | */ | |
| 631 | + | export function landedDeploys(deploys: DeployRow[], shown: readonly LandedChange[] = []): LandedChange[] { | |
| 632 | + | const said = (row: DeployRow) => | |
| 633 | + | shown.some((change) => (change.shipped?.to != null && change.shipped.to === row.to) || (!!change.sha && !!row.commit && change.sha.startsWith(row.commit))); | |
| 622 | 634 | return deploys | |
| 623 | − | .filter((row) => row.kind === "production" && row.outcome === "live") | |
| 635 | + | .filter((row) => row.kind === "production" && !said(row)) | |
| 624 | 636 | .map((row) => ({ | |
| 625 | 637 | key: `landed:${row.key}`, | |
| 626 | 638 | kind: "deploy" as const, | |
| 627 | − | title: `Production of ${row.project} went live`, | |
| 639 | + | title: row.outcome === "failed" ? `Production of ${row.project} failed to deploy` : `Production of ${row.project} went live`, | |
| 628 | 640 | detail: `${row.commit} · by ${row.by ?? "someone"}`, | |
| 629 | 641 | to: row.to, | |
| 630 | 642 | at: row.at, | |
| 643 | + | ...(row.outcome === "failed" ? { failed: true } : {}), | |
| 631 | 644 | })); | |
| 632 | 645 | } | |
| 633 | 646 | ||
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/home"; | |
| 7 | 7 | import { | |
| 8 | − | AwayCard, | |
| 9 | 8 | Card, | |
| 10 | − | DeploysCard, | |
| 11 | − | LandedCard, | |
| 9 | + | LandedFeed, | |
| 12 | 10 | NeedsList, | |
| 13 | − | PeopleCard, | |
| 11 | + | PeopleSummary, | |
| 14 | 12 | RowsSkeleton, | |
| 15 | 13 | RunningList, | |
| 14 | + | SettledCard, | |
| 16 | 15 | SpanSkeleton, | |
| 17 | 16 | SpendCard, | |
| 18 | 17 | SpendSkeleton, | |
| 19 | − | StartHere, | |
| 20 | 18 | WindowSwitch, | |
| 21 | 19 | } from "../../components/home"; | |
| 22 | 20 | import { HomeAsk, WhatChangedCard } from "../../components/home-ask"; | |
| ⋯ | |||
| 154 | 152 | const named = (key: string) => (key ? (names[key] ?? "someone") : "an import"); | |
| 155 | 153 | const each = pushes ? pushRows(pushes.pushes.map((push) => ({ ...push, by: named(push.actorKey) })), pushes.history, span) : []; | |
| 156 | 154 | const fromActivity = landedFromActivity(activity, repoRefs(owned), names, slug).filter((row) => !(row.kind === "push" && pushes?.history[row.key.slice("push:".length)])); | |
| 157 | − | const all = merges == null && activity == null ? null : landed([merges ?? [], each, fromActivity, landedDeploys(deployRows ?? [])]); | |
| 155 | + | const all = merges == null && activity == null ? null : landed([merges ?? [], each, fromActivity]); | |
| 158 | 156 | // Whether each merge and push is live, and which pull requests a harness session made: read together. | |
| 159 | 157 | const [shipped, sessions] = all | |
| 160 | 158 | ? await Promise.all([ | |
| ⋯ | |||
| 162 | 160 | codeWork && codeWork !== "no_access" ? loadPullSessions(viewer, codeWork.pulls.filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))).catch(() => null) : null, | |
| 163 | 161 | ]) | |
| 164 | 162 | : [null, null]; | |
| 163 | + | // Production builds only where no merge or push already says it went live (or failed). | |
| 164 | + | const told = all ? withSessions(withShipped(all, shipped), sessions) : null; | |
| 165 | 165 | return { | |
| 166 | 166 | // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read. | |
| 167 | − | landed: all ? withSessions(withShipped(all, shipped), sessions) : null, | |
| 167 | + | landed: told ? landed([told, landedDeploys(deployRows ?? [], told)]) : null, | |
| 168 | 168 | landedMissing: [ | |
| 169 | 169 | ...(code && merges == null ? ["Pull requests"] : []), | |
| 170 | 170 | ...(code && activity == null ? ["Pushes and releases"] : []), | |
| 171 | 171 | ...(code && deployRows == null ? ["Deployments"] : []), | |
| 172 | 172 | ], | |
| 173 | − | deploys: deployRows, | |
| 174 | − | deploysComplete: deploys?.complete ?? true, | |
| 175 | 173 | }; | |
| 176 | 174 | }) | |
| 177 | 175 | .catch(() => null); | |
| ⋯ | |||
| 291 | 289 | </p> | |
| 292 | 290 | </header> | |
| 293 | 291 | ||
| 294 | − | {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */} | |
| 295 | − | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> | |
| 296 | − | <div id="needed" className="scroll-mt-4 lg:col-start-1 lg:row-start-1"> | |
| 292 | + | {/* | |
| 293 | + | Two columns on wide screens: what to do and what happened on the | |
| 294 | + | left, read top to bottom; what is going on now and what it costs on | |
| 295 | + | the right, kept in view. On a phone it is one column: Where you're | |
| 296 | + | needed, Running now, what happened, then Spent and Settled (the side | |
| 297 | + | column's cards join it, `contents`, each in its place by `order`). | |
| 298 | + | */} | |
| 299 | + | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 300 | + | {/* 1. Where you're needed now, with the one to start with on top: no window. */} | |
| 301 | + | <div id="needed" className="order-1 scroll-mt-4 lg:col-start-1 lg:row-start-1"> | |
| 297 | 302 | <Card | |
| 298 | 303 | title="Where you're needed" | |
| 299 | 304 | hint="Everything waiting on you, however long ago it started" | |
| ⋯ | |||
| 304 | 309 | </Suspense> | |
| 305 | 310 | </Card> | |
| 306 | 311 | </div> | |
| 307 | − | <div className="lg:col-start-2 lg:row-start-1"> | |
| 308 | − | <Suspense | |
| 309 | − | fallback={ | |
| 310 | − | <Card title="Start here"> | |
| 311 | − | <RowsSkeleton rows={1} /> | |
| 312 | − | </Card> | |
| 313 | − | } | |
| 314 | − | > | |
| 315 | − | <Await resolve={waiting}>{(att) => <StartHere attention={att} now={now} />}</Await> | |
| 316 | − | </Suspense> | |
| 317 | − | </div> | |
| 318 | − | </div> | |
| 319 | 312 | ||
| 320 | − | {/* 2. What happened in the span. */} | |
| 321 | − | <section aria-labelledby="since" className="space-y-4"> | |
| 322 | − | <div className="flex flex-wrap items-center gap-x-3 gap-y-2 pt-2"> | |
| 323 | − | <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1"> | |
| 324 | − | <h2 id="since" className="text-lg font-semibold tracking-tight text-fg"> | |
| 325 | − | {span.key === "last" && span.note == null ? "Since you were last here" : span.words} | |
| 326 | − | </h2> | |
| 327 | − | <p className="text-sm text-muted"> | |
| 328 | − | {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)} | |
| 329 | − | </p> | |
| 330 | − | </div> | |
| 331 | − | <div className="sm:ml-auto"> | |
| 332 | − | <WindowSwitch span={span} /> | |
| 333 | − | </div> | |
| 334 | − | </div> | |
| 335 | − | <WhatChangedCard key={span.key} slug={slug} span={span} tz={tz} /> | |
| 336 | − | <Suspense | |
| 337 | − | fallback={ | |
| 338 | − | <Card title={span.key === "last" && span.note == null ? "While you were away" : "Settled for you"}> | |
| 339 | − | <RowsSkeleton rows={2} /> | |
| 340 | − | </Card> | |
| 341 | − | } | |
| 342 | − | > | |
| 343 | − | <Await resolve={away}>{(value) => <AwayCard away={value} span={span} now={now} />}</Await> | |
| 344 | − | </Suspense> | |
| 345 | − | <Suspense fallback={<SpanSkeleton />}> | |
| 346 | − | <Await resolve={digest}>{(value) => <PeopleCard digest={value} span={span} code={loaderData.code} />}</Await> | |
| 347 | − | </Suspense> | |
| 348 | − | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> | |
| 349 | − | <div className="space-y-5"> | |
| 313 | + | {/* 2. Now: what is running, what it cost, what settled by itself. */} | |
| 314 | + | <aside aria-label="Now" className="contents lg:sticky lg:top-4 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:block lg:space-y-5"> | |
| 315 | + | <Card title="Running now" className="order-2"> | |
| 316 | + | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 317 | + | <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await> | |
| 318 | + | </Suspense> | |
| 319 | + | </Card> | |
| 320 | + | <div className="order-4"> | |
| 350 | 321 | <Suspense | |
| 351 | 322 | fallback={ | |
| 352 | − | <Card title="Landed"> | |
| 353 | − | <RowsSkeleton rows={3} /> | |
| 323 | + | <Card title="Spent"> | |
| 324 | + | <SpendSkeleton /> | |
| 354 | 325 | </Card> | |
| 355 | 326 | } | |
| 356 | 327 | > | |
| 357 | − | <Await resolve={happened}> | |
| 358 | − | {(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} byDay={span.key === "7d"} scope={span.key} />} | |
| 359 | − | </Await> | |
| 360 | − | </Suspense> | |
| 361 | − | <Suspense fallback={null}> | |
| 362 | − | <Await resolve={happened}> | |
| 363 | − | {(value) => <DeploysCard deploys={value?.deploys ?? null} complete={value?.deploysComplete ?? true} code={loaderData.code} now={now} tz={tz} scope={span.key} />} | |
| 364 | − | </Await> | |
| 328 | + | <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await> | |
| 365 | 329 | </Suspense> | |
| 366 | 330 | </div> | |
| 367 | − | <div className="space-y-5"> | |
| 331 | + | <div className="order-5"> | |
| 368 | 332 | <Suspense | |
| 369 | 333 | fallback={ | |
| 370 | − | <Card title="Spent"> | |
| 371 | − | <SpendSkeleton /> | |
| 334 | + | <Card title="Settled for you"> | |
| 335 | + | <RowsSkeleton rows={1} /> | |
| 372 | 336 | </Card> | |
| 373 | 337 | } | |
| 374 | 338 | > | |
| 375 | − | <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await> | |
| 339 | + | <Await resolve={away}>{(value) => <SettledCard away={value} now={now} />}</Await> | |
| 376 | 340 | </Suspense> | |
| 377 | 341 | </div> | |
| 378 | − | </div> | |
| 379 | − | </section> | |
| 342 | + | </aside> | |
| 380 | 343 | ||
| 381 | − | {/* 3. Running now. */} | |
| 382 | − | <Card title="Running now"> | |
| 383 | − | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 384 | − | <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await> | |
| 385 | − | </Suspense> | |
| 386 | − | </Card> | |
| 344 | + | {/* 3. What happened in the span: who did what, what changed in plain English, what landed. */} | |
| 345 | + | <section aria-labelledby="since" className="order-3 rounded-2xl border border-line bg-surface lg:col-start-1 lg:row-start-2"> | |
| 346 | + | <header className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 pt-4"> | |
| 347 | + | <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1"> | |
| 348 | + | <h2 id="since" className="text-base font-semibold tracking-tight text-fg"> | |
| 349 | + | {span.key === "last" && span.note == null ? "Since you were last here" : span.words} | |
| 350 | + | </h2> | |
| 351 | + | <p className="text-xs text-muted">{span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)}</p> | |
| 352 | + | </div> | |
| 353 | + | <div className="sm:ml-auto"> | |
| 354 | + | <WindowSwitch span={span} /> | |
| 355 | + | </div> | |
| 356 | + | </header> | |
| 357 | + | <Suspense fallback={<SpanSkeleton />}> | |
| 358 | + | <Await resolve={digest}>{(value) => <PeopleSummary digest={value} span={span} code={loaderData.code} />}</Await> | |
| 359 | + | </Suspense> | |
| 360 | + | <WhatChangedCard key={span.key} slug={slug} span={span} tz={tz} /> | |
| 361 | + | <Suspense | |
| 362 | + | fallback={ | |
| 363 | + | <div className="border-t border-line pt-4"> | |
| 364 | + | <RowsSkeleton rows={3} /> | |
| 365 | + | </div> | |
| 366 | + | } | |
| 367 | + | > | |
| 368 | + | <Await resolve={happened}> | |
| 369 | + | {(value) => ( | |
| 370 | + | <LandedFeed | |
| 371 | + | landed={value?.landed ?? null} | |
| 372 | + | missing={value?.landedMissing ?? []} | |
| 373 | + | code={loaderData.code} | |
| 374 | + | now={now} | |
| 375 | + | tz={tz} | |
| 376 | + | byDay={span.key === "7d"} | |
| 377 | + | scope={span.key} | |
| 378 | + | /> | |
| 379 | + | )} | |
| 380 | + | </Await> | |
| 381 | + | </Suspense> | |
| 382 | + | </section> | |
| 383 | + | </div> | |
| 387 | 384 | </div> | |
| 388 | 385 | ); | |
| 389 | 386 | } | |
Comments
Sign in to comment.