diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc2fa3df..111f37d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,9 +16,21 @@ jobs: node-version: "24" cache: npm + # also builds shared/dist and installs frontend-v2 (package.json postinstall) - run: npm install - name: Lint run: npm run lint - run: npm run build + + - name: Server tests + run: npm test + + - name: Frontend types + working-directory: frontend-v2 + run: npx tsc --noEmit + + - name: Frontend tests + working-directory: frontend-v2 + run: npx vitest run diff --git a/DESIGN.md b/DESIGN.md index 21bc1c4d..49c4d6e6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,6 +31,16 @@ obvious." Tokens are deliberately tempered (chroma below brand); dark-mode values are re-derived, not brightened (the old dark amber was highlighter yellow at 84% lightness). +**One owed call, one amber mark per view.** Amber goes on the one word +that names what someone owes (an update due, a plan past its end, an at +risk or off track health word, a call Decide asks for); the facts behind +it (dates, targets, counts, durations) stay ink. A 2026-10 review counted +one Decide call drawn as 0, 1, 2 and 5 amber marks on four views, and at +100 projects amber sat on most rows, so it stopped meaning "look here." +On Decide, a row's question ("New end?") is its one amber mark, and the +facts above it stay ink. Counts are never amber, however urgent what they +count: a tile that counts what's owed puts the amber on its word. + **Never a background wash for state.** A filled color area out-competes titles at any opacity — salience is form, not volume. Three progressively softer washes all failed before this became a rule: put the signal on the @@ -119,7 +129,10 @@ popover era dismissed a commitment-in-progress on any stray click.) **Ranked lanes explain themselves in three quiet layers**: the sub-line says the ordering in one plain sentence (and is itself the hover-door to -the full story — existing text becomes interactive, no info-icon chrome); +the full story — existing text becomes interactive, no info-icon chrome), +at most one visible per pane, and only where a threshold or an unclear +inclusion changes what you see (Decide's sections have none: each carries +its rule in its header's hover); each card's state popover carries a "why it's up next" line; and every fold's label glosses what lands in it on hover. If a user has to ask why a card is where it is, one of these layers is missing. @@ -269,10 +282,192 @@ icon with a class bolted on. no status dot (the rows inside carry their own pips — a second color code on the band was redundant weight). Clicking anywhere on the band toggles, including the label; hovering the label opens its - one-sentence gloss. Primary lanes greet you with groups open; ledger + one-sentence gloss. A screen reader hears the gloss with the band + (`aria-describedby`), and the hover target is not a second focus + stop. Primary lanes greet you with groups open; ledger groups rest closed, so a quiet stack of bands reads as a table of contents. An explicit open/closed choice is remembered per fold id. +- **Focus is a solid 2px brand ring**, at least 3:1 against both themes' + surfaces (the old 50% gray glow measured about 1.5:1, and on a text + button it was the only sign of focus). A full-width band (a `summary` + in a clipped card) draws it inside its own edge. Never suppress it. +- **Global element rules live in CSS layers** (`@layer base`, `@layer + components`), so a utility on the element wins: unlayered, the pointer + cursor beat the roadmap bars' `cursor-grab`, and `.hit`'s `position: + relative` pulled a corner Close back into the flow. + +## The Projects tab + +- **Every view counts every PR, whatever the filter bar narrows**, so the + tab hides the bar's filters (the PR search stays). A project whose PRs + are filtered out would otherwise look quiet or finished, and one click on + a face (which set an author filter) dropped "in progress" from 11 + projects to 1. +- **A person clicked anywhere in Projects opens People with them + picked.** It never leaves the tab or sets a filter. +- **A view's picks stay with it.** Switching views clears the old view's + sort, team, person, week, narrowing, grouping and Show choice, so one + view never quietly narrows the next; the date range, the find and the + roadmap's zoom carry over, each shown where it applies. The view switcher + is a tab list drawn like the app's lens tabs (it goes somewhere; a pill + segmented control is for a choice within a view): arrows move focus, + Enter switches, and Decide's count rides as a corner badge so it can't + widen the strip. +- **One word per shared fact, kept in `model/words.ts`** ("No plan", + "Update due", "3 weeks past its end", "42 days" in a sentence and "42d" + in a cell). A new shared fact gets its word there first. "In progress" + is only ever a plan's status; projects with PRs moving are "being worked + on", and every count of them names its window ("this week", "in the last + 14 days"), since the same word over two windows gave two numbers. +- **Text buttons and fact links have one look each** (`components/bits.tsx`). + `TextButton` is an action said in words (Undo, Try again, Copy as text) in + brand, or a step back (Cancel, Close) in quiet ink. `FactLink` is a fact + that goes somewhere when clicked (a count that jumps to its rows, a lead, + a plan): ink with a quiet underline, since it isn't an action. +- **j and k move between a list's rows** wherever a list is long enough to + triage (`components/useRowKeys.ts`), opening a "+ N more" on the way past + so every row is reachable, the way they move between PRs on the board. +- **One filled button, one failure line.** `PrimaryButton` ends a form; + a failed load is `LoadFailed` (grey, with Try again: nobody owes a + failed fetch, so never amber, and red stays CI's); a save confirms in + place, where the click was. +- **People is the tab's one person table**, and every column on it follows + the picked range. One header serves every team's fold and sticks, so a + person clicked anywhere lands on labeled numbers; changing the range + keeps the old numbers up, dimmed, until the new ones arrive. Its columns + answer who has too much on: "Wrote on" (projects they wrote PRs for) with + "Reviewed on" beside it, the same two words and counts as Look back's; + Open, Opened, Merged and On non-developers' PRs sit in the opened row (a + moved column rejoins the header while the list sorts by it). Overload + says so in words, "overloaded" inline after the name in amber, the count + in ink. It counts only the projects someone wrote PRs for, never ones they + only reviewed (a reviewer is spread by the job), and the sub-line says so + ("4 or more projects they wrote for is overloaded"): a column that counted + both sat under a line about one and read as a contradiction. When more + than a quarter of people would cross the line it singles out nobody (a + flag on 8 of 23 meant nothing), but never reads as nobody crossing: the + tile says "None singled out" with the count as its note ("6 of 23 wrote + for 4 or more") and sorts the list by Wrote on, and the narrowed list says + the same sentence. The Overview's Overloaded tile keeps its window in its + note ("last 14 days: djmetzle, kwiens and 2 more"), as every tile in that + row does, and opens People on the same 14 days; the Stamps tile's note is + the short mark ("+47"), the sentence in its hover. With no teams set up the + fold reads "Everyone", not "Non-developers", and hovers say "People", not + "Developers". +- **The Overview opens on what's owed**: rows Decide asks about first, in + Decide's own order, then updates owed, then the rest, so its top rows and + Decide's list can't disagree. Its first tab counts projects being worked + on in the last 14 days, plus asked parked or finished work whose PRs + still move, and names the stalled ones inside it ("Being worked on · 43 + (13 stalled)"), so it and the Stalled tile agree on sight. A tile or chart pick that narrows the list is a toggle, + outlined like the chip it puts in the list's sticky bar; letting it go + returns to that tab. The column names stay in view as the rows scroll. +- **A project's page answers in place.** Decide's call strip sits under + its "Decide asks" line; each PR count says where those PRs are on the + page and opens them; its range numbers are one sentence under the + backlog chart, not tiles that open nothing; and its finish forecast + comes from issues closed and added, or there's none. Under 3 closes and + adds together in four weeks it only counts ("1 closed, 1 added in four + weeks"): a pace from one of each read as a verdict. The flag for a + single author says "one author" ("One author: timothyasp · 6 PRs in the + last 14 days"), since the People column beside it shows reviewers too. + A PR someone commented on without a stamp says "mlahargou is looking at + it", a sentence like its siblings ("is testing it"). The Plan's reason + is the short form ("PRs open since Jul 31."), since Status already says + "No plan". +- **Keeping it current costs nobody a visit.** The people who keep Projects + current are the developers and leads shipping the PRs, and other project + tools were dropped for the upkeep they asked. So what can be read off PRs, + labels, issue links and activity is filled in, with Undo, before anyone is + asked for it: + - **A PR counts where it links.** Its project is its label (not the + one-offs label), else the project whose issue it links (a closing + reference or "Parts of #N"), else none, by one rule on every view and the + server. Of 298 merged PRs, 54% linked an issue and 2% carried a project + label. One that links two projects' issues counts under the first and + shows in "In two projects". + - **Issues join by their links.** An issue a project's PRs link joins it, + its line saying "linked by PR #N", with a Remove that sticks. It's one + hop, from an issue the project has by label, by hand or as its own, so a + shared epic can't pull everything in; an issue two projects' PRs link + joins neither, and one another project has stays a suggestion. + - **An update is owed only when the numbers can't vouch.** A plan under + way whose PRs merged in the last 14 days, inside its issues' pace and + any hard end, owes none, unless its lead's last update said at risk or + off track, or its open PRs pile up: 3 or more that grew by 3 in 14 days, + or whose median age passed 30 days (Jarred, Oct 5: a merge says less than + a pile growing behind it). A row says "no update needed", the page says + the numbers. Days + are the team's (`projects.timeZone`), so the board and /api/v1 agree on + "past its end". The rest open on a drafted update with "Post as drafted". A + Planned plan whose PRs moved since its start reads In progress; that's + derived, never written. + - **A new plan takes what its issue says**: its Start date, its Priority + for its place, and a target still ahead as the outlined answer ("Through + its target, Oct 21"). +- **Every save can be taken back, and nothing asks twice.** A plan's Save, + a Remove, a posted update and People's "Go back to config.js" each leave + a receipt where the click was, with Undo; a removed plan is kept, so its + Undo brings back its updates, place and waits. "/" finds within the + Overview, Roadmap and People; on a project's page "a" adds an issue and + "u" posts an update. A new plan opens with its name and end, the rest + behind More. +- **A default is safe to take blind.** An outlined end never shortens a + plan, and "New end?" offers only ends after its current one. Where + nothing is asked (a page's Plan line, an Overview row) there's no strip at + rest, only a quiet "Change" that opens one with nothing outlined. +- **Every guess can be checked in one place.** Decide ends with "Filled in + on its own this week": issues that joined by a link (each with its + Remove), PRs counted by a link, plans read In progress from their PRs, and + updates skipped because the numbers vouched. +- **A project reads sensibly before anyone sets it up**, as prod will on day + one (`?state=fresh` on the dummy board: labels on PRs, nothing else). Only + an issue in the projects repo (`settings.repo`) names a project and sets + its lead; a labeled issue anywhere else is work inside the project, since + on real data people label task issues, and the first one labeled renamed + the project. With no projects repo set, every name comes from its label. + A project with no naming issue is named from its slug, with an acronym + list so it reads as people write it ("GA4 events", "PLP server + component", "npm token"), and its header says "No issue names it yet", + whose door says to label one in the projects repo. With no projects repo + set that fact is gone: it was true of every project and nothing could + change it. It's led by its author with the most PRs, and when that author + is its only one the header shows no face, since the lead fact already + names them (once it was the face, the lead and "One author", in three + lines). + An unassigned issue has no lead, never "ghost". A guessed lead's login is + its normal link, followed by the word "guessed" in ink-3, + dotted-underlined, whose hover gives the reason; the same look on the + Overview, Decide and the project page. The dots sit on the word, never + the login, since the login opens People. When every lead in a table or + section is guessed it's said once (the column head "Guessed lead", a + phrase that reads in caps where "Lead, guessed" read as a CSV header; + Decide's "Leads guessed" line per section) and rows show just the + login. With no projects repo there's no issue to assign, so the hover + says only "Give its plan a lead to name one". A project's start, while its work is open, is its oldest open PR, + the same day on every view. +- **Yours first.** The Overview opens with your projects (you lead it, or + you have a PR open or merged in the last 14 days), and My work carries a + "Your projects" fold, amber only when the call is yours. + The Yours table keeps the Time open and Last activity columns as blank + slots (blank head and cell, same widths as All projects), so the Lead and + the other columns line up between the two tables. +- **The review board knows projects**, by the tab's one rule: a parked + project's PRs sink in every ranked lane (their why says so), the last two + open PRs of a plan in progress win ties, and the PR popover names the + project. Nothing leaves the board, and no lane changes. +- **A call is made where it's seen.** The call strip sits under a project + page's Plan line for any project and in an Overview row's details, + starting collapsed wherever a question is asked (the Roadmap's plan + details show the full set from the start), and + each Decide section answers every row at once, the wording naming the day + its button does ("Promise all 7 by Oct 31"), each row keeping its + receipt, with Undo all. +- **Open boards stay current.** The server sends `projectsChanged` after + every Projects write and sync; the tab refetches once a burst settles, + keeping the old numbers on screen meanwhile. + ## Settings & configuration - **A control lives where its effect is visible.** Notification prefs @@ -302,10 +497,339 @@ icon with a class bolted on. away); a celebration gated only on the fact's transition fires "at random" from the user's seat. Require the user's own action in the gate (climbing demands your own stamp count grew). +- **A deploy says so, calmly.** The server names its build in + `initialize`; when a reconnect brings a different one, the board shows + "Pulldasher was updated." with a Reload button, never an automatic reload, + which would lose a half-typed plan. - **Nags are once per subject** with an explicit refire rule (overtaken refires only after you reclaim and lose the spot again); dedupe keys name the subject, not the tick. +## Charts and dates + +- **Charts are Recharts, drawn in SVG, and colored only with the tokens.** + SVG takes `var(--ok)` in a fill, so dark mode costs no redraw, the same + rule the hand-drawn Stats charts follow. Canvas libraries (uPlot, Chart.js, + ECharts) were set aside for exactly that: every theme switch would need a + redraw with colors read out of CSS. Recharts is the heaviest thing on the + board, so it loads in its own chunk with the views that chart; the review + board never downloads it. The roadmap's load chart is the one exception: + plain positioned elements on the timeline's own axis, so each week's bar + sits exactly above the same weeks of every row. +- **Charts don't animate.** Motion on this board means something changed, + and a chart drawing itself in on load says nothing. +- **Every chart says what it counts.** The unit sits over the y-axis + ("Open PRs", "Developer-days each week"), the x-axis names the days or + weeks, and hovering a mark gives its exact numbers. The load chart is the + exception, with no y-axis: its headline beside the bars names what they + count, and its dashed developer line is the one ruler. Look back's + weekly chart is the other: its heading says what the bars count, so it + carries no unit label either. A list of small weekly + bars gets column heads that date its first and last week. Each chart + answers one question, said plainly in its title ("Is the backlog + growing?"): a cumulative flow chart was replaced by the open PRs each + day and the PRs opened and merged each week, because it needed a + paragraph to read, and "where the merged work went" gave way to where + the time went, since merged PRs count a one-line fix the same as a + month of work. +- **One color vocabulary across Stats and Projects**: ink for opened, green + for merged or finished, brand for what is still open, a paler ink for + closed without merging. +- **A date outside this year says its year** ("Oct 17, 2025"), everywhere + `dayWords` writes one; a year-old "since Oct 17" on a 2026 roadmap read as + a future date. +- **One date range per page, picked with one control**: the button that + names the range, opening the presets an analytics tool offers beside a + two-month calendar (react-day-picker, lazy with its stylesheet). A preset + applies on click; a hand-picked range applies with Apply. Rejected: a row + of segment buttons plus two date inputs (shaped for developers, not the + product manager reading the numbers), and a second control for the + chart's own window (two knobs on one concept). A chart instead shows at + least 90 days ending on the range's last day and pales the days before the + range, so a short range still shows its trend. +- **The roadmap moves by direct manipulation, in whole weeks.** Drag a + row's grip (shown on the row's hover or focus, always on touch, since the + rank beside it already says the rows are ordered) to change its priority (the order is the priority; there's no + separate number to keep in sync), drag a bar to move the plan, and drag + its right edge to change the length. Every drag has a keyboard twin: + arrows on the grip reorder, arrows on a focused bar move it a week, and + Shift with them changes the length. A plan's bar says its status by form + (an outline while planned, a fill once under way, green when done, a gray + outline when parked, faint when dropped), how firm its end is by its + right end (crisp when hard, fading out when soft, running square to the + edge of the weeks shown when ongoing), and its weeks by its length on the + axis: inside it names only an end its form can't show ("Ongoing", + "Promised" when it has room), and its hover and a drag say the dates. A click on a + bar opens the plan under its row, read first, the way its project's page + says a plan: the plan in a line with Decide's calls under it (the one + asked outlined and focused), its latest update and Post an update, its + team and lead, and only the facts it has. The fields wait behind Edit + details, in one column, with the dates said as a sentence ("from the week + of Aug 31 for 8 weeks, to Oct 25"). It all opens inline, never in a + popover, a side sheet or a modal: those hide the timeline a plan is + weighed against, and a half-typed plan shouldn't vanish on a stray click. + A form holding changes keeps its place when another plan is opened. A + plan with no project gets the same details, and its name opens them. Now, + next and later is the same plan without + week dates, for readers who want the order and not the weeks: a card in + Next or Later says only the month its work starts. The columns are + derived from the dates, never set by hand, so the two layouts can't + disagree. +- **A plan's row at rest is one line: rank and name.** A second line only + when Decide asks something, and then only its question, in amber with a + dotted underline, its reason on hover; or, in ink, a clash with what it + waits on, by rank ("Starts before #2 is done", the name on hover), since + order is the roadmap's own business. So a row's height says it wants + you, and amber on the Roadmap means Decide's questions only. An update + owed ("Update due", "No update yet") is said where the lead looks, the + Overview's Plan column and the opened plan, and a health word with + nothing owed isn't said at all. The lead shows on the row's hover, in a + slot it already takes, and "No lead yet" only in the opened plan. The + bar's form says its status, and its hover and screen-reader label say it + in words. A Now, next and later card says the same, with its lead. +- **A plan's end is hard, soft or ongoing.** Hard is a commitment that + Decide asks about once it passes. Soft is an estimate: shown on the + roadmap, never asked about, and passing it is drift said in ink. Ongoing + is upkeep with no end. Every plan starts soft, so a date nobody chose + never nags (Jarred, Oct 5: "we might want to be flexible if hard vs soft + deadline"); "Commit through" is the commitment that makes an end hard, + and Edit details has the choice. A plan with an end has a one-line "Done + when", shown beside Decide's "Done?". +- **Every mark on the timeline says what it is, where it's drawn.** A + mark that needs a legend gets rethought, not explained: an unlabeled gray + line for a project's PR activity and an upright tick for its milestone + were tried and cut because nobody could read them cold. Now a plan still + in flight past its end grows a piece labeled "3 wk overdue" (amber only + when it's the plan's worst call, always ink past a soft end, and faded + past today), a + milestone is a flag with its date while that's ahead ("Due Oct 29"), the + flag alone once it passed (the overrun and Decide's question already say + late), a project with no + plan is a dashed bar that says "since Aug 17, no plan" (a short one says + "since Sep 7" past its end), and the load chart labels its dashed line ("10 developers", always ink, + the way to People; the wash above it was cut as a state wash) instead of + carrying a legend. Its headline beside the bars is the answer and names + what they count and over which span ("15 projects with PRs open this + week", merged that week included). It doesn't say "being worked on": + that's the lanes' 14 days, and one phrase over two windows put 39 over + lanes that added to 43. In amber, "5 more than + developers" when over), so the bars carry no label of their own; "from + PRs" is in its hover. Today is brand words over its line, no filled tag. The chart draws only the weeks up to today: a forecast drawn from + the plan list could only fall, since the list knows nothing that isn't + decided yet, so its tail read as silly. Right of today it says the next + four weeks in words the rows can check ("Next 4 weeks: 2 start, 3 should + end", each count's hover naming the plans). With no plans yet the + heading is "Nothing planned yet", the words right of today say where to + start ("No plans yet. Start with the 7 that need one.") with a Plan the + first one button that opens the first Needs a plan row's chooser, and the + "have a plan · have none" line waits for a plan, since its first half is + always 0. Stripes for projected + weeks were cut the same way, and so were the count words' colored + squares: each count word takes its bars' color instead. A soft end is a + plain rounded end and a promised end has an ink cap ("Promised" + when the bar has room), so a fade only ever means still going past + today. A bar says it can be dragged with the + grab cursor and a resize edge that shows on hover and focus. The line + under the heading says only "Highest priority first", and its door + says how to drag. +- **The timeline is built for a hundred projects in flight.** Every live + project shows, planned or not, since the load is the point; lanes fold, + and remember it like every fold. A project with no plan is one line (its + name, lead and open PRs), and a find box narrows the rows by name, label, + lead or team while the load chart keeps counting everything. Planning one + takes one gesture: its plus (shown on row hover or focus, always on + touch) opens a chooser under the row (from the week + its PRs began, "Commit through" the end of a coming month or quarter, the + nearest outlined, the same calls Decide offers), or drag across its weeks + on the timeline. Projects with no plan sit in three folds by what Decide + asks: "Needs a plan" (big enough to owe a plan, with a Plan it button), + "Stalled" (Decide asks whether to park it, with a Park button), and "No + plan needed", folded, which never holds work Decide asks about. A stalled + project isn't also asked for a plan: its question is "park it?". The + rows in these folds are as calm as plan rows, "lead · N open PRs": the + question lives in the fold's title and the button, as on Decide. In team + lanes, where no fold says it, the row says its call. A row planned or + parked here keeps its fold while its receipt stands. Design it on the dummy board with + `?projects=100`, not at a dozen rows. The header with the column names stays + in place while the rows scroll, and month lines (quarter lines stronger) + run through every row, so a span reads against the calendar anywhere on + the page. A column name zooms that quarter or month to the full width; + the toolbar zooms back out one step and pages to the period before or + after, and the browser's Back does the same, since the zoom is in the URL. +- **Everything on the roadmap does what it names when clicked.** Nothing + a planner would reach for is inert: + - a week's bar in the load chart picks that week (arrow keys move the pick, + Escape clears it), and the rows narrow to what was in flight or planned + then, with the week banded through every row + - the chart's counts filter to what they count ("94 with no plan") and + pick their week, so a count and its rows agree, and the developer + line's label opens the teams + - a column or month name zooms in; while zoomed, Today comes back + - a plain click on a bar opens its details, and only a drag moves it; on a + project with no plan, a plain click opens the chooser, and only a drag + plans it. Escape cancels a drag, a saved move offers Undo in place, and + arrow keys reorder within the list on screen (lanes too), said aloud + - a plan's name opens its project's page, a lead opens their row on + People, a row's words open its details (on its updates, with focus in + the box, when the words are about an update), what it waits on opens + that plan from its details, the milestone flag opens the project, and + the overrun opens the plan + - a Now, next, later card, and the Plan cell in the project list, open + the plan on the timeline + The test for a new mark: what would someone expect clicking it to do? + Build that. +- **The weeks ahead count what nobody decided.** A chart that counted only + the plans after today would drop off a cliff at today and say the team + frees up next week, when nothing says so. Instead every open project big + enough to owe a decision (the rule Decide uses) keeps counting in the + weeks ahead when it has none (a paler tint of the same colors), a plan + still in flight past its end keeps counting, ongoing work counts every + week from its start, and parked, finished and + dropped work stops. A project counts once, however many of its plans + run. The only way to bring the future down is a decision, which is the + point. A picked week's rows come from the same rule as its bar, so the + count and the rows always agree. +- **Look back's "projects" are labeled projects.** "Projects a week" and + "Projects per person" count only work filed under a project; a PR with + none doesn't count on its own (it made one person 53 "projects" a week on + a 46-project board), and "Not in a project" says how much is outside + them. When more than half the days are outside a project the Projects + per person tile steps aside (on 88% unlabeled days it measured the + labeling, "0.5, was 0"); the column stays. With no plan ever saved, the On the roadmap and Plans done tiles + give way to one line: "No plans yet, so nothing here is judged against + one." +- **Look back counts days, not PRs.** "Where did our time go" can't be + read off merged PRs: a one-line fix and a month of work each count once. + A developer-day is a day someone did something on a PR (opened, merged, + commented, stamped, reviewed), split across the PRs they touched that + day, so one person's week always adds up to the days they worked, and a + project's share is the share of people's days it took. Writing and + reviewing are split because review is where the time goes that nobody + plans for, and "whose PR" says whose work it went to. Everyone's weeks + are one chart of two named strips, writing over reviewing, each name over + its own bars, so there's no legend to learn (three greys and two + swatches couldn't be told apart). Each split is one list: every row draws + its own weeks on the same scale in one color, and its name opens the row; + anything that leaves the view (count only this team, show it on the + roadmap) is inside the opened row. The table heads stick under the + section title. People sit in + their teams, sorted by name, so a retro opens on the shape of the work + rather than a leaderboard; a column sorts within the teams. Every PR is a + board row with its days as a footnote. The tiles open on what a retro + asks first, each against the days before, and count every day; each one + clicks through to the split that explains it, its rows banded under the + tile's word ("Reviewing · 55%"). The Writing and Reviewing switch sits on + the weekly chart it changes. The weekly charts plot every week of the + chart's window, zero-filled, and a week the range cuts off says so ("4 of + 7 days") rather than reading as a slow week; the week the range starts in + splits, its days in the range drawn full and its earlier days paler on + top, so no bar counts days before the range. "Is the backlog growing?" + is one `BacklogSection`, on Look back and on a project's page. Developer-days are ink: + brand means "still open" on every chart. +- **A plan's dates go where people already look.** GitHub's issue fields + show on a project's issue and on any board that lists it, so + `bin/sync-issue-fields` copies each plan's dates and priority there, + rather than asking a team to open another tool. A person's value always + wins: it fills blank fields and updates its own, and reports the rest. + Reading goes the other way on its own: a Target date set on the issue is + the project's target wherever one shows. +- **Where the work came from is words, not a color.** A plan can say it + was asked for from above, or found by the team, as a fire to put out or + its own pick, so a planner can see how much of the load anyone chose. + The load chart says it in words under its roadmap count ("1 fire, 4 not + said") once the rows are narrowed to the plans, each a filter like the + counts above it, and says nothing until some plan does. At rest the count + is only its two halves ("11 on the roadmap · 4 with no plan"). The bars keep their two colors: a hue per origin would + need a legend. On Decide it's asked in the receipt, after the call, so + saying it alone never clears a row. +- **Decide is a list that empties, sized for a Monday.** With no product + manager, the tool names the calls owed instead of waiting for someone to + notice: new work with no decision, plans past a hard end or their target, + stalls, updates saying at risk or off track, work marked done or dropped + whose PRs are still open, and parked work that moved. Work with fewer + than 3 PRs ships without a call unless it stalls, so the first visit + isn't a hundred rows. Each row says why it's there in words, under its + name and lead; the facts the call turns on (team, size, people, target) + are on the reason's hover. Each call is + one click that writes the roadmap: commit through the end of a coming + month or quarter, park, mark done, or drop. Each row's reason asks one + question ("Done?", "New end?", "Commit to it?"), in amber, the row's one + amber mark. Its suggested answer comes first on every row, the one filled + button, so it sits in the same place down the list, then a bordered + "Other answers" that opens the "Promise to finish by" and "Or instead" + rows in place. The collapsed button uses the project page's answer words + ("Move the end date to Oct 31", "Restart, finishing by Oct 31", "Promise + to finish by Oct 31", "Mark done", "Park", "Drop", "Mark ongoing") so it + answers the question alone; the expanded rows keep the short "End of Oct". + The reason is one short sentence ("3 weeks overdue, 4 new PRs since.") on + Decide, the project page and the Overview's hover. A section whose rows + all share one question and one suggested answer asks it once in its + header, plural ("Stalled · 13 · Park them for now?"), its bulk button + the filled main action, and each row is one line ("Plp server component · + dhmacs · no PR activity for 268 days") with an outlined answer button and + Other answers; rows whose answers differ keep the full card. On the project page the + row listing a project's child projects is labeled "Projects in it", not + "Parts", which at iFixit means repair parts; every card starts collapsed. After a call, focus lands on the receipt's + words, with Undo the next Tab, so an extra Enter never undoes it. The + roadmap names the same call on the plan's row, the question in amber. Every receipt says what happens next and + offers Undo, which puts the plan back with the times the call replaced, + so the row is asked again after a reload too; a failed save says so in + the row. j and k move between rows. The same strip runs on a project's + page under its "Decide asks" line. A decided row + stays where it was, every line in place, in quiet ink (not dimmed: + opacity took its text under the contrast floor) and saying what was + decided, from the click on rather than when the save returns, so the + next row never slides under the pointer between two clicks; it stays + after a trip to a project page and back. Every plan still under way is + judged on its own, and closing a project's issue counts as a decision, + so the two records can't quietly disagree. Activity means real work (a + push, a person's comment, stamp or review, opening or merging), never + GitHub's updated_at: a label edit moves that, and one labeling pass once + made every project look worked on that morning and hid every stall. Every + Projects view weighs every project, whatever the filter bar narrows (see + "The Projects tab" below). A team switch + gives each team's lead their own rows. The tab's label counts what's + left, and `GET /api/v1/decide` lists the same rows for a script or a + Claude session. A list nobody owns doesn't empty, so it names whose week + it is: people take turns, a week each, and every reader counts the same + weeks from the Monday the turns were set, so nothing assigns them. +- **What a plan waits on is words, not lines across the timeline**: in its + details, "Search reindex, which ends Nov 8", and on its row only when + it's the thing owed, in amber: "starts before Shopify sync ends". Linear + draws dependency lines, but ours would cross team lanes and every row + between the two ends, and the words already say the one thing the + planner acts on. Edit details offers only choices that can be saved: + never the item itself, dropped work, or a loop. +- **A team lane's load is one sentence in its header**: "5 being worked on + this week, for 4 developers", amber in its last words ("more than it can + staff") once that's as many as the team has developers, because then at + least one piece has one developer or none. It counts what takes people + this week: plans under way, projects with PRs open and no plan, and done + or parked plans whose PRs still moved in the last week. Decide's team + sentence uses the same count (`model/teamLoad.ts`), so the two can't + disagree, and offers a Park beside each of the lowest in priority. With no effort + estimates in this workflow, work in flight against people is the one + honest measure; the planner judges the rest. The lane also draws the + point where its people run out: a dashed amber line before the row in + flight this week that's one more than the team has developers, counting + plans in priority order and then its projects with no plan. It says so + in words ("Below here: more being worked on this week than Store's 4 + developers can staff"), turns the priority order into what to park, and opens Decide + on that team. It only shows with nothing narrowing the rows, since a + cut through a filtered list would mean nothing. +- **A plan's health is a word, amber only when someone owes something.** + "On track" reads plain. "At risk", "Off track" and an overdue update are + amber, because each asks someone to act: the planner to replan, the lead + to post. Red stays CI's. An update keeps the plan as it stood, so the + next one can say the end moved "2 weeks later" without anyone keeping a + baseline by hand. +- **A comparison is words, not color.** Each count says how it compares with + the same number of days just before, as a short mark ("+57", "was 6%") + with the full sentence ("57 more than the 30 days before") in the tile's + hover. A row of tiles uses one form: Look back's are all "was N" ("was + 335", "was 35%"), since a "-27" beside them read as a hyphen. + More merged isn't always good news, so no green or red. + ## Copy rules (static text is part of the visual system) - **Say the thing, don't be clever.** Lane names state their contents @@ -330,7 +854,10 @@ icon with a class bolted on. comments.) - **Every curated lane's sub-line is a door** (`SubDoor` in Lane.tsx): the visible sentence states the ordering, hovering it opens what lands in - the lane and how it's ranked. Every group eyebrow glosses itself the + the lane and how it's ranked. At most one shows per pane, where a + threshold or an unclear inclusion changes what you see; never a date + range a picker on screen already says. The rest carry their rule in + the heading's hover. Every group eyebrow glosses itself the same way (WORD_GLOSS). The PR title is a door too: hovering the visible words previews the description, rendered and sanitized (markdown.ts, lazy chunk) — the "can I act on this?" read without diff --git a/README.md b/README.md index 0837f5ab..cbe663d5 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,189 @@ Two query string parameters are available to filter the displayed pulls: `ex. https://pulldasher.example.com?milestone=site-redesign,12/5,12/19` +### Projects + +An optional tab for whoever plans the work: which projects the open PRs +serve, who is on each, how they are moving, and a roadmap to plan them on. +Everything but the roadmap is read from GitHub; something else (a person, +or a job) files the PRs: + +- A PR joins a project through one label, `project:`. PRs that fit no + project get `project:misc`. Keep a label to 32 characters with no spaces: + that's what Pulldasher's label table and filter box hold. +- A project's record is the issue in the projects repo carrying the same + label; with no projects repo, names come from labels. The issue's title + is the project's name, its + assignee the lead, its milestone (and due date) the target, + `parent:` labels its parents (any number), and an `ongoing` label + marks work with no end. GitHub's own issue fields show too: its Start + date, its Priority, and its Target date, which wins over a milestone, + since it's set on that one issue. Close it as completed when it's done, or as not + planned when it's dropped or merged into another. Labeling an issue in + the projects repo is also how a project starts before it has a PR. An + issue with the label in any other repo is work inside the project and + never renames it. + +Turn it on with the `projects` block in `config.js` (see +`config.example.js`): a projects repo if you want one, and `developerTeams`, the teams +whose members count as developers (anyone else with a PR is a +non-developer, whose work needs a developer's review). The teams can also +be edited from the People view or the API; saved ones replace +`developerTeams` until someone goes back to it. Send Issues events in the +tracked repos' webhook, and give a projects repo the same webhook, so a +newly labeled issue shows up right away; an hourly pass catches what a +webhook missed. + +The tab has five views and a page per project: + +- **Overview**: where the plans stand (the latest update on every item in + progress, worst first, which copies as text for a status email), + headline numbers (how many calls Decide has waiting, then a date + range's numbers, each compared with the same number of days before), + whether the backlog is growing (the PRs open each day, and the PRs + opened and merged each week), where the time went (the projects that + took the most developer-days, a door to Look back), and every + project on one list you can sort, group by parent, lead or team, + search, and download as CSV, each with how its roadmap plan is going. + A project's own page shows its plan and latest update too. +- **Decide**: the calls owed now, as a list that empties: projects with 3 + or more PRs (open, or merged in the last 14 days) and no decision, plans + past their end or their target date, stalls (open PRs with no activity + for 21 days), updates that say at risk or off track, work marked done or + dropped (on the roadmap or by closing its issue) whose PRs are still + open, and parked work whose PRs moved. Each row says why it's there, with + its team, size and target, and takes one click: commit through the end + of a coming month or quarter, park, finish, or drop. The row can also say + where the work came from, saved with the call. People take turns running + the list, a week each: it names this week's and next week's, and the + turns can be changed in place or with `PATCH /api/v1/settings`. A team's lead can + take just their team's rows, and copy them as text for a meeting's + notes. It weighs every project, whatever the filter bar narrows the + other views to. The tab's label counts what's left. +- **Roadmap**: the plan set against everything actually in flight. Across + the top, how many projects were in flight each week (from their PRs) + and, for the weeks ahead, the plans plus every project big enough to owe + a decision that has none, as if nothing changes, against a line at the + number of + developers. Below, the plans in priority order, then every live project + with no plan, one click from being planned. + - By month or by quarter; click a quarter or a month to fill the width + with it, and zoom out or step to the next one from the toolbar. Or + now, next and later, without week dates, for readers outside + engineering. + - Click a week in the load chart to see only what was in flight then, or + a count to see only those ("with no plan"). Find narrows by name, + lead or team. + - Each plan can say where its work came from: asked for from above + (top-down), or found by the team, as a fire to put out or its own pick + (bottom-up). Once the rows are narrowed to the plans, the load chart + splits its roadmap count that way, and a click on one shows only those + plans. + - Drag rows to set priority, drag a bar to move it and its edge to change + its length. A click on a bar opens the plan's details under its row, + where Decide's calls commit it through a month or a quarter in one + click, and Edit details holds the rest. + Team lanes say the most each team has in flight at once against its + developers, and draw a dashed line where, in priority order, the work + in flight outnumbers them: what's below it is what to park. + - A linked plan still in flight past its end grows an amber "+3 wk over"; + its project's milestone is a flag with its date. An item can wait on + others, and says so when its plan starts before one of them ends. + Parked work is stopped for now without being dropped, and leaves the + load. + - Each item takes updates: on track, at risk or off track, and a note, + kept as a history. Work in progress with no update for 14 days is + flagged. The roadmap is the one thing the tab stores itself, in the + `roadmap_items` and `roadmap_updates` tables. +- **People**: developers by team and everyone else: live projects each, + open PRs, PRs opened and merged, and reviews given, including how many + went to non-developers' PRs. +- **Look back**: where the time went over a date range, for a retro, in + developer-days rather than PR counts, so a month of work outweighs a + one-line fix. A developer-day is a day someone opened, merged, commented + on, stamped or reviewed a PR, split across the PRs they touched that + day, as writing (their own) or reviewing (anyone else's). Commits aren't + counted: in a sample of 60 merged PRs they added about 4% more days, + since PRs open and merge within a day or two. + - Tiles open it, each against the same number of days before: the + days, how much went to reviewing, to work on the roadmap, to fires, + to PRs filed to no project, and how many different projects each + person touched a week. + - One list splits the days by project, by where the work came from, by + whose PR it was (their own, other developers', non-developers', + bots'), by team, by person or by repo, and can keep only the writing + or the reviewing days. Each row draws its days week by week on one + scale, with its share then and now, and how many of its PRs merged. + - A row's name goes where it names: a project to its page, an origin to + its plans, a team or person to only their days. Anywhere else on the + row opens the PRs that took the most days and who spent them. It all + copies as text for the retro's notes. + +To put the plans where people already look, `bin/sync-issue-fields` copies +each plan under way into its project issue's Start date, Target date and +Priority fields: the plan's first Monday and last day, and Urgent for a +fire, else High, Medium or Low for work now, next or later. A team's project +board shows those fields, and the issue's history keeps every change. It +fills a field nobody has set, and changes one only if its own token set it +last, so a date a person moved stays moved; it says so instead. It's a dry +run unless given `--apply`, and its writes show as `github.token`'s account. + +```sh +bin/sync-issue-fields # what it would write +bin/sync-issue-fields --apply # write it +``` + +Everything the tab shows, and every change the roadmap takes, is in the API, +Bearer-authed with the caller's own GitHub token like `/api/v1/pulls`, so a +script or a Claude session can read the projects and run the roadmap +without a browser. `GET /api/v1` lists every route with what it does, and +the rules a roadmap write is checked against: + +```sh +curl -s -H "Authorization: Bearer $(gh auth token)" https://pulldasher.example.com/api/v1 +``` + +- `GET /api/v1/projects` and `GET /api/v1/people`: each project and each + person with where they stand today and their numbers for a window. Both + take `start` and `end` as `YYYY-MM-DD` days (default: the last 30 days, at + most 400), and `project=` narrows the numbers to one project. +- `GET /api/v1/decide`: the calls owed now, worst first, each with its + project, its roadmap item if it has one, and its reasons. `GET /api/v1` + says what each reason means and which write clears it. It also names who + runs Decide this week and next. +- `GET /api/v1/retro`: where the days went in a window (`start`, `end`, + default the last 30 days): per person and PR, their share of active + days, whether they wrote it, and its project. +- `GET /api/v1/updates-owed`: each lead who owes an update, with their + plans in progress that have gone 14 days without one, longest overdue + first. It's what a weekly reminder would send each lead; nothing sends + it yet. +- `GET /api/v1/load`: projects in flight each week against the developer + count, on the roadmap and not, with the weeks after this one counted as + if nothing changes. `start` and `end` as above (default: 12 weeks back to + 26 ahead). +- `GET /api/v1/roadmap` and `GET /api/v1/roadmap/:id`: the items in + priority order, or one, each with its latest update. +- `POST /api/v1/roadmap`, `PATCH` and `DELETE /api/v1/roadmap/:id`: add, + change or remove an item. A removed item is kept, out of every list, and + `PATCH` with `{"restore": true}` puts it back with its updates, place and + waits. `POST /api/v1/roadmap/:id/move` with + `{"before": }` (or `null` for the bottom) moves one item without + sending the whole order; `PUT /api/v1/roadmap/order` sets the whole order. +- `GET` and `POST /api/v1/roadmap/:id/updates`: an item's updates, or a new + one, `{"health": "at_risk", "body": "..."}`. `DELETE + /api/v1/roadmap/:id/updates/:update` takes one back; only its author can. +- `GET` and `PATCH /api/v1/settings`: the developer teams and whether + they're saved or `config.js`'s. `{"developer_teams": {"Store": ["dana"]}}` + replaces them; `null` goes back to `config.js`. + +A write takes a JSON body and is recorded as the token's login. The board +itself uses the same handlers through `/roadmap`, with its session. + +Pulldasher also re-lists every tracked repo's open PRs once an hour and +refreshes only the ones its database has wrong, so a lost webhook can't leave +a merged PR counted as open. + ## Architecture When first started, the Pulldasher server fetches information about the current diff --git a/app.js b/app.js index c98b0b0a..00077a42 100644 --- a/app.js +++ b/app.js @@ -15,7 +15,15 @@ import mainController from './controllers/main.js'; import hooksController from './controllers/githubHooks.js'; import statsController from './controllers/stats.js'; import userNamesController from './controllers/user-names.js'; -import apiController from './controllers/api.js'; +import projectsController from './controllers/projects.js'; +import roadmapController, { canWrite, forgetProjectCaches } from './controllers/roadmap.js'; +import { API_ROUTES, apiIndex } from './controllers/api-routes.js'; +import settingsController from './controllers/settings.js'; +import { syncRepoIssues } from './lib/project-issue-sync.js'; +import { loadSettingsWithRetry } from './lib/settings.js'; +import { issueRepos, projectSettings } from './lib/projects.js'; +import { setOnWorkSynced, syncWork } from './lib/work.js'; +import { missingMigrations } from './lib/schema-check.js'; import apiAuth from './lib/api-auth.js'; import Debug from './lib/debug.js'; import { createServer } from 'http'; @@ -85,14 +93,56 @@ app.use('/', express.static(__dirname + '/frontend-v2/dist')); app.get('/token', mainController.getToken); app.get('/stats-history', statsController.getHistory); app.get('/user-names', userNamesController.getNames); +app.get('/projects-data', projectsController.getBoardData); +app.get('/retro-data', projectsController.getRetro); +app.get('/work-data', projectsController.getWork); +app.get('/issue-search', projectsController.searchIssues); +app.get('/project-work', projectsController.getProjectWork); +// the review board's view of projects: which projects its PRs link, and +// each project's plan, so it orders and names PRs by the Projects tab's rule +app.get('/project-standing', roadmapController.standing); + +// Something open boards show of the projects changed: what's kept of them is +// read again, and every open board fetches again, so one person's call shows +// on another's screen without a reload. +function projectsChanged() { + forgetProjectCaches(); + pullManager.projectsChanged(); +} +// every write the Projects tab can see +const PROJECT_WRITES = ['/project-issues', '/roadmap', '/settings', '/api/v1/project-issues', '/api/v1/roadmap', '/api/v1/settings']; +app.use(PROJECT_WRITES, function (req, res, next) { + if (req.method !== 'GET') { + res.on('finish', function () { + if (res.statusCode < 300) projectsChanged(); + }); + } + next(); +}); +setOnWorkSynced(projectsChanged); +app.post('/project-issues', canWrite, projectsController.attachIssue); +app.delete('/project-issues', canWrite, projectsController.detachIssue); +// the roadmap is the one part of the Projects tab people edit here: reads are +// gated like the other board data (lib/authentication.js), writes by canWrite +app.get('/roadmap', roadmapController.list); +app.post('/roadmap', canWrite, roadmapController.create); +app.put('/roadmap/order', canWrite, roadmapController.reorder); +app.patch('/roadmap/:id', canWrite, roadmapController.update); +app.delete('/roadmap/:id', canWrite, roadmapController.remove); +app.get('/roadmap/:id/updates', roadmapController.updates); +app.post('/roadmap/:id/updates', canWrite, roadmapController.postUpdate); +app.delete('/roadmap/:id/updates/:update', canWrite, roadmapController.removeUpdate); +app.get('/settings', settingsController.get); +app.patch('/settings', canWrite, settingsController.update); app.post('/hooks/main', hooksController.main); -// /api/v1: machine-to-machine JSON for the review skills, Bearer-authed with -// the caller's own GitHub token (see lib/api-auth). Independent of the -// cookie-session gate -- setupRoutes never registers these paths, so the -// session `auth` middleware doesn't run for them. -app.get('/api/v1/me', apiAuth, apiController.getMe); -app.get('/api/v1/pulls', apiAuth, apiController.getPulls); +// /api/v1: machine-to-machine JSON for the review skills and scripts, +// Bearer-authed with the caller's own GitHub token (see lib/api-auth). Every +// route is in controllers/api-routes.js, and GET /api/v1 lists them. +// Independent of the cookie-session gate -- setupRoutes never registers these +// paths, so the session `auth` middleware doesn't run for them. +app.get('/api/v1', apiAuth, apiIndex); +for (const { method, path, handlers } of API_ROUTES) app[method](path, apiAuth, ...handlers); // Warm the bot-login cache (used to tell a pulldasher claim apart from a // GitHub-UI self-request) before any webhook or socket traffic needs it. @@ -100,6 +150,24 @@ app.get('/api/v1/pulls', apiAuth, apiController.getPulls); // the lookup. git.getBotLogin(); +// Saved settings (the developer teams) replace config.js's once loaded; +// until then, and if the table can't be read, config.js's stand. +loadSettingsWithRetry(); + +// Migrations run by hand, before a deploy (migrations/*.sql never run on +// one). Say loudly at startup which are missing, since the board then +// fails in pieces: pull refreshes, the issue syncs, roadmap reads. +missingMigrations() + .then(missing => { + if (missing.length) { + console.error( + 'The database is missing migrations, so parts of the board will fail. Run these by hand, in order: %s', + missing.join(', ') + ); + } + }) + .catch(err => console.error('Checking the schema failed: %s', (err && err.message) || err)); + debug('Loading all recent pulls from the DB'); dbManager .getRecentPulls(pullManager.getOldestAllowedPullTimestamp()) @@ -114,20 +182,61 @@ dbManager .then(function () { debug('Refreshing all open pulls from the API'); refresh.openPulls().finally(boardLoaded); + syncProjectIssues(); + syncAttachedIssues(); }) .done(); // Webhooks get lost, and a lost `closed` left a PR open on the board until the // next restart (pulldasher#501 repairs it only at startup), which inflates every // backlog number. Once an hour, list each repo's open pulls and refresh just the -// ones the DB has wrong. +// ones the DB has wrong, and pick up project issues that changed. const RECONCILE_MS = 60 * 60 * 1000; setInterval(function () { refresh.reconcileOpenPulls().catch(function (err) { console.error('Hourly open-pull repair failed: %s', (err && err.message) || err); }); + syncProjectIssues(); + syncAttachedIssues(); }, RECONCILE_MS); +// The issues added to projects by hand, and which PRs link each issue a +// project has (lib/work.js), read off GitHub once an hour; a webhook on one +// of those issues reads them sooner. +function syncAttachedIssues() { + syncWork(projectSettings()).catch(function (err) { + console.error('Work sync failed: %s', (err && err.message) || err); + }); +} + +// Project issues can live in any tracked repo (lib/projects.js issueRepos). +// The projects repo is synced whole at startup; a tracked repo only from +// TRACKED_ISSUES_DAYS back, since listing every issue of a big repo costs +// thousands of calls, and webhooks already keep its issues current. So an +// issue labeled while webhooks were down for longer than that waits for its +// next change (or bin/refresh-open-issues). After that, only what changed. +// `since` backs off a few minutes so GitHub's clock can't skip an update. +const TRACKED_ISSUES_DAYS = 7; +function syncProjectIssues() { + const projects = projectSettings(); + if (!projects) return; + const startedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const lookback = new Date(Date.now() - TRACKED_ISSUES_DAYS * 86400 * 1000).toISOString(); + const synced = issueRepos(projects).map(function (repo) { + const firstSince = repo === projects.repo ? null : lookback; + return syncRepoIssues(refresh, repo, firstSince, startedAt).catch(function (err) { + console.error('Project issue sync failed in %s: %s', repo, (err && err.message) || err); + return false; + }); + }); + // a project's name, target or state may have changed: open boards hear it + // once, after every repo is read, and only when an issue changed (a word + // per repo had every open Projects tab fetch it all again once per repo) + Promise.all(synced).then(function (changed) { + if (changed.some(Boolean)) projectsChanged(); + }); +} + //==================================================== // Socket.IO const io = new Server(httpServer); diff --git a/bin/dump-schema b/bin/dump-schema index 47fd9bc5..4a7bf477 100755 --- a/bin/dump-schema +++ b/bin/dump-schema @@ -15,6 +15,11 @@ mysqldump "$@" \ pull_signatures \ pulls \ reviews \ + roadmap_items \ + roadmap_updates \ + project_issues \ + issue_pull_links \ + project_settings \ > $SCHEMA_FILE sed -i -e "s/CREATE TABLE/CREATE TABLE IF NOT EXISTS/" $SCHEMA_FILE diff --git a/bin/migrate-missing b/bin/migrate-missing new file mode 100755 index 00000000..08ad72a1 --- /dev/null +++ b/bin/migrate-missing @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Apply the migrations this database is missing (lib/schema-check.js + * MIGRATIONS), oldest first, each only while information_schema says it's + * still missing, then check again. Exits non-zero when anything is still + * missing, so a deploy runs it inside the new image, with that instance's + * database, and swaps only when it succeeds: + * + * docker run --rm --env-file .env.db pulldasher-dev:$SHA bin/migrate-missing + * + * Usage: bin/migrate-missing [--dry-run] + * --dry-run names what's missing and changes nothing (non-zero if any is). + */ +import mysql from 'mysql2/promise'; +import config from '../lib/config-loader.js'; +import db from '../lib/db.js'; +import { applyMissing, missingMigrations } from '../lib/schema-check.js'; + +const dryRun = process.argv.includes('--dry-run'); + +// a file can hold several statements (0028 makes two tables), which the +// board's own connections don't allow +const conn = dryRun + ? null + : await mysql.createConnection({ + host: config.mysql.host, + database: config.mysql.db, + user: config.mysql.user, + password: config.mysql.pass, + charset: 'utf8mb4', + multipleStatements: true, + }); +// an ALTER waits for a metadata lock, and everything queued behind it waits +// too, so the live board would stall behind a long read; give up after 10s +// and let the deploy say so instead (MySQL's default wait is a year) +if (conn) await conn.query('SET SESSION lock_wait_timeout = 10'); +try { + const missing = dryRun + ? await missingMigrations() + : await applyMissing(async (file, sql) => { + console.log('Applying %s', file); + await conn.query(sql); + }); + if (missing.length) { + console.error('%s: %s', dryRun ? 'Missing' : 'Still missing', missing.join(', ')); + process.exitCode = 1; + } else { + console.log('Every migration is in.'); + } +} catch (err) { + console.error('Migrating failed: %s', (err && err.message) || err); + process.exitCode = 1; +} finally { + await conn?.end(); + await db.end(); +} diff --git a/bin/sync-issue-fields b/bin/sync-issue-fields new file mode 100755 index 00000000..4e34c839 --- /dev/null +++ b/bin/sync-issue-fields @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Copy each roadmap plan's dates and priority into GitHub's issue fields on + * its project's issue, so they show on the issue and on any project board + * that lists it (lib/issue-fields.js says how, and when a value is left + * alone). A dry run unless given --apply: it says what it would write and + * writes nothing. Writes show as config.github.token's account. + * + * Usage: bin/sync-issue-fields [--apply] + */ +import db from '../lib/db.js'; +import { fieldsFromNodes, getBotLogin, graphql } from '../lib/git-manager.js'; +import { + FIELD_NAMES, + ISSUE_STATE_QUERY, + ORG_FIELDS_QUERY, + SET_FIELDS_MUTATION, + fieldWrites, + lastActors, + mutationInput, + orgFields, + wantedFields, +} from '../lib/issue-fields.js'; +import { loadProjects, projectSettings } from '../lib/projects.js'; +import { listItems } from '../lib/roadmap.js'; +import { isUnderWay, planFor, utcDay } from '../shared/dist/index.js'; + +const apply = process.argv.includes('--apply'); + +async function main() { + const settings = projectSettings(); + if (!settings) throw new Error('this Pulldasher has no projects config'); + const [projects, items, me] = await Promise.all([ + loadProjects(settings), + listItems(), + getBotLogin(), + ]); + const today = utcDay(Date.now() / 1000); + // each organization's fields, looked up once + const fieldsOf = new Map(); + let changed = 0; + for (const project of projects) { + const plan = planFor(project.slug, items); + if (project.state === 'closed' || !plan || !isUnderWay(plan.status)) continue; + const [owner, name] = project.repo.split('/'); + if (!fieldsOf.has(owner)) { + const org = await graphql(ORG_FIELDS_QUERY, { login: owner }); + fieldsOf.set(owner, orgFields(org.organization?.issueFields?.nodes)); + } + const data = await graphql(ISSUE_STATE_QUERY, { owner, name, number: project.number }); + const issue = data.repository?.issue; + if (!issue) continue; + const wanted = wantedFields(plan, today); + const { writes, kept } = fieldWrites({ + current: fieldsFromNodes(issue.issueFieldValues?.nodes), + wanted, + actors: lastActors(issue.timelineItems?.nodes), + me, + }); + const where = `${project.repo}#${project.number} (${project.slug})`; + for (const k of kept) { + console.log( + `${where}: left ${FIELD_NAMES[k.key]} at ${k.value ?? 'blank'}, set by ${ + k.by ?? 'someone else' + }; the plan says ${wanted[k.key]}` + ); + } + if (!writes.length) continue; + changed++; + console.log(`${where}: ${writes.map(k => `${FIELD_NAMES[k]} ${wanted[k]}`).join(', ')}`); + if (apply) { + const input = mutationInput(issue.id, writes, wanted, fieldsOf.get(owner)); + await graphql(SET_FIELDS_MUTATION, { input }); + } + } + const issues = `${changed} ${changed === 1 ? 'issue' : 'issues'}`; + console.log( + apply + ? `Wrote ${issues}.` + : `${issues} to write. A dry run: nothing was written; --apply writes.` + ); +} + +main() + .catch(err => { + console.error((err && err.message) || err); + process.exitCode = 1; + }) + .finally(() => db.end()); diff --git a/config.example.js b/config.example.js index 48e5ba09..c916e1ca 100644 --- a/config.example.js +++ b/config.example.js @@ -110,6 +110,38 @@ module.exports = { 'size: XL': 'XL', }, + // Projects (optional; leave it out and the Projects tab stays hidden). A PR + // joins a project through one label, ``, for example + // `project:workbench`. A project's record is the issue in `repo` carrying + // that same label (without `repo`, names come from labels): the title is its name, the + // assignee its lead, the milestone and its due date the rough target, + // `parent:` labels its parents (any number), and an `ongoing` label + // marks work with no end. Its Start date, Target date and Priority issue + // fields show too, the Target date ahead of the milestone; `github.token` + // above reads them over GraphQL. Closing the issue as completed means + // done; as not planned, dropped or merged into another. An issue with the + // label in any other repo is work inside the project and never renames + // it. The server only reads these; something else writes them, and + // bin/sync-issue-fields can copy each plan into its issue's fields. `repo` + // is optional; give it the same webhook as a tracked repo, and send Issues + // events in both. Keep label names to 32 characters with no spaces: that's + // what the label table and the filter box hold. A PR also joins a project + // when its body links one of the project's issues ("Parts of #N"). + projects: { + repo: 'owner/projects', + labelPrefix: 'project:', + // The team's day, for "past its end", "today" and the weekly calls, so + // the board and /api/v1 agree whoever reads them. Defaults to + // America/Los_Angeles. + timeZone: 'America/Los_Angeles', + // Team name -> the GitHub logins on it. The Projects tab splits people + // into these teams; anyone who opens PRs without being listed here + // counts as a non-developer. + developerTeams: { + Store: ['login1', 'login2'], + }, + }, + // The usual MySQL stuff. Like every other MySQL webapp, basically. // You will need to source the `schema.sql` file in the database to create // all the tables that Pulldasher expects. diff --git a/controllers/api-routes.js b/controllers/api-routes.js new file mode 100644 index 00000000..90e8b774 --- /dev/null +++ b/controllers/api-routes.js @@ -0,0 +1,304 @@ +import apiController from './api.js'; +import projectsController from './projects.js'; +import roadmapController, { canWrite } from './roadmap.js'; +import settingsController from './settings.js'; +import { + DONE_WHEN_MAX, + END_KINDS, + MAX_WEEKS, + PILE_AGE_DAYS, + PILE_GROWTH, + PILE_MIN, + ROADMAP_HEALTHS, + ROADMAP_ORIGINS, + ROADMAP_STATUSES, + DECIDE_MIN_PRS, + STALL_DAYS, + UPDATE_DUE_DAYS, + WAITS_ON_MAX, +} from '../shared/dist/index.js'; + +/** + * Every /api/v1 route in one table. app.js registers them from it, and + * GET /api/v1 lists it, so a script (or an agent) reading the list can't + * miss a route. Each takes the caller's own GitHub token as + * `Authorization: Bearer `; a write also needs a JSON body and is + * recorded as that token's login. + */ +export const API_ROUTES = [ + { + method: 'get', + path: '/api/v1/me', + handlers: [apiController.getMe], + does: 'The GitHub login the token belongs to', + }, + { + method: 'get', + path: '/api/v1/pulls', + handlers: [apiController.getPulls], + does: 'Every open PR with its review state, as the review board sees it', + }, + { + method: 'get', + path: '/api/v1/projects', + handlers: [projectsController.getProjects], + does: + 'Every project: its issue fields, where it stands today (`stages` counts its open PRs: ' + + 'ready to merge, on hold, waiting on review, being worked on), and its numbers for ' + + '?start=&end= (YYYY-MM-DD days, default the last 30); ?project= narrows the numbers to one. ' + + 'A PR counts toward its project label, or with none the project whose issue it links; ' + + '`double_labeled` holds the open PRs in two', + }, + { + method: 'get', + path: '/api/v1/people', + handlers: [projectsController.getPeople], + does: "Per person: team, the window's numbers, reviews given, live projects, open PRs; same ?start=&end=", + }, + { + method: 'get', + path: '/api/v1/retro', + handlers: [projectsController.getRetro], + does: + 'Where the days went in ?start=&end= (default the last 30), week by week: rows of [person, ' + + 'pr, week, days] into the people, prs (title, author, project, merged) and weeks lists. A ' + + 'day is one a person opened, merged, commented on, stamped or reviewed PRs, split across ' + + 'them; writing when they wrote the PR', + }, + { + method: 'get', + path: '/api/v1/work', + handlers: [projectsController.getWork], + does: + "Every plan's PRs by the dates (how many are open, the ones opened after its end, and " + + 'for a plan marked done or dropped the ones opened more than a week after), and how each ' + + "project's attached issues stand, by slug", + }, + { + method: 'get', + path: '/api/v1/issue-search', + handlers: [projectsController.searchIssues], + does: + 'Issues to pick from for ?q=: the issue a link or owner/repo#123 names, the issues with a ' + + "number (#123) in every tracked repo, or GitHub's search for words in issue titles and bodies", + }, + { + method: 'get', + path: '/api/v1/project-work', + handlers: [projectsController.getProjectWork], + does: + 'A project page for ?project=slug: each issue attached to it (by its label, by hand, or ' + + 'joined by a link from one of its PRs) with the PRs that link it, its PRs that link none ' + + 'of them, and the issues its PRs link that are not attached (`joins`: it joins on its own ' + + 'at the next hourly sync)', + }, + { + method: 'post', + path: '/api/v1/project-issues', + handlers: [canWrite, projectsController.attachIssue], + does: + 'Add an issue to a project by hand, or put back one taken off: {project, issue} with issue ' + + 'as owner/repo#123 or a link. A missing issue is a 404; a PR, an issue outside the tracked ' + + "organizations, or a project's own issue a 409. `inserted`: whether this put it in (false " + + 'when it was taken off before, or there already)', + }, + { + method: 'delete', + path: '/api/v1/project-issues', + handlers: [canWrite, projectsController.detachIssue], + does: + 'Take an issue added by hand or joined by a link off a project, for good (a link never ' + + 'brings it back; adding it does): ?project=slug&repo=owner/repo&number=123. &forget=1 ' + + 'takes back an add whose POST said `inserted`, as if it was never added; undo any other ' + + 'add without it, so an issue taken off before stays off', + }, + { + method: 'get', + path: '/api/v1/updates-owed', + handlers: [roadmapController.owed], + does: + 'The leads who owe an update, each with the plans in progress they have not updated for ' + + `${UPDATE_DUE_DAYS} days, longest overdue first: what a reminder would send each of them. ` + + `A plan whose PRs merged in the last ${UPDATE_DUE_DAYS} days, inside its issues' pace and, for a hard ` + + 'end, inside its end, owes none, unless its last update said at risk or off track, or its open PRs ' + + `pile up: ${PILE_MIN} or more that grew by ${PILE_GROWTH} in ${UPDATE_DUE_DAYS} days, or whose median ` + + `age passed ${PILE_AGE_DAYS} days`, + }, + { + method: 'get', + path: '/api/v1/decide', + handlers: [projectsController.getDecide], + does: + 'The decisions owed now, worst first, each with its project, roadmap item, and reasons (see ' + + '`decide`); days are the team’s (config.js projects.timeZone, default America/Los_Angeles)', + }, + { + method: 'get', + path: '/api/v1/load', + handlers: [projectsController.getLoad], + does: + 'Projects in flight each week against the developer count, on the roadmap (split by where the ' + + 'work came from) and not; weeks after this one as if nothing changes. ?start=&end= (default 12 ' + + 'weeks back to 26 ahead)', + }, + { + method: 'get', + path: '/api/v1/roadmap', + handlers: [roadmapController.list], + does: + 'Every roadmap item in priority order (top first), each with its latest update and `lately` ' + + '(read only): PRs merged in the last 14 days, open PRs by stage, how many more are open than 14 days ' + + 'ago (`grew`) and their median age in days (`medianAge`), newest PR activity, its issues’ pace. ' + + 'What the board reads off them: `in_progress` (marked so, or planned with PRs moving since its ' + + 'start, which the board shows as In progress) and `standing`, where its updates stand: `owed` ' + + '(its lead owes one), `vouched` (none owed: its PRs vouch for it), `current` (its latest stands), ' + + 'or null (none yet, none owed)', + }, + { + method: 'post', + path: '/api/v1/roadmap', + handlers: [canWrite, roadmapController.create], + does: + 'Add an item at the bottom: {name, project?, team?, lead?, status?, origin?, start?, weeks?, end_kind?, ' + + 'done_when?, notes?, waits_on?}; no end_kind means soft. ' + + 'For a project, no start means its issue’s Start date, and its issue’s Priority places it above the first ' + + 'plan under way whose issue says lower', + }, + { + method: 'put', + path: '/api/v1/roadmap/order', + handlers: [canWrite, roadmapController.reorder], + does: 'Set the whole order: {ids} naming every item once, top first; 409 with the current list if it changed', + }, + { + method: 'get', + path: '/api/v1/roadmap/:id', + handlers: [roadmapController.get], + does: 'One item with its latest update', + }, + { + method: 'patch', + path: '/api/v1/roadmap/:id', + handlers: [canWrite, roadmapController.update], + does: + 'Change only the fields sent; null or "" clears an optional one. {restore: true} alone puts back ' + + 'a removed item as it was, with its updates and its place', + }, + { + method: 'delete', + path: '/api/v1/roadmap/:id', + handlers: [canWrite, roadmapController.remove], + does: 'Take an item off the roadmap; its updates are kept, so PATCH {restore: true} puts it back', + }, + { + method: 'post', + path: '/api/v1/roadmap/:id/move', + handlers: [canWrite, roadmapController.move], + does: 'Move one item just above another: {before: }, or {before: null} for the bottom', + }, + { + method: 'get', + path: '/api/v1/roadmap/:id/updates', + handlers: [roadmapController.updates], + does: "An item's updates, newest first, each with the plan as it stood then", + }, + { + method: 'post', + path: '/api/v1/roadmap/:id/updates', + handlers: [canWrite, roadmapController.postUpdate], + does: 'Post an update: {health, body?}', + }, + { + method: 'delete', + path: '/api/v1/roadmap/:id/updates/:update', + handlers: [canWrite, roadmapController.removeUpdate], + does: + 'Take back an update you posted; returns the latest update now ({update}, null with none). ' + + "403 for someone else's", + }, + { + method: 'get', + path: '/api/v1/settings', + handlers: [settingsController.get], + does: 'The settings people change, and whether each is saved or config.js’s', + }, + { + method: 'patch', + path: '/api/v1/settings', + handlers: [canWrite, settingsController.update], + does: + 'Replace the developer teams, {developer_teams} (null goes back to config.js), or who ' + + 'takes turns running Decide, {decide_rotation: [logins]}, the first this week (null for nobody)', + }, +]; + +/** GET /api/v1 -- the route table and the rules a roadmap write is checked + * against, so a caller can learn the API from the API. */ +export function apiIndex(req, res) { + res.json({ + auth: 'Authorization: Bearer , from a member of the org', + endpoints: API_ROUTES.map(({ method, path, does }) => ({ + method: method.toUpperCase(), + path, + does, + })), + roadmap_item: { + name: 'required on create, up to 120 characters', + project: 'a project label slug to track its PRs, or null', + team: 'a developer team name, or null', + lead: 'a GitHub login, or null', + status: ROADMAP_STATUSES, + origin: { + values: ROADMAP_ORIGINS, + means: + 'where the work came from: asked for from above (top-down), or found by the team, ' + + 'as a fire to put out or its own pick (bottom-up); null until someone says', + }, + start: 'YYYY-MM-DD; moved to its Monday', + weeks: `a whole number, 1 to ${MAX_WEEKS}`, + end_kind: { + values: END_KINDS, + means: + 'how firm its end is: hard, a commitment Decide asks about once it passes; soft, an ' + + 'estimate shown on the roadmap and never asked about (the default); ongoing, upkeep with ' + + 'no end, whatever its weeks say', + }, + done_when: `one line on what finished looks like, up to ${DONE_WHEN_MAX} characters; line breaks become spaces`, + notes: 'up to 2000 characters', + waits_on: `ids of other items this one waits on, at most ${WAITS_ON_MAX}, never a loop`, + }, + update: { + health: ROADMAP_HEALTHS, + body: 'up to 2000 characters, may be empty', + }, + decide: { + reasons: { + new: `in flight with ${DECIDE_MIN_PRS} or more PRs (open, or merged in the last 14 days) and never a roadmap item; since = its first open PR’s day`, + stalled: `open PRs with no activity for ${STALL_DAYS} days or more, and no call since`, + over: 'a plan under way with a hard end ended `weeks` ago and its project still has PRs open', + ended: 'a plan under way with a hard end ended `weeks` ago with no PRs open: probably done', + missed: + 'its target date `due` passed with `open` PRs open, and the plan hasn’t changed since', + off_track: 'its latest update says off track, and the plan hasn’t changed since', + at_risk: 'its latest update says at risk, and the plan hasn’t changed since', + issue_closed: + 'its project issue was closed (`as` done or dropped, `on` a day) after the plan last changed, and the plan is still under way', + reopened: + 'marked done or dropped a week ago or more (`by` the roadmap or by closing the issue), but `open` PRs are still open', + moving: 'parked, but its PRs changed after it was parked', + issues_done: + 'every issue in its project is closed (`done`, `dropped`), and the plan running today hasn’t changed since the last one closed; `open` = its PRs still open', + }, + to_clear: + 'Make the call as a roadmap write. With no item: POST /api/v1/roadmap {name, project, status, ' + + 'start, weeks, end_kind: "hard"}. With one: PATCH /api/v1/roadmap/:id with {status: "active", weeks, ' + + 'end_kind: "hard"} to commit, {end_kind: "ongoing"} for upkeep with no end, ' + + '{status: "parked"}, {status: "done"}, or {status: "dropped"}. Any PATCH to an item counts as ' + + `a decision, and quiets a stall for ${STALL_DAYS} days.`, + }, + settings: { + developer_teams: + 'an object of team name to GitHub logins, {"Store": ["dana", "erin"]}; a login on one team at most', + }, + }); +} diff --git a/controllers/githubHooks.js b/controllers/githubHooks.js index 9b16a526..80fc4f12 100644 --- a/controllers/githubHooks.js +++ b/controllers/githubHooks.js @@ -13,6 +13,8 @@ import getLogin from '../lib/get-user-login.js'; import utils from '../lib/utils.js'; import dbManager from '../lib/db-manager.js'; import git from '../lib/git-manager.js'; +import { projectSettings } from '../lib/projects.js'; +import { forgetIssue, workIssueTouched, workPullTouched } from '../lib/work.js'; const hooksDebug = debug('pulldasher:hooks'); @@ -103,6 +105,17 @@ const HooksController = { return dbManager.updatePull(Pull.fromGithubApi(body.pull_request)); }); + // a "Parts of #N" in a new or edited PR counts toward that issue's + // project a minute later, as a label does, not at the hourly sync + if (body.action === 'opened' || body.action === 'edited') { + workPullTouched( + projectSettings(), + body.repository.full_name, + body.pull_request.body, + body.pull_request.labels + ); + } + if (reconcileAfterUpdate) { // A `synchronize` webhook never triggers a full refresh on its own, // and Pulldasher has no periodic poll, so re-derive review state from @@ -235,12 +248,33 @@ function handleIssueEvent(body) { hooksDebug('Webhook action: %s for issue #%s', body.action, body.issue.number); var doneHandling = handleLabelEvents(body); + // an issue attached to a project changed, or a project label came or went: + // the work is read again soon + if (body.repository) { + workIssueTouched(projectSettings(), body.repository.full_name, body.issue.number, [ + ...(body.issue.labels ?? []), + ...(body.label ? [body.label] : []), + ]); + } // Always refresh from the API rather than upserting the webhook body // directly. The body is just a subset of the fields (and for pull requests, // not even an issue), and it carries no events, which we need to attribute // labels and to date the current assignment (date_assigned). // refreshPullOrIssue dispatches pull-vs-issue from the body itself. + // A deleted or transferred issue is gone from GitHub, so the refresh + // below would only 404: drop it from projects and the board's issues. + if (body.action === 'deleted' || body.action === 'transferred') { + return doneHandling + .then(() => forgetIssue({ repo: body.repository.full_name, number: body.issue.number })) + .catch(err => + console.error( + 'forgetting issue %s failed: %s', + body.issue.number, + (err && err.message) || err + ) + ); + } return doneHandling.then(function () { // Not returning here cause we don't want to delay replying to the // hook with a 200 since we know what needs to be done. diff --git a/controllers/projects.js b/controllers/projects.js new file mode 100644 index 00000000..a8c89e2a --- /dev/null +++ b/controllers/projects.js @@ -0,0 +1,650 @@ +import pullManager from '../lib/pull-manager.js'; +import { respondOrError } from '../lib/controller-utils.js'; +import { + loadProjects, + loadTimeSpent, + loadWindow, + parseWindow, + projectSettings, + teamDay, + todayFromBoard, +} from '../lib/projects.js'; +import { listItems } from '../lib/roadmap.js'; +import { + attachIssue, + detachIssue, + loadProjectWork, + loadPullLinks, + loadWork, + searchIssues, +} from '../lib/work.js'; +import { + addWeeks, + closedIssues, + decideProjects, + decideQueue, + decideTurn, + endOf, + needsDecision, + parseIssueRef, + loadByWeek, + mondayOf, + mondaysBetween, + peakFrom, + planEnd, + spansFrom, + utcDay, + windowStats, + prStage, + MISC_SLUG, + PROJECT_SLUG, + STALL_DAYS, +} from '../shared/dist/index.js'; + +const key = d => `${d.repo}#${d.number}`; + +/** a project label's slug: what follows the prefix (the shared rule) */ +const SLUG = PROJECT_SLUG; + +/** The issue a request names, as {repo, number} or "owner/repo#123" (or a + * link), from the body or the query string; null when it names none. */ +function issueOfRequest(from) { + if (from?.repo && from?.number) { + const number = Number(from.number); + return Number.isInteger(number) && number > 0 ? { repo: String(from.repo), number } : null; + } + return typeof from?.issue === 'string' ? parseIssueRef(from.issue) : null; +} + +/** Projects that run with no end: marked on the board, or by the `ongoing` + * label on their issue. */ +function ongoingSlugs(settings, projects) { + return new Set([...settings.ongoing, ...projects.filter(p => p.ongoing).map(p => p.slug)]); +} + +/** + * Validate the projects config and the requested window, then load the + * projects, which projects each PR links, the window's PR history and its + * CR/QA stamps. An optional `project` slug narrows the window's numbers and + * stamps to that project's PRs (the project list stays whole). Answers the + * request itself (404 when projects aren't set up here, 400 for a bad + * window) and returns null, or returns a promise of what the handler needs. + */ +function load(req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return null; + } + const window = parseWindow(req.query); + const only = req.query.project; + const badProject = only !== undefined && (typeof only !== 'string' || !only || only.length > 64); + if (window.error || badProject) { + res.status(400).json({ error: window.error || 'project must be one project slug' }); + return null; + } + return Promise.all([loadProjects(settings), loadPullLinks(settings)]).then( + ([projects, linked]) => + loadWindow(settings, window.start, window.end, only, linked).then( + ({ spans, reviews }) => ({ + settings, + projects, + linked, + stats: windowStats(spans, window.start, window.end, { + teamOf: settings.teamOf, + reviews, + }), + }) + ) + ); +} + +/** + * One record per project for the API: what its issue says, where it stands + * today, and its numbers for the window. Live projects first in Today's order, + * then quiet ones, then the rest (done, dropped, or only in the window's + * history) by slug. + */ +function projectRecords(projects, today, stats, prefix) { + const bySlug = new Map(projects.map(p => [p.slug, p])); + const groups = new Map(); + const standing = new Map(); + for (const [list, word] of [ + [today.live, 'live'], + [today.quiet, 'quiet'], + ]) { + for (const g of list) { + groups.set(g.slug, g); + standing.set(g.slug, word); + } + } + const rest = [...new Set([...bySlug.keys(), ...Object.keys(stats.projects)])] + .filter(slug => slug !== MISC_SLUG && !groups.has(slug)) + .sort(); + return [...groups.keys(), ...rest].map(slug => { + const p = bySlug.get(slug) || null; + const g = groups.get(slug) || null; + const closedAs = + p && p.state === 'closed' ? (p.state_reason === 'not_planned' ? 'dropped' : 'done') : null; + return { + slug, + name: p ? p.name : slug, + label: prefix + slug, + issue: p + ? { + repo: p.repo, + number: p.number, + url: `https://github.com/${p.repo}/issues/${p.number}`, + state: p.state, + state_reason: p.state_reason, + } + : null, + ongoing: p ? p.ongoing : false, + parents: p ? p.parents : [], + lead: p ? p.lead : null, + target: p ? p.target : null, + fields: p ? p.fields : null, + // live and quiet come from Today; a closed issue with no open PR is + // done or dropped; a slug seen only in the window's history is null + standing: standing.get(slug) || closedAs, + open: g ? g.open.map(d => key(d.data)) : [], + merged_recently: g ? g.merged.map(key) : [], + people: g ? g.people : [], + idle_days: g ? g.idleDays : null, + flags: g ? g.flags : [], + // its open PRs by where they stand, in the project page's words + stages: stageCounts(g ? g.open : []), + window: stats.projects[slug] || null, + }; + }); +} + +/** How many open PRs are at each stage (shared/model/stage.ts prStage). */ +function stageCounts(open) { + const out = { ready: 0, hold: 0, review: 0, work: 0 }; + for (const d of open) out[prStage(d)]++; + return out; +} + +/** Where each project with PRs open began, by slug: the spans that run to today. */ +function liveStarts(today) { + return Object.fromEntries( + decideProjects(today) + .filter(p => p.open > 0) + .map(p => [p.slug, p.firstOpened]) + ); +} + +export default { + /** + * GET /projects-data?start=&end=&project= -- the Projects tab's fetch: + * every project issue plus the window's numbers (one project's, with + * `project`), and which projects each of the board's PRs links. The tab + * builds Today itself from the live socket pulls, so it moves with the + * board, and by the same rule as here. Session-authed like /stats-history. + */ + getBoardData: function (req, res) { + const loaded = load(req, res); + if (!loaded) return; + respondOrError( + res, + loaded.then(({ settings, projects, linked, stats }) => { + // the PRs Today is built from: the board's open and recently closed + const onBoard = new Set(pullManager.getPulls().map(p => key(p.data).toLowerCase())); + return { + label_prefix: settings.prefix, + projects_repo: settings.repo, + teams: settings.teams, + teams_from: settings.teamsFrom, + decide_rotation: settings.decideRotation, + ongoing: settings.ongoing, + projects, + pull_links: Object.fromEntries( + Object.entries(linked).filter(([pull]) => onBoard.has(pull)) + ), + window: stats, + }; + }), + 'projects-data query failed' + ); + }, + + /** + * GET /api/v1/projects?start=&end= -- every project with its issue fields, + * where it stands today (open PR ids, people, idle days, flags), and its + * numbers for the window (default: the last 30 days) -- backlog, throughput, + * developers vs. non-developers, and median days to merge -- plus the + * totals and one point per day for the backlog chart. Bearer-authed + * (lib/api-auth). Dates are YYYY-MM-DD UTC days, both counted; + * `project=` narrows the window's numbers to that project's PRs. + */ + getProjects: function (req, res) { + const loaded = load(req, res); + if (!loaded) return; + respondOrError( + res, + loaded.then(({ settings, projects, linked, stats }) => { + const now = Date.now() / 1000; + const pulls = pullManager.getPulls(); + const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); + return { + server_time: Math.floor(now), + start: stats.start, + end: stats.end, + label_prefix: settings.prefix, + projects: projectRecords(projects, today, stats, settings.prefix), + misc: { + open: today.misc.map(d => key(d.data)), + window: stats.projects[MISC_SLUG] || null, + }, + unsorted: { open: today.unsorted.map(d => key(d.data)), window: stats.unsorted }, + double_labeled: today.doubleLabeled.map(d => key(d.data)), + totals: stats.totals, + days: stats.days, + }; + }), + 'projects query failed' + ); + }, + + /** + * GET /api/v1/people?start=&end= -- per person: their developer team (from + * config, null if unlisted), the window's numbers, the CR/QA stamps they + * gave in it and how many landed on a non-developer's PR, the projects + * they had PRs in during it, the live projects they're on today, and how + * many PRs they have open now. Someone who only reviewed in the window + * still gets a row. Most live projects first, which is how a reader spots + * someone spread thin. Bots are left out. + */ + getPeople: function (req, res) { + const loaded = load(req, res); + if (!loaded) return; + respondOrError( + res, + loaded.then(({ settings, projects, linked, stats }) => { + const now = Date.now() / 1000; + const pulls = pullManager.getPulls(); + const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); + const live = new Map(); + for (const g of today.live) { + for (const login of g.people) { + if (!live.has(login)) live.set(login, []); + live.get(login).push(g.slug); + } + } + const openNow = new Map(); + for (const d of [ + ...today.live.flatMap(g => g.open), + ...today.misc, + ...today.unsorted, + ]) { + const login = d.data.user.login; + openNow.set(login, (openNow.get(login) || 0) + 1); + } + const logins = new Set([...Object.keys(stats.people), ...live.keys()]); + const people = [...logins].map(login => { + const w = stats.people[login]; + return { + login, + team: w ? w.team : settings.teamOf(login), + window: w + ? { + backlog_start: w.backlog_start, + backlog_end: w.backlog_end, + opened: w.opened, + merged: w.merged, + closed: w.closed, + median_age_start_days: w.median_age_start_days, + median_age_end_days: w.median_age_end_days, + } + : null, + projects_in_window: w ? w.projects : [], + live_projects: (live.get(login) || []).sort(), + open_now: openNow.get(login) || 0, + reviews: w ? w.reviews : 0, + reviews_on_non_dev: w ? w.reviews_on_non_dev : 0, + }; + }); + people.sort( + (a, b) => + b.live_projects.length - a.live_projects.length || + b.open_now - a.open_now || + a.login.localeCompare(b.login) + ); + return { + server_time: Math.floor(now), + start: stats.start, + end: stats.end, + people, + }; + }), + 'people query failed' + ); + }, + + /** + * GET /retro-data (session) and /api/v1/retro (Bearer) ?start=&end= -- + * where people's days went in the window, week by week: rows of [person, + * pr, week, days] pointing into the people, prs and weeks lists + * (lib/projects.js loadTimeSpent). Default: the last 30 days. + */ + getRetro: function (req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + const window = parseWindow(req.query); + if (window.error) { + res.status(400).json({ error: window.error }); + return; + } + respondOrError( + res, + loadPullLinks(settings) + .then(linked => loadTimeSpent(settings, window.start, window.end, linked)) + .then(spent => ({ + start: window.start, + end: window.end, + ...spent, + })), + 'retro query failed' + ); + }, + + /** + * GET /work-data (session) and /api/v1/work (Bearer) -- every plan's PRs + * by the dates: how many are still open, the ones that opened after its + * end, and for a plan marked done or dropped, the ones opened more than a + * week after; and how every project's attached issues stand, by slug + * (shared/model/work.ts). + */ + getWork: function (req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + respondOrError(res, loadWork(settings), 'work query failed'); + }, + + /** + * GET /issue-search?q= (session) and /api/v1/issue-search (Bearer) -- + * issues to pick from for what someone typed: the issue a link or + * "owner/repo#123" names, the issues with a number ("#123") in every + * tracked repo, or GitHub's search for words in issue titles and bodies. + */ + searchIssues: function (req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + const q = typeof req.query.q === 'string' ? req.query.q : ''; + if (q.length > 200) { + res.status(400).json({ error: 'q is at most 200 characters' }); + return; + } + respondOrError( + res, + searchIssues(settings, q).then(issues => ({ issues })), + 'issue search failed' + ); + }, + + /** + * GET /project-work?project=slug (session) and /api/v1/project-work + * (Bearer) -- a project's page: every issue attached to it (by its label, + * by hand, or by a link from one of its PRs) with the PRs that link it, + * its PRs that link none of them, and the issues its PRs link that aren't + * attached (shared/model/work.ts projectWork). + */ + getProjectWork: function (req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + const slug = req.query.project; + if (typeof slug !== 'string' || !SLUG.test(slug)) { + res.status(400).json({ error: 'send project, a project label’s slug' }); + return; + } + respondOrError(res, loadProjectWork(settings, slug), 'project work query failed'); + }, + + /** + * POST /project-issues and /api/v1/project-issues {project, issue} -- + * add an issue to a project by hand: `issue` is "owner/repo#123", a link, + * or {repo, number}. It's read off GitHub, so a missing issue is a 404, + * and a PR, an issue outside the tracked organizations, or a project's + * own issue a 409. Adding one that's already there changes nothing; one + * taken off comes back as it was. `inserted` says whether this put it + * in, which is when its Undo forgets it (DELETE with forget=1); else + * its Undo is a Remove, so one taken off before stays off. + */ + attachIssue: function (req, res) { + const body = req.body || {}; + const ref = issueOfRequest(typeof body.issue === 'object' ? body.issue : body); + if (typeof body.project !== 'string' || !SLUG.test(body.project) || !ref) { + res.status(400).json({ + error: 'send project (a project label’s slug) and issue ("owner/repo#123" or a link)', + }); + return; + } + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + attachIssue(settings, body.project, ref, req.roadmapLogin) + .then(({ issue, inserted, missing, refused }) => { + if (issue) res.status(201).json({ issue, inserted }); + else if (missing) res.status(404).json({ error: 'GitHub has no issue by that name.' }); + else res.status(409).json({ error: refused }); + }) + .catch(err => { + console.error('adding an issue to a project failed:', err); + res.status(500).json({ error: 'adding the issue failed' }); + }); + }, + + /** + * DELETE /project-issues?project=slug&repo=owner/repo&number=123 and + * /api/v1/project-issues -- take an issue added by hand, or joined by a + * link, off a project, for good: a link never brings it back. One + * attached by label stays until the label comes off it. `forget=1` + * takes back an add that put it in (the POST's `inserted`) instead: the + * issue is as if never added. + */ + detachIssue: function (req, res) { + const { project } = req.query; + const ref = issueOfRequest(req.query); + if (typeof project !== 'string' || !SLUG.test(project) || !ref) { + res.status(400).json({ error: 'send project, repo and number' }); + return; + } + detachIssue(project, ref, { forget: req.query.forget === '1' }) + .then(gone => + gone + ? res.json({ ok: true }) + : res.status(404).json({ error: 'no issue by that name was added here' }) + ) + .catch(err => { + console.error('taking an issue off a project failed:', err); + res.status(500).json({ error: 'taking the issue off failed' }); + }); + }, + + /** + * GET /api/v1/decide -- the decisions owed now, worst first: what the + * board's Decide view lists (shared/model/decide.ts). Each row names the + * project, its roadmap item if it has one, and why it needs a call. A + * roadmap write clears it: see `decide` in GET /api/v1. + */ + getDecide: function (req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + const load = Promise.all([loadProjects(settings), listItems(), loadPullLinks(settings)]).then( + ([projects, plans, linked]) => + loadWork(settings, { plans, projects }).then(work => [projects, plans, work, linked]) + ); + respondOrError( + res, + load.then(([projects, items, work, linked]) => { + const now = Date.now() / 1000; + const pulls = pullManager.getPulls(); + const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); + const bySlug = new Map(projects.map(p => [p.slug, p])); + const open = new Map(today.live.map(g => [g.slug, g.open.length])); + const rows = decideQueue({ + live: decideProjects(today), + items, + closed: closedIssues(projects), + planCounts: new Map( + work.plans.map(p => [ + p.planId, + { + openPulls: p.openPulls, + afterEnd: p.afterEnd.length, + afterDone: p.afterDone.length, + }, + ]) + ), + issues: new Map(Object.entries(work.projects)), + ongoing: ongoingSlugs(settings, projects), + // the board's Decide asks by the browser's day: the team's + today: teamDay(now), + now, + }); + const day = teamDay(now); + return { + server_time: Math.floor(now), + stall_days: STALL_DAYS, + // who takes their turn running this list, this week and next + runs_this_week: decideTurn(settings.decideRotation, day), + runs_next_week: decideTurn(settings.decideRotation, addWeeks(day, 1)), + decisions: rows.map(({ slug, item, reasons }) => { + const p = slug ? bySlug.get(slug) : undefined; + return { + project: slug, + name: item?.name ?? p?.name ?? slug, + lead: item?.lead ?? p?.lead ?? null, + open: slug ? open.get(slug) ?? 0 : 0, + item: item && { + id: item.id, + status: item.status, + start: item.start, + weeks: item.weeks, + end_kind: item.end_kind, + // ongoing work has no end + end: endOf(item), + }, + reasons, + }; + }), + }; + }), + 'decide query failed' + ); + }, + + /** + * GET /api/v1/load?start=&end= -- how loaded each week is, against the + * developers there are: projects in flight on the roadmap and not, from + * PRs through this week and, after it, as if nothing changes (the plans, + * plus every open project big enough to owe a decision that has none). + * Default: the 12 weeks before this one through the 26 after. + */ + getLoad: function (req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + const now = Date.now() / 1000; + const day = utcDay(now); + const thisWeek = mondayOf(day); + const window = parseWindow({ + start: req.query.start ?? addWeeks(thisWeek, -12), + end: req.query.end ?? planEnd({ start: thisWeek, weeks: 27 }), + }); + if (window.error) { + res.status(400).json({ error: window.error }); + return; + } + // the PRs tell the weeks up to today; the plans tell the rest + const pastEnd = window.end < day ? window.end : day; + const links = loadPullLinks(settings); + const history = + window.start <= pastEnd + ? links + .then(linked => loadWindow(settings, window.start, pastEnd, undefined, linked)) + .then( + ({ spans, reviews }) => + windowStats(spans, window.start, pastEnd, { + teamOf: settings.teamOf, + reviews, + }).projects + ) + : Promise.resolve({}); + respondOrError( + res, + Promise.all([loadProjects(settings), listItems(), history, links]).then( + ([projects, items, past, linked]) => { + const pulls = pullManager.getPulls(); + const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); + const spans = spansFrom(past, liveStarts(today), window.start); + // the projects with no plan that owe a decision count ahead + const closed = closedIssues(projects); + const ahead = new Set( + decideProjects(today) + .filter(p => needsDecision(p, closed)) + .map(p => p.slug) + ); + const weeks = loadByWeek({ + weeks: mondaysBetween(window.start, addWeeks(mondayOf(window.end), 1)), + today: day, + plans: items, + spans, + ahead, + }); + const developers = new Set( + Object.values(settings.teams) + .flat() + .map(login => login.toLowerCase()) + ).size; + const [current] = loadByWeek({ + weeks: [thisWeek], + today: day, + plans: items, + spans, + ahead, + }); + return { + server_time: Math.floor(now), + developers, + this_week: { + week: thisWeek, + on_plan: current.onPlan, + on_plan_by_origin: current.origins, + off_plan: current.offPlan, + }, + peak: peakFrom(weeks, day), + weeks: weeks.map(w => ({ + week: w.week, + on_plan: w.onPlan, + on_plan_by_origin: w.origins, + off_plan: w.offPlan, + projected: w.projected, + })), + }; + } + ), + 'load query failed' + ); + }, +}; diff --git a/controllers/roadmap.js b/controllers/roadmap.js new file mode 100644 index 00000000..de427ccd --- /dev/null +++ b/controllers/roadmap.js @@ -0,0 +1,455 @@ +import pullManager from '../lib/pull-manager.js'; +import { respondOrError } from '../lib/controller-utils.js'; +import { loadProjects, projectSettings, teamDay, todayFromBoard } from '../lib/projects.js'; +import { + addUpdate, + createItem, + deleteUpdate, + getItem, + latelyBySlug, + listItems, + listUpdates, + moveItem, + removeItem, + reorderItems, + restoreItem, + updateItem, +} from '../lib/roadmap.js'; +import { sharedValue } from '../lib/ttl-cache.js'; +import { loadPullLinks, loadWork } from '../lib/work.js'; +import { + checkRoadmapFields, + checkRoadmapUpdate, + healthStanding, + inProgress, + planFor, + updatesOwed, + updateStanding, + waitsOnProblem, +} from '../shared/dist/index.js'; + +const FAKE_USER = process.env.MOCK_AUTH_AS_USER; + +/** how long what the projects did lately is kept: the roadmap reloads each + * minute an open board shows it, and once is enough for all of them. A + * project write forgets it (forgetProjectCaches). */ +const LATELY_MS = 60 * 1000; + +/** + * What each project did lately, by slug (lib/roadmap.js latelyBySlug): the + * board's PRs now, each in its project by the same rule as Today's + * (controllers/projects.js), and its issues' pace from the work model when + * it sends one (`pace`, by slug). Empty when projects aren't set up. + */ +const latelyNow = sharedValue(LATELY_MS, async () => { + const settings = projectSettings(); + if (!settings) return new Map(); + const [projects, linked] = await Promise.all([ + loadProjects(settings), + loadPullLinks(settings), + ]); + const work = await loadWork(settings, { plans: [], projects }).catch(err => { + console.error('roadmap: reading the issues’ pace failed:', err); + return null; + }); + const now = Date.now() / 1000; + const ongoing = new Set([ + ...settings.ongoing, + ...projects.filter(p => p.ongoing).map(p => p.slug), + ]); + const pulls = pullManager.getPulls(); + const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); + return latelyBySlug(today, work?.pace ?? {}, ongoing, now); +}); + +/** What each project did lately (latelyNow); empty when the reads fail: + * every plan is then judged by its updates alone, as before. */ +async function lately() { + try { + return await latelyNow.get(); + } catch (err) { + console.error('roadmap: reading what projects did lately failed:', err); + return new Map(); + } +} + +/** + * Items as the roadmap sends them: each with what its project did lately + * (shared/model/roadmap.ts PlanLately), and what the board reads off it, so + * a script says what the board says: `in_progress` (marked so, or planned + * with its PRs moving since its start: inProgress) and `standing`, where its + * updates stand (updateStanding: owed, vouched or current), judged by the + * team's day. + */ +async function sent(items) { + const bySlug = await lately(); + const now = Date.now() / 1000; + const today = teamDay(now); + return items.map(item => { + const read = { ...item, lately: (item.project && bySlug.get(item.project)) || null }; + return { + ...read, + in_progress: inProgress(read), + standing: updateStanding(healthStanding(read, now, today)), + }; + }); +} + +/** One item as the roadmap sends it; null stays null. */ +const sentOne = async item => (item ? (await sent([item]))[0] : null); + +/** how long the review board's project standing is kept; a project write + * forgets it */ +const STANDING_MS = 60 * 1000; + +/** + * What the review board needs to know of projects: which projects each of + * its PRs links (lib/work.js loadPullLinks), so it files a PR in its project + * by the Projects tab's rule, and each project's plan (shared/model/roadmap.ts + * planFor) by slug: its status, and whether it's in progress by its PRs + * (inProgress), so a parked one's PRs sink and one under way can finish, + * and its name, for the board to say which. + */ +const standingNow = sharedValue(STANDING_MS, async () => { + const settings = projectSettings(); + const [linked, items] = await Promise.all([loadPullLinks(settings), listItems().then(sent)]); + // the board's PRs: open, and closed lately + const onBoard = new Set( + pullManager.getPulls().map(p => `${p.data.repo}#${p.data.number}`.toLowerCase()) + ); + const plans = {}; + for (const slug of new Set(items.map(i => i.project).filter(Boolean))) { + const plan = planFor(slug, items); + plans[slug] = { status: plan.status, in_progress: plan.in_progress, name: plan.name }; + } + return { + pull_links: Object.fromEntries(Object.entries(linked).filter(([pr]) => onBoard.has(pr))), + plans, + }; +}); + +/** Forget what's kept of the projects: a project write, or a sync of the + * issues and their links, changed it (app.js). */ +export function forgetProjectCaches() { + latelyNow.forget(); + standingNow.forget(); +} + +/** + * The gate on every roadmap write. The writer is the /api/v1 caller whose + * Bearer token lib/api-auth.js already checked, or else a signed-in board + * session (401 when neither). The body must be JSON (415 otherwise): a JSON + * write from another site needs a CORS preflight, which this server never + * answers, so a cross-site form post can't change the roadmap on someone's + * behalf. + */ +export function canWrite(req, res, next) { + const signedIn = typeof req.isAuthenticated === 'function' && req.isAuthenticated(); + const login = req.apiUser?.login ?? (signedIn ? req.user.username : FAKE_USER); + if (!login) { + return res.status(401).json({ error: 'sign in to make changes' }); + } + if (req.method !== 'DELETE' && !req.is('application/json')) { + return res.status(415).json({ error: 'send the change as JSON' }); + } + req.roadmapLogin = login; + next(); +} + +/** + * Why item `id` (null for a new one) can't wait on what `fields` says, in + * words; null when it can, or when the fields don't touch waits_on. + * ponytail: two people saving at once could still close a loop; it would + * only show both items as clashing, so no lock. + */ +async function waitsOnError(id, fields) { + if (!fields.waits_on) return null; + return waitsOnProblem(id, fields.waits_on, await listItems()); +} + +/** The :id param as a positive integer, or null. */ +function idOf(req) { + const id = Number(req.params.id); + return Number.isInteger(id) && id > 0 ? id : null; +} + +/** + * The times an Undo puts back: whole seconds, none in the future (a time from + * the future would hide every call until it passed). Null when the body + * sends none or they don't check out. + */ +function undoTimes(undo, now = Math.floor(Date.now() / 1000)) { + if (!undo || typeof undo !== 'object') return null; + const ok = t => Number.isInteger(t) && t > 0 && t <= now; + if (!ok(undo.updated_at)) return null; + if (undo.status_at != null && !ok(undo.status_at)) return null; + return { updated_at: undo.updated_at, status_at: undo.status_at ?? null }; +} + +export default { + /** + * GET /roadmap (session) and GET /api/v1/roadmap (Bearer) -- every + * roadmap item in priority order: the plan a project manager laid out, + * each with its first week, length in weeks, an optional project label + * slug linking it to that project's PRs, its latest update, what its + * project did lately, and what the board reads off them (`sent`). + */ + list: function (req, res) { + respondOrError( + res, + listItems() + .then(sent) + .then(items => ({ items })), + 'roadmap query failed' + ); + }, + + /** + * GET /project-standing (session) -- the review board's view of projects + * (standingNow): which projects each of its PRs links, and each project's + * plan's status and whether it's in progress, by slug. + */ + standing: function (req, res) { + if (!projectSettings()) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return; + } + respondOrError(res, standingNow.get(), 'project standing query failed'); + }, + + /** + * GET /api/v1/updates-owed -- the leads who owe an update, each with the + * plans in progress they haven't updated for UPDATE_DUE_DAYS, longest + * overdue first: what a reminder would send each of them. A plan whose + * PRs merged lately, inside its end and its issues' pace, owes none + * (shared/model/roadmap.ts vouchFor), as on the board, by the team's day. + */ + owed: function (req, res) { + const now = Math.floor(Date.now() / 1000); + respondOrError( + res, + listItems().then(sent).then(items => ({ + server_time: now, + leads: updatesOwed(items, now, teamDay(now)).map(({ lead, owed }) => ({ + lead, + plans: owed.map(({ item, kind, days }) => ({ + id: item.id, + name: item.name, + project: item.project, + // never updated, or not for a while + owes: kind === 'missing' ? 'a first update' : 'a new update', + days, + })), + })), + })), + 'updates query failed' + ); + }, + + /** + * POST /roadmap {name, project?, team?, lead?, status?, start?, weeks?, + * notes?, waits_on?} -- a new item at the bottom of the order, or for a + * project, where its issue's Priority field puts it, starting on its Start + * date when no start is sent (lib/roadmap.js createItem). 201 with the + * item. + */ + create: function (req, res) { + const checked = checkRoadmapFields(req.body, { partial: false }); + if (checked.error) return res.status(400).json({ error: checked.error }); + waitsOnError(null, checked.fields) + .then(async error => { + if (error) return res.status(400).json({ error }); + const item = await createItem(checked.fields, req.roadmapLogin); + res.status(201).json({ item: await sentOne(item) }); + }) + .catch(err => { + console.error('roadmap create failed:', err); + res.status(500).json({ error: 'roadmap create failed' }); + }); + }, + + /** GET /api/v1/roadmap/:id -- one item with its latest update. */ + get: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + getItem(id) + .then(sentOne) + .then(item => + item ? res.json({ item }) : res.status(404).json({ error: 'no such roadmap item' }) + ) + .catch(err => { + console.error('roadmap get failed:', err); + res.status(500).json({ error: 'roadmap get failed' }); + }); + }, + + /** + * POST /api/v1/roadmap/:id/move {before} -- put the item just before + * item `before`, or at the bottom for `before: null`. 409 with the list + * as it is now if someone added or removed an item at the same moment. + */ + move: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + const before = req.body ? req.body.before : undefined; + if (before !== null && !(Number.isInteger(before) && before > 0)) { + return res + .status(400) + .json({ error: 'send before as the id to move above, or null for the bottom' }); + } + moveItem(id, before) + .then(async result => { + if (result.missing) return res.status(404).json({ error: 'no such roadmap item' }); + const items = await sent(result.items); + if (result.conflict) { + return res.status(409).json({ error: 'the roadmap changed; here it is again', items }); + } + res.json({ items }); + }) + .catch(err => { + console.error('roadmap move failed:', err); + res.status(500).json({ error: 'roadmap move failed' }); + }); + }, + + /** PATCH /roadmap/:id with any of the item's fields -- changes just those. + * `restate: true` says a status sent as it already was is a call made + * again; `undo: { updated_at, status_at }` puts back the times a call + * replaced. `restore: true`, alone, puts back an item removed by mistake + * (Remove's Undo), as it was. */ + update: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + if (req.body?.restore === true) { + return restoreItem(id) + .then(sentOne) + .then(item => + item + ? res.json({ item }) + : res.status(404).json({ error: 'no removed roadmap item by that id' }) + ) + .catch(err => { + console.error('roadmap restore failed:', err); + res.status(500).json({ error: 'roadmap restore failed' }); + }); + } + const checked = checkRoadmapFields(req.body, { partial: true }); + if (checked.error) return res.status(400).json({ error: checked.error }); + waitsOnError(id, checked.fields) + .then(async error => { + if (error) return res.status(400).json({ error }); + const item = await updateItem(id, checked.fields, req.roadmapLogin, { + restate: req.body?.restate === true, + undo: undoTimes(req.body?.undo), + }); + if (item) res.json({ item: await sentOne(item) }); + else res.status(404).json({ error: 'no such roadmap item' }); + }) + .catch(err => { + console.error('roadmap update failed:', err); + res.status(500).json({ error: 'roadmap update failed' }); + }); + }, + + /** DELETE /roadmap/:id -- takes the item off the roadmap. Its row and + * updates are kept, so PATCH with `restore: true` puts it back. */ + remove: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + removeItem(id) + .then(gone => + gone ? res.json({ ok: true }) : res.status(404).json({ error: 'no such roadmap item' }) + ) + .catch(err => { + console.error('roadmap delete failed:', err); + res.status(500).json({ error: 'roadmap delete failed' }); + }); + }, + + /** GET /roadmap/:id/updates (session) and /api/v1/roadmap/:id/updates + * (Bearer) -- the item's updates, newest first. */ + updates: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + respondOrError( + res, + listUpdates(id).then(updates => ({ updates })), + 'roadmap updates query failed' + ); + }, + + /** + * POST /roadmap/:id/updates {health, body?} -- a new update on the item, + * with the plan as it stands kept beside it. 201 with the update. + */ + postUpdate: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + const checked = checkRoadmapUpdate(req.body); + if (checked.error) return res.status(400).json({ error: checked.error }); + addUpdate(id, checked.fields, req.roadmapLogin) + .then(update => + update + ? res.status(201).json({ update }) + : res.status(404).json({ error: 'no such roadmap item' }) + ) + .catch(err => { + console.error('roadmap update post failed:', err); + res.status(500).json({ error: 'roadmap update post failed' }); + }); + }, + + /** + * DELETE /roadmap/:id/updates/:update -- takes back an update the caller + * posted (the Undo beside a post): 200 with the item's latest update now, + * the one before it, or null. 403 for someone else's update; 404 when + * the item has no such update. + */ + removeUpdate: function (req, res) { + const id = idOf(req); + const updateId = Number(req.params.update); + if (!id || !Number.isInteger(updateId) || updateId <= 0) { + return res.status(400).json({ error: 'the ids must be positive whole numbers' }); + } + deleteUpdate(id, updateId, req.roadmapLogin) + .then(result => { + if (result.missing) { + return res.status(404).json({ error: 'no such update on that item' }); + } + if (result.notYours) { + return res + .status(403) + .json({ error: 'only the person who posted an update can take it back' }); + } + res.json({ update: result.latest }); + }) + .catch(err => { + console.error('roadmap update delete failed:', err); + res.status(500).json({ error: 'roadmap update delete failed' }); + }); + }, + + /** + * PUT /roadmap/order {ids} -- the whole order, top first. It has to name + * every item once; if the list changed since the person loaded it, 409 + * with the list as it is now, so their board can catch up before retrying. + */ + reorder: function (req, res) { + const ids = req.body && req.body.ids; + if (!Array.isArray(ids) || !ids.every(id => Number.isInteger(id) && id > 0)) { + return res.status(400).json({ error: 'send ids as a list of item ids, top first' }); + } + reorderItems(ids) + .then(async result => { + const items = await sent(result.items); + if (result.conflict) { + return res.status(409).json({ error: 'the roadmap changed; here it is again', items }); + } + res.json({ items }); + }) + .catch(err => { + console.error('roadmap reorder failed:', err); + res.status(500).json({ error: 'roadmap reorder failed' }); + }); + }, +}; diff --git a/controllers/settings.js b/controllers/settings.js new file mode 100644 index 00000000..3ab5ab58 --- /dev/null +++ b/controllers/settings.js @@ -0,0 +1,124 @@ +import { projectSettings } from '../lib/projects.js'; +import { saveSetting, settingsLoaded } from '../lib/settings.js'; +import { + checkDecideRotation, + checkDeveloperTeams, + checkOngoingProjects, + mondayOf, + utcDay, +} from '../shared/dist/index.js'; + +/** The settings as a reply: each value, and where the teams come from. */ +function current() { + const settings = projectSettings(); + return { + developer_teams: settings.teams, + decide_rotation: settings.decideRotation, + ongoing_projects: settings.ongoing, + from: { developer_teams: settings.teamsFrom }, + }; +} + +const has = (body, key) => Object.prototype.hasOwnProperty.call(body, key); + +// changes to the ongoing list run one at a time, each on the list the last +// one saved, so two quick clicks can't drop each other's project +let ongoingSaves = Promise.resolve(); +function changeOngoing(change) { + const next = ongoingSaves.then(change); + ongoingSaves = next.catch(() => {}); + return next; +} + +/** Mark one project ongoing, or take it back, on the saved list. */ +function markOngoing(slug, ongoing, login) { + return changeOngoing(() => { + const rest = projectSettings().ongoing.filter(s => s !== slug); + const list = ongoing ? [...rest, slug].sort() : rest; + return saveSetting('ongoing_projects', list.length ? list : null, login); + }); +} + +export default { + /** GET /settings (session) and /api/v1/settings (Bearer) -- the settings + * people can change, and whether each is saved or config.js's. */ + get: function (req, res) { + if (!projectSettings()) { + return res.status(404).json({ error: 'projects are not set up in config.js' }); + } + res.json(current()); + }, + + /** + * PATCH /settings and /api/v1/settings {developer_teams?, decide_rotation?, + * ongoing_projects?, ongoing_project?} -- replace the developer teams + * (null goes back to config.js's), who takes turns running Decide, + * starting with the first this week (null for nobody), or the projects + * marked ongoing; or mark one project ongoing or not with + * ongoing_project {slug, ongoing}. Behind canWrite (controllers/roadmap.js), + * so it takes JSON from a signed-in person or a Bearer caller, and records + * who. + */ + update: function (req, res) { + if (!projectSettings()) { + return res.status(404).json({ error: 'projects are not set up in config.js' }); + } + if (!settingsLoaded()) { + return res + .status(503) + .json({ error: 'Settings are still loading, try again in a moment' }); + } + const body = req.body || {}; + if ( + !has(body, 'developer_teams') && + !has(body, 'decide_rotation') && + !has(body, 'ongoing_projects') && + !has(body, 'ongoing_project') + ) { + return res.status(400).json({ + error: + 'send developer_teams (an object of team name to logins, or null), ' + + 'decide_rotation (a list of logins, or null), ongoing_projects (a list of ' + + 'project slugs, or null) or ongoing_project ({slug, ongoing})', + }); + } + const saves = []; + if (has(body, 'ongoing_project')) { + const { slug, ongoing } = body.ongoing_project ?? {}; + const checked = checkOngoingProjects([slug]); + if (checked.error || typeof ongoing !== 'boolean') { + return res.status(400).json({ + error: 'send ongoing_project as {slug, ongoing}: a project slug and true or false', + }); + } + saves.push(() => markOngoing(checked.slugs[0], ongoing, req.roadmapLogin)); + } + if (has(body, 'developer_teams')) { + const checked = checkDeveloperTeams(body.developer_teams); + if (checked.error) return res.status(400).json({ error: checked.error }); + saves.push(() => saveSetting('developer_teams', checked.teams, req.roadmapLogin)); + } + if (has(body, 'ongoing_projects')) { + const checked = checkOngoingProjects(body.ongoing_projects); + if (checked.error) return res.status(400).json({ error: checked.error }); + const list = checked.slugs && checked.slugs.length ? checked.slugs : null; + saves.push(() => + changeOngoing(() => saveSetting('ongoing_projects', list, req.roadmapLogin)) + ); + } + if (has(body, 'decide_rotation')) { + const checked = checkDecideRotation(body.decide_rotation); + if (checked.error) return res.status(400).json({ error: checked.error }); + const from = mondayOf(utcDay(Date.now() / 1000)); + const rotation = checked.logins && { logins: checked.logins, from }; + saves.push(() => saveSetting('decide_rotation', rotation, req.roadmapLogin)); + } + saves + .reduce((done, save) => done.then(save), Promise.resolve()) + .then(() => res.json(current())) + .catch(err => { + console.error('settings save failed:', err); + res.status(500).json({ error: 'settings save failed' }); + }); + }, +}; diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 08d6d16c..db244ac9 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -106,6 +106,26 @@ seven-column layout.) first payload after load just primes the baseline, so it never alerts for the backlog already there, and a "send a test" button confirms the setup. `src/notifications.ts`. +- **Projects.** An optional tab, shown when the server's `config.js` has a + `projects` block, for whoever plans the work: an Overview (headline + numbers against the period before, the backlog chart, where merged work + went, and every project on one sortable, groupable list), a Roadmap (the + plan by month or quarter; drag to set priority, timing and length, with + linked projects' real PR activity drawn under each bar), People + (developers by team and everyone else, with the reviews each gave), and a + page per project. Today is built on the board from the live socket pulls + by the same `shared/model/projects.ts` the server's `/api/v1/projects` + runs; the project issues and history come from `GET /projects-data`, the + roadmap from `/roadmap` (`model/roadmapData.ts` shows each change at once + and rolls it back if the server refuses). The tab, its charts (Recharts) + and its date picker (react-day-picker) are lazy chunks, so the review + board never downloads them (DESIGN.md § Charts and dates). The dummy board + files its pulls into projects (`backend/dummy.ts`), and + `backend/dummyProjects.ts` has the project issues, teams and roadmap + that show every state; only the lazy tab loads that second file. To see + the tab at a real team's size, add `?projects=100` to the dummy board's + URL (before the `#`): it adds that many projects in flight, a few of them + planned, and more developers (`backend/dummyScale.ts`). - **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the focused row's branch. diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json index bf13a968..5e336246 100644 --- a/frontend-v2/package-lock.json +++ b/frontend-v2/package-lock.json @@ -12,7 +12,9 @@ "lucide-react": "^1.25.0", "marked": "^18.0.7", "react": "^18.3.1", + "react-day-picker": "^10.0.1", "react-dom": "^18.3.1", + "recharts": "^3.10.1", "socket.io-client": "^4.8.3" }, "devDependencies": { @@ -513,6 +515,12 @@ "node": ">=20.19.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1023,6 +1031,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1386,6 +1420,18 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1769,6 +1815,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1780,14 +1889,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1811,6 +1920,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2043,6 +2158,15 @@ "node": ">= 16" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2068,9 +2192,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2085,6 +2330,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2109,6 +2364,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2201,6 +2462,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2263,6 +2536,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2336,6 +2615,25 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2900,6 +3198,32 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -2913,6 +3237,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2923,6 +3277,51 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2933,6 +3332,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -3097,6 +3502,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3259,6 +3670,37 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/frontend-v2/package.json b/frontend-v2/package.json index 9926e237..940c7582 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -16,7 +16,9 @@ "lucide-react": "^1.25.0", "marked": "^18.0.7", "react": "^18.3.1", + "react-day-picker": "^10.0.1", "react-dom": "^18.3.1", + "recharts": "^3.10.1", "socket.io-client": "^4.8.3" }, "devDependencies": { diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 2c6f140c..286530ae 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,4 +1,6 @@ import { + lazy, + Suspense, useCallback, useDeferredValue, useEffect, @@ -10,7 +12,7 @@ import { import { ago, closedEpoch, n, pullKey, shortRepo } from '../../shared/format'; import type { ActionStateKey } from './model/actions'; import { actionState } from './model/actions'; -import { LENS_LABELS, type Lens } from './lens'; +import { DEFAULT_RANGE, DEFAULT_SORT, LENS_LABELS, ORIGIN_KEYS, ZOOM_KEY, type Lens } from './lens'; import type { DerivedPull } from '../../shared/model/status'; import { matchesWeightFilter } from '../../shared/model/status'; import { buildParentLookup } from './model/stack'; @@ -27,7 +29,7 @@ import { ToastStack, useToasts } from './toasts'; import { requestNames, useNames } from './model/names'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from '../../shared/model/visibility'; import { reviewRequestedFrom } from './model/reviewers'; -import { CornerBadge, textInputClass } from './components/bits'; +import { CornerBadge, QuietButton, textInputClass } from './components/bits'; import { foldDomId, openFold } from './components/Lane'; import { Legend } from './components/Legend'; import { LensMenu } from './components/LensMenu'; @@ -48,18 +50,29 @@ import { } from './model/savedFilters'; import { SavedFiltersInput, SavedFiltersMenu } from './components/SavedFiltersPanel'; import type { RowOptions } from './components/Row'; +import { pullStanding, useProjectStanding } from './model/standing'; import { Review } from './views/Review'; -import { MyWork } from './views/MyWork'; import { Team } from './views/Team'; import { Classic } from './views/Classic'; import { Ci } from './views/Ci'; import { Stats } from './views/Stats'; +import type { ProjectsNav } from './views/Projects'; import { Search } from './views/Search'; import { Settings } from './components/Settings'; export type { Lens }; -const LENSES: Lens[] = ['review', 'mine', 'team', 'classic', 'ci', 'stats']; +// The Projects tab is for whoever plans the work; reviewers never open it, so +// its code (and the chart and calendar chunks under it) loads only on demand. +const Projects = lazy(() => import('./views/Projects').then(m => ({ default: m.Projects }))); +// My work lists your projects in the Projects tab's own words, so it carries +// the projects model; it loads on its own too (fetched once the board is up, +// so the tab never waits on it), keeping that model out of the review +// board's bundle. +const loadMyWork = () => import('./views/MyWork').then(m => ({ default: m.MyWork })); +const MyWork = lazy(loadMyWork); + +const LENSES: Lens[] = ['review', 'mine', 'team', 'projects', 'classic', 'ci', 'stats']; /** every actionState bucket, for validating the `state=` hash param against */ const ACTION_STATE_KEYS: ActionStateKey[] = [ @@ -94,6 +107,9 @@ interface HashState { reveal: string[]; /** session override of the drafts default (null = use the durable default) */ drafts: 'mine' | 'all' | null; + /** the Projects tab's own state: the view, an open project page, the + * date range, and the list's and roadmap's controls */ + projects: ProjectsNav; } /** @@ -127,6 +143,35 @@ function readHash(): HashState { hidden: p.get('hidden') === '1', reveal: p.get('show')?.split(',').filter(Boolean) ?? [], drafts: p.get('drafts') === 'all' ? 'all' : p.get('drafts') === 'mine' ? 'mine' : null, + projects: { + view: + (['decide', 'roadmap', 'people', 'retro'] as const).find(v => v === p.get('view')) ?? + 'overview', + project: p.get('project') || null, + range: p.get('range') || DEFAULT_RANGE, + status: p.get('status') || 'live', + group: p.get('group') || 'none', + sort: p.get('sort') || DEFAULT_SORT, + find: p.get('find') ?? '', + scale: p.get('scale') === 'month' ? 'month' : p.get('scale') === 'now' ? 'now' : 'quarter', + item: Number(p.get('item')) || null, + // not `show`, which the board already uses for revealed hidden groups + show: + p.get('unplanned') === 'hide' + ? 'plan' + : p.get('unplanned') === 'only' + ? 'unplanned' + : 'all', + week: /^\d{4}-\d{2}-\d{2}$/.test(p.get('week') ?? '') ? p.get('week') : null, + zoom: ZOOM_KEY.test(p.get('zoom') ?? '') ? p.get('zoom') : null, + team: p.get('team') || null, + origin: ORIGIN_KEYS.find(o => o === p.get('origin')) ?? null, + by: (['origin', 'author', 'repo'] as const).find(b => b === p.get('by')) ?? 'team', + kind: (['writing', 'reviewing'] as const).find(k => k === p.get('kind')) ?? 'all', + who: p.get('who') || null, + only: p.get('only') || null, + psort: p.get('psort') ?? '', + }, }; } @@ -142,6 +187,25 @@ function buildHash(s: HashState): string { if (s.hidden) p.set('hidden', '1'); if (s.reveal.length) p.set('show', s.reveal.join(',')); if (s.drafts) p.set('drafts', s.drafts); + if (s.projects.view !== 'overview') p.set('view', s.projects.view); + if (s.projects.project) p.set('project', s.projects.project); + if (s.projects.range !== DEFAULT_RANGE) p.set('range', s.projects.range); + if (s.projects.status !== 'live') p.set('status', s.projects.status); + if (s.projects.group !== 'none') p.set('group', s.projects.group); + if (s.projects.sort !== DEFAULT_SORT) p.set('sort', s.projects.sort); + if (s.projects.find) p.set('find', s.projects.find); + if (s.projects.scale !== 'quarter') p.set('scale', s.projects.scale); + if (s.projects.item) p.set('item', String(s.projects.item)); + if (s.projects.show !== 'all') p.set('unplanned', s.projects.show === 'plan' ? 'hide' : 'only'); + if (s.projects.week) p.set('week', s.projects.week); + if (s.projects.zoom) p.set('zoom', s.projects.zoom); + if (s.projects.team) p.set('team', s.projects.team); + if (s.projects.origin) p.set('origin', s.projects.origin); + if (s.projects.by !== 'team') p.set('by', s.projects.by); + if (s.projects.kind !== 'all') p.set('kind', s.projects.kind); + if (s.projects.who) p.set('who', s.projects.who); + if (s.projects.only) p.set('only', s.projects.only); + if (s.projects.psort) p.set('psort', s.projects.psort); return p.toString(); } @@ -175,9 +239,9 @@ function Banner({ warn: 'border-warn bg-surface', brand: 'notice-inner border-brand bg-brand-50 text-brand-700', }; - const surface = `flex w-full items-center gap-2 rounded-lg border px-3 py-[7px] text-left ${inner[tone]} ${ - onClick ? 'pressable hover:bg-brand-100' : '' - }`; + const surface = `flex w-full items-center gap-2 rounded-lg border px-3 py-[7px] text-left ${ + inner[tone] + } ${onClick ? 'pressable hover:bg-brand-100' : ''}`; return (
{onClick ? ( @@ -227,11 +291,13 @@ export function App() { me, connection, initialized, + updated, authFailed, lastPayloadAt, lastSeen, refreshProgress, snoozed, + projectLabelPrefix, } = usePulldasher(); // per-repo reviewer pools and whose turn each starved, unclaimed pull is — // computed ONCE over the whole board and shared by the rows (rowOpts), @@ -306,6 +372,18 @@ export function App() { const [draftsMode, setDraftsMode] = useState<'mine' | 'all'>( () => urlState.drafts ?? getSettings().draftsMode ); + const [projectsNav, setProjectsNav] = useState(() => urlState.projects); + // a click that narrows by find (a lead, a team cell) wants its own history + // entry even though find itself isn't tracked below (typing must keep + // replacing) — recorded here, spent once by the history effect + const pushRequested = useRef(false); + const navigateProjects = useCallback( + (patch: Partial, opts?: { push?: boolean }) => { + if (opts?.push) pushRequested.current = true; + setProjectsNav(cur => ({ ...cur, ...patch })); + }, + [] + ); // login-level twin of isBot, for row-level consumers (the avatar's square // bot tile) that hold a login rather than a DerivedPull const isBotAuthor = useCallback((login: string) => isBotLogin(login, extraBots), [extraBots]); @@ -332,6 +410,10 @@ export function App() { ); const dark = settings.theme === 'dark' || (settings.theme === 'system' && systemDark); const searchRef = useRef(null); + // My work's code, fetched once the board is up so the tab opens at once + useEffect(() => { + if (initialized) void loadMyWork(); + }, [initialized]); // The whole session view as one object — what buildHash writes to the URL // AND what "Save current filter…" would capture right now must never @@ -350,19 +432,70 @@ export function App() { hidden: showAll, reveal, drafts: draftsMode !== settings.draftsMode ? draftsMode : null, + projects: projectsNav, }), - [lens, query, scope, weightSel, stateSel, showAll, reveal, draftsMode, settings.draftsMode] + [ + lens, + query, + scope, + weightSel, + stateSel, + showAll, + reveal, + draftsMode, + settings.draftsMode, + projectsNav, + ] ); - // View changes (lens switches) earn a history entry so the back button - // navigates between boards; filter tweaks replace in place so typing a - // query doesn't bury history under keystrokes. - const prevView = useRef({ lens }); + // View changes (lens switches, opening or leaving a project page, and + // picks that land somewhere new — a tile, a chart bar, a Decide team, a + // picked week) earn a history entry so the back button navigates between + // boards; filter tweaks and typing replace in place so a query doesn't + // bury history under keystrokes. + const view = { + lens, + project: projectsNav.project, + projectsView: projectsNav.view, + // zooming the roadmap in or out is a view change too, so Back undoes it + zoom: projectsNav.zoom, + only: projectsNav.only, + team: projectsNav.team, + week: projectsNav.week, + origin: projectsNav.origin, + item: projectsNav.item, + // Look back's person and split picks + who: projectsNav.who, + by: projectsNav.by, + }; + const prevView = useRef(view); useEffect(() => { + // spent even when the hash already says so (after Back, or a hash + // link), so the next change compares with what's on screen and a + // click that changed nothing doesn't push the next keystroke + const prev = prevView.current; + prevView.current = view; + const asked = pushRequested.current; + pushRequested.current = false; const next = buildHash(hashState); if (next === location.hash.slice(1)) return; - const prev = prevView.current; - prevView.current = { lens }; - if (prev.lens !== lens) { + const viewChanged = + prev.lens !== view.lens || + prev.project !== view.project || + prev.projectsView !== view.projectsView || + prev.zoom !== view.zoom || + prev.only !== view.only || + prev.team !== view.team || + prev.week !== view.week || + prev.origin !== view.origin || + prev.item !== view.item || + prev.who !== view.who || + prev.by !== view.by; + if (viewChanged || asked) { + // a project's page or another Projects view opens at its top; Back + // returns to the old one where it was left + if (prev.project !== view.project || prev.projectsView !== view.projectsView) { + window.scrollTo(0, 0); + } if (next) { // fires hashchange; the listener below re-reads idempotently location.hash = next; @@ -377,7 +510,8 @@ export function App() { } // buildHash's omission rule reads settings.defaultLens too (via // defaultLensFallback) even though it's not one of hashState's own - // fields, so it stays a separate dependency here. + // fields, so it stays a separate dependency here. `view` is built from + // hashState's own fields, so hashState covers it. }, [hashState, lens, settings.defaultLens]); useEffect(() => { const onHash = () => { @@ -389,6 +523,7 @@ export function App() { setShowAll(h.hidden); setReveal(h.reveal); setDraftsMode(h.drafts ?? getSettings().draftsMode); + setProjectsNav(h.projects); // the hash is the whole view, scope included: applying a saved // search (or walking history) must land its repos/authors too. // Primed, not persisted — same rule as opening a shared link. @@ -679,6 +814,24 @@ export function App() { extraBots, ]); + // The Projects tab counts every person's PR, drafts and hidden repos + // included, whatever the filter bar narrows (it isn't shown there): a + // project's size can't depend on one viewer's review preferences, and a + // project whose PRs are filtered out mustn't look finished. Bots are + // left out, as in the API. + const allProjectPulls = useMemo(() => pulls.filter(p => !isBot(p)), [pulls, isBot]); + const allProjectClosed = useMemo( + () => closed.filter(p => !isBotLogin(p.user.login, extraBots)), + [closed, extraBots] + ); + // how each PR's project stands, for the review board's lanes: counted + // over every person's PR, as every Projects view counts them + const standing = useProjectStanding(projectLabelPrefix); + const reviewStanding = useMemo( + () => pullStanding(standing, allProjectPulls, projectLabelPrefix), + [standing, allProjectPulls, projectLabelPrefix] + ); + // the hidden-PR ledger's numbers: stable per-category sizes (what each // rule covers, whether or not a session reveal currently shows it) plus // the live currently-hidden total for the trigger label @@ -733,6 +886,15 @@ export function App() { const goToLens = useCallback((id: Lens) => { setQuery(''); setLens(id); + // the Projects tab, clicked from a project page, goes back to the list + if (id === 'projects') setProjectsNav(cur => ({ ...cur, project: null })); + }, []); + // a PR's project, from its state popover anywhere on the board: the + // Projects tab opens on that project's page, the board's filters kept + const onProject = useCallback((slug: string) => { + setQuery(''); + setLens('projects'); + setProjectsNav(cur => ({ ...cur, project: slug })); }, []); // and the Team lens shows the result const onPerson = useCallback( @@ -770,6 +932,7 @@ export function App() { pools, turns, isBotAuthor, + onProject, }), [ me, @@ -786,10 +949,19 @@ export function App() { pools, turns, isBotAuthor, + onProject, ] ); const mineCount = humans.filter(p => p.data.user.login === me).length; + const onProjects = lens === 'projects'; + // the Projects views whose own find box takes "/" (their inputs carry + // aria-keyshortcuts="/", which hooks.ts focuses before the PR search) + const viewFinds = + onProjects && + !searching && + !projectsNav.project && + ['overview', 'roadmap', 'people'].includes(projectsNav.view); // a tab's count rides as the corner badge (out of flow), so it appearing // when data lands can never widen the tab and shove the strip sideways const tab = (id: Lens, label: string, count?: number) => ( @@ -821,8 +993,8 @@ export function App() { lens === 'review' ? scopedClosed.length : lens === 'mine' - ? closed.filter(p => p.user.login === me).length - : 0; + ? closed.filter(p => p.user.login === me).length + : 0; // memoized: this feeds the shippedExtras useMemo below, which feeds // useToasts' extras effect — a fresh identity every render would re-run // that chain on every unrelated keystroke/toggle while on these lenses @@ -906,7 +1078,8 @@ export function App() { onClaimTurn, initialized, names, - snoozedKeys + snoozedKeys, + reviewStanding.parked ); // A fired nudge outlives the PR it points at: once that PR merges or closes @@ -970,11 +1143,11 @@ export function App() { connection === 'connected' ? 'conn-live bg-ok' : connection === 'connecting' - ? // in-progress, not an alarm — the same slate hue CI - // running wears, not warn (which means "you owe - // something") - 'bg-slate' - : 'bg-bad' + ? // in-progress, not an alarm — the same slate hue CI + // running wears, not warn (which means "you owe + // something") + 'bg-slate' + : 'bg-bad' }`} title={connection} > @@ -1026,6 +1199,7 @@ export function App() { {tab('review', LENS_LABELS.review)} {tab('mine', LENS_LABELS.mine, mineCount)} {tab('team', LENS_LABELS.team)} + {projectLabelPrefix && tab('projects', LENS_LABELS.projects)} {tab('classic', LENS_LABELS.classic)} {tab('ci', LENS_LABELS.ci)} {tab('stats', LENS_LABELS.stats)} @@ -1034,11 +1208,13 @@ export function App() { className="sm:hidden" lens={lens} setLens={goToLens} - options={LENSES.map(id => ({ - id, - label: LENS_LABELS[id], - count: id === 'mine' ? mineCount : undefined, - }))} + options={LENSES.filter(id => id !== 'projects' || projectLabelPrefix).map( + id => ({ + id, + label: LENS_LABELS[id], + count: id === 'mine' ? mineCount : undefined, + }) + )} />
@@ -1049,8 +1225,9 @@ export function App() { away and stops a growing set from wrapping the dimension triggers below. Click applies it on the lens you're standing on; click again clears. State is a background tint; the text never changes, - so nothing shifts. */} - {pinnedSearches.length > 0 && ( + so nothing shifts. Not on Projects: every view there counts every + PR, so a pin there would change nothing. */} + {pinnedSearches.length > 0 && !onProjects && (
{pinnedSearches.map(f => { const active = matchesView(f.hash, currentHash); @@ -1077,57 +1254,69 @@ export function App() { )}
0 ? '' : 'border-t border-secondary' + pinnedSearches.length > 0 && !onProjects ? '' : 'border-t border-secondary' }`} > - - - - - - - {sessionActive && ( - - )} - - {bots.length > 0 && ( - - {n(bots.length, 'bot PR')} - + {/* the filters narrow every tab but Projects, whose views count + every PR (DESIGN.md), so there they'd only take room: two + rows of a phone's header (177px down to 106). The search + stays: it opens Search from anywhere */} + {!onProjects && ( + <> + + + + + + + {sessionActive && ( + + )} + + {bots.length > 0 && ( + + {n(bots.length, 'bot PR')} + + )} + )} )} - {initialized && - !authFailed && - (connection === 'disconnected' || connection === 'error') && ( - - Live updates lost. - - Showing data as of {lastPayloadAt ? `${ago(lastPayloadAt)} ago` : 'page load'}, - retrying in the background. - - - )} + {updated && ( + + Pulldasher was updated. + + window.location.reload()}>Reload + + )} + {initialized && !authFailed && (connection === 'disconnected' || connection === 'error') && ( + + Live updates lost. + + Showing data as of {lastPayloadAt ? `${ago(lastPayloadAt)} ago` : 'page load'}, + retrying in the background. + + + )}
@@ -1199,10 +1395,20 @@ export function App() { botsForReady={botsBypassingHideBots} closed={scopedClosed} opts={{ ...rowOpts, showSnooze: true }} + standing={reviewStanding} /> )} {initialized && !searching && lens === 'mine' && ( - + + + )} {initialized && !searching && lens === 'team' && ( )} {initialized && !searching && lens === 'ci' && } + {initialized && !searching && lens === 'projects' && ( + + Loading projects… +
+ } + > + + + )} {initialized && !searching && lens === 'stats' && ( )} diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 61d62b62..d7e91e02 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -43,7 +43,15 @@ export async function loadDummy(): Promise { ]; const withBody = (p: PullData, i: number): PullData => p.body === 'pull request dummy body' ? { ...p, body: BODIES[i % BODIES.length] } : p; - const pulls = withSyntheticStacks(raw).map((p, i) => withBody(withSizes(redate(p, i), i), i)); + const pulls = withSyntheticStacks(raw).map((p, i) => + withProject(withBody(withSizes(redate(p, i), i), i), i) + ); + // ?projects=100 adds that many projects in flight, to design at real size + const scaled = new URLSearchParams(location.search).has('projects') + ? await import('./dummyScale').then(m => + m.scalePulls(pulls, m.scaleCount(), DUMMY_PROJECT_PREFIX) + ) + : []; return { repos: [{ name: 'iFixit/ifixit' }], // config the live server delivers here too — a bot login and the weight @@ -56,11 +64,95 @@ export async function loadDummy(): Promise { 'size: L': 'L', 'size: XL': 'XL', }, + projectLabelPrefix: DUMMY_PROJECT_PREFIX, // The fixture carries almost no closed/merged pulls and no diff sizes, so // the Stats lens (merge-time-by-size, leaderboards over shipped work) has // nothing to show. Synthesize a fortnight of merged PRs across the size // range so those panels demo. Dummy-only — real data carries this for real. - pulls: [...pulls, ...synthMerged(pulls)], + pulls: [...pulls, ...scaled, ...synthMerged(pulls)], + }; +} + +export const DUMMY_PROJECT_PREFIX = 'project:'; + +// The Projects tab reads project labels off the pulls, and the fixture +// predates them, so each pull gets one here by the theme its title already +// has (the Grafana dashboards, the webdriver deflakes...), keyed by index like +// everything else in this file. The picks keep every Today state on the bench: +// one person on a project (the Grafana four), a project whose PRs all wait on +// review (store-picker, its sign-offs cleared below), a lead with work in many +// other projects (type-refresh), a closed issue with a PR still open +// (akeneo-4), a label with no issue yet (core-primitives, the synthetic fork), +// one-offs, PRs not sorted yet, and one PR with two project labels. +// backend/dummyProjects.ts holds the issues behind them. synthMerged clones indexes +// 0-15, labels included, so those projects get recent merges too. +const DUMMY_PROJECT_PULLS: Record = { + 'webdriver-deflake': [0, 9, 17, 19, 32, 35, 48, 55], + 'grafana-dashboards': [21, 30, 31, 41], + 'training-periods': [8, 27, 37], + 'release-gate-sso': [5, 6, 7], + // the next phase: its one PR does an issue SSO approvals still has, so + // its page says where that work may belong ("Does #35004, in …") + // (24 characters at most, as a real label's slug: pull_labels.title is + // varchar(32) with the prefix in it) + 'sso-second-path': [38], + // 10-12 are the viewer's stacked chain: one chain, one project + 'shopify-sync': [10, 11, 12, 40], + 'core-primitives': [13, 14, 15], + 'shipping-shelf-weight': [26], + 'mysql-8': [23, 34, 50], + 'akeneo-4': [25, 56, 57], + 'newsletter-promo': [44, 45], + 'store-picker': [16, 36], + 'type-refresh': [1, 24, 42], + // its one PR moved this week, so its plan, marked Planned from Monday, + // reads In progress: Decide's "Filled in on its own this week" + translations: [22], + misc: [3, 18, 20, 29, 43, 47, 53], +}; +const SECOND_PROJECT: Record = { 50: 'webdriver-deflake' }; +// a project whose PRs all went quiet weeks ago (index to days since anyone +// worked on them), so the Overview's Stalled tile and chart bar and Decide's +// stall have one to show +const QUIET_FOR_DAYS: Record = { 26: 25 }; +const ALL_WAITING_ON_REVIEW = new Set(DUMMY_PROJECT_PULLS['store-picker']); +const projectsByIndex = new Map(); +for (const [slug, indexes] of Object.entries(DUMMY_PROJECT_PULLS)) + for (const i of indexes) projectsByIndex.set(i, [...(projectsByIndex.get(i) ?? []), slug]); +for (const [i, slug] of Object.entries(SECOND_PROJECT)) + projectsByIndex.set(Number(i), [...(projectsByIndex.get(Number(i)) ?? []), slug]); + +function withProject(given: PullData, i: number): PullData { + const pull = QUIET_FOR_DAYS[i] == null ? given : quietly(given, QUIET_FOR_DAYS[i]); + const out: PullData = ALL_WAITING_ON_REVIEW.has(i) + ? { + ...pull, + draft: false, + status: { ...pull.status, allCR: [], allQA: [], dev_block: [], deploy_block: [] }, + } + : pull; + const slugs = projectsByIndex.get(i); + if (!slugs) return out; + const labels = slugs.map(slug => ({ + title: DUMMY_PROJECT_PREFIX + slug, + number: pull.number, + repo: pull.repo, + user: 'projects-bot[bot]', + created_at: pull.created_at, + })); + return { ...out, labels: [...out.labels, ...labels] }; +} + +// Nobody has touched it for `days`: its activity, last update and sign-offs +// all fall before then, and it was opened at least two weeks before that. +function quietly(pull: PullData, days: number): PullData { + const at = Date.now() - days * 86400_000; + const created = Math.min(Date.parse(pull.created_at), at - 14 * 86400_000); + return { + ...pull, + created_at: new Date(created).toISOString(), + updated_at: new Date(at).toISOString(), + status: { ...redateSigs(pull.status, created, at), activity_at: new Date(at).toISOString() }, }; } diff --git a/frontend-v2/src/backend/dummyProjects.ts b/frontend-v2/src/backend/dummyProjects.ts new file mode 100644 index 00000000..4e67b114 --- /dev/null +++ b/frontend-v2/src/backend/dummyProjects.ts @@ -0,0 +1,582 @@ +import { utcDay, type Project } from '../../../shared/model/projects'; +import { + addWeeks, + mondayOf, + type RoadmapItem, + type RoadmapUpdate, +} from '../../../shared/model/roadmap'; +import type { AttachedIssue, IssueHit, IssueRef } from '../../../shared/model/work'; +import { SCALE_TEAM_ADDS, scaleCount, scaleProjects } from './dummyScale'; + +// The dummy board's projects, teams and roadmap: what /projects-data and +// /roadmap would send. They live apart from backend/dummy.ts, which the +// review board loads, so only the lazy Projects tab carries them. + +const inDays = (days: number) => new Date(Date.now() + days * 86400_000).toISOString(); + +/** `?state=fresh`: the board as a new install has it on day one, the PRs' + * project labels and the links in their bodies, and nothing else: no project + * issues (so no issues of theirs), no plans or updates, no teams. Every view + * has to read sensibly there before it reads well with everything filled in. */ +const FRESH = + typeof location !== 'undefined' && new URLSearchParams(location.search).get('state') === 'fresh'; + +/** Developer teams, as the server's config.js `projects.developerTeams` sends + * them. Everyone else in the fixture shows as a non-developer, so the People + * view and the developer split on every project demo both kinds. */ +const BASE_TEAMS: Record = { + Store: ['danielbeardsley', 'jarstelfox', 'sctice', 'zdmitchell'], + FixBot: ['mlahargou', 'ardelato'], + Community: ['rjmccluskey', 'sivadnor', 'hackalot805', 'djmetzle'], +}; +// a board scaled with ?projects= has a real-sized team too (dummyScale.ts) +const SCALE = scaleCount(); +export const DUMMY_TEAMS: Record = FRESH + ? {} + : SCALE + ? Object.fromEntries( + Object.entries(BASE_TEAMS).map(([team, logins]) => [ + team, + [...logins, ...(SCALE_TEAM_ADDS[team] ?? [])], + ]) + ) + : BASE_TEAMS; +const SCALED = scaleProjects(SCALE); +const dummyProject = ( + number: number, + slug: string, + name: string, + over: Partial = {} +): Project => ({ + slug, + name, + repo: 'iFixit/projects', + number, + state: 'open', + state_reason: null, + ongoing: false, + parents: [], + lead: null, + target: null, + fields: { start: null, target: null, priority: null }, + created_at: inDays(-60 + number * 3), + closed_at: over.state === 'closed' ? inDays(-number) : null, + ...over, +}); + +/** The project issues behind the dummy labels, as /projects-data sends them. + * onboarding-emails has no PRs (it lands in quiet), translations has one, + * and old-checkout was dropped, so Today never shows it. */ +const BASE_PROJECTS: Project[] = [ + dummyProject(1, 'webdriver-deflake', 'Deflake the webdriver tests', { + ongoing: true, + lead: 'mlahargou', + parents: ['ci'], + }), + // no plan yet, but its issue says when: Decide's outlined answer is + // "Its target date", and the plan it makes starts on the Start date + dummyProject(2, 'grafana-dashboards', 'Grafana dashboards for content', { + lead: 'sivadnor', + fields: { + start: inDays(-21).slice(0, 10), + target: inDays(20).slice(0, 10), + priority: 'high', + }, + }), + dummyProject(3, 'training-periods', 'Training periods', { + lead: 'hackalot805', + target: { title: 'October', due_on: inDays(30) }, + }), + dummyProject(4, 'release-gate-sso', 'SSO approvals for releases', { + parents: ['security'], + lead: 'rjmccluskey', + target: { title: 'Security review', due_on: inDays(-5) }, + }), + // dated the way GitHub's issue fields date an issue, with no milestone + dummyProject(5, 'shopify-sync', 'Shopify product and order sync', { + lead: 'zdmitchell', + parents: ['store'], + fields: { + start: inDays(-40).slice(0, 10), + target: inDays(20).slice(0, 10), + priority: 'high', + }, + }), + dummyProject(6, 'shipping-shelf-weight', 'Ship by shelf weight', { + parents: ['store', 'warehouse'], + }), + dummyProject(7, 'mysql-8', 'MySQL 8 upgrade', { + lead: 'evannoronha', + target: { title: 'Q4 infrastructure', due_on: inDays(75) }, + }), + dummyProject(8, 'akeneo-4', 'Akeneo 4 migration', { + state: 'closed', + state_reason: 'completed', + }), + dummyProject(9, 'newsletter-promo', 'Newsletter promo page'), + dummyProject(10, 'store-picker', 'Store picker', { parents: ['store'] }), + dummyProject(11, 'type-refresh', 'Type and spacing refresh', { lead: 'danielbeardsley' }), + dummyProject(12, 'translations', 'Translations upkeep', { ongoing: true }), + dummyProject(13, 'onboarding-emails', 'Onboarding emails'), + // the SSO plan ships its first path alone; the second is a phase of its own + dummyProject(15, 'sso-second-path', 'SSO approvals: the second path', { + lead: 'rjmccluskey', + parents: ['release-gate-sso'], + }), + dummyProject(14, 'old-checkout', 'Old checkout cleanup', { + state: 'closed', + state_reason: 'not_planned', + }), +]; + +export const DUMMY_PROJECTS: Project[] = FRESH + ? [] + : [ + ...BASE_PROJECTS, + ...SCALED.map((p, k) => + dummyProject(100 + k, p.slug, p.name, { + lead: p.lead, + created_at: inDays(-p.startDaysAgo), + }) + ), + ]; + +/** + * The roadmap a project manager might have laid out, relative to this week: + * linked items whose PRs draw over the plan (SSO approvals ran past its + * plan, Akeneo 4 is done), plans with no PRs yet (Checkout redesign, Search + * relevance), and several live projects left off it, so the "not on the + * roadmap" list demos too. The updates show every standing: SSO approvals + * slid from on track to off track as its plan grew, the webdriver work's + * last update is overdue, MySQL 8 never had one, and Checkout redesign is + * flagged at risk before it starts. Every kind of end is here: plans start + * soft, an estimate, as prod's did; SSO approvals and Training periods were + * committed to (hard), SSO's passed so Decide asks "New end?"; MySQL 8 ran + * past its soft end, drift drawn in ink that nothing asks about; and + * Translations upkeep is ongoing, with no end. + */ +export const { DUMMY_ROADMAP, DUMMY_ROADMAP_UPDATES } = (() => { + if (FRESH) return { DUMMY_ROADMAP: [] as RoadmapItem[], DUMMY_ROADMAP_UPDATES: [] }; + const monday = mondayOf(utcDay(Date.now() / 1000)); + const at = (weeks: number) => addWeeks(monday, weeks); + const now = Math.floor(Date.now() / 1000); + let nextUpdate = 1; + const update = ( + itemId: number, + daysAgo: number, + health: RoadmapUpdate['health'], + author: string, + plan: [number, number], + body: string + ): RoadmapUpdate => ({ + id: nextUpdate++, + item_id: itemId, + health, + body, + plan_start: at(plan[0]), + plan_weeks: plan[1], + author, + at: now - daysAgo * 86400 - 3600, + }); + // oldest first, the way they were posted + const updates = [ + update(8, 60, 'on_track', 'zdmitchell', [-11, 8], 'Catalog import works end to end.'), + update( + 1, + 30, + 'on_track', + 'rjmccluskey', + [-8, 4], + 'Design approved; building the approval step.' + ), + update(3, 24, 'on_track', 'mlahargou', [-6, 12], 'Down to four flaky tests.'), + update( + 5, + 16, + 'on_track', + 'evannoronha', + [-7, 5], + 'The replica runs 8.0; the primary is next.' + ), + update( + 1, + 16, + 'at_risk', + 'rjmccluskey', + [-8, 5], + 'Security review found two gaps in the approval step. Fixing them first.' + ), + // on track by its lead's word, while its pace says it finishes after + // its October target: the forecast's amber. Over two weeks old, with + // a merge since and no pile of PRs behind it, so its numbers vouch + // for it: "no update needed". The webdriver work's open PRs grew by 3 + // in two weeks, so its merges no longer do + update( + 10, + 18, + 'on_track', + 'hackalot805', + [-6, 10], + 'Payroll export works; leave balances next.' + ), + update(2, 5, 'on_track', 'zdmitchell', [-4, 7], 'Orders sync in staging; products are next.'), + update( + 1, + 3, + 'off_track', + 'rjmccluskey', + [-8, 6], + 'The audit moved up a week, and the second approval path won’t make it.\nAsking to ship the first path alone.' + ), + update( + 4, + 2, + 'at_risk', + 'jarstelfox', + [3, 8], + 'Waiting on the design review; the start may slip.' + ), + update( + 13, + 2, + 'at_risk', + 'danielbeardsley', + [-3, 8], + 'The type scale needs a design pass before the spacing can land.' + ), + ]; + const item = ( + id: number, + name: string, + start: number, + weeks: number, + over: Partial + ): RoadmapItem => ({ + id, + name, + project: null, + team: null, + lead: null, + status: 'planned', + origin: null, + start: at(start), + weeks, + // an estimate, as every plan starts + end_kind: 'soft', + done_when: '', + priority: id - 1, + notes: '', + waits_on: [], + updated_by: 'danielbeardsley', + updated_at: Math.floor(Date.now() / 1000) - id * 3600, + // made when they started, as far as updates go + created_at: null, + update: updates.filter(u => u.item_id === id).at(-1) ?? null, + ...over, + }); + const items = [ + item(1, 'SSO approvals for releases', -8, 6, { + project: 'release-gate-sso', + team: 'Community', + lead: 'rjmccluskey', + status: 'active', + // where the work came from, one plan of each kind, so the load + // chart's words and Look back's Fires tile and bands have words + origin: 'asked', + notes: 'Security asked for this before the audit.', + // committed to, and past it: Decide's "New end?" + end_kind: 'hard', + done_when: 'Both approval paths ship and security signs off the audit', + }), + item(2, 'Shopify product and order sync', -4, 8, { + project: 'shopify-sync', + team: 'Store', + lead: 'zdmitchell', + status: 'active', + // last touched before its last issue closed, so Decide asks "Done?", + // with what done means beside it + updated_at: Math.floor(Date.now() / 1000) - 10 * 86400, + done_when: 'Products and orders sync nightly for every store', + }), + item(3, 'Deflake the webdriver tests', -6, 12, { + project: 'webdriver-deflake', + team: 'FixBot', + lead: 'mlahargou', + status: 'active', + }), + item(4, 'Checkout redesign', 3, 8, { team: 'Store', lead: 'jarstelfox', waits_on: [2] }), + // its last update is 16 days old and nothing merged since, so its + // numbers can't vouch for it: the board's "Update due". Its soft end + // passed two weeks ago with its PRs still open: drift, in ink + item(5, 'MySQL 8 upgrade', -7, 5, { + project: 'mysql-8', + team: 'Community', + lead: 'evannoronha', + status: 'active', + }), + item(6, 'Search relevance', 7, 6, { team: 'FixBot', waits_on: [3] }), + // marked Planned from this Monday, and its PR has moved since: it reads + // In progress, which Decide lists as filled in on its own this week + // upkeep: ongoing, with no end, so it runs on past the weeks shown + item(7, 'Translations upkeep', 0, 26, { + project: 'translations', + team: 'Community', + end_kind: 'ongoing', + }), + // marked done three weeks ago; its issue closed later, with a PR still + // open, so Decide asks again (the "reopened" call, by its issue) + item(8, 'Akeneo 4 migration', -11, 8, { + project: 'akeneo-4', + team: 'Store', + status: 'done', + updated_at: now - 20 * 86400, + status_at: now - 20 * 86400, + }), + // FixBot's two developers would each be alone on a plan while this + // overlaps the webdriver work, so its lane's load shows amber + // under way three weeks with no PRs and no update yet ("No update + // yet"), beside the webdriver work: FixBot's two developers on two + // things this week, so its lane reads "no one to spare" + item(9, 'Visual regression checks', -3, 6, { + team: 'FixBot', + lead: 'ardelato', + status: 'active', + }), + item(10, 'Training periods', -6, 10, { + project: 'training-periods', + team: 'Community', + lead: 'hackalot805', + status: 'active', + origin: 'chosen', + // committed to, with its end still ahead: a crisp edge + end_kind: 'hard', + done_when: 'Leads can see and approve leave balances', + }), + // planned to start in two weeks: the list's "Starts" cell + item(11, 'Newsletter promo page', 2, 4, { + project: 'newsletter-promo', + team: 'Store', + lead: 'zdmitchell', + }), + // parked twelve days ago while its PRs kept moving: "Parked, still + // worked on", which Decide asks about + item(12, 'Store picker', -5, 6, { + project: 'store-picker', + team: 'Store', + lead: 'sctice', + status: 'parked', + updated_at: now - 12 * 86400, + status_at: now - 12 * 86400, + }), + // under way, and its lead says at risk since the plan last changed: + // the at-risk call + item(13, 'Type and spacing refresh', -3, 8, { + project: 'type-refresh', + team: 'Store', + lead: 'danielbeardsley', + status: 'active', + origin: 'fire', + updated_at: now - 6 * 86400, + }), + ]; + // about one in eight scaled projects is planned: active since its first + // PR, in the lane of its lead's team + const teamOf = (login: string) => + Object.keys(DUMMY_TEAMS).find(team => DUMMY_TEAMS[team].includes(login)) ?? null; + SCALED.forEach((p, k) => { + if (!p.planned) return; + const id = 100 + k; + items.push( + item(id, p.name, -Math.round(p.startDaysAgo / 7), 4 + (k % 9), { + project: p.slug, + team: teamOf(p.lead), + lead: p.lead, + status: 'active', + // a third committed to, as a Monday of Decide calls leaves them + end_kind: k % 3 ? 'soft' : 'hard', + }) + ); + }); + return { DUMMY_ROADMAP: items, DUMMY_ROADMAP_UPDATES: updates }; +})(); + +/** + * The dummy board's issues attached to projects, by slug (model/workData.ts): + * SSO approvals has work left, Shopify sync has every issue closed (so + * Decide asks "Done?"), the webdriver work is midway, and Akeneo is done. + * One webdriver issue was added to SSO approvals by hand too, so both pages + * say it's in the other. + */ +export const DUMMY_ATTACHED: Record = ((): Record< + string, + AttachedIssue[] +> => { + if (FRESH) return {}; + const day = 86400; + const now = Math.floor(Date.now() / 1000); + const issue = ( + number: number, + title: string, + state: AttachedIssue['state'], + daysAgo: { joined?: number; closed?: number } = {} + ): AttachedIssue => ({ + ref: { repo: 'iFixit/ifixit', number }, + title, + state, + closedAt: daysAgo.closed == null ? null : now - daysAgo.closed * day, + // opened a couple of days before it joined the project + author: number % 2 ? 'danielbeardsley' : 'zdmitchell', + createdAt: now - ((daysAgo.joined ?? 30) + 2) * day, + via: ['label'], + attachedAt: daysAgo.joined == null ? null : now - daysAgo.joined * day, + addedBy: null, + }); + // its flaky tests hold up the release gates + const gateTests = issue(35806, 'Deflake the release-gate approval tests', 'open', { + joined: 9, + }); + return { + 'release-gate-sso': [ + // the spec it was planned with (its plan started 59 days ago); the + // second path's flip came later + issue(35001, 'Require a second approver on release gates', 'done', { + joined: 65, + closed: 40, + }), + issue(35002, 'Make the release-gate signoffs user editable', 'done', { + joined: 65, + closed: 20, + }), + issue(35003, 'Audit log for approvals', 'done', { joined: 65, closed: 15 }), + issue(35004, 'Flip the default for the second approval path', 'open', { joined: 30 }), + issue(35005, 'Email approvers when a gate waits', 'dropped', { joined: 65, closed: 25 }), + { ...gateTests, via: ['hand'], attachedAt: now - 5 * day, addedBy: 'rjmccluskey' }, + ], + 'shopify-sync': [ + // planned with these two; the backfill and the alert came later + issue(35401, 'Sync products nightly', 'done', { joined: 35, closed: 12 }), + issue(35402, 'Sync orders as they land', 'done', { joined: 35, closed: 6 }), + issue(35403, 'Backfill a year of orders', 'done', { joined: 20, closed: 3 }), + issue(35404, 'Alert on sync drift', 'dropped', { joined: 20, closed: 4 }), + ], + 'webdriver-deflake': [ + // by its label and added by hand too: its line says both + { + // the first three were the plan (it started 45 days ago); the + // rest came as new flakes turned up + ...issue(35801, 'Retry the network-bound steps once', 'done', { + joined: 50, + closed: 30, + }), + via: ['label', 'hand'], + addedBy: 'mlahargou', + }, + issue(35802, 'Quarantine the five flakiest tests', 'done', { joined: 50, closed: 21 }), + issue(35803, 'Run IE11 tests in parallel', 'open', { joined: 50 }), + issue(35804, 'Record a video on failure', 'open', { joined: 10 }), + issue(35805, 'Seed the test store once per run', 'open', { joined: 8 }), + // its PR merged and it's still open: the "PRs merged" band + issue(35807, 'Drop the cloud search test’s outside dependency', 'open', { joined: 12 }), + gateTests, + ], + // its one PR is signed off: the "Ready to merge" band + 'core-primitives': [issue(36301, 'Ship core primitives 1.2', 'open', { joined: 14 })], + 'akeneo-4': [ + issue(36201, 'Upgrade the connector', 'done', { joined: 85, closed: 50 }), + issue(36202, 'Move the attribute mapping', 'done', { joined: 85, closed: 45 }), + ], + }; +})(); + +/** Which dummy PRs link which issues, by "owner/repo#number" of the PR: + * the "Parts of #N" a real board reads off a PR's body and off each + * issue's cross-references. Two link issues no one attached yet, so their + * projects' pages suggest them. */ +export const DUMMY_LINKS: Record = { + 'iFixit/ifixit#35059': [{ repo: 'iFixit/ifixit', number: 35002 }], + 'iFixit/ifixit#35103': [ + { repo: 'iFixit/ifixit', number: 35004 }, + { repo: 'iFixit/ifixit', number: 35021 }, + ], + 'iFixit/ifixit#351011': [{ repo: 'iFixit/ifixit', number: 35004 }], + 'iFixit/ifixit#33495': [ + { repo: 'iFixit/ifixit', number: 35803 }, + { repo: 'iFixit/ops', number: 812 }, + ], + 'iFixit/ifixit#35154': [{ repo: 'iFixit/ifixit', number: 35802 }], + // no project label: it joins webdriver-deflake by linking its issue + 'iFixit/ifixit#35501': [{ repo: 'iFixit/ifixit', number: 35805 }], + // held for a deploy: its issue sits On hold + 'iFixit/ifixit#35258': [{ repo: 'iFixit/ifixit', number: 35804 }], + // merged after the plan ended: its issue's line says so + 'iFixit/ifixit#90005': [{ repo: 'iFixit/ifixit', number: 35004 }], + 'iFixit/ifixit#90009': [{ repo: 'iFixit/ifixit', number: 35807 }], + 'iFixit/ifixit#35249': [{ repo: 'iFixit/ifixit', number: 36301 }], + // opened this week with no project label: it counts in core-primitives + // by this link, which Decide lists as filled in on its own this week + 'iFixit/ifixit#35554': [{ repo: 'iFixit/ifixit', number: 36301 }], + // the second path's PR does an issue SSO approvals still has + 'iFixit/ifixit#35553': [{ repo: 'iFixit/ifixit', number: 35004 }], + // no label, and its issue is in two projects: the Overview's "In two + // projects" fold + 'iFixit/ifixit#35529': [{ repo: 'iFixit/ifixit', number: 35806 }], +}; + +/** Issues the dummy board's issue search finds that no project has, for + * adding one by hand (model/projectWork.ts). */ +export const DUMMY_ISSUES: IssueHit[] = (() => { + const day = 86400; + const now = Math.floor(Date.now() / 1000); + const hit = ( + repo: string, + number: number, + title: string, + state: IssueHit['state'], + author: string, + daysAgo: number + ): IssueHit => ({ repo, number, title, state, author, createdAt: now - daysAgo * day }); + return [ + hit('iFixit/ifixit', 35021, 'SSO: let a lead approve from Slack', 'open', 'rjmccluskey', 6), + hit( + 'iFixit/ifixit', + 35410, + 'Shopify: retry an order sync that failed', + 'open', + 'zdmitchell', + 3 + ), + hit( + 'iFixit/ifixit', + 35411, + 'Shopify: log sync drift per store', + 'open', + 'danielbeardsley', + 9 + ), + hit( + 'iFixit/ifixit', + 35020, + 'SSO: show who approved a release gate', + 'done', + 'rjmccluskey', + 40 + ), + hit('iFixit/ops', 812, 'Webdriver: keep failure videos for a week', 'open', 'mlahargou', 12), + hit( + 'iFixit/ifixit', + 36210, + 'Akeneo: drop the old attribute sync job', + 'dropped', + 'danielbeardsley', + 70 + ), + hit( + 'iFixit/ifixit', + 35415, + 'Translations: flag strings nobody has reviewed', + 'open', + 'sctice', + 5 + ), + ]; +})(); diff --git a/frontend-v2/src/backend/dummyScale.ts b/frontend-v2/src/backend/dummyScale.ts new file mode 100644 index 00000000..7995927e --- /dev/null +++ b/frontend-v2/src/backend/dummyScale.ts @@ -0,0 +1,161 @@ +import type { PullData } from '../../../shared/types'; + +/** + * The dummy board at the size of the real one. `?projects=100` in the URL + * adds that many projects in flight, each with a few open PRs cloned from the + * fixture, so the Projects tab can be designed against a hundred rows + * instead of a dozen. Everything is derived from the index, so a reload + * looks the same. Lazy: only a board with the parameter loads it. + */ +export function scaleCount(): number { + // no location under the unit tests, which never scale + const search = typeof location === 'undefined' ? '' : location.search; + const n = Number(new URLSearchParams(search).get('projects')); + return Number.isInteger(n) && n > 0 ? Math.min(n, 300) : 0; +} + +const AREAS = [ + 'Cart', + 'Checkout', + 'Search', + 'Guide editor', + 'Answers', + 'Wiki', + 'Store', + 'Parts finder', + 'Teardowns', + 'Pro tools', + 'Warehouse', + 'Shipping', + 'Returns', + 'Translations', + 'Accounts', + 'Emails', + 'Media', + 'API', + 'CI', + 'Monitoring', +]; +const THINGS = [ + 'autosave', + 'redesign', + 'price rules', + 'speedups', + 'cleanup', + 'accessibility pass', + 'mobile layout', + 'filters', + 'tax rules', + 'caching', + 'error handling', + 'onboarding', + 'reports', + 'migration', + 'bulk edit', + 'sync fixes', + 'image pipeline', + 'permissions', + 'ranking', + 'dark mode', +]; +/** fixture logins: the first ten are the dummy developer teams, the rest are + * the extra developers a scaled board adds, then non-developers */ +export const SCALE_AUTHORS = [ + 'danielbeardsley', + 'jarstelfox', + 'sctice', + 'zdmitchell', + 'mlahargou', + 'ardelato', + 'rjmccluskey', + 'sivadnor', + 'hackalot805', + 'djmetzle', + 'scbarber', + 'ianrohde', + 'BaseInfinity', + 'jyee27', + 'cdcline', + 'evannoronha', + 'andrewjpiro', + 'andyg0808', + 'Michelle5102', + 'addison-grant', + 'ShadyAlzayat', + 'bhuminson', + 'bcooper94', + 'emmalopezcode', +]; + +/** The developers a scaled board adds to each dummy team. */ +export const SCALE_TEAM_ADDS: Record = { + Store: ['scbarber', 'ianrohde'], + FixBot: ['BaseInfinity', 'jyee27', 'cdcline'], + Community: ['evannoronha', 'andrewjpiro', 'andyg0808'], +}; + +export interface ScaleProject { + slug: string; + name: string; + lead: string; + /** days ago its first PR opened */ + startDaysAgo: number; + prs: number; + /** whether the dummy roadmap plans it: about one in eight */ + planned: boolean; +} + +export function scaleProjects(n: number): ScaleProject[] { + return Array.from({ length: n }, (_, k) => { + const name = `${AREAS[k % AREAS.length]} ${ + THINGS[(k * 7 + Math.floor(k / AREAS.length)) % THINGS.length] + }`; + const slug = `s${k}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` + .slice(0, 24) + .replace(/-+$/, ''); + return { + slug, + name, + lead: SCALE_AUTHORS[(k * 5) % SCALE_AUTHORS.length], + startDaysAgo: 3 + ((k * 37) % 170), + prs: 1 + (k % 4), + planned: k % 8 === 0, + }; + }); +} + +/** Each scaled project's open PRs, cloned from the fixture's open ones. */ +export function scalePulls(template: PullData[], n: number, prefix: string): PullData[] { + const open = template.filter(p => p.state === 'open' && !p.user.login.includes('[')); + const now = Date.now(); + let number = 80000; + return scaleProjects(n).flatMap((project, k) => + Array.from({ length: project.prs }, (_, j) => { + const base = open[(k * 3 + j) % open.length]; + const created = new Date( + now - Math.max(1, project.startDaysAgo - j * 5) * 86400_000 + ).toISOString(); + number += 1; + return { + ...base, + number, + title: `${project.name}: part ${j + 1}`, + user: { + ...base.user, + login: j === 0 ? project.lead : SCALE_AUTHORS[(k + j * 3) % SCALE_AUTHORS.length], + }, + created_at: created, + updated_at: new Date(now - ((k + j) % 9) * 86400_000).toISOString(), + labels: [ + { + title: prefix + project.slug, + number, + repo: base.repo, + user: 'projects-bot[bot]', + created_at: created, + }, + ], + }; + }) + ); +} diff --git a/frontend-v2/src/backend/socket.ts b/frontend-v2/src/backend/socket.ts index 92c846ac..4fff914e 100644 --- a/frontend-v2/src/backend/socket.ts +++ b/frontend-v2/src/backend/socket.ts @@ -41,6 +41,10 @@ export interface Backend { * released, or removed on GitHub, same as any other review request. */ claimReview: (repo: string, number: number) => void; releaseReview: (repo: string, number: number) => void; + /** Calls `handler` when something the Projects tab shows changed on the + * server (a roadmap write, an issue added, a setting, a sync), so open + * boards fetch it again; returns the unsubscribe. */ + onProjectsChanged: (handler: () => void) => () => void; } function liveBackend(): Backend { @@ -50,12 +54,17 @@ function liveBackend(): Backend { const getToken = () => { token = token ?? - fetch('/token').then(async r => { + // the redirect isn't followed: it goes to GitHub, cross-site, and a + // blocked hop would read as a network blip and retry forever + fetch('/token', { redirect: 'manual' }).then(async r => { // an expired session redirects toward the OAuth flow (or 401s) - // instead of returning JSON. Separate that dead-session case from - // a transient network/5xx failure, so only the former says "sign - // in again" while a blip keeps retrying quietly. - if (r.redirected || r.status === 401) throw new AuthExpiredError(r.status); + // instead of returning JSON (a server restart drops every + // session). Separate that dead-session case from a transient + // network/5xx failure, so only the former says "sign in again" + // while a blip keeps retrying quietly. + if (r.type === 'opaqueredirect' || r.redirected || r.status === 401) { + throw new AuthExpiredError(r.status); + } if (!r.ok) throw new Error(`token fetch failed: ${r.status}`); const t = (await r.json()) as TokenResponse; if (!t.socketToken) throw new Error('token response missing socketToken'); @@ -101,8 +110,10 @@ function liveBackend(): Backend { token = null; getToken().then( t => { - // a valid /token proves the app session is alive again + // a valid /token proves the app session is alive again, and + // clears a "live updates lost" a failed attempt left up onAuth?.(false); + onState?.('connected'); socket!.emit('authenticate', t.socketToken); }, (err: unknown) => { @@ -120,9 +131,14 @@ function liveBackend(): Backend { ); }; + // how many board snapshots this socket has had: the first is the connect, + // every later one a reconnect. Counted here, before any subscriber, since + // subscribers often start only after the first one arrived. + let snapshots = 0; const getSocket = () => { if (socket) return socket; socket = io(); + socket.on('initialize', () => snapshots++); socket.on('connect', () => { clearAuthRetry(); authenticate(); @@ -135,6 +151,12 @@ function liveBackend(): Backend { // returning — skips the wait and reconnects now. const wake = () => { if (socket && !socket.connected) socket.connect(); + // connected but waiting out a sign-in retry: try now (as a retry, so + // a dead session can reload the page and sign back in) + else if (socket && authRetry != null) { + clearAuthRetry(); + authenticate(1); + } }; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') wake(); @@ -151,6 +173,21 @@ function liveBackend(): Backend { s.on('initialize', (data: InitializePayload) => handler(data)); s.on('pullChange', (pull: PullData) => handler(pull)); }, + onProjectsChanged(handler) { + const s = getSocket(); + // a change sent while the socket was down is never replayed, so a + // reconnect (any snapshot after the first, counted socket-wide) + // fetches again, even for a subscriber that started after the first + const reconnected = () => { + if (snapshots > 1) handler(); + }; + s.on('projectsChanged', handler); + s.on('initialize', reconnected); + return () => { + s.off('projectsChanged', handler); + s.off('initialize', reconnected); + }; + }, onConnection(handler) { onState = handler; const s = getSocket(); @@ -214,6 +251,13 @@ function dummyBackend(): Backend { } return { + // one person on the dummy board: nobody else's writes to hear, so + // nothing to unsubscribe from either + // the dummy roadmap's writes say it (model/roadmapData.ts announcing) + onProjectsChanged(handler) { + window.addEventListener('pd:projectsChanged', handler); + return () => window.removeEventListener('pd:projectsChanged', handler); + }, whoami: () => Promise.resolve(dummyUser()), onPulls(handler) { emit = handler; diff --git a/frontend-v2/src/components/GitHubRef.tsx b/frontend-v2/src/components/GitHubRef.tsx new file mode 100644 index 00000000..2deffe93 --- /dev/null +++ b/frontend-v2/src/components/GitHubRef.tsx @@ -0,0 +1,179 @@ +import { + CircleCheck, + CircleDot, + CircleSlash, + GitMerge, + GitPullRequest, + GitPullRequestClosed, +} from 'lucide-react'; +import { issueUrl, n, shortRepo } from '../../../shared/format'; +import { dateWords } from '../model/stage'; +import { Icon, type LucideComponent } from './Icon'; +import { Avatar } from './identity'; +import { Popover } from './Popover'; + +/** + * An issue or PR named the way Claude and GitHub show one: a small chip + * (its state's icon, #number, repo) that opens a card with its state, repo + * and number, the day it opened, title, author, and for a PR its size, the + * way a board row's repo#number opens its state. The card links it on + * GitHub. Whatever isn't known is left off the card. + */ + +export interface GitHubRefData { + kind: 'issue' | 'pr'; + repo: string; + number: number; + title?: string | null; + /** an issue: open, done or dropped; a PR: open, merged or closed */ + state?: 'open' | 'done' | 'dropped' | 'merged' | 'closed' | null; + author?: string | null; + /** epoch secs it was opened */ + createdAt?: number | null; + additions?: number | null; + deletions?: number | null; + files?: number | null; +} + +interface Look { + icon: LucideComponent; + word: string; + /** a color token, by the board's one color rule (DESIGN.md): green is + * the quiet confirmation (done, merged), ink everything else */ + color: string; +} + +const LOOKS: Record = { + 'issue:open': { icon: CircleDot, word: 'Open', color: 'var(--ink-2)' }, + 'issue:done': { icon: CircleCheck, word: 'Done', color: 'var(--ok)' }, + 'issue:dropped': { icon: CircleSlash, word: 'Dropped', color: 'var(--ink-3)' }, + 'pr:open': { icon: GitPullRequest, word: 'Open', color: 'var(--ink-2)' }, + 'pr:merged': { icon: GitMerge, word: 'Merged', color: 'var(--ok)' }, + 'pr:closed': { icon: GitPullRequestClosed, word: 'Closed', color: 'var(--ink-3)' }, +}; + +const lookOf = (ref: GitHubRefData): Look | null => + ref.state ? LOOKS[`${ref.kind}:${ref.state}`] ?? null : null; + +/** When it was opened, as the tab says a day: "opened Sep 21". */ +export const openedWords = (epochSecs: number) => `opened ${dateWords(epochSecs)}`; + +/** The state in words, with its icon; no wash behind it (a filled area + * outshouts the title). */ +export function StatePill({ data }: { data: GitHubRefData }) { + const look = lookOf(data); + if (!look) return null; + return ( + + + {look.word} + + ); +} + +/** The hover card: state, repo and number, the day it opened, title, + * author, PR size. */ +export function RefCard({ data }: { data: GitHubRefData }) { + const size = + data.kind === 'pr' && data.additions != null && data.deletions != null ? ( + + +{data.additions.toLocaleString()} + −{data.deletions.toLocaleString()} + {data.files != null && ( + {n(data.files, 'file')} + )} + + ) : null; + return ( + + + + {/* the way to it on GitHub, whether or not its title is known */} + + {data.repo} #{data.number} + + {data.createdAt != null && ( + {openedWords(data.createdAt)} + )} + + {data.title && ( + + {data.title} + + )} + {(data.author || size) && ( + + {data.author && ( + <> + + {data.author} + + )} + {size} + + )} + + ); +} + +/** + * The small chip: the state's icon, #number, and the repo when it isn't + * the page's usual one; a PR's says "PR" and its state too, so it reads + * without the card. The icon's shape tells the state; where it sits (an + * Open or Done fold) already says so, so it stays ink. Like a row's + * repo#number, hovering shows its card and a click pins it; the title + * beside it is what opens it on GitHub. `tabStop` false where that title + * is the line's way in for a keyboard: one stop for the issue, not two. + */ +export function RefChip({ + data, + repoShown = true, + tabStop = true, +}: { + data: GitHubRefData; + repoShown?: boolean; + tabStop?: boolean; +}) { + const look = lookOf(data); + const fallback = data.kind === 'pr' ? GitPullRequest : CircleDot; + return ( + ( + + )} + > + + + ); +} diff --git a/frontend-v2/src/components/IssueSearch.tsx b/frontend-v2/src/components/IssueSearch.tsx new file mode 100644 index 00000000..c085ae7d --- /dev/null +++ b/frontend-v2/src/components/IssueSearch.tsx @@ -0,0 +1,227 @@ +import { Search } from 'lucide-react'; +import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { shortRepo } from '../../../shared/format'; +import { issueKey, issueQuery, type IssueHit } from '../../../shared/model/work'; +import { searchIssues } from '../model/projectWork'; +import { openedWords, StatePill } from './GitHubRef'; +import { Icon } from './Icon'; +import { Avatar } from './identity'; + +/** how long typing rests before a search */ +const WAIT_MS = 300; + +/** + * Find an issue on GitHub as you type and pick it: words from its title or + * body, "#123" (in any tracked repo), "owner/repo#123", or its link. Each + * result reads like the issue's card: state, number and repo, where it is + * already, age, title, author. The ones that can be picked come first; + * arrow keys move among them, Enter picks, Escape closes the list (and only + * the list). The list is drawn over the page, so a clipped box can't cut + * it off. + */ +export function IssueSearch({ + onPick, + label, + placeholder = 'Find an issue: title words, #123, or a link', + taken, + takenWords = 'added already', + whereIs, + autoFocus = false, +}: { + onPick: (hit: IssueHit) => void; + /** the input's accessible name */ + label: string; + placeholder?: string; + /** issues that can't be picked again, by issueKey */ + taken?: ReadonlySet; + /** what a taken result says about itself */ + takenWords?: string; + /** where a result is already, in words ("in Shopify sync"), or null */ + whereIs?: (hit: IssueHit) => string | null; + autoFocus?: boolean; +}) { + const [text, setText] = useState(''); + const [found, setFound] = useState<{ text: string; hits: IssueHit[] | null; error?: string }>(); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const [box, setBox] = useState<{ top: number; left: number; width: number }>(); + const inputRef = useRef(null); + const listRef = useRef(null); + const listId = useId(); + const asked = issueQuery(text) != null; + useEffect(() => { + if (!issueQuery(text)) return; + let live = true; + const wait = setTimeout(() => { + void searchIssues(text).then(got => { + if (!live) return; + setFound( + Array.isArray(got) ? { text, hits: got } : { text, hits: null, error: got.error } + ); + setActive(0); + }); + }, WAIT_MS); + return () => { + live = false; + clearTimeout(wait); + }; + }, [text]); + const current = found?.text === text ? found : undefined; + const isTaken = (hit: IssueHit) => !!taken?.has(issueKey(hit)); + // the ones that can be picked first, so the highlight starts on one and + // the arrows move among them only + const all = current?.hits ?? []; + const hits = [...all.filter(hit => !isTaken(hit)), ...all.filter(isTaken)]; + const pickable = all.filter(hit => !isTaken(hit)).length; + const at = Math.min(active, Math.max(pickable - 1, 0)); + const shown = open && asked; + // the list hangs under the input wherever the page scrolls + useLayoutEffect(() => { + if (!shown) return; + const place = () => { + const r = inputRef.current?.getBoundingClientRect(); + if (r) setBox({ top: r.bottom + 4, left: r.left, width: r.width }); + }; + place(); + window.addEventListener('scroll', place, true); + window.addEventListener('resize', place); + return () => { + window.removeEventListener('scroll', place, true); + window.removeEventListener('resize', place); + }; + }, [shown]); + useEffect(() => { + listRef.current?.querySelector(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' }); + }, [at]); + const pick = (hit: IssueHit | undefined) => { + if (!hit || isTaken(hit)) return; + onPick(hit); + setText(''); + setFound(undefined); + setOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown' && pickable) { + e.preventDefault(); + setOpen(true); + setActive(Math.min(at + 1, pickable - 1)); + } else if (e.key === 'ArrowUp' && pickable) { + e.preventDefault(); + setActive(Math.max(at - 1, 0)); + } else if (e.key === 'Enter') { + // never submit a form this sits in; pick only what's on screen + e.preventDefault(); + if (shown && pickable) pick(hits[at]); + } else if (e.key === 'Escape' && shown) { + // close the list, not whatever holds this box + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + } + }; + const optionId = (i: number) => `${listId}-${i}`; + let status = ''; + if (asked && !current) status = 'Searching GitHub…'; + else if (current?.error) status = current.error; + else if (current && !hits.length) { + status = /\/pull\/\d+/.test(text) + ? 'That’s a PR. A PR joins a project by the project’s label, or by linking one of its issues.' + : `No issue matches “${text.trim()}”.`; + } else if (current && !pickable) status = `Every issue found is ${takenWords}.`; + else if (current) status = `${hits.length} found`; + return ( + + + { + setText(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={onKeyDown} + placeholder={placeholder} + aria-label={label} + autoFocus={autoFocus} + role="combobox" + aria-expanded={shown} + aria-controls={listId} + aria-autocomplete="list" + aria-activedescendant={shown && pickable ? optionId(at) : undefined} + className="w-full rounded-lg border border-line bg-surface py-1.5 pl-8 pr-2.5 text-[13px] text-ink placeholder:text-ink-3 focus:border-brand" + /> + + {status} + + {shown && + box && + createPortal( +
e.preventDefault()} + style={{ position: 'fixed', top: box.top, left: box.left, width: box.width }} + className="popover z-50 max-h-[min(60vh,440px)] overflow-y-auto rounded-lg border border-line bg-surface p-1 shadow-md" + > + {(!current || current.error || !pickable) && ( +

{status}

+ )} +
+ {hits.map((hit, i) => { + const off = isTaken(hit); + // when every hit is taken, the line above says so once + const where = off ? (pickable ? takenWords : null) : whereIs?.(hit); + return ( +
!off && setActive(i)} + onClick={() => pick(hit)} + className={`rounded-md px-2 py-1.5 ${ + !off && i === at ? 'bg-muted' : '' + } ${off ? 'cursor-default opacity-60' : 'cursor-pointer'}`} + > + + + {/* the number first, so a narrow list never cuts it */} + + #{hit.number} + + {shortRepo(hit.repo)} + {where && {where}} + {hit.createdAt != null && ( + + {openedWords(hit.createdAt)} + + )} + + + {hit.title} + + {hit.author && ( + + + {hit.author} + + )} +
+ ); + })} +
+
, + document.body + )} +
+ ); +} diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index d47ce426..eed26c64 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -1,4 +1,11 @@ -import { useRef, useState, type ReactNode, type SyntheticEvent } from 'react'; +import { + useId, + useRef, + useState, + type MouseEvent, + type ReactNode, + type SyntheticEvent, +} from 'react'; import { ChevronRight } from 'lucide-react'; import { pullKey } from '../../../shared/format'; import type { DerivedPull } from '../../../shared/model/status'; @@ -24,6 +31,13 @@ export function foldDomId(id: string): string { return `fold-${id.replace(/:/g, '-')}`; } +/** Every fold's remembered open or closed choice, by store id, for a list + * that needs to know which of its folds are open (one issue's PRs drawn in + * full only where they're expanded). */ +export function useFoldChoices(): Record { + return foldOpenStore.useValue(); +} + /** Open a fold from outside Lane.tsx — e.g. the "N merged since your last * look" banner jumping straight to the shipped fold — by writing the same * store its own toggle reads. A no-op if it's already open. */ @@ -43,9 +57,12 @@ export function SubDoor({ label, text, children, + inLine = false, }: { /** the popover's accessible name, e.g. "How the queue is ranked" */ label: string; + /** part of a sentence: the sentence's size and ink, not a sub-line's */ + inLine?: boolean; /** the visible sub-line sentence */ text: string; children: ReactNode; @@ -62,7 +79,9 @@ export function SubDoor({ @@ -75,7 +94,10 @@ export function SubDoor({ export function Rows({ children }: { children: ReactNode }) { return ( -
{children}
+ // clip, not hidden: it rounds the rows off the same way without making + // a scroll box, so a header inside can stick to the page and + // scrollIntoView keeps a row's scroll margin +
{children}
); } @@ -88,6 +110,7 @@ export function GroupHeader({ count, compact, headerExtra, + level = 2, }: { title: string; /** plain string for most lanes; a ReactNode when the sub-line itself is @@ -97,21 +120,30 @@ export function GroupHeader({ count?: number; compact?: boolean; headerExtra?: ReactNode; + /** 3 for a section under a page's own title (a project's page) */ + level?: 2 | 3; }) { + const Heading = level === 3 ? 'h3' : 'h2'; return ( // sticky just under the app header (top from the measured --header-h), // opaque over the canvas so long lanes keep their context while rows // scroll beneath; the old margin-below became padding so the spacing // itself is part of the opaque surface + // the sub-line wraps under the title on a narrow screen rather than + // squeezing the title into a column of words
-

+ {title} -

- {sub && {sub}} + + {sub && {sub}} {/* an empty section earns a title, never a "0" — the count only appears once there's something to count; headerExtra alone can still earn the right-aligned slot on an otherwise count-less lane */} @@ -219,8 +251,9 @@ const expandedIds = new Set(); /** * The one truncation behavior: show `cap` items and a working "+ N more" - * button. Every capped list in the app goes through this — a count the user - * can see but not open is a lie. + * button, and once open, a "Show fewer" that folds them back. Every capped + * list in the app goes through this — a count the user can see but not open + * is a lie. */ export function Truncated({ children, @@ -238,10 +271,26 @@ export function Truncated({ // don't stagger a fold that was already open on mount (a lens revisit this // session): the reveal animation is earned only on the click that opens it const [justExpanded, setJustExpanded] = useState(false); - const expand = () => { + const expand = (e: MouseEvent) => { setExpanded(true); setJustExpanded(true); if (id) expandedIds.add(id); + // the button goes away as the rows arrive: hand focus to the first new + // row, where the button was, rather than dropping it to the page + const last = e.currentTarget.previousElementSibling; + requestAnimationFrame(() => { + const first = last?.nextElementSibling; + const target = + first?.querySelector( + 'a[href]:not([tabindex="-1"]), button:not([tabindex="-1"]), summary, [tabindex="0"]' + ) ?? (first instanceof HTMLElement ? first : null); + target?.focus({ preventScroll: true }); + }); + }; + const collapse = () => { + setExpanded(false); + setJustExpanded(false); + if (id) expandedIds.delete(id); }; const base = children.slice(0, cap); const extra = expanded ? children.slice(cap) : []; @@ -262,12 +311,23 @@ export function Truncated({ {more > 0 && ( )} + {extra.length > 0 && ( + + )} ); } @@ -290,6 +350,16 @@ export const eyebrowText = 'text-[11px] font-semibold tracking-wide uppercase'; * (locality — the legend stays optional reading); clicking anywhere on the * band toggles the fold. */ +/** A band's open state and its setter, remembered with every 's, for a + * band that isn't a (the roadmap's lanes, which carry a chart). */ +export function useFoldState(id: string, defaultOpen: boolean): [boolean, (open: boolean) => void] { + const stored = foldOpenStore.useValue(); + return [ + stored[id] ?? defaultOpen, + open => foldOpenStore.set({ ...foldOpenStore.get(), [id]: open }), + ]; +} + export function Fold({ count, label, @@ -324,6 +394,7 @@ export function Fold({ children: ReactNode; }) { const detailsRef = useRef(null); + const glossId = useId(); const stored = foldOpenStore.useValue(); if (!count) return null; const explicit = id ? stored[id] : undefined; @@ -341,21 +412,27 @@ export function Fold({ if (foldOpenStore.get()[id] !== next) foldOpenStore.set({ ...foldOpenStore.get(), [id]: next }); }; + // the label and its count stay on one line: on a phone a long detail + // beside them split "FIXBOT · 3" across two const labelInner = ( - <> + {label} {showCount && · {count}} - + ); return (
captures the click, so the // band's promise (click anywhere toggles) is honored // here by hand — the gloss itself stays hover-only @@ -384,7 +466,8 @@ export function Fold({ const el = detailsRef.current; if (el) el.open = !el.open; }} - className={`hit rounded border-0 bg-transparent p-0 text-left ${ + // dotted, as every explanation on hover is + className={`hit rounded border-0 bg-transparent p-0 text-left underline decoration-dotted underline-offset-2 ${ caps ? eyebrowText : 'text-[11px] font-semibold' }`} > @@ -405,6 +488,11 @@ export function Fold({ )} + {gloss && ( + + {gloss} + + )}
{children}
); diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index b43a1dba..ef319df2 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -188,7 +188,10 @@ export function Legend() { - /} def="focus the filter" /> + /} + def="focus the filter; on Projects, the view’s own find box" + /> @@ -198,8 +201,13 @@ export function Legend() { } def="walk down / up the rows" /> - ↵} def="open the selected PR" /> + ↵} + def="open the selected PR or project; on Decide, make the call" + /> c} def="copy the selected branch name" /> + a} def="on a project’s page, add an issue" /> + u} def="on a project’s page, post an update" /> ); diff --git a/frontend-v2/src/components/Popover.tsx b/frontend-v2/src/components/Popover.tsx index 45dc0474..e2568f9e 100644 --- a/frontend-v2/src/components/Popover.tsx +++ b/frontend-v2/src/components/Popover.tsx @@ -1,5 +1,6 @@ import { type PointerEvent as ReactPointerEvent, + useEffect, useLayoutEffect, useState, type ReactNode, @@ -96,8 +97,13 @@ export function Popover({ // — the phone kebab menu found this the hard way const panelW = pop.panelRef.current?.offsetWidth ?? 0; const max = window.innerWidth - panelW - 8; + // a right-anchored panel hangs left from its trigger's right edge; on + // a trigger near the left edge there's no room for that, and the + // clamp pushed it over whatever sat beside the trigger, so it hangs + // right from the trigger's left edge instead + const roomLeft = r.right - panelW >= 8; setPos( - side === 'right' + side === 'right' && roomLeft ? { top, right: Math.max(Math.min(window.innerWidth - r.right, max), 8) } : { top, left: Math.max(Math.min(r.left, max), 8) } ); @@ -116,6 +122,13 @@ export function Popover({ }; }, [pop.open, side]); + // the panel mounts a render after it opens, once its place is measured, + // so usePopover's own focus call finds nothing: focus it when it's there + const shown = pop.open && pos != null; + useEffect(() => { + if (shown && pop.isPinned()) pop.panelRef.current?.focus(); + }, [shown]); + return ( {trigger({ diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 985297cf..ba5f3cdf 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -79,6 +79,12 @@ export interface RowOptions { /** bot detection for the avatar's square-tile shape (app.tsx's config-fed * isBotLogin — the row can't know the config's extra-bots list itself) */ isBotAuthor?: (login: string) => boolean; + /** open a PR's project page from its state popover; unset where the rows + * already sit on that project's page */ + onProject?: (slug: string) => void; + /** leave the Hide items out of the row's menu, where hidden PRs show + * anyway (the Projects tab) */ + noHide?: boolean; } /** @@ -380,10 +386,12 @@ function RowActionsKebab({ pull, claim, showSnooze, + noHide, }: { pull: DerivedPull; claim: Claim | null; showSnooze?: boolean; + noHide?: boolean; }) { const a = useRowActions(pull); const settings = useSettings(); @@ -459,33 +467,38 @@ function RowActionsKebab({ Re-fetch from GitHub -
- - {/* never offered for your own pulls — you can't hide yourself from - your own board */} - {author !== me && ( - + {/* where hidden PRs show anyway, hiding one would change nothing */} + {!noHide && ( + <> +
+ + {/* never offered for your own pulls — you can't hide yourself from + your own board */} + {author !== me && ( + + )} + )} ); @@ -561,7 +574,12 @@ function MetricRail({ const me = opts.me; return ( - + {/* one instrument, humans first: CR (label, weight letter, pips — one door into one panel), then QA, then the machine's circle at the rail's end, where its optional render (green only on hover) @@ -626,6 +644,7 @@ function RowImpl({ pull, opts, depth = 0, + footnote, }: { pull: DerivedPull; opts: RowOptions; @@ -633,6 +652,10 @@ function RowImpl({ * or not rendered through a stack-aware list). Per-row data, not an * option — it varies row to row within the same list. */ depth?: number; + /** a line the context adds under the meta (who holds it, why it's + * here): inside the row, so it sits above the age line instead of under + * it. A control in it wears .pd-raise to sit over the row's click layer. */ + footnote?: ReactNode; }) { const d = pull.data; const key = pullKey(d); @@ -710,6 +733,7 @@ function RowImpl({ turn={turn} poolSize={poolSize} whyHere={opts.rankReason?.(pull) ?? null} + onProject={opts.onProject} title="see the full state" > @@ -754,6 +778,9 @@ function RowImpl({ )} + {/* a line of its own, and the last one, even in compact's one + flowing line, where the rail comes before it */} + {footnote && {footnote}} } rail={} @@ -797,5 +824,8 @@ export const Row = memo( a.opts.parentOf === b.opts.parentOf && a.opts.pools === b.opts.pools && a.opts.turns === b.opts.turns && - a.opts.isBotAuthor === b.opts.isBotAuthor + a.opts.isBotAuthor === b.opts.isBotAuthor && + a.opts.onProject === b.opts.onProject && + a.opts.noHide === b.opts.noHide && + a.footnote === b.footnote ); diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index d997ddc8..72b31dd0 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -19,6 +19,8 @@ import { useArmedConfirm } from './useArmedConfirm'; import { commitKeyHandler } from './useCommitOnEnter'; const LENS_OPTIONS: [string, string][] = Object.entries(LENS_LABELS); +// the Projects tab exists only on a server set up for projects +const LENS_OPTIONS_NO_PROJECTS = LENS_OPTIONS.filter(([id]) => id !== 'projects'); // Stats has no lanes, so it's left out of the per-lens lane-length list // (unlike LENS_OPTIONS above, which covers every tab including Stats) — the @@ -116,7 +118,7 @@ export function Settings({ }) { const [open, setOpen] = useState(false); const s = useSettings(); - const { refreshProgress } = usePulldasher(); + const { refreshProgress, projectLabelPrefix } = usePulldasher(); const panelRef = useRef(null); const triggerRef = useRef(null); const { armed: armReset, run: runReset } = useArmedConfirm(); @@ -251,7 +253,7 @@ export function Settings({ set({ defaultLens })} /> diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index e5ee2477..4c6f347e 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -1,10 +1,14 @@ import type { ReactNode } from 'react'; import { headStatuses, type DerivedPull } from '../../../shared/model/status'; +// the small module, so the board's rows don't load the projects model +import { MISC_SLUG } from '../../../shared/model/projectLabel'; import type { Claim, RowNote } from '../model/actions'; import { rowNote } from '../model/actions'; +import { projectOfPull, useProjectStanding } from '../model/standing'; import type { PullData } from '../../../shared/types'; import { ago, closedEpoch, epoch, githubUrl, issueUrl, signatureUrl } from '../../../shared/format'; -import { ClosedBadge, STATUS_LABEL } from './bits'; +import { usePulldasher } from '../store'; +import { ClosedBadge, FactLink, STATUS_LABEL } from './bits'; import { Avatar } from './identity'; import { Popover } from './Popover'; @@ -117,13 +121,22 @@ function FactsSection({ claim, turn, poolSize, + onProject, }: { pull: DerivedPull; claim?: Claim | null; turn?: string | null; poolSize?: number; + /** open its project's page; unset on that page itself */ + onProject?: (slug: string) => void; }) { const d = pull.data; + // its project by the Projects tab's rule: its label, else the project + // whose issue it links. The board has the slug, not the project's name + // (that comes with the Projects tab's own fetch), so the link shows it + const { projectLabelPrefix } = usePulldasher(); + const standing = useProjectStanding(projectLabelPrefix); + const slug = projectLabelPrefix ? projectOfPull(pull, projectLabelPrefix, standing) : null; const crReq = d.status.cr_req; const qaReq = d.status.qa_req; const ciWord = @@ -218,6 +231,22 @@ function FactsSection({ )}

)} + {slug && slug !== MISC_SLUG && ( +

+ project{' '} + {onProject ? ( + // through the app, so the board's filters stay + onProject(slug)} + aria-label={`${slug}: open its page on the Projects tab`} + > + {slug} + + ) : ( + {slug} + )} +

+ )} {/* a claim always trumps the rotation guess — same precedence as model/actions.ts's withCoordination */} {claim ? ( @@ -276,6 +305,7 @@ function StatePopoverBody({ turn, poolSize, whyHere, + onProject, }: { pull: DerivedPull; me: string; @@ -283,11 +313,18 @@ function StatePopoverBody({ turn?: string | null; poolSize?: number; whyHere?: string | null; + onProject?: (slug: string) => void; }) { return ( <> - + {/* ranked lanes explain their pick per-card here — behind the same door as everything else, never inline on the row */} @@ -331,6 +368,7 @@ export function StatePopover({ turn, poolSize, whyHere, + onProject, title = 'see the full state', children, }: { @@ -341,6 +379,8 @@ export function StatePopover({ poolSize?: number; /** a ranked lane's one-line reason this pull sits where it does */ whyHere?: string | null; + /** open the PR's project page (RowOptions.onProject) */ + onProject?: (slug: string) => void; title?: string; children: ReactNode; }) { @@ -365,6 +405,7 @@ export function StatePopover({ turn={turn} poolSize={poolSize} whyHere={whyHere} + onProject={onProject} /> ); diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index d0e2f7ff..2d27ad73 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -94,6 +94,39 @@ export function CornerBadge({ text, tone = 'brand' }: { text: string; tone?: 'br ); } +/** + * The one filled button: a form's Save, Post, Apply. One recipe, so "save" + * looks the same on every view (it had been copied by hand five times). + * `type` defaults to submit, since it ends a form. + */ +export function PrimaryButton({ + className = '', + type = 'submit', + ...props +}: ButtonHTMLAttributes) { + return ( + - ))} + {options.map(([val, label], i) => { + const count = counts?.[val]; + return ( + + ); + })}
); } diff --git a/frontend-v2/src/components/identity.tsx b/frontend-v2/src/components/identity.tsx index 6d0a2bbd..1ee244a8 100644 --- a/frontend-v2/src/components/identity.tsx +++ b/frontend-v2/src/components/identity.tsx @@ -196,8 +196,10 @@ export function Avatar({ type="button" // .hit: the circle is 16-22px, under the 24px target floor className="hit pressable cursor-pointer border-0 p-0 transition-[scale] duration-150 ease-out hover:scale-115 motion-reduce:transition-none" - aria-label={you ? `${login} (you): view your PRs` : `${login}: view their PRs`} - // click filters to this person; the hover card is the extra + // what a click opens depends on the tab (their PRs on the board, + // their row on Projects' People), so the name promises neither + aria-label={you ? `${login} (you): see your work` : `${login}: see their work`} + // click opens this person's work; the hover card is the extra onClick={() => onClick(login)} > {face} diff --git a/frontend-v2/src/components/usePopover.ts b/frontend-v2/src/components/usePopover.ts index c8cef9fd..3e437edc 100644 --- a/frontend-v2/src/components/usePopover.ts +++ b/frontend-v2/src/components/usePopover.ts @@ -104,6 +104,10 @@ export function usePopover el instanceof HTMLElement && el.classList.contains('popover'))) return; setOpen(false); }; const onKey = (e: KeyboardEvent) => { @@ -128,5 +132,7 @@ export function usePopover pinned.current; + return { open, toggle, rootRef, panelRef, triggerRef, hoverProps, isPinned }; } diff --git a/frontend-v2/src/components/useRowKeys.ts b/frontend-v2/src/components/useRowKeys.ts new file mode 100644 index 00000000..badbd9f1 --- /dev/null +++ b/frontend-v2/src/components/useRowKeys.ts @@ -0,0 +1,82 @@ +import { useEffect } from 'react'; + +/** + * j and k move between a list's rows, the way they move between PRs on the + * board: focus lands on each row's `target` (its answer, its name), and the + * row scrolls clear of the sticky headers above it. A "+ N more" between two + * rows (Truncated's, marked `data-row-more`) opens first, so j reaches every + * row and not only the ones drawn. Rows hidden in a closed fold are + * skipped. Typing in a field never moves. + * + * It listens before the board's own j and k (useBoardHotkeys, which steps + * between PR links) and claims the key, so a list's rows win wherever both + * would answer: an opened Overview row shows board rows inside it. + */ +export function useRowKeys(row: string, target: string) { + useEffect(() => { + const focusRow = (el: HTMLElement) => { + el.querySelector(target)?.focus({ preventScroll: true }); + // by hand: inside a rounded box (overflow hidden), scrollIntoView + // drops the row's scroll margin, which is what keeps it below the + // sticky headers + const { top, bottom } = el.getBoundingClientRect(); + const under = parseFloat(getComputedStyle(el).scrollMarginTop) || 0; + if (top < under) window.scrollBy(0, top - under); + else if (bottom > window.innerHeight) window.scrollBy(0, bottom - window.innerHeight + 16); + }; + const onKey = (e: KeyboardEvent) => { + if ((e.key !== 'j' && e.key !== 'k') || e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target as HTMLElement; + if (['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable) return; + // drawn rows only: one in a closed fold has no box to land on + const drawn = () => + [...document.querySelectorAll(row)].filter(r => r.getClientRects().length); + const rows = drawn(); + if (!rows.length) return; + const at = rows.findIndex(r => r.contains(document.activeElement)); + const step = e.key === 'j' ? 1 : -1; + e.preventDefault(); + if (step > 0 && at !== -1) { + // the rows after this one are folded away: a list's "+ N more" + // comes right after its last drawn row, so open it, then go on + const more = [...document.querySelectorAll('[data-row-more]')].find(m => { + if (rows.some(r => r.contains(m))) return false; + const before = rows.filter( + r => r.compareDocumentPosition(m) & Node.DOCUMENT_POSITION_FOLLOWING + ); + return before.at(-1) === rows[at]; + }); + if (more) { + more.click(); + requestAnimationFrame(() => { + const now = drawn(); + const from = now.indexOf(rows[at]); + const to = now[from + 1]; + if (to) focusRow(to); + }); + return; + } + } + if (at === -1) { + // from outside the rows (a section's header, a receipt): the + // next row after where focus is, or the one before it for k + const from = document.activeElement; + const after = (r: HTMLElement) => + !!from && + from !== document.body && + !!(from.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING); + const next = + step > 0 + ? rows.find(after) ?? rows[0] + : [...rows].reverse().find(r => !after(r)) ?? rows[rows.length - 1]; + focusRow(next); + return; + } + focusRow(rows[Math.min(Math.max(at + step, 0), rows.length - 1)]); + }; + // capture: ahead of the board's hotkeys, which stand down for a key + // claimed here (hooks.ts checks defaultPrevented) + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [row, target]); +} diff --git a/frontend-v2/src/hooks.ts b/frontend-v2/src/hooks.ts index 39419b9b..84e60cc1 100644 --- a/frontend-v2/src/hooks.ts +++ b/frontend-v2/src/hooks.ts @@ -1,21 +1,33 @@ import { type RefObject, useEffect, useLayoutEffect } from 'react'; +/** A key press the board's keys leave alone: one a view's own keys + * (components/useRowKeys.ts) already took, one typed into a field, or one + * with a modifier, which belongs to the browser. */ +function notOurs(e: KeyboardEvent): boolean { + if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return true; + const t = e.target as HTMLElement; + return ['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable; +} + /** * The keyboard model for an audience that lives in editors: - * / jump to the filter box (v1's hotkey) + * / jump to the filter box (v1's hotkey), or to a view's own find box + * where it has one (Projects' Overview, Roadmap and People mark theirs + * aria-keyshortcuts="/"), so the key never leaves the view * j/k move focus down/up the rows (Enter opens — it's a link) * c copy the focused row's branch name */ export function useBoardHotkeys(searchRef: RefObject) { useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.metaKey || e.ctrlKey || e.altKey) return; - const t = e.target as HTMLElement; - if (['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable) return; + if (notOurs(e)) return; if (e.key === '/') { e.preventDefault(); - searchRef.current?.focus(); - searchRef.current?.select(); + const box = + document.querySelector('input[aria-keyshortcuts="/"]') ?? + searchRef.current; + box?.focus(); + box?.select(); return; } if (e.key === 'j' || e.key === 'k') { @@ -47,6 +59,23 @@ export function useBoardHotkeys(searchRef: RefObject) { }, [searchRef]); } +/** + * A page's own key, as "a" adds an issue on a project's page: heard + * anywhere on the page but in a field, never with a modifier. The control + * it works carries it in aria-keyshortcuts, so a screen reader says so. + */ +export function usePageKey(key: string, run: () => void) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== key || notOurs(e)) return; + e.preventDefault(); + run(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [key, run]); +} + /** * Lane/section headers stick just below the app header; its height varies * (the toolbar wraps on narrow screens), so this publishes the measured diff --git a/frontend-v2/src/lens.ts b/frontend-v2/src/lens.ts index b48a54b3..82a58a36 100644 --- a/frontend-v2/src/lens.ts +++ b/frontend-v2/src/lens.ts @@ -1,7 +1,27 @@ -export type Lens = 'review' | 'mine' | 'team' | 'classic' | 'ci' | 'stats'; +export type Lens = 'review' | 'mine' | 'team' | 'projects' | 'classic' | 'ci' | 'stats'; + +/** The Projects tab's default date range. It lives here, not in + * model/projectData.ts, so app.tsx can read and write the URL without + * pulling the tab's code into the review board's bundle. */ +export const DEFAULT_RANGE = '30d'; + +/** What the URL holds when nobody picked the project list's sort, which the + * list reads as its default order: what's owed first, then the rest (the + * Plan column's order). Kept here for the same reason (model/portfolio.ts + * reads it); Last activity's own key is `last`. */ +export const DEFAULT_SORT = 'idle'; + +/** A roadmap zoom's key in the URL, "2026-Q4" or "2026-10", checked here for + * the same reason (model/roadmapTime.ts reads it). */ +export const ZOOM_KEY = /^(\d{4})-(?:Q([1-4])|(0[1-9]|1[0-2]))$/; + +/** The origins a roadmap filter can pick in the URL, kept here for the same + * reason: shared/model/roadmap.ts's ROADMAP_ORIGINS, plus `unsaid` (a test + * holds the two lists together). */ +export const ORIGIN_KEYS = ['asked', 'fire', 'chosen', 'unsaid'] as const; /** Canonical label for each lens — the single source every surface naming the - * six views reads from: the tab strip and phone dropdown (app.tsx / + * views reads from: the tab strip and phone dropdown (app.tsx / * LensMenu), saved-filter descriptions (model/savedFilters.ts), and the * Settings panel's lens pickers (components/Settings.tsx). One restated copy * (model/savedFilters.ts's old LENS_LABEL) drifted and was missing `ci`, @@ -10,6 +30,7 @@ export const LENS_LABELS: Record = { review: 'Review', mine: 'My work', team: 'Team', + projects: 'Projects', classic: 'Classic', ci: 'CI', stats: 'Stats', diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 7aec4a77..b99691c0 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -1,32 +1,12 @@ import { ago } from '../../../shared/format'; import { requestedReviewers } from './reviewers'; +import { authorOwnsIt, feedbackAnswered, parked } from '../../../shared/model/stage'; import { CR_INCOMPLETE, unique, type DerivedPull } from '../../../shared/model/status'; import { isSuffixBot } from '../../../shared/model/visibility'; -/** - * The three statuses that put the ball wholly with the author — while one - * holds, the board asks reviewers for NOTHING, not even an owed re-stamp: a - * draft isn't reviewable, a dev block means more pushes are coming, and - * ci_red only exists once a pull is fully signed off (model/status moved CI - * to the ready gate), so nothing is left to ask of reviewers but the build. - * A red build on a pull still AWAITING review keeps its needs_cr/needs_recr - * status and stays in the queues — whether to review a failing build is the - * reviewer's call, not the board's. Gates the personal-obligation checks - * (Re-stamp / Finish QA / Re-QA) in reviewerMove, reviewerNote, and the - * `is:restamp` query token. (QA obligations stay un-gated across the OTHER - * statuses on purpose — QA runs in parallel with CR on this board.) - */ -export const authorOwnsIt = (p: DerivedPull): boolean => - p.status === 'draft' || p.status === 'dev_block' || p.status === 'ci_red'; - -/** - * A parked pull (the Cryogenic Storage label) asks nothing of ANYONE — - * author included — until the label comes off. Stronger than authorOwnsIt - * (where the author still has a move): parked means deliberately shelved, - * so no do-word, no lane slot, no turn, no nudge, whether or not the pull - * is visible on the board. - */ -export const parked = (p: DerivedPull): boolean => p.cryo; +// these three live in the shared model, so the project flags (which the +// server builds too) read the same rules the board does +export { authorOwnsIt, feedbackAnswered, parked }; /** * The verb column: what moves this pull, and whose move is it? Review's @@ -206,28 +186,6 @@ function authorNote(p: DerivedPull, me: string): RowNote { return waitOnly(FALLBACK_STATUS_LABEL[p.status]); } -/** Epoch secs of the newest still-standing changes-requested review, or null - * when none of the unstamped reviews carries that verdict (or the wire didn't - * send them). */ -function lastChangesRequestedAt(p: DerivedPull): number | null { - const dates = (p.data.status.unstamped_reviewers ?? []) - .filter(r => r.state === 'CHANGES_REQUESTED' && !isSuffixBot(r.login)) - .map(r => r.date); - return dates.length ? Math.max(...dates) : null; -} - -/** - * The author pushed AFTER the newest changes-requested review: the ball is - * back with the reviewer, so "Address feedback" would nag the author about - * work they already did — the truthful note is "waiting on X to re-review" - * (and, for the reviewer who asked, an actionable "Re-review"). False when - * the review can't be dated: nagging beats wrongly absolving. - */ -function feedbackAnswered(p: DerivedPull): boolean { - const at = lastChangesRequestedAt(p); - return at != null && p.headPushedAt != null && p.headPushedAt > at; -} - /** A reviewer's claim on a pull: who, and when (epoch seconds — see * types.ts's review_requests). `at` is null when the server can't say yet * (e.g. it restarted before the webhook backfilled the timestamp); a claim diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index fb230fa3..8de6411e 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -810,6 +810,40 @@ describe('evaluateCheers — loading guard', () => { }); }); +describe('evaluateCheers — start-here and parked projects', () => { + it("names a parked project's PR last, as the review board sinks it", () => { + // the fields the move functions read too, so actionState can file them + const reviewable = (p: DerivedPull) => + ({ + ...p, + dependent: false, + qaingLogin: null, + crHave: 0, + qaHave: 0, + changesRequestedBy: [], + engagedNoStamp: [], + ciFailing: [], + headPushedAt: null, + devBlockedBy: [], + deployBlockedBy: [], + externalBlock: false, + cryo: false, + } as DerivedPull); + // the older one would be the best start, but its project is parked + const parkedOne = reviewable(pull('org/a', 1, { author: 'alice', ageDays: 9 })); + const other = reviewable(pull('org/a', 2, { author: 'bob', ageDays: 2 })); + const start = (parked?: ReadonlyMap) => + evaluateCheers( + { pulls: [parkedOne, other], turns: new Map(), me: 'me', ready: true, parked }, + EMPTY_BASELINE + ).toasts.find(t => t.dedupeKey?.startsWith('start:'))?.dedupeKey; + expect(start()).toBe(`start:${pullKey(parkedOne.data)}`); + expect(start(new Map([[pullKey(parkedOne.data), 'store-picker']]))).toBe( + `start:${pullKey(other.data)}` + ); + }); +}); + describe('cheer catalog', () => { it('documents exactly every toast kind, once each', () => { const catalogKinds = CHEER_CATALOG.map(c => c.kind); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 327b1329..391620dc 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -198,6 +198,9 @@ interface CheerInput { * snapshot. Defaults to {} so an unresolved login just falls back to * itself. */ names?: Readonly>; + /** open PRs whose project's plan is parked (model/standing.ts): start-here + * puts them last, as the review board sinks them */ + parked?: ReadonlyMap; /** the board's first payload has arrived. `false` means it's still loading * (an empty snapshot), and diffing that against a primed baseline would fire * a phantom "Board's clear" / "Inbox zero" and count every stamp as newly @@ -393,9 +396,16 @@ function readSignals(input: CheerInput): Signals { // deprioritize bots exactly as Review's Deal-me-one does, so start-here never // calls a dependabot bump "the single best pull to review next" — it's only // the best when nothing human is left - const bestStart = dealFrom(dealRank(reviewableUnclaimed, { me, pulls, deprioritize: isBot }), { - passed: new Set(), - }); + // and a parked project's PRs with them, as the review board sinks them + const parked = input.parked; + const bestStart = dealFrom( + dealRank(reviewableUnclaimed, { + me, + pulls, + deprioritize: p => isBot(p) || !!parked?.has(pullKey(p.data)), + }), + { passed: new Set() } + ); const startReason = bestStart ? startHereReason(bestStart, pulls, me, true, input.names ?? {}) : ''; diff --git a/frontend-v2/src/model/days.ts b/frontend-v2/src/model/days.ts new file mode 100644 index 00000000..e43af458 --- /dev/null +++ b/frontend-v2/src/model/days.ts @@ -0,0 +1,18 @@ +/** + * Local calendar days as YYYY-MM-DD, apart from model/projectData.ts so code + * the review board loads (the URL's zoom check) can use them without pulling + * in the Projects tab. + */ + +/** A local Date's calendar day, YYYY-MM-DD. */ +export function dayOf(date: Date): string { + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${m}-${d}`; +} + +/** A YYYY-MM-DD day as a local Date at midnight. */ +export function dateOf(day: string): Date { + const [y, m, d] = day.split('-').map(Number); + return new Date(y, m - 1, d); +} diff --git a/frontend-v2/src/model/deal.test.ts b/frontend-v2/src/model/deal.test.ts index dcf68ede..4358d9b5 100644 --- a/frontend-v2/src/model/deal.test.ts +++ b/frontend-v2/src/model/deal.test.ts @@ -142,6 +142,21 @@ describe('deal rank+from — deprioritize', () => { expect(deal([bot, human], { ...opts, passed: new Set(['org/repo#2']) })).toBe(bot); }); + it('sinks a demoted pull however large its starved score grows', () => { + // starveScore is age × lines changed: a week-old 2,000-line lockfile + // bump scores 14,000, past the offset that once stood for "demoted" + const lockfileBump = dp({ + number: 1, + author: 'dependabot', + ageDays: 7, + starved: true, + starveScore: 14_000, + }); + const human = dp({ number: 2, author: 'alice', ageDays: 1 }); + const opts = baseOpts({ deprioritize: p => p.data.user.login === 'dependabot' }); + expect(dealRank([lockfileBump, human], opts)).toEqual([human, lockfileBump]); + }); + it('still orders demoted pulls among themselves', () => { const olderBot = dp({ number: 1, diff --git a/frontend-v2/src/model/deal.ts b/frontend-v2/src/model/deal.ts index 75996cda..eccc9bed 100644 --- a/frontend-v2/src/model/deal.ts +++ b/frontend-v2/src/model/deal.ts @@ -50,8 +50,9 @@ function isClaimed(p: DerivedPull): boolean { * - +2 reciprocity: this pull's author has stamped one of the viewer's own * pulls — a nudge toward returning the favor. * - +1 quick win: a known XS/S pull, a small bias toward clearing easy ones. + * In units of the urgency ramp: one is warnDays of waiting. */ -function score(p: DerivedPull, opts: DealRankOptions): number { +export function dealScore(p: DerivedPull, opts: DealRankOptions): number { const urgency = p.starved ? p.starveScore : p.ageDays / (opts.warnDays ?? STARVE_DAYS); const repo = p.data.repo; const author = p.data.user.login; @@ -60,10 +61,7 @@ function score(p: DerivedPull, opts: DealRankOptions): number { other => other.data.user.login === opts.me && hasStamp(other, author) ); const quickWin = p.weight === 'XS' || p.weight === 'S'; - const base = urgency + (familiar ? 2 : 0) + (owedByAuthor ? 2 : 0) + (quickWin ? 1 : 0); - // a demoted pull (a bot's) sinks below every non-demoted one no matter how - // old, but keeps its relative order among the other demoted ones - return opts.deprioritize?.(p) ? base - 1000 : base; + return urgency + (familiar ? 2 : 0) + (owedByAuthor ? 2 : 0) + (quickWin ? 1 : 0); } /** @@ -74,10 +72,17 @@ function score(p: DerivedPull, opts: DealRankOptions): number { */ export function dealRank(pool: DerivedPull[], opts: DealRankOptions): DerivedPull[] { const base = crSort(pool); - return base - .map((p, i) => ({ p, s: score(p, opts), i })) - .sort((a, b) => b.s - a.s || a.i - b.i) - .map(x => x.p); + const demoted = (p: DerivedPull) => Number(!!opts.deprioritize?.(p)); + return ( + base + .map((p, i) => ({ p, s: dealScore(p, opts), i })) + // a demoted pull (a bot's) sinks below every other one however old, + // keeping its order among the other demoted ones. Its own sort key, + // not a score offset: a starved score is age × lines changed, so a + // week-old 2,000-line lockfile bump scores 14,000, past any offset + .sort((a, b) => demoted(a.p) - demoted(b.p) || b.s - a.s || a.i - b.i) + .map(x => x.p) + ); } /** diff --git a/frontend-v2/src/model/decide.test.ts b/frontend-v2/src/model/decide.test.ts new file mode 100644 index 00000000..af85eb36 --- /dev/null +++ b/frontend-v2/src/model/decide.test.ts @@ -0,0 +1,852 @@ +import { describe, expect, it } from 'vitest'; +import { dayStart, type Today } from '../../../shared/model/projects'; +import { + closedIssues, + decideQueue, + type DecideProject, + type DecideReason, + type DecideRow, + type PlanCounts, +} from '../../../shared/model/decide'; +import type { DerivedPull } from '../../../shared/model/status'; +import type { IssueCounts, ProjectIssue } from '../../../shared/model/work'; +import type { PullData } from '../../../shared/types'; +import type { RoadmapItem, RoadmapUpdate } from '../../../shared/model/roadmap'; +import { + answerOf, + bulkWords, + callWords, + capOwed, + endsFor, + filledIn, + keepCalls, + planRow, + reasonWords, + teamOrder, + whyNot, + writeFor, + type Call, + type Made, +} from '../views/projects/Decide'; +import { teamLoad } from './teamLoad'; + +const today = '2026-09-30'; +const NOW = dayStart(today) as number; +const DAY = 86400; +const ago = (days: number) => NOW - days * DAY; + +const item = (id: number, over: Partial): RoadmapItem => ({ + id, + name: `Plan ${id}`, + project: null, + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-09-07', + weeks: 8, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: ago(30), + created_at: null, + update: null, + ...over, +}); +const project = (slug: string, over: Partial = {}): DecideProject => ({ + slug, + firstOpened: '2026-09-21', + lastActivity: ago(1), + open: 2, + prs: 3, + due: null, + ...over, +}); +const closedOn = (slug: string, reason: string, daysAgo: number) => ({ + slug, + state: 'closed' as const, + state_reason: reason, + closed_at: new Date(ago(daysAgo) * 1000).toISOString(), +}); +const update = (health: RoadmapUpdate['health'], daysAgo: number): RoadmapUpdate => ({ + id: 1, + item_id: 1, + health, + body: '', + plan_start: '2026-09-07', + plan_weeks: 8, + author: 'dana', + at: ago(daysAgo), +}); +const kinds = (rows: ReturnType) => + rows.map(r => [r.slug ?? `#${r.item?.id}`, ...r.reasons.map(x => x.kind)]); + +describe('decideQueue', () => { + it('asks for a first call on work with no decision, and to park stalled work, not plan it', () => { + const rows = decideQueue({ + live: [project('fresh'), project('quiet', { lastActivity: ago(30) })], + items: [], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([ + ['quiet', 'stalled'], + ['fresh', 'new'], + ]); + }); + + it('flags plans past their end, open or not, and updates the plan hasn’t answered', () => { + const rows = decideQueue({ + live: [project('late'), project('shaky')], + items: [ + item(1, { project: 'late', start: '2026-08-03', weeks: 4 }), + item(2, { project: 'shaky', update: update('off_track', 2) }), + item(3, { project: 'gone', start: '2026-08-03', weeks: 2 }), + // replanned after its at-risk update: already answered + item(4, { project: 'answered', update: update('at_risk', 10), updated_at: ago(3) }), + ], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([ + ['shaky', 'off_track'], + ['late', 'over'], + ['gone', 'ended'], + ]); + }); + + it('asks about a hard end once it passes, never a soft end or ongoing work', () => { + const late = { start: '2026-08-03', weeks: 4 }; + const rows = decideQueue({ + live: [project('hard'), project('soft'), project('ongoing')], + items: [ + item(1, { project: 'hard', ...late }), + item(2, { project: 'soft', ...late, end_kind: 'soft' }), + item(3, { project: 'ongoing', ...late, end_kind: 'ongoing' }), + // nothing open: a hard one would be asked "Is it done?" + item(4, { project: 'gone', ...late, end_kind: 'soft' }), + ], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([['hard', 'over']]); + }); + + it('takes decided work out, and brings it back when the PRs disagree', () => { + const rows = decideQueue({ + live: [ + project('parked'), + project('still-parked', { lastActivity: ago(20) }), + project('finished', { open: 2 }), + project('just-finished'), + project('committed', { lastActivity: ago(30) }), + ], + items: [ + item(1, { project: 'parked', status: 'parked', updated_at: ago(5) }), + item(2, { project: 'still-parked', status: 'parked', updated_at: ago(5) }), + item(3, { project: 'finished', status: 'done', updated_at: ago(10) }), + item(4, { project: 'just-finished', status: 'done', updated_at: ago(2) }), + // decided a week ago: its stall stays quiet for three weeks + item(5, { project: 'committed', updated_at: ago(7) }), + ], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([ + ['finished', 'reopened'], + ['parked', 'moving'], + ]); + }); + + it('judges each plan under way, so a finished first phase can’t hide a late second', () => { + const rows = decideQueue({ + live: [project('two-phase')], + items: [ + item(1, { project: 'two-phase', status: 'done', updated_at: ago(40) }), + item(2, { project: 'two-phase', start: '2026-08-03', weeks: 4 }), + ], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([['two-phase', 'over']]); + expect(rows[0].item?.id).toBe(2); + }); + + it('says in flight only with PRs open, and asks a first call only of work big enough', () => { + const rows = decideQueue({ + live: [ + project('merged-only', { open: 0, prs: 2 }), + project('small', { open: 1, prs: 2 }), + project('small-stuck', { open: 1, prs: 1, lastActivity: ago(30) }), + project('merged-no-plan', { open: 0, prs: 4 }), + ], + items: [item(1, { project: 'merged-only', start: '2026-08-03', weeks: 4 })], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([ + ['merged-only', 'ended'], + ['small-stuck', 'stalled'], + ]); + }); + + it('counts a closed issue as a decision', () => { + const rows = decideQueue({ + live: [project('closed-plan'), project('closed-open'), project('closed-fresh')], + items: [item(1, { project: 'closed-plan' })], + closed: closedIssues([ + closedOn('closed-plan', 'completed', 3), + closedOn('closed-open', 'not_planned', 10), + closedOn('closed-fresh', 'completed', 2), + ]), + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([ + ['closed-open', 'reopened'], + ['closed-plan', 'issue_closed'], + ]); + expect(rows[0].reasons[0]).toEqual({ + kind: 'reopened', + open: 2, + late: 0, + as: 'dropped', + by: 'issue', + }); + expect(rows[1].reasons[0]).toEqual({ kind: 'issue_closed', as: 'done', on: '2026-09-27' }); + }); + + it('flags a missed target until someone replans', () => { + const rows = decideQueue({ + live: [ + project('missed', { due: '2026-09-25' }), + project('replanned', { due: '2026-09-25' }), + ], + items: [ + item(1, { project: 'missed' }), + item(2, { project: 'replanned', updated_at: ago(2) }), + ], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([['missed', 'missed']]); + expect(rows[0].reasons[0]).toEqual({ kind: 'missed', due: '2026-09-25', open: 2 }); + }); + + it('asks "Is it done?" when every issue attached is closed, until the plan changes after', () => { + const counts = (over: Partial = {}): IssueCounts => ({ + total: 5, + open: 0, + done: 4, + dropped: 1, + lastClosedAt: ago(2), + ...over, + }); + const rows = decideQueue({ + live: ['shipped', 'busy', 'answered', 'unknown', 'none'].map(slug => project(slug)), + items: [ + item(1, { project: 'shipped' }), + item(2, { project: 'busy' }), + // the plan changed after its last issue closed: already answered + item(3, { project: 'answered', updated_at: ago(1) }), + item(4, { project: 'unknown' }), + item(5, { project: 'none' }), + ], + issues: new Map([ + ['shipped', counts()], + ['busy', counts({ open: 1, done: 3 })], + ['answered', counts()], + // no close time known: it asks + ['unknown', counts({ lastClosedAt: null })], + // nothing attached: nothing to be done with + ['none', counts({ total: 0, done: 0, dropped: 0, lastClosedAt: null })], + ]), + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([ + ['shipped', 'issues_done'], + ['unknown', 'issues_done'], + ]); + // the plan's PRs still open come along, so the ask can say so + expect(rows[0].reasons[0]).toEqual({ kind: 'issues_done', done: 4, dropped: 1, open: 2 }); + }); + + it('asks only the plan running today, not one that hasn’t started', () => { + const rows = decideQueue({ + live: [project('phases')], + items: [ + item(1, { project: 'phases', start: '2026-09-07' }), + item(2, { project: 'phases', start: '2026-09-21' }), + item(3, { project: 'phases', status: 'planned', start: '2026-11-02' }), + ], + issues: new Map([ + ['phases', { total: 2, open: 0, done: 2, dropped: 0, lastClosedAt: ago(2) }], + ]), + today, + now: NOW, + }); + expect( + rows.filter(r => r.reasons.some(x => x.kind === 'issues_done')).map(r => r.item?.id) + ).toEqual([2]); + }); + + const noPulls: PlanCounts = { openPulls: 0, afterEnd: 0, afterDone: 0 }; + + it('says how many PRs opened after a plan’s end, and puts the busiest first', () => { + const end = { start: '2026-08-03', weeks: 4 }; + const rows = decideQueue({ + live: [project('quiet-tail'), project('running-on')], + items: [ + item(1, { project: 'quiet-tail', ...end }), + item(2, { project: 'running-on', ...end }), + ], + planCounts: new Map([ + [1, { ...noPulls, afterEnd: 1, openPulls: 2 }], + [2, { ...noPulls, afterEnd: 9, openPulls: 2 }], + ]), + today, + now: NOW, + }); + expect(rows.map(r => [r.slug, r.reasons[0]])).toEqual([ + ['running-on', { kind: 'over', weeks: 5, since: 9 }], + ['quiet-tail', { kind: 'over', weeks: 5, since: 1 }], + ]); + }); + + it('reopens a finished plan when new work arrives more than a week after it', () => { + const rows = decideQueue({ + // its PRs merged fast, so none is open now + live: [project('comeback', { open: 0 })], + items: [item(1, { project: 'comeback', status: 'done', updated_at: ago(100) })], + planCounts: new Map([[1, { ...noPulls, afterEnd: 2, afterDone: 2 }]]), + today, + now: NOW, + }); + expect(rows[0].reasons).toEqual([ + { kind: 'reopened', open: 0, late: 2, as: 'done', by: 'roadmap' }, + ]); + }); + + it('judges a finished launch by its own PRs, not its follow-on’s', () => { + const rows = decideQueue({ + live: [project('workbench', { open: 3 })], + items: [ + item(1, { project: 'workbench', start: '2026-05-18', weeks: 15 }), + item(2, { project: 'workbench', start: '2026-09-21', weeks: 12 }), + ], + planCounts: new Map([ + // the three open PRs are the feedback round's + [1, { ...noPulls, afterEnd: 2 }], + [2, { ...noPulls, openPulls: 3 }], + ]), + today, + now: NOW, + }); + expect(rows.map(r => [r.item?.id, r.reasons[0].kind])).toEqual([[1, 'ended']]); + }); + + it('lets an ongoing project’s work go on after a finished plan', () => { + const rows = decideQueue({ + live: [project('upkeep', { open: 2 }), project('feature', { open: 2 })], + items: [ + item(1, { project: 'upkeep', status: 'done', updated_at: ago(100) }), + item(2, { project: 'feature', status: 'done', updated_at: ago(100) }), + ], + ongoing: new Set(['upkeep']), + today, + now: NOW, + }); + expect(rows.map(r => [r.slug, r.reasons[0].kind])).toEqual([['feature', 'reopened']]); + }); + + it('never asks an ongoing project for a first plan', () => { + const rows = decideQueue({ + live: [project('upkeep'), project('feature')], + items: [], + ongoing: new Set(['upkeep']), + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([['feature', 'new']]); + }); +}); + +describe('Decide’s calls', () => { + const done = item(7, { project: 'sso', status: 'done', start: '2026-08-03' }); + const reopened: DecideRow = { + slug: 'sso', + item: done, + reasons: [{ kind: 'reopened', open: 2, late: 0, as: 'done', by: 'roadmap' }], + }; + + it('says done or dropped again on a plan already marked so, to accept the PRs after it', () => { + expect(writeFor({ kind: 'done' }, reopened, undefined, today)).toEqual({ + id: 7, + fields: { status: 'done' }, + restate: true, + }); + expect(writeFor({ kind: 'drop' }, reopened, undefined, today)).toMatchObject({ + restate: true, + }); + // a commit keeps the plan's start and runs through the end it names, + // which it commits to: a hard end, asked about once it passes + expect( + writeFor( + { kind: 'commit', label: 'End of Oct', end: '2026-10-31', through: 'the end of Oct' }, + reopened, + undefined, + today + ) + ).toEqual({ + id: 7, + fields: { status: 'active', start: '2026-08-03', weeks: 13, end_kind: 'hard' }, + restate: false, + }); + }); + + it('records work with no plan as a new one, from its week through this one', () => { + const fresh: DecideRow = { + slug: 'fresh', + item: null, + reasons: [{ kind: 'new', since: null }], + }; + expect(writeFor({ kind: 'park' }, fresh, undefined, today)).toEqual({ + id: null, + fields: { + name: 'fresh', + project: 'fresh', + team: null, + lead: null, + start: '2026-09-28', + weeks: 1, + status: 'parked', + }, + }); + }); + + it('says what was decided and when Decide asks again', () => { + const quiet = { open: 0, ongoing: false }; + expect(callWords({ kind: 'done' }, reopened, quiet, today)).toBe( + 'Marked done. Decide asks again if a PR opens after Oct 7.' + ); + expect(callWords({ kind: 'drop' }, reopened, { ...quiet, open: 2 }, today)).toBe( + 'Dropped. Decide asks again if a PR is still open on Oct 7, or a new one opens after.' + ); + // work with no end goes on after its plans, so nothing comes back + expect(callWords({ kind: 'done' }, reopened, { open: 2, ongoing: true }, today)).toBe( + 'Marked done.' + ); + expect( + callWords( + { + kind: 'commit', + label: 'End of Q1 2027', + end: '2027-03-31', + through: 'the end of Q1 2027', + }, + reopened, + quiet, + today + ) + ).toBe('Promised to finish by the end of Q1 2027. Decide asks again if it runs past that.'); + }); + + it('gives a plan that hasn’t started a new end without starting it', () => { + const promo = (start: string): DecideRow => ({ + slug: 'promo', + item: item(9, { project: 'promo', status: 'planned', start, weeks: 4 }), + reasons: [], + }); + const oct: Call = { + kind: 'commit', + label: 'End of Oct', + end: '2026-10-31', + through: 'the end of Oct', + }; + expect(writeFor(oct, promo('2026-10-12'), undefined, today).fields).toEqual({ + status: 'planned', + start: '2026-10-12', + weeks: 3, + end_kind: 'hard', + }); + // one already under way is committed to, as ever + expect(writeFor(oct, promo('2026-09-07'), undefined, today).fields.status).toBe('active'); + }); + + it('answers each row the way its suggested answer does: its target first while ahead', () => { + const row = (reasons: DecideReason[], over: Partial | null = null) => ({ + slug: 'p', + item: over && item(3, { project: 'p', ...over }), + reasons, + }); + const target = (due_on: string) => ({ target: { title: null, due_on } }); + const fresh: DecideReason = { kind: 'new', since: null }; + expect(answerOf(row([fresh]), target('2026-10-21'), today)).toMatchObject({ + kind: 'commit', + end: '2026-10-21', + label: 'Its target date, Oct 21', + }); + // a milestone's due date is a timestamp; its day is what counts + expect(answerOf(row([fresh]), target('2026-10-21T07:00:00Z'), today)).toMatchObject({ + end: '2026-10-21', + }); + // a target passed is a miss, not an answer + expect(answerOf(row([fresh]), target('2026-09-26'), today)).toMatchObject({ + label: 'End of Oct', + }); + expect(answerOf(row([fresh]), undefined, today)).toMatchObject({ label: 'End of Oct' }); + // a plan that starts after its target can't run through it, and a + // new end comes after the one it has (Dec 20) + expect( + answerOf(row([{ kind: 'at_risk' }], { start: '2026-10-26' }), target('2026-10-21'), today) + ).toMatchObject({ label: 'End of Q4' }); + expect(answerOf(row([{ kind: 'stalled', days: 30 }], {}), undefined, today)).toEqual({ + kind: 'park', + }); + expect(answerOf(row([{ kind: 'ended', weeks: 2, since: 0 }], {}), undefined, today)).toEqual({ + kind: 'done', + }); + expect( + callWords( + answerOf(row([fresh]), target('2026-10-21'), today) as Call, + reopened, + { open: 0, ongoing: false }, + today + ) + ).toBe( + 'Promised to finish by its target date, Oct 21. Decide asks again if it runs past that.' + ); + }); + + it('outlines only an answer safe to take blind: never an end that cuts a plan short', () => { + // Type and spacing refresh: Sep 7 to Nov 1, 8 weeks + const row = (reasons: DecideReason[], over: Partial = {}): DecideRow => ({ + slug: 'p', + item: item(3, { project: 'p', ...over }), + reasons, + }); + const labels = (r: DecideRow, due?: string) => + endsFor(r, due ? { target: { title: null, due_on: due } } : undefined, today).map( + c => c.label + ); + // "Move the end date?" offers only the ends after Nov 1: "End of Oct" left it + // as it was and cleared the call + const later = ['End of Nov', 'End of Q4', 'End of Q1 2027']; + expect(labels(row([{ kind: 'at_risk' }]))).toEqual(later); + expect(answerOf(row([{ kind: 'at_risk' }]), undefined, today)).toMatchObject({ + label: 'End of Nov', + }); + // nor is a target before its end a new end + expect(labels(row([{ kind: 'missed', due: '2026-09-26', open: 2 }]), '2026-10-21')).toEqual( + later + ); + // past every end offered: nothing outlined, so a person picks + expect(answerOf(row([{ kind: 'off_track' }], { weeks: 40 }), undefined, today)).toBeNull(); + // back on, for a parked plan that ran to Jan 24: no end before that + expect( + answerOf(row([{ kind: 'moving' }], { status: 'parked', weeks: 20 }), undefined, today) + ).toMatchObject({ label: 'End of Q1 2027' }); + // nobody asked (a plan under its page): nothing outlined, and its + // strip leaves out the end it has already; a parked plan can resume on it + expect(answerOf(row([]), undefined, today)).toBeNull(); + expect(labels(row([]))).toEqual(later); + expect(labels(row([], { status: 'parked' }))[0]).toBe('End of Oct'); + // work with no plan: every end + expect(labels({ slug: 'p', item: null, reasons: [] })[0]).toBe('End of Oct'); + }); + + it('commits to a soft end on its own date, and gives ongoing work any end', () => { + const row = (reasons: DecideReason[], over: Partial): DecideRow => ({ + slug: 'p', + item: item(3, { project: 'p', ...over }), + reasons, + }); + const labels = (r: DecideRow) => endsFor(r, undefined, today).map(c => c.label); + // Sep 7 to Nov 1: committing through the end of Oct makes the estimate a promise + expect(labels(row([], { end_kind: 'soft' }))[0]).toBe('End of Oct'); + // "Move the end date?" on work with no end: every end is new + const endless = row([{ kind: 'at_risk' }], { end_kind: 'ongoing', weeks: 40 }); + expect(labels(endless)[0]).toBe('End of Oct'); + expect(answerOf(endless, undefined, today)).toMatchObject({ label: 'End of Oct' }); + // "It’s ongoing" on a plan under way gives the plan no end; on new work + // it marks the project + const quiet = { open: 0, ongoing: false }; + expect(callWords({ kind: 'ongoing' }, row([], {}), quiet, today)).toBe( + 'Marked ongoing, with no end, so Decide never asks about one.' + ); + expect( + callWords({ kind: 'ongoing' }, { slug: 'p', item: null, reasons: [] }, quiet, today) + ).toBe('Marked ongoing, so Decide stops asking it for a plan or an end.'); + }); + + it('says a section’s one click in a few words, each answer with how many', () => { + const oct: Call = { + kind: 'commit', + label: 'End of Oct', + end: '2026-10-31', + through: 'the end of Oct', + }; + const own = (day: number): Call => ({ + kind: 'commit', + label: `Its target date, Oct ${day}`, + end: `2026-10-${day}`, + through: `its target date, Oct ${day}`, + target: true, + }); + const times = (call: Call, count: number) => Array(count).fill(call); + expect(bulkWords(times(oct, 52), 'do')).toBe('Promise all 52 by Oct 31'); + expect(bulkWords(times(oct, 52), 'did')).toBe('Promised 52 by Oct 31'); + expect(bulkWords([...times(oct, 40), own(21), own(23)], 'do')).toBe( + 'Promise 40 by Oct 31 and 2 by their target dates' + ); + expect(bulkWords([oct, own(21)], 'did')).toBe( + 'Promised 1 by Oct 31 and 1 by its target date, Oct 21' + ); + expect(bulkWords([oct, { kind: 'done' }, oct, { kind: 'done' }], 'do')).toBe( + 'Promise 2 by Oct 31 and mark 2 done' + ); + expect(bulkWords(times({ kind: 'done' }, 3), 'do')).toBe('Mark all 3 done'); + expect(bulkWords([{ kind: 'park' }, { kind: 'drop' }], 'did')).toBe('Parked 1 and dropped 1'); + }); + + it('keeps a call made under a plan in its place, even once it has made the plan', () => { + const plan = item(4, { project: 'p' }); + const asked: DecideRow = { slug: 'p', item: plan, reasons: [{ kind: 'at_risk' }] }; + // nothing made here: the plan, or nothing yet to plan + expect(planRow('p', plan, [asked])).toEqual({ slug: 'p', item: plan, reasons: [] }); + expect(planRow('p', null, [])).toEqual({ slug: 'p', item: null, reasons: [] }); + // the call that made its plan keeps its receipt where it was clicked + const started: DecideRow = { slug: 'p', item: null, reasons: [] }; + expect(planRow('p', plan, [started])).toBe(started); + // a call on another of its plans stays with that one + const other: DecideRow = { slug: 'p', item: item(5, { project: 'p' }), reasons: [] }; + expect(planRow('p', plan, [other])).toEqual({ slug: 'p', item: plan, reasons: [] }); + }); + + it('says why a call didn’t save, in the row, without repeating “couldn’t save”', () => { + expect(whyNot('Couldn’t save the plan: no such roadmap item.')).toBe( + ' No such roadmap item.' + ); + expect(whyNot('Your sign-in expired. Reload the page to sign in again.')).toBe( + ' Your sign-in expired. Reload the page to sign in again.' + ); + // a plain failure: the receipt's "Didn’t save. Try again" says it all + expect(whyNot('Couldn’t save the plan. Try again in a minute.')).toBe(''); + }); + + it('keeps a call in its row’s place until the row comes back for a new reason', () => { + const row = (slug: string, reason: DecideReason): DecideRow => ({ + slug, + item: null, + reasons: [reason], + }); + const fresh: DecideReason = { kind: 'new', since: null }; + const made = (slug: string, state: Made['state']): [string, Made] => [ + `${slug}:`, + { + row: row(slug, fresh), + call: { kind: 'park' }, + words: 'Parked.', + state, + origin: null, + token: 1, + }, + ]; + const { owed, all } = keepCalls( + [row('back', { kind: 'stalled', days: 30 }), row('other', fresh)], + new Map([ + // its row left the queue: decided, still in its place + made('gone', 'made'), + // back for a new reason: owed again + made('back', 'made'), + // taken back, though the queue dropped it: owed again + made('undone', 'undone'), + ]) + ); + expect(owed.map(r => r.slug)).toEqual(['back', 'other', 'undone']); + expect(all.map(r => r.slug)).toEqual(['back', 'gone', 'other', 'undone']); + }); + + it('never asks again about work parked from a team’s load and taken back', () => { + // parked from the team's load: a row Decide never asked about + const parked: DecideRow = { slug: 'side', item: null, reasons: [] }; + const made = (state: Made['state']) => + new Map([ + [ + 'side:', + { + row: parked, + call: { kind: 'park' }, + words: 'Parked.', + state, + origin: null, + token: 1, + }, + ], + ]); + expect(keepCalls([], made('made')).all.map(r => r.slug)).toEqual(['side']); + expect(keepCalls([], made('undone')).owed).toEqual([]); + expect(keepCalls([], made('failed')).owed).toEqual([]); + }); + + it('draws a section’s rows until ten are owed, so decided rows never take a slot', () => { + const rows = ['a', 'b', 'c', 'd', 'e', 'f'].map( + (slug): DecideRow => ({ slug, item: null, reasons: [{ kind: 'new', since: null }] }) + ); + const decided = (row: DecideRow) => row.slug === 'a' || row.slug === 'd'; + // two owed wanted: a (decided), b, c, d (decided), then e would be the third + expect(capOwed(rows, decided, 2)).toBe(4); + // fewer owed than the cap: every row is drawn + expect(capOwed(rows, decided, 10)).toBe(6); + expect(capOwed(rows, () => true, 2)).toBe(6); + }); + + it('offers the teams in their configured order, then any other by name', () => { + expect( + teamOrder(['Store', 'FixBot', 'Community'], ['Old', null, 'Community', 'Ads', 'Old']) + ).toEqual(['Store', 'FixBot', 'Community', 'Ads', 'Old']); + }); + + it('counts a team’s work in flight in priority order: plans, then work with no plan', () => { + const project = (slug: string, open: number, openSince = '2026-09-01', team = 'Store') => ({ + slug, + name: slug, + team, + open, + openSince, + }); + const plans = [ + item(1, { name: 'Second', team: 'Store', project: 'b', priority: 2 }), + item(2, { name: 'First', team: 'Store', project: 'a', priority: 1 }), + // no PRs open: nothing in flight + item(3, { name: 'Quiet', team: 'Store', project: 'q', priority: 0 }), + item(4, { name: 'Parked', team: 'Store', project: 'p', status: 'parked' }), + item(5, { name: 'Theirs', team: 'Other', project: 'o' }), + // a second plan of a project already counted + item(6, { name: 'Again', team: 'Store', project: 'a', priority: 3 }), + ]; + const items = [ + project('a', 2), + project('b', 1), + project('q', 0), + project('p', 3), + project('o', 1, '2026-09-01', 'Other'), + project('newer', 1, '2026-09-20'), + project('older', 2, '2026-08-01'), + ]; + expect(teamLoad('Store', plans, items, today).map(w => w.name)).toEqual([ + 'First', + 'Second', + 'older', + 'newer', + ]); + }); +}); + +describe('reasonWords', () => { + it('ends a sentence once when the update it quotes already ends', () => { + const said = (body: string) => + reasonWords( + { kind: 'at_risk' }, + item(1, { project: 'p', update: { ...update('at_risk', 2), body } }) + ); + expect(said('Not sure the import can land.')).toMatch(/can land\. Move the end date\?$/); + expect(said('Can the import land?')).toMatch(/land\? Move the end date\?$/); + expect(said('Waiting on the vendor')).toMatch(/vendor\. Move the end date\?$/); + }); + + it('asks new work in the verb its answer uses', () => { + expect(reasonWords({ kind: 'new', since: '2026-08-30' }, null)).toBe( + 'PRs open since Aug 30, and no plan yet. When will it finish?' + ); + }); +}); + +describe('filledIn', () => { + const iso = (days: number) => new Date(ago(days) * 1000).toISOString(); + const label = (slug: string) => ({ title: `project:${slug}` }); + const pr = (number: number, opened: number, labels: { title: string }[] = []) => + ({ repo: 'iFixit/ifixit', number, created_at: iso(opened), labels } as unknown as PullData); + const open = (data: PullData) => ({ data } as unknown as DerivedPull); + const ref = (number: number) => ({ repo: 'iFixit/ifixit', number }); + const issue = (number: number, via: ProjectIssue['via'], joined: number, prs: number[] = []) => + ({ + ref: ref(number), + title: `Issue ${number}`, + via, + attachedAt: ago(joined), + linkedBy: ref(900), + prs: prs.map(ref), + } as unknown as ProjectIssue); + const group = (slug: string, openPrs: PullData[], merged: PullData[] = []) => + ({ slug, open: openPrs.map(open), merged } as unknown as Today['live'][number]); + + it('lists each guess the board made this week, and none a person made', () => { + const today = { + live: [ + group( + 'sso', + [ + pr(1, 1, [label('sso')]), // by its label + pr(2, 2), // by a link, this week + pr(3, 10), // by a link, but before this week + pr(4, 3, [label('misc')]), // misc counts where it links + ], + [pr(5, 4)] // merged this week, by a link + ), + ], + quiet: [], + }; + const pages = new Map([ + [ + 'sso', + { + issues: [ + issue(10, ['link'], 2), // joined this week + issue(11, ['link', 'hand'], 2), // someone added it too + issue(12, ['link'], 10), // joined before this week + issue(13, ['label'], 30, [2]), // the issue PR 2 links + ], + }, + ], + ]); + const lately = { + merged: 2, + open: { ready: 0, hold: 0, review: 1, work: 0 }, + activityAt: ago(1), + issues: null, + grew: 0, + medianAge: 4, + }; + const plans = [ + // marked Planned, read In progress, from this week + { + ...item(1, { project: 'sso', status: 'active', start: '2026-09-28' }), + marked: 'planned' as const, + }, + // the same, but it started weeks ago: an earlier week's + { + ...item(2, { project: 'a', status: 'active', start: '2026-09-07' }), + marked: 'planned' as const, + }, + // marked In progress by a person, its PRs merging: no update owed + item(3, { project: 'b', start: '2026-08-31', weeks: 12, updated_at: null, lately }), + ]; + const found = filledIn({ today, prefix: 'project:', plans, pages, now: NOW }); + expect(found.joined.map(j => j.issue.ref.number)).toEqual([10]); + expect(found.linked.map(l => [dataOfNumber(l.pull), l.issue?.number ?? null])).toEqual([ + [2, 13], + [4, null], + [5, null], + ]); + expect(found.started.map(p => p.id)).toEqual([1]); + expect(found.vouched.map(v => [v.plan.id, v.vouch.merged])).toEqual([[3, 2]]); + }); +}); + +const dataOfNumber = (p: unknown) => ((p as { data?: PullData }).data ?? (p as PullData)).number; diff --git a/frontend-v2/src/model/load.test.ts b/frontend-v2/src/model/load.test.ts new file mode 100644 index 00000000..667d2cf0 --- /dev/null +++ b/frontend-v2/src/model/load.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { + loadByWeek, + mondaysBetween, + peakFrom, + spansFrom, + weekMembers, +} from '../../../shared/model/load'; + +const plan = (id: number, over: Partial): RoadmapItem => ({ + id, + name: `Plan ${id}`, + project: null, + team: null, + lead: null, + status: 'planned', + origin: null, + start: '2026-09-14', + weeks: 4, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, + ...over, +}); + +/** a week's plans on the roadmap, none saying where their work came from */ +const unsaid = (count: number) => ({ asked: 0, fire: 0, chosen: 0, unsaid: count }); + +describe('loadByWeek', () => { + const weeks = mondaysBetween('2026-09-16', '2026-10-12'); + const today = '2026-09-30'; + const load = loadByWeek({ + weeks, + today, + plans: [ + plan(1, { project: 'a', status: 'active', start: '2026-09-14', weeks: 4 }), // to Oct 11 + plan(2, { status: 'active', start: '2026-09-21', weeks: 2 }), // no PRs, to Oct 4 + plan(3, { start: '2026-10-05', weeks: 2 }), + plan(4, { project: 'c', status: 'dropped' }), + ], + spans: [ + { slug: 'a', start: '2026-09-01', end: null }, + { slug: 'b', start: '2026-09-20', end: '2026-09-25' }, + { slug: 'c', start: '2026-09-29', end: null }, + ], + }); + + it('walks whole weeks from Monday', () => { + expect(weeks).toEqual(['2026-09-14', '2026-09-21', '2026-09-28', '2026-10-05']); + }); + + it('counts what the PRs did up to this week, and plans under way with no PRs', () => { + expect(load.slice(0, 3)).toEqual([ + { week: '2026-09-14', onPlan: 1, origins: unsaid(1), offPlan: 1, projected: false }, + { week: '2026-09-21', onPlan: 2, origins: unsaid(2), offPlan: 1, projected: false }, + // b closed on the 25th; c opened on the 29th, and its plan was dropped + { week: '2026-09-28', onPlan: 2, origins: unsaid(2), offPlan: 1, projected: false }, + ]); + }); + + it('counts ahead, if nothing changes: plans to their end, open work with no decision', () => { + // plan 1 runs to Oct 11 and plan 3 starts Oct 5; plan 2 (no PRs) ended + // Oct 4; c is still open, and its only item was dropped, a decision + expect(load[3]).toEqual({ + week: '2026-10-05', + onPlan: 2, + origins: unsaid(2), + offPlan: 0, + projected: true, + }); + }); + + it('keeps an overrun plan and undecided open work in every week ahead', () => { + const later = loadByWeek({ + weeks: ['2026-11-02'], + today, + plans: [plan(1, { project: 'a', status: 'active', start: '2026-08-03', weeks: 4 })], + spans: [ + { slug: 'a', start: '2026-08-03', end: null }, + { slug: 'z', start: '2026-09-01', end: null }, + { slug: 'p', start: '2026-09-01', end: null }, + { slug: 'q', start: '2026-09-01', end: '2026-09-20' }, + ], + }); + // a ran past its Aug 30 end and is still open; z has no decision; q closed + expect(later[0]).toEqual({ + week: '2026-11-02', + onPlan: 1, + origins: unsaid(1), + offPlan: 2, + projected: true, + }); + }); + + it('counts ongoing work in every week from its start, with no end to stop at', () => { + const ahead = loadByWeek({ + weeks: ['2027-03-01'], + today, + plans: [ + plan(1, { project: 'a', status: 'active', start: '2026-08-03', end_kind: 'ongoing' }), + plan(2, { status: 'active', start: '2026-08-03', end_kind: 'ongoing' }), + ], + spans: [{ slug: 'a', start: '2026-08-03', end: '2026-09-20' }], + }); + expect(ahead[0].onPlan).toBe(2); + }); + + it('drops parked and finished work from the weeks ahead', () => { + const ahead = loadByWeek({ + weeks: ['2026-11-02'], + today, + plans: [ + plan(1, { project: 'a', status: 'parked' }), + plan(2, { project: 'b', status: 'done' }), + ], + spans: [ + { slug: 'a', start: '2026-09-01', end: null }, + { slug: 'b', start: '2026-09-01', end: null }, + ], + }); + expect(ahead[0]).toEqual({ + week: '2026-11-02', + onPlan: 0, + origins: unsaid(0), + offPlan: 0, + projected: true, + }); + }); + + it('counts a project once however many of its plans run, and only named work ahead', () => { + const plans = [ + plan(1, { project: 'a', status: 'active', start: '2026-10-05', weeks: 4 }), + plan(2, { project: 'a', start: '2026-10-12', weeks: 4 }), + ]; + const spans = [ + { slug: 'a', start: '2026-09-01', end: null }, + { slug: 'big', start: '2026-09-01', end: null }, + { slug: 'small', start: '2026-09-01', end: null }, + ]; + const [week] = loadByWeek({ + weeks: ['2026-10-19'], + today, + plans, + spans, + ahead: new Set(['big']), + }); + // a counts once for its two plans; small ships without a plan + expect(week).toEqual({ + week: '2026-10-19', + onPlan: 1, + origins: unsaid(1), + offPlan: 1, + projected: true, + }); + // a picked week's rows are exactly what it counts + const members = weekMembers({ today, plans, spans, ahead: new Set(['big']) })('2026-10-19'); + expect([...members.projects]).toEqual([ + ['a', 'on'], + ['big', 'off'], + ]); + expect([...members.plans]).toEqual([1, 2]); + }); + + it('splits the plans on the roadmap by where the work came from', () => { + const [week] = loadByWeek({ + weeks: ['2026-09-28'], + today, + plans: [ + // a's first plan under way speaks for it, as on its row + plan(1, { project: 'a', status: 'active', origin: 'fire' }), + plan(2, { project: 'a', start: '2026-11-02', origin: 'asked' }), + // a plan with no PRs counts by its own word + plan(3, { status: 'active', start: '2026-09-21', origin: 'chosen' }), + plan(4, { project: 'd', status: 'active' }), + ], + spans: [ + { slug: 'a', start: '2026-09-01', end: null }, + { slug: 'd', start: '2026-09-01', end: null }, + { slug: 'e', start: '2026-09-01', end: null }, + ], + }); + expect(week.onPlan).toBe(3); + expect(week.origins).toEqual({ asked: 0, fire: 1, chosen: 1, unsaid: 1 }); + // work with no plan has no origin: it stays in offPlan + expect(week.offPlan).toBe(1); + }); +}); + +describe('peakFrom', () => { + it('finds the busiest week from this one on', () => { + const week = (w: string, onPlan: number, offPlan: number) => ({ + week: w, + onPlan, + origins: { asked: 0, fire: 0, chosen: 0, unsaid: onPlan }, + offPlan, + projected: false, + }); + const weeks = [week('2026-09-21', 9, 9), week('2026-09-28', 1, 2), week('2026-10-05', 2, 2)]; + expect(peakFrom(weeks, '2026-09-30')).toEqual({ count: 4, week: '2026-10-05' }); + expect(peakFrom([], '2026-09-30')).toBeNull(); + }); +}); + +describe('spansFrom', () => { + it('runs open projects from their oldest open PR to today, closed ones to their last close', () => { + expect( + spansFrom( + { + a: { first_opened: '2026-08-01', last_closed: '2026-09-02' }, + b: { first_opened: '2026-07-01', last_closed: '2026-07-20' }, + misc: { first_opened: '2026-07-01', last_closed: '2026-09-20' }, + d: { first_opened: '2026-08-10', last_closed: null }, + }, + { a: '2026-08-05', c: '2026-09-10', d: null }, + '2026-08-01' + ) + ).toEqual([ + { slug: 'a', start: '2026-08-05', end: null }, + { slug: 'c', start: '2026-09-10', end: null }, + // no open PR day known: the window's first PR + { slug: 'd', start: '2026-08-10', end: null }, + ]); + }); +}); diff --git a/frontend-v2/src/model/portfolio.test.ts b/frontend-v2/src/model/portfolio.test.ts new file mode 100644 index 00000000..de8e2411 --- /dev/null +++ b/frontend-v2/src/model/portfolio.test.ts @@ -0,0 +1,736 @@ +import { describe, expect, it } from 'vitest'; +import { + closedIssues, + decideProjects, + decideQueue, + type DecideReason, +} from '../../../shared/model/decide'; +import type { DerivedPull } from '../../../shared/model/status'; +import type { Project, ProjectGroup, ProjectWindow, Today } from '../../../shared/model/projects'; +import type { RoadmapItem, RoadmapUpdate } from '../../../shared/model/roadmap'; +import type { IssueCounts } from '../../../shared/model/work'; +import type { PullData } from '../../../shared/types'; +import { + behindWords, + bucketOf, + groupItems, + IDLE_BUCKETS, + AGE_BUCKETS, + isYours, + mainTeam, + matchesFind, + matchesOnly, + matchesStatus, + onlyWords, + parseSort, + planCell, + portfolioCsv, + portfolioItems, + portfolioText, + slugName, + sortItems, + STATUS_FILTERS, + withCalls, + withWorkers, + type PortfolioItem, +} from './portfolio'; + +// Tuesday 2026-09-29, midday UTC +const NOW = Date.UTC(2026, 8, 29, 12); +const DAY_MS = 86_400_000; +const iso = (daysAgo: number) => new Date(NOW - daysAgo * DAY_MS).toISOString(); +const teams: Record = { dana: 'Store', erin: 'FixBot', finn: 'Store' }; +const teamOf = (login: string) => teams[login] ?? null; + +function project(slug: string, over: Partial = {}): Project { + return { + slug, + name: slug.toUpperCase(), + repo: 'iFixit/projects', + number: 1, + state: 'open', + state_reason: null, + ongoing: false, + parents: [], + lead: null, + target: null, + fields: { start: null, target: null, priority: null }, + created_at: null, + closed_at: null, + ...over, + }; +} + +/** An open PR: opened `opened` days ago, last worked on `active` days ago. + * Its updated_at is today, the way a label edit leaves it. */ +function pull(number: number, opened: number, active: number, login = 'dana', status = 'ready') { + return { + status, + data: { + repo: 'iFixit/ifixit', + number, + title: `PR ${number}`, + user: { login }, + labels: [], + created_at: iso(opened), + updated_at: iso(0), + status: { activity_at: iso(active) }, + }, + } as unknown as DerivedPull; +} + +const merge = (number: number, daysAgo: number, login = 'dana') => + ({ + repo: 'iFixit/ifixit', + number, + title: `PR ${number}`, + user: { login }, + labels: [], + created_at: iso(daysAgo + 3), + merged_at: iso(daysAgo), + } as unknown as PullData); + +function group( + slug: string, + people: string[], + open: DerivedPull[] = [], + p: Project | null = null, + merged: PullData[] = [] +): ProjectGroup { + return { + slug, + project: p, + open, + merged, + closed: [], + people, + idleDays: null, + lastActivity: null, + flags: [], + }; +} + +const w = (merged: number): ProjectWindow => ({ + backlog_start: 0, + backlog_end: 0, + opened: 0, + merged, + closed: 0, + median_age_start_days: null, + median_age_end_days: null, + median_days_to_merge: null, + developers: 0, + non_developers: 0, + first_opened: null, + last_closed: null, +}); + +const update = (health: RoadmapUpdate['health'], daysAgo: number): RoadmapUpdate => ({ + id: 1, + item_id: 1, + health, + body: 'Shipping the importer next.', + plan_start: '2026-09-21', + plan_weeks: 4, + author: 'dana', + at: (NOW - daysAgo * DAY_MS) / 1000, +}); + +const plan = (id: number, slug: string | null, over: Partial = {}): RoadmapItem => ({ + id, + name: `Plan ${id}`, + project: slug, + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-09-21', + weeks: 4, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, + ...over, +}); + +const alpha = project('alpha', { + lead: 'dana', + parents: ['store'], + target: { title: 'October', due_on: new Date(NOW + 5 * DAY_MS).toISOString() }, +}); +const today: Today = { + live: [ + // oldest open first, as buildToday sorts them + group( + 'alpha', + ['dana', 'kyle'], + [pull(1, 40, 2, 'dana', 'needs_cr'), pull(2, 35, 30, 'kyle')], + alpha, + [merge(9, 4)] + ), + group('label-only', ['erin'], [pull(3, 60, 25, 'erin', 'draft')]), + group('paused', ['finn'], [pull(4, 90, 50, 'finn')], project('paused')), + group('merges-only', ['dana'], [], null, [merge(5, 6)]), + ], + quiet: [group('beta', [], [], project('beta', { parents: ['store', 'warehouse'] }))], + misc: [], + unsorted: [], + doubleLabeled: [], +}; +const projects = [ + alpha, + project('beta', { parents: ['store', 'warehouse'] }), + project('paused'), + project('gone', { state: 'closed', state_reason: 'not_planned' }), + project('shipped', { state: 'closed', state_reason: 'completed' }), +]; +const plans = [ + plan(1, 'paused', { status: 'parked' }), + plan(2, 'label-only', { name: 'Label importer', lead: 'erin', team: 'FixBot' }), +]; +const items = portfolioItems(projects, today, { alpha: w(4), misc: w(9) }, teamOf, NOW, plans); +const bySlug: Record = Object.fromEntries(items.map(i => [i.slug, i])); + +describe('portfolioItems', () => { + it('joins every source into one row per project, misc left out', () => { + expect(Object.keys(bySlug).sort()).toEqual([ + 'alpha', + 'beta', + 'gone', + 'label-only', + 'merges-only', + 'paused', + 'shipped', + ]); + expect(bySlug.alpha).toMatchObject({ + status: 'live', + stage: 'progress', + lead: 'dana', + team: 'Store', + dueInDays: 5, + open: 2, + waiting: 1, + merged: 1, + developers: ['dana'], + nonDevelopers: ['kyle'], + }); + expect(bySlug.alpha.window?.merged).toBe(4); + }); + + it('puts each project in a stage, the roadmap first', () => { + const stages = Object.fromEntries(items.map(i => [i.slug, [i.status, i.stage]])); + expect(stages).toEqual({ + alpha: ['live', 'progress'], + 'label-only': ['live', 'progress'], + // live by its PRs, but parked on the roadmap + paused: ['live', 'parked'], + 'merges-only': ['live', 'progress'], + beta: ['quiet', 'quiet'], + gone: ['dropped', 'closed'], + shipped: ['done', 'closed'], + }); + }); + + it('falls back to the plan for a project with no issue, then to its label read as words', () => { + expect(bySlug['label-only']).toMatchObject({ + name: 'Label importer', + lead: 'erin', + team: 'FixBot', + }); + // a name, not a code: what a fresh install shows for every project + expect(bySlug['merges-only'].name).toBe('Merges only'); + }); + + it('leads by PRs where no issue or plan names a lead, and says so', () => { + const leads = Object.fromEntries(items.map(i => [i.slug, [i.lead, i.leadByPrs]])); + expect(leads).toEqual({ + // its issue's assignee, then its plan's lead + alpha: ['dana', false], + 'label-only': ['erin', false], + // the one with the most PRs open or merged lately + 'merges-only': ['dana', true], + paused: ['finn', true], + // nobody's PRs: nobody + beta: [null, false], + gone: [null, false], + shipped: [null, false], + }); + }); + + it('dates a project by its oldest open PR and its newest real work', () => { + const a = bySlug.alpha; + expect([a.openSince, a.ageDays]).toEqual([iso(40).slice(0, 10), 40]); + // activity_at, not the updated_at a label edit moved to today + expect(a.lastActivity).toMatchObject({ days: 2, pr: { number: 1 } }); + expect(a.stalest).toMatchObject({ days: 30, opened: false, pr: { number: 2 } }); + // with nothing open, the last merge is the last activity and there's no age + expect(bySlug['merges-only']).toMatchObject({ ageDays: null, stalest: null }); + expect(bySlug['merges-only'].lastActivity?.days).toBe(6); + }); + + it('calls a project in progress stalled after 21 days without activity, but not a parked one', () => { + expect(items.filter(i => i.stalled).map(i => i.slug)).toEqual(['label-only']); + expect(bySlug.paused.lastActivity?.days).toBe(50); + }); +}); + +describe('slugName', () => { + it('cases acronyms and brands their own way, and the rest as a sentence', () => { + expect(slugName('webdriver-deflake')).toBe('Webdriver deflake'); + expect(slugName('ga4-events')).toBe('GA4 events'); + expect(slugName('plp_server-component')).toBe('PLP server component'); + expect(slugName('editor-v2')).toBe('Editor v2'); + expect(slugName('netsuite-sync')).toBe('NetSuite sync'); + // a lowercase brand first stays lowercase, as the label writes it + expect(slugName('npm-token')).toBe('npm token'); + }); +}); + +describe('planCell', () => { + const day = '2026-09-29'; + const now = NOW / 1000; + const cell = (over: Partial[0]>) => + planCell({ plan: null, project: null, ...over }, day, now); + + it('says what the plan owes when Decide asks nothing, the worst first', () => { + const offTrack = plan(1, 'a', { update: update('off_track', 1), start: '2026-08-03' }); + expect(cell({ plan: offTrack })).toEqual({ + kind: 'off_track', + text: 'Off track', + warn: true, + planId: 1, + }); + expect(cell({ plan: plan(1, 'a', { start: '2026-08-03' }) }).text).toBe('5 weeks overdue'); + expect(cell({ plan: plan(1, 'a', { update: update('at_risk', 1) }) }).text).toBe('At risk'); + expect(cell({ plan: plan(1, 'a', { start: '2026-09-07', weeks: 6 }) }).text).toBe( + 'No update yet' + ); + expect( + cell({ + plan: plan(1, 'a', { start: '2026-09-07', weeks: 6, update: update('on_track', 20) }), + }).text + ).toBe('Update due'); + }); + + it('reads calm when nothing is owed, and blank with no plan nobody asks for', () => { + expect(cell({ plan: plan(1, 'a', { update: update('on_track', 1) }) })).toEqual({ + kind: 'on_track', + text: 'On track', + warn: false, + planId: 1, + }); + expect(cell({ plan: plan(1, 'a') }).text).toBe('Ends Oct 18'); + expect(cell({ plan: plan(1, 'a', { status: 'planned', start: '2026-10-12' }) }).text).toBe( + 'Starts Oct 12' + ); + expect(cell({ plan: plan(1, 'a', { status: 'parked' }) }).text).toBe('Parked'); + expect(cell({ plan: plan(1, 'a', { status: 'dropped' }) }).text).toBe('Dropped'); + expect( + cell({ project: project('x', { state: 'closed', state_reason: 'completed' }) }).text + ).toBe('Done'); + // small work ships without a plan, so the cell says nothing at all + expect(cell({})).toEqual({ kind: 'no_plan', text: '', warn: false, planId: null }); + }); + + it('says a soft end run past in ink, ongoing work as ongoing, and never counts them behind', () => { + const soft = plan(1, 'alpha', { + start: '2026-08-03', + end_kind: 'soft', + update: update('on_track', 1), + }); + expect(cell({ plan: soft })).toEqual({ + kind: 'past_end', + text: '5 weeks past its estimate', + warn: false, + planId: 1, + }); + // put on the roadmap three days ago, so no update is owed yet + const upkeep = { + start: '2026-08-03', + end_kind: 'ongoing' as const, + created_at: now - 3 * 86400, + }; + expect(cell({ plan: plan(1, 'a', upkeep) }).text).toBe('Ongoing'); + const items = portfolioItems(projects, today, {}, teamOf, NOW, [soft]); + expect(items.find(i => i.slug === 'alpha')?.behind).toBeNull(); + }); + + it('names the worst call Decide asks, in amber, about the plan it asks', () => { + const done = plan(7, 'a', { status: 'done' }); + const asked = (...reasons: DecideReason[]) => + cell({ plan: done, asks: reasons.map(reason => ({ reason, item: done })) }); + expect(asked({ kind: 'new', since: null })).toMatchObject({ + text: 'Needs a plan', + warn: true, + }); + // a finished plan Decide asks about again, whatever else it asks + expect( + asked( + { kind: 'stalled', days: 30 }, + { kind: 'reopened', open: 2, late: 0, as: 'done', by: 'roadmap' } + ) + ).toEqual({ kind: 'reopened', text: 'Done, still worked on', warn: true, planId: 7 }); + expect(asked({ kind: 'over', weeks: 3, since: 0 }).text).toBe('3 weeks overdue'); + expect(asked({ kind: 'missed', due: '2026-09-20', open: 2 }).text).toBe( + 'Missed its Sep 20 target date' + ); + // a call about no plan opens the work with no plan + expect(cell({ asks: [{ reason: { kind: 'stalled', days: 25 }, item: null }] })).toMatchObject( + { text: 'Stalled', planId: null } + ); + }); + + it('says the issues are all closed exactly when Decide asks, as Decide asks it', () => { + const closed: IssueCounts = { + total: 2, + open: 0, + done: 2, + dropped: 0, + lastClosedAt: now - 2 * 86400, + }; + const issues = new Map([['alpha', closed]]); + // [the cell says so, Decide asks] + const flags = (alphaPlans: RoadmapItem[], all: Project[] = projects) => { + const rows = decideQueue({ + live: decideProjects(today), + items: alphaPlans, + closed: closedIssues(all), + issues, + today: day, + now, + }); + const items = withCalls( + portfolioItems(all, today, {}, teamOf, NOW, alphaPlans, issues), + rows, + NOW + ); + return [ + items.find(i => i.slug === 'alpha')?.planCell.kind === 'issues_done', + rows.some(r => r.slug === 'alpha' && r.reasons.some(x => x.kind === 'issues_done')), + ]; + }; + // its one plan starts next month + expect(flags([plan(3, 'alpha', { status: 'planned', start: '2026-10-12' })])).toEqual([ + false, + false, + ]); + const first = plan(3, 'alpha', { start: '2026-09-07', weeks: 8 }); + expect(flags([first])).toEqual([true, true]); + // its own issue closed since the plan last changed: Decide asks about + // that first, so the cell says so instead + const shut = projects.map(p => + p.slug === 'alpha' + ? { + ...p, + state: 'closed' as const, + state_reason: 'completed', + closed_at: new Date((now - 3600) * 1000).toISOString(), + } + : p + ); + expect(flags([first], shut)).toEqual([false, false]); + }); + + it('counts a project behind when off track, or past its end or target with PRs open', () => { + const behind = portfolioItems(projects, today, {}, teamOf, NOW, [ + plan(3, 'alpha', { start: '2026-08-03' }), + plan(4, 'merges-only', { start: '2026-08-03' }), + plan(5, 'beta', { start: '2026-09-21', weeks: 2 }), + ]); + const by = Object.fromEntries(behind.map(i => [i.slug, i])); + expect(by.alpha.behind).toBe('past_end'); + // past its end, but nothing open: Decide asks to finish it, not to catch up + expect(by['merges-only'].behind).toBeNull(); + expect(by.beta.endsSoon).toBe(true); + // off track counts until the plan changes after the update, as Decide reads it + const offTrack = (changedDaysAgo: number) => + portfolioItems(projects, today, {}, teamOf, NOW, [ + plan(3, 'alpha', { + update: update('off_track', 2), + updated_at: now - changedDaysAgo * 86400, + }), + ]).find(i => i.slug === 'alpha')?.behind; + expect(offTrack(5)).toBe('off_track'); + expect(offTrack(1)).toBeNull(); + }); + + it('counts whole days from the milestone’s UTC day, whatever the hour', () => { + const dueIn = (due_on: string, now: number) => + portfolioItems( + [project('alpha', { target: { title: 'T', due_on } })], + today, + {}, + teamOf, + now, + [] + ).find(i => i.slug === 'alpha')?.dueInDays; + // a late evening and an early morning of the same local day agree + expect(dueIn('2026-09-29T00:00:00Z', new Date(2026, 8, 29, 23, 30).getTime())).toBe(0); + expect(dueIn('2026-09-29T00:00:00Z', new Date(2026, 8, 29, 0, 30).getTime())).toBe(0); + expect(dueIn('2026-09-30T00:00:00Z', new Date(2026, 8, 29, 23, 30).getTime())).toBe(1); + expect(dueIn('2026-09-28T23:59:59Z', new Date(2026, 8, 29, 0, 30).getTime())).toBe(-1); + }); + + it('says how a project is behind in its row’s words', () => { + const day = '2026-09-29'; + const late = portfolioItems(projects, today, {}, teamOf, NOW, [ + plan(3, 'alpha', { start: '2026-08-03' }), + ]).find(i => i.slug === 'alpha') as PortfolioItem; + expect(behindWords(late, day)).toBe('5 weeks overdue'); + const due = { title: null, due_on: '2026-09-20T00:00:00Z' }; + expect(behindWords({ behind: 'missed', plan: null, target: due }, day)).toBe( + 'Missed its Sep 20 target date' + ); + expect(behindWords({ behind: 'off_track', plan: null, target: null }, day)).toBe('Off track'); + expect(behindWords(bySlug.beta, day)).toBeNull(); + }); +}); + +describe('the list’s filters', () => { + it('matches the tabs, being worked on by default', () => { + const pick = (f: string) => + items + .filter(i => matchesStatus(i, f)) + .map(i => i.slug) + .sort(); + expect(pick('live')).toEqual(['alpha', 'label-only', 'merges-only']); + expect(pick('parked')).toEqual(['paused']); + expect(pick('quiet')).toEqual(['beta']); + expect(pick('closed')).toEqual(['gone', 'shipped']); + expect(pick('all')).toHaveLength(7); + }); + + it('counts parked or finished work Decide asks about as being worked on while its PRs move', () => { + const asked = withCalls( + items, + [ + { slug: 'paused', item: plans[0], reasons: [{ kind: 'moving' }] }, + // asked, but nothing of it moves + { slug: 'beta', item: null, reasons: [{ kind: 'stalled', days: 30 }] }, + ], + NOW + ); + const tabs = (slug: string) => { + const item = asked.find(i => i.slug === slug) as PortfolioItem; + return STATUS_FILTERS.map(([key]) => key).filter(key => matchesStatus(item, key)); + }; + // in the list a Monday opens to, and still in its own tab + expect(tabs('paused')).toEqual(['live', 'parked', 'all']); + expect(tabs('beta')).toEqual(['quiet', 'all']); + }); + + it('narrows to what a tile or a bar picked, and says so', () => { + const only = (o: string) => items.filter(i => matchesOnly(i, o)).map(i => i.slug); + expect(only('stalled')).toEqual(['label-only']); + // alpha is 40 days old: 1 to 2 months + expect(bucketOf(40, AGE_BUCKETS)).toBe(2); + expect(only('age-2')).toEqual(['alpha']); + // the parked project is 50 days quiet, but the charts count only work in progress + expect(bucketOf(50, IDLE_BUCKETS)).toBe(3); + expect(only('idle-3')).toEqual(['label-only']); + expect(onlyWords('age-2')).toBe('open 1 to 2 months'); + expect(onlyWords('idle-3')).toBe('stalled, nothing for 21 days or more'); + expect(onlyWords('nonsense')).toBeNull(); + expect(items.filter(i => matchesOnly(i, null))).toHaveLength(7); + }); + + it('finds by name, slug, parent, lead or team', () => { + expect(items.filter(i => matchesFind(i, 'WARE')).map(i => i.slug)).toEqual(['beta']); + // a lead by PRs is a lead + expect(items.filter(i => matchesFind(i, 'dana')).map(i => i.slug)).toEqual([ + 'alpha', + 'merges-only', + ]); + expect(items.filter(i => matchesFind(i, 'fixbot')).map(i => i.slug)).toEqual(['label-only']); + }); + + it('matches a lead or team it starts after "lead:" or "team:", a parent exactly', () => { + const find = (f: string) => + items + .filter(i => matchesFind(i, f)) + .map(i => i.slug) + .sort(); + // what a click on a parent or a team puts in the box + expect(find('parent:store')).toEqual(['alpha', 'beta']); + expect(find('parent:Warehouse')).toEqual(['beta']); + expect(find('lead:dana')).toEqual(['alpha', 'merges-only']); + expect(find('team:fixbot')).toEqual(['label-only']); + // a lead or team is found before it's typed out + expect(find('lead:dan')).toEqual(['alpha', 'merges-only']); + expect(find('team:Fix')).toEqual(['label-only']); + expect(find('lead:ana')).toEqual([]); + // part of a parent isn't the parent + expect(find('parent:stor')).toEqual([]); + // without the prefix, a word still matches inside any of them + expect(find('stor')).toEqual(expect.arrayContaining(['alpha', 'beta'])); + }); +}); + +describe('isYours', () => { + it('is a project you lead, by PRs too, or have a PR in, until it’s done', () => { + const yours = (me: string) => + items + .filter(i => isYours(i, me)) + .map(i => i.slug) + .sort(); + // dana leads alpha and has the merge in merges-only + expect(yours('dana')).toEqual(['alpha', 'merges-only']); + // kyle only has a PR open in alpha; logins match in any case + expect(yours('Kyle')).toEqual(['alpha']); + // finn's project is parked: still his + expect(yours('finn')).toEqual(['paused']); + expect(yours('nobody')).toEqual([]); + // done or dropped leaves it, unless Decide asks because its PRs still move + const done = { ...bySlug.alpha, stage: 'closed' as const }; + expect(isYours(done, 'dana')).toBe(false); + const reason: DecideReason = { kind: 'new', since: null }; + expect(isYours({ ...done, asks: [{ reason, item: null }] }, 'dana')).toBe(true); + }); +}); + +describe('sortItems', () => { + it('puts what’s owed first by default, then the longest quiet, and sinks no activity', () => { + const owed = withCalls( + items, + [{ slug: 'alpha', item: null, reasons: [{ kind: 'new', since: null }] }], + NOW + ); + expect(sortItems(owed, '').map(i => i.slug)).toEqual([ + 'alpha', + 'paused', + 'label-only', + 'merges-only', + 'beta', + 'gone', + 'shipped', + ]); + // Decide's calls in Decide's order, worst first, whatever was quieter; + // then an update owed, which nobody decides + const asked = withCalls( + items, + [ + { slug: 'alpha', item: null, reasons: [{ kind: 'new', since: null }] }, + { slug: 'label-only', item: null, reasons: [{ kind: 'stalled', days: 25 }] }, + { slug: 'paused', item: plans[0], reasons: [{ kind: 'moving' }] }, + ], + NOW + ).map(i => + i.slug === 'merges-only' + ? { + ...i, + planCell: { + kind: 'update_due' as const, + text: 'Update due', + warn: true, + planId: 9, + }, + } + : i + ); + expect( + sortItems(asked, '') + .slice(0, 4) + .map(i => i.slug) + ).toEqual(['paused', 'label-only', 'alpha', 'merges-only']); + // the URL's nothing-picked value and an unknown key are the default too + expect(parseSort('idle')).toEqual({ key: 'plan', reversed: false }); + expect(parseSort('nonsense')).toEqual({ key: 'plan', reversed: false }); + // Last activity alone + expect(sortItems(owed, 'last')[0].slug).toBe('paused'); + }); + + it('sorts by a column, and reverses with a minus', () => { + expect(sortItems(items, 'age')[0].slug).toBe('paused'); + // reversed, the blanks still sink + expect(sortItems(items, '-age').map(i => i.ageDays)).toEqual([ + 40, + 60, + 90, + null, + null, + null, + null, + ]); + expect(sortItems(items, 'target')[0].slug).toBe('alpha'); + }); + + it('sorts by who worked on it once those days load', () => { + const withPeople = withWorkers( + items, + new Map([['beta', [{ login: 'dana', days: 1, writing: 1 }]]]) + ); + expect(sortItems(withPeople, 'people')[0].slug).toBe('beta'); + }); +}); + +describe('groupItems', () => { + it('shows a two-parent project under both parents, with no parent last', () => { + const groups = groupItems(sortItems(items, 'name'), 'parent'); + expect(groups.map(g => [g.title, g.items.map(i => i.slug)])).toEqual([ + ['store', ['alpha', 'beta']], + ['warehouse', ['beta']], + ['No parent', ['gone', 'label-only', 'merges-only', 'paused', 'shipped']], + ]); + }); + + it('puts a parent at the top of its own group, not under no parent', () => { + const store = { ...bySlug.gone, slug: 'store', name: 'Store' }; + const groups = groupItems(sortItems([...items, store], 'name'), 'parent'); + expect(groups.map(g => [g.title, g.items.map(i => i.slug)])).toEqual([ + ['store', ['store', 'alpha', 'beta']], + ['warehouse', ['beta']], + ['No parent', ['gone', 'label-only', 'merges-only', 'paused', 'shipped']], + ]); + }); + + it('groups by the plan’s team, or else the team most of its developers are on', () => { + expect(mainTeam(bySlug.alpha, teamOf)).toBe('Store'); + const groups = groupItems(items, 'team'); + expect(groups.map(g => g.title)).toEqual(['FixBot', 'Store', 'No team']); + }); + + it('puts the teams in their configured order, then any other by name', () => { + const titles = (teams: string[]) => + groupItems(items, 'team', undefined, teams).map(g => g.title); + expect(titles(['Store', 'FixBot'])).toEqual(['Store', 'FixBot', 'No team']); + expect(titles(['Store'])).toEqual(['Store', 'FixBot', 'No team']); + }); + + it('marks a project an earlier group already showed, so its call is drawn once', () => { + const groups = groupItems(sortItems(items, 'name'), 'parent'); + expect(groups.map(g => [g.title, [...g.repeats]])).toEqual([ + ['store', []], + ['warehouse', ['beta']], + ['No parent', []], + ]); + }); +}); + +describe('the list as text', () => { + it('writes one CSV row per project, quoting cells with commas', () => { + const csv = portfolioCsv([{ ...bySlug.alpha, name: 'Alpha, the first' }]); + const [head, row] = csv.trim().split('\n'); + expect(head.startsWith('Project,Label slug,Stage,Lead,Team,Open since')).toBe(true); + expect(row.startsWith('"Alpha, the first",alpha,Being worked on,dana,Store,')).toBe(true); + }); + + it('keeps a name that starts like a formula as text', () => { + const csv = portfolioCsv([{ ...bySlug.alpha, name: '=HYPERLINK("x")' }]); + expect(csv.trim().split('\n')[1].startsWith('"\'=HYPERLINK(""x"")",alpha,')).toBe(true); + }); + + it('copies each project’s plan words and when it last moved', () => { + const text = portfolioText([bySlug.alpha, bySlug['label-only']], '2026-09-29'); + expect(text.split('\n')).toEqual([ + 'Where the projects stand, Sep 29', + '- ALPHA: No plan. 2 open PRs, last activity 2 days ago.', + '- Label importer: Ends Oct 18. 1 open PR, last activity 25 days ago.', + ]); + }); +}); diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts new file mode 100644 index 00000000..d626af80 --- /dev/null +++ b/frontend-v2/src/model/portfolio.ts @@ -0,0 +1,927 @@ +import { epoch, n } from '../../../shared/format'; +import { + compareRows, + RANK, + STALL_DAYS, + type DecideReason, + type DecideRow, +} from '../../../shared/model/decide'; +import { + dayStart, + MISC_SLUG, + targetOf, + utcDay, + type Project, + type ProjectFlag, + type ProjectGroup, + type ProjectTarget, + type ProjectWindow, + type Today, +} from '../../../shared/model/projects'; +import { + endOf, + HEALTH_WORD, + healthStanding, + isUnderWay, + planFor, + type RoadmapItem, +} from '../../../shared/model/roadmap'; +import type { Status } from '../../../shared/model/status'; +import type { PullData } from '../../../shared/types'; +import type { IssueCounts } from '../../../shared/model/work'; +import { dayOf } from './days'; +import { dayWords } from './projectData'; +import type { ProjectWorker } from './retro'; +import { + ALL_ISSUES_CLOSED, + BEING_WORKED_ON, + missedTarget, + NEEDS_A_PLAN, + NO_PLAN, + NO_UPDATE_YET, + pastEnd, + UPDATE_DUE, +} from './words'; + +/** + * The portfolio: one row per project, for the person who plans the work + * rather than reviews it. It joins the sources the Projects tab already + * has: the project issues (name, lead, target, parents, open or closed), + * Today (open PRs, people, flags, from the live board), the roadmap's plans, + * and the picked range's numbers. Pure, so the list, the charts over it and + * the CSV export can't disagree. + */ + +/** Words written for the middle of a sentence ("being worked on") as a + * label that starts with them. */ +export const upperFirst = (words: string) => words.charAt(0).toUpperCase() + words.slice(1); +/** Words written to start a line ("No PR activity") in the middle of one. */ +export const lowerFirst = (words: string) => words.charAt(0).toLowerCase() + words.slice(1); + +/** Words a slug can't case on its own, from the live P- labels: acronyms, + * and brands spelled their own way. */ +const SLUG_WORDS: Record = Object.fromEntries( + [ + 'GA4', + 'BI', + 'UPS', + 'CI', + 'PLP', + 'SSR', + 'API', + 'CAD', + 'npm', + 'idx', + 'ChatGPT', + 'NetSuite', + 'FixHub', + 'FixBot', + 'RepairDesk', + 'v2', + 'v5', + ].map(w => [w.toLowerCase(), w]) +); + +/** A label's slug read as words, the name of a project no issue or plan + * names yet: "webdriver-deflake" is "Webdriver deflake", "ga4-events" is + * "GA4 events", and "npm-token" stays "npm token", as the brand writes it. */ +export const slugName = (slug: string) => { + const words = slug.split(/[-_]+/).map(w => SLUG_WORDS[w.toLowerCase()] ?? w); + const name = words.join(' '); + return SLUG_WORDS[words[0].toLowerCase()] ? name : upperFirst(name); +}; + +/** Where a project is: being worked on (an open PR or a merge in the last + * 14 days, and not parked, done or dropped on the roadmap), parked, quiet + * (open, nothing in flight), or closed (done or dropped). "In progress" is + * only ever a plan's status. */ +export type Stage = 'progress' | 'parked' | 'quiet' | 'closed'; + +/** Whether it has work in flight by its PRs alone: live (an open PR or a + * merge in the last 14 days), quiet, or its issue closed as done or dropped. + * The roadmap and Decide read this; the list reads `stage`, and both + * (beingWorkedOn). */ +export type ProjectStatus = 'live' | 'quiet' | 'done' | 'dropped'; + +/** Enough of a PR to name and link it. */ +export interface PrRef { + repo: string; + number: number; + title: string; +} + +/** A call Decide asks about a project: why, and the plan it's about (null + * for work with no plan). */ +export interface Ask { + reason: DecideReason; + item: RoadmapItem | null; +} + +/** A project's plan in a few words for its table cell: the worst call + * Decide asks, or else what its plan says. */ +export type PlanKind = + | 'reopened' + | 'issue_closed' + | 'moving' + | 'off_track' + | 'missed' + | 'past_end' + | 'issues_done' + | 'stalled' + | 'at_risk' + | 'no_update' + | 'update_due' + | 'on_track' + | 'ends' + | 'parked' + | 'no_plan' + | 'stopped'; +export interface PlanCell { + kind: PlanKind; + /** empty for work with no plan that needs none yet */ + text: string; + /** amber, the row's one mark: someone owes a call or an update */ + warn: boolean; + /** the plan the words are about, which a click opens */ + planId: number | null; +} + +export interface PortfolioItem { + slug: string; + /** its issue's title, or else its plan's name, or else its label's slug + * read as words (slugName) */ + name: string; + project: Project | null; + /** Today's group when the project is live or quiet */ + group: ProjectGroup | null; + status: ProjectStatus; + stage: Stage; + /** its issue's assignee, or else its plan's lead, or else whoever has the + * most PRs in it, open or merged in the last 14 days (`leadByPrs`) */ + lead: string | null; + /** no issue or plan names a lead, so it's the one with the most PRs: + * said "by PRs" wherever the lead shows */ + leadByPrs: boolean; + /** its plan's team, or else the team most of its developers are on */ + team: string | null; + /** its issue's Target date, or else its milestone (projects.ts targetOf) */ + target: ProjectTarget | null; + /** whole days until the target's due date, negative once it's past */ + dueInDays: number | null; + /** parent slugs, from the issue's parent: labels */ + parents: string[]; + open: number; + /** open PRs waiting on a CR or QA */ + waiting: number; + /** PRs merged in the last 14 days */ + merged: number; + /** people with an open PR or a merge in the last 14 days, split by team */ + developers: string[]; + nonDevelopers: string[]; + /** who wrote or reviewed on it in the last 14 days (withWorkers); empty + * until those days load */ + workers: ProjectWorker[]; + /** the picked range's numbers; null when nothing of it overlaps the range */ + window: ProjectWindow | null; + /** the day its oldest open PR opened, and whole days since; null with none open */ + openSince: string | null; + ageDays: number | null; + /** its newest activity on any PR (opened, pushed, a human comment, stamp + * or review, a merge): when, whole days ago, and on which PR */ + lastActivity: { at: number; days: number; pr: PrRef } | null; + /** its open PR gone longest with no activity; `opened` when it has had + * none since it opened */ + stalest: { pr: PrRef; days: number; opened: boolean } | null; + flags: ProjectFlag[]; + /** the plan that speaks for this project (roadmap.ts planFor): the first + * under way, or else its latest decision; null when it isn't on the roadmap */ + plan: RoadmapItem | null; + planCell: PlanCell; + /** the calls Decide asks about it (withCalls); empty until those load */ + asks: Ask[]; + /** how the issues attached to it stand (shared/model/work.ts); null + * with none attached, or before they load */ + issues: IssueCounts | null; + /** it runs with no end: marked on the board or by its issue's label */ + ongoing: boolean; + /** being worked on (stage progress), with open PRs and no activity for + * STALL_DAYS */ + stalled: boolean; + /** how it's behind, the first that holds: its latest update says off + * track and the plan hasn't changed since, it's past its plan's hard end + * with PRs open, or past its target with PRs open; null when it isn't */ + behind: 'off_track' | 'past_end' | 'missed' | null; + /** its plan under way ends, hard or soft, within the next ENDS_SOON_DAYS */ + endsSoon: boolean; +} + +const WAITING: Status[] = ['needs_cr', 'needs_recr', 'needs_qa']; +const DAY = 86400; +/** how far ahead a plan's end counts as soon */ +export const ENDS_SOON_DAYS = 14; + +const ref = (p: PullData): PrRef => ({ repo: p.repo, number: p.number, title: p.title }); + +function stageOf( + live: 'live' | 'quiet' | null, + project: Project | null, + plan: RoadmapItem | null +): Stage { + const closedIssue = project?.state === 'closed'; + const stopped = plan?.status === 'done' || plan?.status === 'dropped'; + if (plan?.status === 'parked' && !closedIssue) return 'parked'; + if (live === 'live' && !stopped) return 'progress'; + return closedIssue || stopped ? 'closed' : 'quiet'; +} + +/** Whole weeks from a plan's last day to `day`. */ +const weeksPast = (end: string, day: string) => + Math.ceil(((dayStart(day) as number) - (dayStart(end) as number)) / (7 * DAY)); + +/** A call Decide asks, in the few words a table cell has room for. */ +function callWords(reason: DecideReason): Pick { + switch (reason.kind) { + case 'reopened': + return { + kind: 'reopened', + text: `${reason.as === 'dropped' ? 'Dropped' : 'Done'}, still worked on`, + }; + case 'issue_closed': + return { kind: 'issue_closed', text: 'Issue closed, plan still going' }; + case 'moving': + return { kind: 'moving', text: 'Parked, still worked on' }; + case 'off_track': + case 'at_risk': + return { kind: reason.kind, text: HEALTH_WORD[reason.kind] }; + case 'missed': + return { kind: 'missed', text: missedTarget(dayWords(reason.due)) }; + case 'over': + case 'ended': + return { kind: 'past_end', text: pastEnd(reason.weeks) }; + case 'issues_done': + return { kind: 'issues_done', text: ALL_ISSUES_CLOSED }; + case 'stalled': + return { kind: 'stalled', text: 'Stalled' }; + case 'new': + return { kind: 'no_plan', text: NEEDS_A_PLAN }; + } +} + +/** + * A project's plan in a few words, carrying the row's one amber mark. When + * Decide asks about it (`asks`), the worst call it asks, in Decide's order. + * Otherwise what the plan says, amber only where someone still owes + * something: off track, past a hard end, at risk, or an update owed. A soft + * end that passed is drift, said in ink, and ongoing work says so. A project + * with no plan that Decide doesn't ask about says nothing, since small work + * ships without one. + */ +export function planCell( + item: Pick & { asks?: readonly Ask[] }, + day: string, + now: number +): PlanCell { + const { plan, asks = [] } = item; + if (asks.length) { + const worst = asks.reduce((a, b) => + // Decide's own order, worst first, so the cell names the call + // Decide puts first + RANK[b.reason.kind] < RANK[a.reason.kind] ? b : a + ); + return { + ...callWords(worst.reason), + warn: true, + planId: (worst.item ?? plan)?.id ?? null, + }; + } + if (plan && isUnderWay(plan.status)) { + const standing = healthStanding(plan, now); + const update = + standing.kind === 'current' || standing.kind === 'stale' ? standing.update : null; + const end = endOf(plan); + const owed = { warn: true, planId: plan.id }; + if (update?.health === 'off_track') + return { kind: 'off_track', text: HEALTH_WORD.off_track, ...owed }; + if (plan.end_kind === 'hard' && end && end < day) { + return { kind: 'past_end', text: pastEnd(weeksPast(end, day)), ...owed }; + } + if (update?.health === 'at_risk') + return { kind: 'at_risk', text: HEALTH_WORD.at_risk, ...owed }; + if (standing.kind === 'missing') return { kind: 'no_update', text: NO_UPDATE_YET, ...owed }; + if (standing.kind === 'stale') return { kind: 'update_due', text: UPDATE_DUE, ...owed }; + const calm = { warn: false, planId: plan.id }; + // a soft end run past: drift, in ink, the newer fact than an old "On track" + if (end && end < day) { + return { kind: 'past_end', text: pastEnd(weeksPast(end, day), 'soft'), ...calm }; + } + if (update) return { kind: 'on_track', text: HEALTH_WORD.on_track, ...calm }; + if (plan.start > day) + return { kind: 'ends', text: `Starts ${dayWords(plan.start)}`, ...calm }; + return { kind: 'ends', text: end ? `Ends ${dayWords(end)}` : 'Ongoing', ...calm }; + } + if (plan?.status === 'parked') { + return { kind: 'parked', text: 'Parked', warn: false, planId: plan.id }; + } + if (plan || item.project?.state === 'closed') { + const dropped = plan + ? plan.status === 'dropped' + : item.project?.state_reason === 'not_planned'; + return { + kind: 'stopped', + text: dropped ? 'Dropped' : 'Done', + warn: false, + planId: plan?.id ?? null, + }; + } + return { kind: 'no_plan', text: '', warn: false, planId: null }; +} + +/** Every project the tab knows about: issues, live or quiet groups, and + * labels seen in the range's history. Misc is one-offs, not a project. */ +export function portfolioItems( + projects: readonly Project[], + today: Today, + window: Record, + teamOf: (login: string) => string | null, + now: number = Date.now(), + plans: readonly RoadmapItem[] = [], + issues: ReadonlyMap | null = null, + ongoing: ReadonlySet = new Set() +): PortfolioItem[] { + const secs = now / 1000; + const day = dayOf(new Date(now)); + const soon = utcDay((dayStart(day) as number) + (ENDS_SOON_DAYS - 1) * DAY); + const daysSince = (at: number) => Math.max(0, Math.floor((secs - at) / DAY)); + const bySlug = new Map(projects.map(p => [p.slug, p])); + const groups = new Map(); + for (const g of today.live) groups.set(g.slug, [g, 'live']); + for (const g of today.quiet) groups.set(g.slug, [g, 'quiet']); + const slugs = new Set([...bySlug.keys(), ...groups.keys(), ...Object.keys(window)]); + slugs.delete(MISC_SLUG); + return [...slugs].map(slug => { + const project = bySlug.get(slug) ?? null; + const [group, live] = groups.get(slug) ?? [null, null]; + const plan = planFor(slug, plans); + const people = group?.people ?? []; + const target = targetOf(project); + // the milestone's UTC day against today's day, whole days, as Decide + // counts: an instant minus now would turn over at the milestone's hour + const dueDay = target?.due_on ? dayStart(target.due_on.slice(0, 10)) : null; + const open = group?.open ?? []; + // open PRs come oldest first (projects.ts buildToday) + const oldest = open[0]?.data; + // real work only: updated_at moves on any label edit + const onOpen = open + .map(p => ({ at: epoch(p.data.status?.activity_at ?? p.data.updated_at), pr: p.data })) + .filter(t => Number.isFinite(t.at)); + const onMerged = (group?.merged ?? []) + .map(p => ({ at: epoch(p.merged_at ?? ''), pr: p })) + .filter(t => Number.isFinite(t.at)); + type Touch = typeof onOpen[number]; + const newest = [...onOpen, ...onMerged].reduce( + (a, t) => (!a || t.at > a.at ? t : a), + null + ); + const stalest = onOpen.reduce((a, t) => (!a || t.at < a.at ? t : a), null); + const stage = stageOf(live, project, plan); + const status: ProjectStatus = + live ?? + (project?.state === 'closed' + ? project.state_reason === 'not_planned' + ? 'dropped' + : 'done' + : 'quiet'); + const named = project?.lead ?? plan?.lead ?? null; + const item: PortfolioItem = { + slug, + name: project?.name ?? plan?.name ?? slugName(slug), + project, + group, + status, + stage, + // people come most PRs first (projects.ts buildToday) + lead: named ?? people[0] ?? null, + leadByPrs: !named && people.length > 0, + team: null, + target, + dueInDays: dueDay == null ? null : Math.round((dueDay - (dayStart(day) as number)) / DAY), + parents: project?.parents ?? [], + open: open.length, + waiting: open.filter(p => WAITING.includes(p.status)).length, + merged: group?.merged.length ?? 0, + developers: people.filter(login => teamOf(login) != null), + nonDevelopers: people.filter(login => teamOf(login) == null), + workers: [], + window: window[slug] ?? null, + openSince: oldest ? oldest.created_at.slice(0, 10) : null, + ageDays: oldest ? daysSince(epoch(oldest.created_at)) : null, + lastActivity: newest + ? { at: newest.at, days: daysSince(newest.at), pr: ref(newest.pr) } + : null, + stalest: stalest + ? { + pr: ref(stalest.pr), + days: daysSince(stalest.at), + opened: stalest.at <= epoch(stalest.pr.created_at), + } + : null, + flags: group?.flags ?? [], + plan, + // what Decide asks comes in later (withCalls) + planCell: planCell({ plan, project }, day, secs), + asks: [], + issues: issues?.get(slug) ?? null, + ongoing: ongoing.has(slug) || !!project?.ongoing, + stalled: false, + behind: null, + endsSoon: false, + }; + item.team = plan?.team ?? mainTeam(item, teamOf); + const underWay = plan && isUnderWay(plan.status) ? plan : null; + const standing = underWay && healthStanding(underWay, secs); + // an off-track update the plan has changed since is answered, as + // Decide reads it, so the tile's words agree with the row's + const offTrack = + !!standing && + (standing.kind === 'current' || standing.kind === 'stale') && + standing.update.health === 'off_track' && + standing.update.at > (underWay?.updated_at ?? 0); + item.stalled = + stage === 'progress' && item.open > 0 && (item.lastActivity?.days ?? 0) >= STALL_DAYS; + const end = underWay && endOf(underWay); + item.behind = offTrack + ? 'off_track' + : underWay?.end_kind === 'hard' && end && end < day && item.open > 0 + ? 'past_end' + : stage !== 'closed' && item.open > 0 && (item.dueInDays ?? 0) < 0 && target?.due_on + ? 'missed' + : null; + item.endsSoon = !!end && end >= day && end <= soon; + return item; + }); +} + +/** How a project is behind (`behind`), in the words its row uses: "Off + * track", "3 weeks overdue", "Missed its Sep 26 target date"; null when it + * isn't. */ +export function behindWords( + item: Pick, + day: string +): string | null { + switch (item.behind) { + case 'off_track': + return HEALTH_WORD.off_track; + case 'past_end': { + // `behind` reads the plan under way, which is the one planFor gives + const end = item.plan && endOf(item.plan); + return end && pastEnd(weeksPast(end, day), item.plan?.end_kind); + } + case 'missed': + return item.target?.due_on ? missedTarget(dayWords(item.target.due_on)) : null; + default: + return null; + } +} + +/** + * The items with the calls Decide asks about each, so a row's Plan cell + * names the worst one and wears the row's one amber mark. Without Decide's + * rows (the roadmap still loading) the cells say only what each plan says. + */ +export function withCalls( + items: readonly PortfolioItem[], + decisions: readonly DecideRow[] | null, + now: number = Date.now() +): PortfolioItem[] { + const asks = new Map(); + for (const row of decisions ?? []) { + if (!row.slug) continue; + const list = asks.get(row.slug) ?? []; + list.push(...row.reasons.map(reason => ({ reason, item: row.item }))); + asks.set(row.slug, list); + } + const day = dayOf(new Date(now)); + return items.map(i => { + const a = asks.get(i.slug); + return a ? { ...i, asks: a, planCell: planCell({ ...i, asks: a }, day, now / 1000) } : i; + }); +} + +/** The items with who worked on each in the last 14 days (retro.ts peopleByProject). */ +export function withWorkers( + items: readonly PortfolioItem[], + workers: ReadonlyMap +): PortfolioItem[] { + return items.map(i => ({ ...i, workers: workers.get(i.slug) ?? [] })); +} + +/** + * Whether its PRs are moving on work that's going: being worked on, or + * parked, done or dropped work Decide asks about because its PRs still + * move, which belongs on the list a Monday opens to. The Overview's first + * tab, its tile and its charts count these, so they agree. + */ +export function beingWorkedOn(item: Pick): boolean { + return item.stage === 'progress' || (item.status === 'live' && item.asks.length > 0); +} + +/** + * Whether a project is the viewer's: they lead it (by PRs too), or have a + * PR open in it or merged in the last 14 days. A done or dropped one stays + * only while Decide asks about it, since its PRs still move. The Overview + * lists these first, as Yours. + */ +export function isYours( + item: Pick, + me: string +): boolean { + const mine = (login: string | null) => !!login && login.toLowerCase() === me.toLowerCase(); + return ( + (mine(item.lead) || [...item.developers, ...item.nonDevelopers].some(mine)) && + (item.stage !== 'closed' || beingWorkedOn(item)) + ); +} + +/** The tabs over the list, in the order they show. `live` is the projects + * being worked on, named for the URLs shared before parked had a tab of + * its own. Parked or finished work Decide asks about shows in two. */ +export const STATUS_FILTERS: [string, string][] = [ + ['live', upperFirst(BEING_WORKED_ON)], + ['parked', 'Parked'], + ['quiet', 'Quiet'], + ['closed', 'Done or dropped'], + ['all', 'All'], +]; + +export function matchesStatus(item: PortfolioItem, filter: string): boolean { + if (filter === 'all') return true; + if (filter === 'live') return beingWorkedOn(item); + return item.stage === filter; +} + +/** One bar of the Overview's charts: its axis label, the same in words for + * the line over the list, and the days it holds (fewer than `under`). */ +export interface BucketDef { + tick: string; + words: string; + under: number; +} + +/** How long the projects being worked on have been open, from their oldest open PR. */ +export const AGE_BUCKETS: BucketDef[] = [ + { tick: 'Under 2 wk', words: 'open under 2 weeks', under: 14 }, + { tick: '2-4 wk', words: 'open 2 to 4 weeks', under: 28 }, + { tick: '1-2 mo', words: 'open 1 to 2 months', under: 60 }, + { tick: '2-3 mo', words: 'open 2 to 3 months', under: 90 }, + { tick: '3-6 mo', words: 'open 3 to 6 months', under: 180 }, + { tick: '6+ mo', words: 'open 6 months or more', under: Infinity }, +]; + +/** How long since anyone worked on a project being worked on; the last bar is stalled. */ +export const IDLE_BUCKETS: BucketDef[] = [ + { tick: 'Last 7 days', words: 'last worked on in the last 7 days', under: 7 }, + { tick: '1-2 wk ago', words: 'last worked on 1 to 2 weeks ago', under: 14 }, + { tick: '2-3 wk ago', words: 'last worked on 2 to 3 weeks ago', under: STALL_DAYS }, + { + tick: 'Stalled, 3+ wk', + words: `stalled, nothing for ${STALL_DAYS} days or more`, + under: Infinity, + }, +]; + +export function bucketOf(days: number, buckets: readonly BucketDef[]): number { + return buckets.findIndex(b => days < b.under); +} + +/** The days a bucket chart counts an item by: its age, or days since its + * last activity. Null leaves it out. */ +export function bucketDays(item: PortfolioItem, chart: 'age' | 'idle'): number | null { + return chart === 'age' ? item.ageDays : item.lastActivity?.days ?? null; +} + +/** + * What a tile or a chart's bar narrowed the list to, from the URL's `only`: + * stalled, behind, ending soon, or one bar, `age-2` or `idle-3`. Null or an + * unknown value keeps everything. + */ +export function matchesOnly(item: PortfolioItem, only: string | null): boolean { + if (only === 'stalled') return item.stalled; + if (only === 'behind') return !!item.behind; + if (only === 'ending') return item.endsSoon; + const bar = /^(age|idle)-(\d)$/.exec(only ?? ''); + if (!bar) return true; + const chart = bar[1] as 'age' | 'idle'; + const days = bucketDays(item, chart); + return ( + beingWorkedOn(item) && + days != null && + bucketOf(days, chart === 'age' ? AGE_BUCKETS : IDLE_BUCKETS) === Number(bar[2]) + ); +} + +/** The `only` narrowing in words, for the line over the list; null for none. */ +export function onlyWords(only: string | null): string | null { + if (only === 'stalled') return `stalled, nothing for ${STALL_DAYS} days or more`; + if (only === 'behind') return 'behind plan'; + if (only === 'ending') return `plans ending in the next ${ENDS_SOON_DAYS} days`; + const bar = /^(age|idle)-(\d)$/.exec(only ?? ''); + if (!bar) return null; + return (bar[1] === 'age' ? AGE_BUCKETS : IDLE_BUCKETS)[Number(bar[2])]?.words ?? null; +} + +/** What a row offers the find box. */ +export interface FindFields { + name: string; + slug: string | null; + lead: string | null; + team: string | null; + parents: readonly string[]; +} + +/** + * The find box read once, for every view that narrows by it: null when it's + * empty, else a test for a row. Words match anywhere in the name, slug, + * lead, team or parents. "lead:" or "team:" before a name matches the + * leads or teams it starts, so "lead:mla" finds mlahargou's before the + * login is typed out; "parent:" matches a parent exactly, which is what a + * click on a parent puts in the box. + */ +export function findFilter(find: string): ((row: FindFields) => boolean) | null { + const q = find.trim().toLowerCase(); + if (!q) return null; + const exact = /^(lead|team|parent):\s*(.+)$/.exec(q); + if (exact) { + const [, field, name] = exact; + if (field === 'parent') return row => row.parents.some(p => p.toLowerCase() === name); + return row => (row[field as 'lead' | 'team'] ?? '').toLowerCase().startsWith(name); + } + return row => + [row.name, row.slug, row.lead, row.team, ...row.parents].some(s => + (s ?? '').toLowerCase().includes(q) + ); +} + +/** Whether a project's row matches the find box (findFilter). */ +export function matchesFind(item: PortfolioItem, find: string): boolean { + const filter = findFilter(find); + return !filter || filter(item); +} + +/** The list's columns by sort key. Last activity is `last`, not the `idle` + * it once was, since `idle` is what the URL holds when nothing was picked + * (lens.ts DEFAULT_SORT), and that now means the list's default order. */ +export type SortKey = + | 'name' + | 'lead' + | 'team' + | 'age' + | 'last' + | 'people' + | 'open' + | 'waiting' + | 'merged' + | 'plan' + | 'issues' + | 'target'; + +const STAGE_RANK: Record = { progress: 0, parked: 1, quiet: 2, closed: 3 }; + +/** A row's place in the default order: a call Decide asks, then an update + * its lead owes (amber too, but nobody decides it), then the rest. */ +const owedRank = (i: PortfolioItem) => (i.asks.length ? 0 : i.planCell.warn ? 1 : 2); + +/** The calls asked about a project as one of Decide's rows, for Decide's + * own order (decide.ts compareRows). */ +const asRow = (i: PortfolioItem): DecideRow => ({ + slug: i.slug, + item: null, + reasons: i.asks.map(a => a.reason), +}); + +/** Each column's natural order: names A to Z, the soonest target first, and + * the biggest number first everywhere else, so Last activity leads with the + * longest quiet. The Plan column, the default, puts what's owed first: the + * calls Decide asks in Decide's own order, so its worst call tops both + * lists, then the updates owed, then the rest, each by the longest quiet. + * `dir` is -1 to reverse; blanks sink either way. */ +const SORTS: Record number> = { + name: (a, b, dir) => dir * a.name.localeCompare(b.name), + lead: (a, b, dir) => nullsLast(a.lead, b.lead, (x, y) => dir * x.localeCompare(y)), + team: (a, b, dir) => nullsLast(a.team, b.team, (x, y) => dir * x.localeCompare(y)), + age: (a, b, dir) => nullsLast(a.ageDays, b.ageDays, (x, y) => dir * (y - x)), + last: (a, b, dir) => + nullsLast( + a.lastActivity?.days ?? null, + b.lastActivity?.days ?? null, + (x, y) => dir * (y - x) + ), + people: (a, b, dir) => dir * (b.workers.length - a.workers.length), + open: (a, b, dir) => dir * (b.open - a.open), + waiting: (a, b, dir) => dir * (b.waiting - a.waiting), + merged: (a, b, dir) => dir * (b.merged - a.merged), + plan: (a, b, dir) => + dir * + (owedRank(a) - owedRank(b) || + (a.asks.length && b.asks.length ? compareRows(asRow(a), asRow(b)) : 0)) || + SORTS.last(a, b, dir), + // the most issues still open first + issues: (a, b, dir) => + nullsLast(a.issues?.open ?? null, b.issues?.open ?? null, (x, y) => dir * (y - x)), + target: (a, b, dir) => nullsLast(a.dueInDays, b.dueInDays, (x, y) => dir * (x - y)), +}; + +function nullsLast(a: T | null, b: T | null, cmp: (x: T, y: T) => number): number { + if (a == null) return b == null ? 0 : 1; + if (b == null) return -1; + return cmp(a, b); +} + +/** Parse a sort param: a column key, `-` in front to reverse it. Anything + * else, the URL's nothing-picked `idle` included, is the default order. */ +export function parseSort(raw: string | null): { key: SortKey; reversed: boolean } { + const reversed = !!raw?.startsWith('-'); + const key = (reversed ? (raw as string).slice(1) : raw) as SortKey; + return key in SORTS ? { key, reversed } : { key: 'plan', reversed: false }; +} + +/** Sort a copy: the chosen column, then work being worked on before the + * rest, then more open PRs, then the name, so ties never shuffle between + * renders. */ +export function sortItems(items: readonly PortfolioItem[], sort: string): PortfolioItem[] { + const { key, reversed } = parseSort(sort); + return [...items].sort( + (a, b) => + SORTS[key](a, b, reversed ? -1 : 1) || + STAGE_RANK[a.stage] - STAGE_RANK[b.stage] || + SORTS.open(a, b, 1) || + SORTS.name(a, b, 1) + ); +} + +export const GROUPINGS: [string, string][] = [ + ['none', 'No grouping'], + ['parent', 'Parent'], + ['lead', 'Lead'], + ['team', 'Team'], +]; + +/** + * The team a project mostly belongs to: the team most of its developers + * are on (ties to the name first alphabetically), or null when only + * non-developers have work in it. + */ +export function mainTeam( + item: Pick, + teamOf: (login: string) => string | null +): string | null { + const counts = new Map(); + for (const login of item.developers) { + const team = teamOf(login) as string; + counts.set(team, (counts.get(team) ?? 0) + 1); + } + return ( + [...counts].sort(([a, x], [b, y]) => y - x || a.localeCompare(b)).map(([team]) => team)[0] ?? + null + ); +} + +/** + * Split sorted items into titled groups, keeping each group's order. A + * project with two parents shows under both, since it serves both, and a + * parent heads its own group, ahead of its parts, rather than sitting under + * "No parent". Groups come in name order, teams in their configured order + * (`teams`, as settings list them) and then any other by name, with the + * no-value group last. `repeats` holds the projects an earlier group + * already showed, so one call isn't drawn in amber twice. + */ +export function groupItems( + items: readonly PortfolioItem[], + by: string, + nameOf: (slug: string) => string = slug => slug, + teams: readonly string[] = [] +): { title: string; items: PortfolioItem[]; repeats: ReadonlySet }[] { + if (by !== 'parent' && by !== 'lead' && by !== 'team') + return [{ title: '', items: [...items], repeats: new Set() }]; + const none = { parent: 'No parent', lead: 'No lead', team: 'No team' }[by]; + const groups = new Map(); + const add = (title: string, item: PortfolioItem) => { + if (!groups.has(title)) groups.set(title, []); + groups.get(title)?.push(item); + }; + const parents = new Set(by === 'parent' ? items.flatMap(i => i.parents) : []); + for (const item of items) if (parents.has(item.slug)) add(nameOf(item.slug), item); + for (const item of items) { + if (by === 'parent') { + for (const p of item.parents) add(nameOf(p), item); + if (!item.parents.length && !parents.has(item.slug)) add(none, item); + } else add((by === 'lead' ? item.lead : item.team) ?? none, item); + } + // a team settings don't list sorts after the ones they do + const place = (title: string) => { + const at = by === 'team' ? teams.indexOf(title) : -1; + return at === -1 ? teams.length : at; + }; + const seen = new Set(); + return [...groups] + .sort(([a], [b]) => + a === none ? 1 : b === none ? -1 : place(a) - place(b) || a.localeCompare(b) + ) + .map(([title, list]) => { + const repeats = new Set(list.filter(i => seen.has(i.slug)).map(i => i.slug)); + for (const i of list) seen.add(i.slug); + return { title, items: list, repeats }; + }); +} + +const STAGE_WORD: Record = { + progress: upperFirst(BEING_WORKED_ON), + parked: 'Parked', + quiet: 'Quiet', + closed: 'Done or dropped', +}; + +/** A project's stage in a word or two, for its page: a closed one says + * done or dropped. */ +export function stageWord(item: Pick): string { + if (item.stage !== 'closed') return STAGE_WORD[item.stage]; + const dropped = + item.project?.state === 'closed' + ? item.project.state_reason === 'not_planned' + : item.plan?.status === 'dropped'; + return dropped ? 'Dropped' : 'Done'; +} + +/** "today", "1 day ago", "12 days ago". */ +export function agoWords(days: number): string { + return days === 0 ? 'today' : `${n(days, 'day')} ago`; +} + +function csvCell(value: string | number | null): string { + const raw = value == null ? '' : String(value); + // a title that starts like a formula (=, +, -, @) runs as one in a + // spreadsheet; a leading quote keeps it text + const text = typeof value === 'string' && /^[=+\-@]/.test(raw) ? `'${raw}` : raw; + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** The list as CSV, the columns a planner pastes into a sheet. */ +export function portfolioCsv(items: readonly PortfolioItem[]): string { + const head = [ + 'Project', + 'Label slug', + 'Stage', + 'Lead', + 'Team', + 'Open since', + 'Days since last activity', + 'Worked on it, last 14 days', + 'Open PRs', + 'Waiting on review', + 'Merged, last 14 days', + 'Plan', + 'Issues open', + 'Issues done', + 'Issues dropped', + 'Target', + 'Target due', + 'Parents', + 'Issue', + ]; + const rows = items.map(i => [ + i.name, + i.slug, + STAGE_WORD[i.stage], + i.lead, + i.team, + i.openSince, + i.lastActivity?.days ?? null, + i.workers.map(w => w.login).join(' '), + i.open, + i.waiting, + i.merged, + // the table leaves a plan nobody needs blank; a sheet says so + i.planCell.text || NO_PLAN, + i.issues ? i.issues.open : null, + i.issues ? i.issues.done : null, + i.issues ? i.issues.dropped : null, + i.target?.title ?? null, + i.target?.due_on?.slice(0, 10) ?? null, + i.parents.join(' '), + i.project ? `https://github.com/${i.project.repo}/issues/${i.project.number}` : null, + ]); + return [head, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n'; +} + +/** The list as plain text for an email or a chat post: each project's plan + * words, its latest update, and when it last moved. */ +export function portfolioText(items: readonly PortfolioItem[], day: string): string { + const lines = items.map(i => { + const u = i.plan && isUnderWay(i.plan.status) ? i.plan.update : null; + const by = u ? ` (${u.author}, ${dayWords(utcDay(u.at))})` : ''; + const moved = i.lastActivity ? `last activity ${agoWords(i.lastActivity.days)}` : 'no PRs'; + const said = u?.body ? ` ${u.body.replace(/\s*\n\s*/g, ' ')}` : ''; + const plan = i.planCell.text || NO_PLAN; + return `- ${i.name}: ${plan}${by}. ${n(i.open, 'open PR')}, ${moved}.${said}`; + }); + return [`Where the projects stand, ${dayWords(day)}`, ...lines].join('\n'); +} diff --git a/frontend-v2/src/model/projectData.test.ts b/frontend-v2/src/model/projectData.test.ts new file mode 100644 index 00000000..5336b037 --- /dev/null +++ b/frontend-v2/src/model/projectData.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { chartWindow, previousRange, rangeDays, resolveRange } from './projectData'; + +// noon on 2026-09-29 where the test runs: presets count the reader's own days +const NOW = new Date(2026, 8, 29, 12).getTime() / 1000; + +describe('resolveRange', () => { + it('counts the last N days through today', () => { + expect(resolveRange('30d', NOW)).toEqual({ start: '2026-08-31', end: '2026-09-29' }); + expect(resolveRange('7d', NOW)).toEqual({ start: '2026-09-23', end: '2026-09-29' }); + }); + + it('covers the calendar presets an analytics tool offers', () => { + expect(resolveRange('month', NOW)).toEqual({ start: '2026-09-01', end: '2026-09-29' }); + expect(resolveRange('last-month', NOW)).toEqual({ start: '2026-08-01', end: '2026-08-31' }); + expect(resolveRange('quarter', NOW)).toEqual({ start: '2026-07-01', end: '2026-09-29' }); + expect(resolveRange('last-quarter', NOW)).toEqual({ + start: '2026-04-01', + end: '2026-06-30', + }); + expect(resolveRange('ytd', NOW)).toEqual({ start: '2026-01-01', end: '2026-09-29' }); + }); + + it('reaches back across a year boundary', () => { + const jan = new Date(2027, 0, 10, 12).getTime() / 1000; + expect(resolveRange('last-month', jan)).toEqual({ start: '2026-12-01', end: '2026-12-31' }); + expect(resolveRange('last-quarter', jan)).toEqual({ + start: '2026-10-01', + end: '2026-12-31', + }); + }); + + it('takes a custom window the server would take, and nothing else', () => { + expect(resolveRange('2026-09-01..2026-09-10', NOW)).toEqual({ + start: '2026-09-01', + end: '2026-09-10', + }); + expect(resolveRange('2026-09-10..2026-09-01', NOW)).toBeNull(); + expect(resolveRange('2020-01-01..2026-09-10', NOW)).toBeNull(); + expect(resolveRange('2026-02-30..2026-03-10', NOW)).toBeNull(); + expect(resolveRange('500d', NOW)).toBeNull(); + expect(resolveRange('soon', NOW)).toBeNull(); + }); +}); + +describe('previousRange', () => { + it('is the same number of days just before', () => { + const range = { start: '2026-08-31', end: '2026-09-29' }; + expect(rangeDays(range)).toBe(30); + expect(previousRange(range)).toEqual({ start: '2026-08-01', end: '2026-08-30' }); + }); +}); + +describe('chartWindow', () => { + it('stretches a short range back to 90 days and leaves a long one alone', () => { + expect(chartWindow({ start: '2026-09-01', end: '2026-09-29' })).toEqual({ + start: '2026-07-02', + end: '2026-09-29', + }); + expect(chartWindow({ start: '2026-03-01', end: '2026-09-29' })).toEqual({ + start: '2026-03-01', + end: '2026-09-29', + }); + }); +}); diff --git a/frontend-v2/src/model/projectData.ts b/frontend-v2/src/model/projectData.ts new file mode 100644 index 00000000..8de77cc1 --- /dev/null +++ b/frontend-v2/src/model/projectData.ts @@ -0,0 +1,314 @@ +import { useEffect, useState } from 'react'; +import { createMemoryStore } from '../storage'; +import { dateOf, dayOf } from './days'; +import { isDummy, loadDummy } from '../backend/dummy'; +import { DUMMY_PROJECTS, DUMMY_TEAMS } from '../backend/dummyProjects'; +import type { DecideRotation } from '../../../shared/model/settings'; +import { epoch } from '../../../shared/format'; +import { issueKey } from '../../../shared/model/issueRef'; +import { + dayStart, + projectOf, + utcDay, + windowStats, + type Project, + type PullLinks, + type PullSpan, + type ReviewSpan, + type WindowStats, +} from '../../../shared/model/projects'; +import { isSuffixBot } from '../../../shared/model/visibility'; +import { dummyPullLinks } from './workData'; +import { refreshRetroData } from './retroData'; + +/** + * The Projects tab's server data: the project issues and one window's + * numbers, from GET /projects-data. Only what the socket doesn't carry: Today + * itself is built from the live socket pulls. + */ + +export interface ProjectsData { + label_prefix: string; + projects_repo: string | null; + projects: Project[]; + /** developer teams: team name to logins. Anyone not listed is a + * non-developer. */ + teams: Record; + /** saved from the board or the API, or config.js's */ + teams_from: 'saved' | 'config'; + /** who takes turns running Decide; null for nobody */ + decide_rotation: DecideRotation | null; + /** projects marked ongoing (no end) from the board; a project's issue can + * also say so with the `ongoing` label (Project.ongoing). Older servers + * omit it. */ + ongoing?: string[]; + /** which projects each of the board's PRs links (PullLinks), so Today + * puts a PR with no project label where the server does. Older servers + * omit it. */ + pull_links?: PullLinks; + window: WindowStats; +} + +/** A login's developer team, or null for a non-developer. GitHub logins + * compare case-insensitively, as on GitHub. */ +export function teamLookup(teams: Record): (login: string) => string | null { + const byLogin = new Map(); + for (const [team, logins] of Object.entries(teams)) + for (const login of logins) byLogin.set(login.toLowerCase(), team); + return login => byLogin.get(login.toLowerCase()) ?? null; +} + +const DAY = 86400; +/** the server's cap on one window (lib/projects.js MAX_WINDOW_DAYS) */ +export const MAX_RANGE_DAYS = 400; + +/** The presets a date picker in any analytics tool offers, in its order. */ +export const RANGE_PRESETS: [string, string][] = [ + ['7d', 'Last 7 days'], + ['14d', 'Last 14 days'], + ['30d', 'Last 30 days'], + ['90d', 'Last 90 days'], + ['month', 'This month'], + ['last-month', 'Last month'], + ['quarter', 'This quarter'], + ['last-quarter', 'Last quarter'], + ['ytd', 'Year to date'], +]; +export { DEFAULT_RANGE } from '../lens'; +export { dateOf, dayOf } from './days'; + +export interface Range { + /** first and last day, both counted */ + start: string; + end: string; +} + +/** A YYYY-MM-DD day in words, "Sep 22", read as UTC so it never shifts a + * day. Milestone due dates go through here as their UTC day, the way the + * board has always shown them (StatePopover, the CSV). A date outside the + * current year carries it: "Oct 17, 2025". */ +export function dayWords(day: string, { now = Date.now() }: { now?: number } = {}): string { + const d = new Date(`${day.slice(0, 10)}T00:00:00Z`); + return d.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: d.getUTCFullYear() === new Date(now).getFullYear() ? undefined : 'numeric', + timeZone: 'UTC', + }); +} + +/** How many days a range covers, both ends counted. */ +export function rangeDays({ start, end }: Range): number { + return Math.round(((dayStart(end) as number) - (dayStart(start) as number)) / DAY) + 1; +} + +/** The same number of days just before a range: what a tile compares with. */ +export function previousRange(range: Range): Range { + const n = rangeDays(range); + const end = (dayStart(range.start) as number) - DAY; + return { start: utcDay(end - (n - 1) * DAY), end: utcDay(end) }; +} + +/** + * A range key's days. Presets count calendar days where the reader is, so + * "Last 30 days" ends on the day their own calendar shows; the server counts + * the same labels as UTC days, a few hours off at each edge. A custom key is + * `YYYY-MM-DD..YYYY-MM-DD`. Null for anything else, including a window the + * server would refuse (more than 400 days). + */ +export function resolveRange(key: string, now: number = Date.now() / 1000): Range | null { + const t = new Date(now * 1000); + const [y, m, d] = [t.getFullYear(), t.getMonth(), t.getDate()]; + const today = dayOf(t); + const q = Math.floor(m / 3) * 3; + const lastDays = /^(\d+)d$/.exec(key); + if (lastDays) { + const n = Number(lastDays[1]); + if (n < 1 || n > MAX_RANGE_DAYS) return null; + return { start: dayOf(new Date(y, m, d - (n - 1))), end: today }; + } + switch (key) { + case 'month': + return { start: dayOf(new Date(y, m, 1)), end: today }; + case 'last-month': + return { start: dayOf(new Date(y, m - 1, 1)), end: dayOf(new Date(y, m, 0)) }; + case 'quarter': + return { start: dayOf(new Date(y, q, 1)), end: today }; + case 'last-quarter': + return { start: dayOf(new Date(y, q - 3, 1)), end: dayOf(new Date(y, q, 0)) }; + case 'ytd': + return { start: dayOf(new Date(y, 0, 1)), end: today }; + } + const custom = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/.exec(key); + if (custom) { + const [, start, end] = custom; + const from = dayStart(start); + const to = dayStart(end); + if (from == null || to == null || from > to || (to - from) / DAY + 1 > MAX_RANGE_DAYS) + return null; + return { start, end }; + } + return null; +} + +/** "Last 30 days" for a preset, "Custom range" for anything picked by hand. */ +export function rangeName(key: string): string { + return RANGE_PRESETS.find(([k]) => k === key)?.[1] ?? 'Custom range'; +} + +/** "Aug 31 to Sep 29", with the year only when the range leaves this one. */ +export function rangeWords({ start, end }: Range, now: number = Date.now() / 1000): string { + const thisYear = String(new Date(now * 1000).getFullYear()); + const word = (day: string) => + dateOf(day).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: day.slice(0, 4) === thisYear ? undefined : 'numeric', + }); + return start === end ? word(start) : `${word(start)} to ${word(end)}`; +} + +/** A trend wants more days than the numbers do, so a chart never shows less. */ +export const CHART_MIN_DAYS = 90; + +/** + * The days a chart draws for a picked range: the range itself, stretched back + * to CHART_MIN_DAYS so a short range still shows its trend. The charts dim the + * days outside the range, so the numbers' days stay the bright part. + */ +export function chartWindow(range: Range): Range { + const earliest = utcDay((dayStart(range.end) as number) - (CHART_MIN_DAYS - 1) * DAY); + return { start: range.start < earliest ? range.start : earliest, end: range.end }; +} + +// the dummy board has no server: run the real windowStats over its own +// pulls, with their sign-offs standing in for the review history, each PR +// in its project by the server's rule +async function dummyData({ start, end }: Range, project: string | null): Promise { + const [{ pulls, bots, projectLabelPrefix }, linked] = await Promise.all([ + loadDummy(), + dummyPullLinks(), + ]); + const prefix = projectLabelPrefix ?? 'project:'; + const projectOfPull = (p: typeof pulls[number]) => + projectOf(p.labels, prefix, linked[issueKey(p)]); + const people = pulls.filter( + p => + !isSuffixBot(p.user.login) && + !(bots ?? []).includes(p.user.login) && + (project == null || projectOfPull(p) === project) + ); + const spans: PullSpan[] = people.map(p => ({ + author: p.user.login, + project: projectOfPull(p), + opened: epoch(p.created_at), + closed: p.closed_at ? epoch(p.closed_at) : null, + merged: !!p.merged_at, + })); + const reviews: ReviewSpan[] = people.flatMap(p => + [...p.status.allCR, ...p.status.allQA].map(sig => ({ + reviewer: sig.data.user.login, + author: p.user.login, + at: epoch(sig.data.created_at), + })) + ); + return { + label_prefix: prefix, + projects_repo: 'iFixit/projects', + projects: DUMMY_PROJECTS, + teams: dummyTeams ?? DUMMY_TEAMS, + teams_from: dummyTeams ? 'saved' : 'config', + decide_rotation: dummyRotation, + ongoing: dummyOngoing, + pull_links: linked, + window: windowStats(spans, start, end, { + teamOf: teamLookup(dummyTeams ?? DUMMY_TEAMS), + reviews, + }), + }; +} + +function load(range: Range, project: string | null): Promise { + if (isDummy()) return dummyData(range, project); + const query = new URLSearchParams({ start: range.start, end: range.end }); + if (project != null) query.set('project', project); + return fetch(`/projects-data?${query}`) + .then(r => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null); +} + +// one fetch per window, reused for 5 minutes: switching tabs and back, or the +// range view and Today asking for the same window, costs nothing +const TTL_MS = 5 * 60_000; +const cache = new Map }>(); + +/** + * One window's projects data, or one project's with `project`: undefined + * while it loads, null if the fetch failed (the caller says so and moves on). + * A failure isn't cached, so the next mount tries again. + */ +// Bumped when the developer teams change, since every window's developer +// counts do too: each loaded window is fetched again. +const version = createMemoryStore({ n: 0 }); + +/** Load every window again, after the developer teams change. */ +export function refreshProjectsData(): void { + cache.clear(); + refreshRetroData(); + version.set({ n: version.get().n + 1 }); +} + +// the dummy board's saved teams and Decide turns, standing in for the +// server's table +let dummyTeams: Record | null = null; +export function setDummyTeams(teams: Record | null): void { + dummyTeams = teams; +} +let dummyRotation: DecideRotation | null = null; +export function setDummyRotation(rotation: DecideRotation | null): void { + dummyRotation = rotation; +} +let dummyOngoing: string[] = ['translations']; +export function markDummyOngoing(slug: string, ongoing: boolean): void { + dummyOngoing = [...dummyOngoing.filter(s => s !== slug), ...(ongoing ? [slug] : [])]; +} + +/** Every project that runs with no end: marked on the board, or by the + * `ongoing` label on its issue. */ +export function ongoingSlugs(data: ProjectsData | null | undefined): ReadonlySet { + return new Set([ + ...(data?.ongoing ?? []), + ...(data?.projects ?? []).filter(p => p.ongoing).map(p => p.slug), + ]); +} + +export function useProjectsData( + range: Range | null, + project: string | null = null +): ProjectsData | null | undefined { + const { n } = version.useValue(); + const base = range ? `${range.start}..${range.end}:${project ?? ''}` : ''; + const key = range ? `${n}:${base}` : ''; + const [got, setGot] = useState<{ key: string; base: string; data: ProjectsData | null }>(); + useEffect(() => { + if (!range) return; + let hit = cache.get(key); + if (!hit || Date.now() - hit.at > TTL_MS) { + hit = { at: Date.now(), data: load(range, project) }; + cache.set(key, hit); + } + let live = true; + hit.data.then(data => { + if (data == null) cache.delete(key); + if (live) setGot({ key, base, data }); + }); + return () => { + live = false; + }; + // keyed on the days, not the range object, which callers rebuild + // every render + }, [key]); + // a refetch of the same days (after a change on the server) keeps the + // numbers it had on screen until the new ones land: no flash of loading + return got && (got.key === key || got.base === base) ? got.data : undefined; +} diff --git a/frontend-v2/src/model/projectWork.ts b/frontend-v2/src/model/projectWork.ts new file mode 100644 index 00000000..e52ab649 --- /dev/null +++ b/frontend-v2/src/model/projectWork.ts @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; +import { isDummy } from '../backend/dummy'; +import { DUMMY_ATTACHED, DUMMY_ISSUES, DUMMY_PROJECTS } from '../backend/dummyProjects'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { + issueKey, + issueQuery, + projectWork, + type IssueHit, + type IssueRef, + type ProjectWork, +} from '../../../shared/model/work'; +import { refreshProjectsData } from './projectData'; +import { dummyRows, dummyWorkInputs, saveDummyRows, workVersion } from './workData'; + +/** + * A project's page and the issue search: GET /project-work and + * /issue-search on a real board, which read GitHub through the server's + * token; the dummy board answers from its fixtures. Adding an issue by hand + * or taking one off loads the work and the page again. + */ + +/** Every issue the dummy board's search can find, once each: an issue in + * two projects is in two lists. */ +function dummyCorpus(): IssueHit[] { + const attached = Object.values(DUMMY_ATTACHED) + .flat() + .map(issue => ({ + ...issue.ref, + title: issue.title, + state: issue.state, + author: issue.author, + createdAt: issue.createdAt, + closedAt: issue.closedAt, + })); + return [...new Map([...attached, ...DUMMY_ISSUES].map(hit => [issueKey(hit), hit])).values()]; +} + +/** What the dummy board's search finds: the way the server's does, from + * its fixtures (lib/work.js searchIssues), each hit with its projects. */ +function dummySearch(text: string): IssueHit[] { + const q = issueQuery(text); + if (!q) return []; + const all = dummyCorpus(); + let hits: IssueHit[]; + if (q.kind === 'ref') hits = all.filter(hit => issueKey(hit) === issueKey(q.ref)); + else if (q.kind === 'number') hits = all.filter(hit => hit.number === q.number); + else { + const words = q.words.toLowerCase().split(/\s+/); + hits = all.filter(hit => words.every(w => hit.title.toLowerCase().includes(w))).slice(0, 10); + } + // the fixtures' labeled issues and the ones added by hand or by a link + // now, never one taken off + const lists = [ + ...Object.entries(DUMMY_ATTACHED).map(([slug, list]) => ({ + slug, + refs: list.filter(i => i.via.includes('label')).map(i => i.ref), + })), + ...[...dummyRows].map(([slug, rows]) => ({ + slug, + refs: rows.filter(row => row.removedAt == null).map(row => row.ref), + })), + ]; + return hits.map(hit => ({ + ...hit, + projects: [ + ...new Set( + lists + .filter(({ refs }) => refs.some(ref => issueKey(ref) === issueKey(hit))) + .map(({ slug }) => slug) + ), + ], + })); +} + +const SIGNED_OUT = 'Your sign-in expired. Reload the page to sign in again.'; + +/** Issues to pick from for what someone typed (shared/model/work.ts + * issueQuery); an error says why GitHub couldn't be searched. */ +export async function searchIssues(text: string): Promise { + if (isDummy()) return dummySearch(text); + const res = await fetch(`/issue-search?q=${encodeURIComponent(text)}`).catch(() => null); + if (!res || res.redirected || res.status === 401) return { error: SIGNED_OUT }; + if (!res.ok) return { error: 'Couldn’t search GitHub. Try again in a minute.' }; + return ((await res.json()) as { issues: IssueHit[] }).issues; +} + +/** + * A project's page (shared/model/work.ts projectWork): undefined while it + * loads, null if that failed. The dummy board builds it from the plans. + */ +export function useProjectWork( + slug: string, + plans: readonly RoadmapItem[] | null +): ProjectWork | null | undefined { + const [got, setGot] = useState<{ slug: string; work: ProjectWork | null }>(); + const { n: version } = workVersion.useValue(); + const dummyPlans = isDummy() ? plans : null; + useEffect(() => { + if (isDummy() && !dummyPlans) return; + let live = true; + const load = isDummy() + ? dummyWorkInputs(dummyPlans ?? []).then(inputs => projectWork(inputs, slug)) + : fetch(`/project-work?project=${encodeURIComponent(slug)}`) + .then(r => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null); + void load.then(work => { + if (live) setGot({ slug, work }); + }); + return () => { + live = false; + }; + }, [slug, version, dummyPlans]); + return got?.slug === slug ? got.work : undefined; +} + +/** Load every project page again: the way back from one that failed. */ +export function reloadProjectWork(): void { + workVersion.set({ n: workVersion.get().n + 1 }); +} + +/** Add an issue to a project by hand (or put back one taken off), or take + * one added by hand or by a link off it, for good: its row stays, so a link + * never brings it back. `forget` takes back an add instead (its Undo), as + * if it was never added, so a suggestion goes back to being one: only for + * an add that said it `inserted` its row, so an Undo never forgets a + * Remove made before the add. */ +export async function changeProjectIssue( + slug: string, + ref: IssueRef, + add: boolean, + { forget = false } = {} +): Promise<{ ok: true; inserted?: boolean } | { error: string }> { + if (isDummy()) { + const rows = dummyRows.get(slug) ?? []; + const row = rows.find(r => issueKey(r.ref) === issueKey(ref)); + const hit = dummyCorpus().find(h => issueKey(h) === issueKey(ref)); + const now = Math.floor(Date.now() / 1000); + if (add && DUMMY_PROJECTS.some(p => issueKey(p) === issueKey(ref))) { + return { + error: 'That’s a project’s own issue: it names the project, so it isn’t one of its issues.', + }; + } + if (add && !row && !hit) return { error: 'GitHub has no issue by that name.' }; + // the server's table: added again, a row comes back as it was + if (row && forget) { + dummyRows.set( + slug, + rows.filter(r => r !== row) + ); + } else if (row) { + row.removedAt = add ? null : row.removedAt ?? now; + } else if (add && hit) { + dummyRows.set(slug, [ + ...rows, + { + ref: { repo: hit.repo, number: hit.number }, + title: hit.title, + state: hit.state, + closedAt: hit.closedAt ?? null, + author: hit.author, + createdAt: hit.createdAt, + via: ['hand'], + attachedAt: now, + addedBy: 'you', + removedAt: null, + }, + ]); + } + saveDummyRows(); + reloadProjectWork(); + // which project each PR is in can change with it, on every view + refreshProjectsData(); + // as the server says it: an add with no row before put one in + return add ? { ok: true, inserted: !row } : { ok: true }; + } + const res = await (add + ? fetch('/project-issues', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: slug, issue: ref }), + }) + : fetch( + `/project-issues?${new URLSearchParams({ + project: slug, + repo: ref.repo, + number: String(ref.number), + ...(forget ? { forget: '1' } : {}), + })}`, + { method: 'DELETE' } + ) + ).catch(() => null); + if (!res || res.redirected || res.status === 401) return { error: SIGNED_OUT }; + if (!res.ok) { + // the server's reasons for a 404 or 409 read well; a 500's doesn't + const json = (await res.json().catch(() => ({}))) as { error?: string }; + return { + error: res.status < 500 && json.error ? json.error : 'Couldn’t save that.', + }; + } + reloadProjectWork(); + // which project each PR is in can change with it, on every view + refreshProjectsData(); + if (!add) return { ok: true }; + const json = (await res.json().catch(() => ({}))) as { inserted?: boolean }; + return { ok: true, inserted: json.inserted }; +} diff --git a/frontend-v2/src/model/projects.test.ts b/frontend-v2/src/model/projects.test.ts new file mode 100644 index 00000000..85ef5f2e --- /dev/null +++ b/frontend-v2/src/model/projects.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, it } from 'vitest'; +import type { PullData } from '../../../shared/types'; +import type { DerivedPull, Status } from '../../../shared/model/status'; +import { + buildToday, + dayStart, + projectOf, + projectSlugs, + windowStats, + targetOf, + type Project, + type PullSpan, +} from '../../../shared/model/projects'; + +const DAY = 86400; +const NOW = 1_790_000_000; +const P = 'project:'; +const labels = (...titles: string[]) => titles.map(title => ({ title })); +const iso = (secs: number) => new Date(secs * 1000).toISOString(); + +let n = 0; +function open(over: { + labels?: string[]; + author?: string; + status?: Status; + ageDays?: number; + idleDays?: number; + /** days since real work, when the server says (status.activity_at) */ + workedDays?: number; + /** a reviewer who asked for changes the author hasn't answered */ + changesBy?: string; +}): DerivedPull { + return { + status: over.status ?? 'needs_cr', + data: { + repo: 'iFixit/ifixit', + number: ++n, + user: { login: over.author ?? 'alice' }, + labels: labels(...(over.labels ?? [])), + created_at: iso(NOW - (over.ageDays ?? 1) * DAY), + updated_at: iso(NOW - (over.idleDays ?? 0) * DAY), + status: { + ...(over.workedDays == null ? {} : { activity_at: iso(NOW - over.workedDays * DAY) }), + unstamped_reviewers: [], + }, + }, + changesRequestedBy: over.changesBy ? [over.changesBy] : [], + } as unknown as DerivedPull; +} + +function merged(over: { labels?: string[]; author?: string; daysAgo: number }): PullData { + return { + repo: 'iFixit/ifixit', + number: ++n, + user: { login: over.author ?? 'alice' }, + labels: labels(...(over.labels ?? [])), + merged_at: iso(NOW - over.daysAgo * DAY), + closed_at: iso(NOW - over.daysAgo * DAY), + } as unknown as PullData; +} + +function project(over: Partial & { slug: string }): Project { + return { + name: over.slug.toUpperCase(), + repo: 'iFixit/projects', + number: ++n, + state: 'open', + state_reason: null, + ongoing: false, + parents: [], + lead: null, + target: null, + fields: { start: null, target: null, priority: null }, + created_at: null, + closed_at: null, + ...over, + }; +} + +describe('targetOf', () => { + it('takes the issue’s Target date over its milestone', () => { + const milestone = { title: 'October', due_on: '2026-10-31T00:00:00Z' }; + const fields = { start: null, target: null, priority: null }; + expect(targetOf({ target: milestone, fields })).toBe(milestone); + expect(targetOf({ target: milestone, fields: { ...fields, target: '2026-11-27' } })).toEqual({ + title: null, + due_on: '2026-11-27', + }); + expect(targetOf({ target: null, fields })).toBeNull(); + expect(targetOf(null)).toBeNull(); + }); +}); + +describe('projectSlugs / projectOf', () => { + it('reads project labels in sorted order and skips the bare prefix', () => { + expect( + projectSlugs(labels('size: S', 'project:zeta', 'project:', 'project:alpha'), P) + ).toEqual(['alpha', 'zeta']); + }); + + it('prefers a real project over misc, and is null with no project label', () => { + expect(projectOf(labels('project:misc', 'project:zeta'), P)).toBe('zeta'); + expect(projectOf(labels('project:misc'), P)).toBe('misc'); + expect(projectOf(labels('security'), P)).toBeNull(); + }); + + it('with no real project label, takes the first project whose issue it links', () => { + // a label settles it + expect(projectOf(labels('project:zeta'), P, ['alpha'])).toBe('zeta'); + expect(projectOf(labels('security'), P, ['alpha', 'beta'])).toBe('alpha'); + // misc is for work with no project, so a link outranks it + expect(projectOf(labels('project:misc'), P, ['beta'])).toBe('beta'); + expect(projectOf(labels(), P, [])).toBeNull(); + }); +}); + +describe('buildToday', () => { + it('files open PRs into projects, misc, and not-sorted, and lists double labels', () => { + const a = open({ labels: ['project:workbench'] }); + const b = open({ labels: ['project:misc'] }); + const c = open({ labels: [] }); + const d = open({ labels: ['project:workbench', 'project:search'] }); + const today = buildToday([project({ slug: 'workbench' })], [a, b, c, d], [], P, NOW); + expect(today.misc).toEqual([b]); + expect(today.unsorted).toEqual([c]); + expect(today.doubleLabeled).toEqual([d]); + // d files under its first label alphabetically ("search"), not workbench + expect(today.live.map(g => [g.slug, g.open.length])).toEqual([ + ['search', 1], + ['workbench', 1], + ]); + }); + + it('files a PR with no project label under the project whose issue it links', () => { + const labeled = open({ labels: ['project:workbench'] }); + const linked = open({ labels: [] }); + const oneOff = open({ labels: ['project:misc'] }); + const two = open({ labels: [] }); + const done = merged({ labels: [], daysAgo: 2 }); + const key = (p: { repo: string; number: number }) => `${p.repo}#${p.number}`.toLowerCase(); + const today = buildToday( + [project({ slug: 'workbench' }), project({ slug: 'search' })], + [labeled, linked, oneOff, two], + [done], + P, + NOW, + { + [key(labeled.data)]: ['search'], + [key(linked.data)]: ['workbench'], + [key(oneOff.data)]: ['workbench'], + [key(two.data)]: ['search', 'workbench'], + [key(done)]: ['workbench'], + } + ); + const groups = Object.fromEntries(today.live.map(g => [g.slug, g])); + // its label wins over a link; with none, or only misc, the link counts + expect(groups.workbench.open).toEqual([labeled, linked, oneOff]); + expect(groups.workbench.merged).toEqual([done]); + // linking two projects' issues, it counts under the first and asks to be sorted + expect(groups.search.open).toEqual([two]); + expect(today.doubleLabeled).toEqual([two]); + expect([today.misc, today.unsorted]).toEqual([[], []]); + }); + + it('counts idle days from real work, not from a label edit that moved updated_at', () => { + // labeled this morning (updated_at today), last worked on 30 days ago + const pr = open({ labels: ['project:stale'], idleDays: 0, workedDays: 30 }); + const [g] = buildToday([project({ slug: 'stale' })], [pr], [], P, NOW).live; + expect(g.idleDays).toBe(30); + expect(g.lastActivity).toBe(NOW - 30 * DAY); + }); + + it('keeps a project live on a merge in the last 14 days, but not an older one', () => { + const today = buildToday( + [project({ slug: 'recent' }), project({ slug: 'old' })], + [], + [ + merged({ labels: ['project:recent'], daysAgo: 13 }), + merged({ labels: ['project:old'], daysAgo: 15 }), + ], + P, + NOW + ); + expect(today.live.map(g => g.slug)).toEqual(['recent']); + expect(today.quiet.map(g => g.slug)).toEqual(['old']); + }); + + it('keeps the last 14 days’ PRs closed without merging apart, and never live on them', () => { + const closed = (labels: string[], daysAgo: number) => + ({ ...merged({ labels, daysAgo }), merged_at: null } as PullData); + const today = buildToday( + [project({ slug: 'tidy' })], + [], + [closed(['project:tidy'], 3), closed(['project:tidy'], 20)], + P, + NOW + ); + expect(today.live).toEqual([]); + expect(today.quiet[0].closed.map(p => p.closed_at)).toEqual([iso(NOW - 3 * DAY)]); + expect(today.quiet[0].merged).toEqual([]); + }); + + it('drops a closed project whose only activity is merges, keeps one with an open PR', () => { + const today = buildToday( + [ + project({ slug: 'done', state: 'closed', state_reason: 'completed' }), + project({ slug: 'reopenme', state: 'closed', state_reason: 'completed' }), + ], + [open({ labels: ['project:reopenme'] })], + [merged({ labels: ['project:done'], daysAgo: 2 })], + P, + NOW + ); + expect(today.live.map(g => g.slug)).toEqual(['reopenme']); + // no flag of its own: Decide asks about a closed issue with PRs still + // open, after a week's grace, so the call is said once + expect(today.live[0].flags).toEqual([]); + expect(today.quiet).toEqual([]); + }); + + it('shows a label with no issue under its slug', () => { + const today = buildToday([], [open({ labels: ['project:new-thing'] })], [], P, NOW); + expect(today.live[0].project).toBeNull(); + expect(today.live[0].slug).toBe('new-thing'); + }); + + it('flags one person only past the minimum PR count', () => { + const three = buildToday( + [], + [open({ labels: ['project:solo'], author: 'bob' })], + [ + merged({ labels: ['project:solo'], author: 'bob', daysAgo: 1 }), + merged({ labels: ['project:solo'], author: 'bob', daysAgo: 2 }), + ], + P, + NOW + ); + expect(three.live[0].flags).toEqual(['one_person']); + const two = buildToday( + [], + [open({ labels: ['project:solo'], author: 'bob' })], + [merged({ labels: ['project:solo'], author: 'bob', daysAgo: 1 })], + P, + NOW + ); + expect(two.live[0].flags).toEqual([]); + }); + + it('flags a project when every open PR is waiting on review, from two PRs up', () => { + const today = buildToday( + [], + [ + open({ labels: ['project:stuck'], status: 'needs_cr', author: 'a' }), + open({ labels: ['project:stuck'], status: 'needs_qa', author: 'b' }), + open({ labels: ['project:moving'], status: 'needs_cr', author: 'a' }), + open({ labels: ['project:moving'], status: 'ready', author: 'b' }), + ], + [], + P, + NOW + ); + const flags = Object.fromEntries(today.live.map(g => [g.slug, g.flags])); + expect(flags.stuck).toEqual(['waiting_on_review']); + expect(flags.moving).toEqual([]); + }); + + it('leaves the flag off while an author owes an answer to asked-for changes', () => { + const today = buildToday( + [], + [ + open({ labels: ['project:asked'], status: 'needs_cr', author: 'a', changesBy: 'c' }), + open({ labels: ['project:asked'], status: 'needs_qa', author: 'b' }), + ], + [], + P, + NOW + ); + expect(today.live[0].flags).toEqual([]); + }); + + it('reports idle days from the stalest open PR and sorts the busiest first', () => { + const today = buildToday( + [], + [ + open({ labels: ['project:small'], idleDays: 2 }), + open({ labels: ['project:big'], idleDays: 3 }), + open({ labels: ['project:big'], idleDays: 40 }), + ], + [], + P, + NOW + ); + expect(today.live.map(g => [g.slug, g.idleDays])).toEqual([ + ['big', 40], + ['small', 2], + ]); + }); +}); + +describe('dayStart', () => { + it('parses real UTC days and rejects anything else', () => { + expect(dayStart('2026-09-29')).toBe(Date.UTC(2026, 8, 29) / 1000); + expect(dayStart('2026-02-30')).toBeNull(); + expect(dayStart('9/29/2026')).toBeNull(); + }); +}); + +describe('windowStats', () => { + const start = '2026-09-01'; + const end = '2026-09-10'; + const from = dayStart(start) as number; + const to = (dayStart(end) as number) + DAY; + const span = (over: Partial): PullSpan => ({ + author: 'alice', + project: null, + opened: from - 5 * DAY, + closed: null, + merged: false, + ...over, + }); + const spans = [ + // open before, still open: backlog at both ends + span({ project: 'workbench' }), + // open before, merged inside + span({ project: 'workbench', closed: from + 2 * DAY, merged: true }), + // opened inside, closed without merging inside + span({ author: 'bob', opened: from + DAY, closed: from + 3 * DAY }), + // opened inside, still open + span({ author: 'bob', project: 'misc', opened: from + 9 * DAY + 60 }), + // entirely before the window: ignored + span({ opened: from - 20 * DAY, closed: from - 10 * DAY, merged: true }), + // opened after the window: ignored + span({ opened: to + DAY }), + ]; + const w = windowStats(spans, start, end); + + it('counts backlog and throughput with the identity holding in every bucket', () => { + expect(w.totals).toMatchObject({ + backlog_start: 2, + backlog_end: 2, + opened: 2, + merged: 1, + closed: 1, + }); + const buckets = [ + w.totals, + w.unsorted, + ...Object.values(w.projects), + ...Object.values(w.people), + ]; + for (const c of buckets) + expect(c.backlog_start + c.opened - c.merged - c.closed).toBe(c.backlog_end); + }); + + it('splits by project and person, leaving misc out of a person’s projects', () => { + expect(w.projects.workbench).toMatchObject({ backlog_start: 2, backlog_end: 1, merged: 1 }); + expect(w.projects.misc).toMatchObject({ opened: 1, backlog_end: 1 }); + expect(w.unsorted).toMatchObject({ opened: 1, closed: 1, backlog_end: 0 }); + expect(w.people.alice.projects).toEqual(['workbench']); + expect(w.people.bob.projects).toEqual([]); + }); + + it('gives the median age at each end in days', () => { + // at the start, both open PRs are 5 days old + expect(w.totals.median_age_start_days).toBe(5); + // at the end: one is 15 days old, the other a minute short of 1 day + expect(w.totals.median_age_end_days).toBe(8); + }); + + it('draws one cumulative point per day that ends on the closing backlog', () => { + expect(w.days).toHaveLength(10); + expect(w.days[0]).toEqual({ date: '2026-09-01', arrived: 2, departed: 0, backlog: 2 }); + expect(w.days[1]).toMatchObject({ arrived: 3, departed: 0 }); + expect(w.days[3]).toMatchObject({ arrived: 3, departed: 2, backlog: 1 }); + expect(w.days[9]).toMatchObject({ arrived: 4, departed: 2, backlog: w.totals.backlog_end }); + }); +}); + +describe('windowStats: teams, reviews, weeks', () => { + // Tue 2026-09-01 to Sun 2026-09-13: the weeks start Mon Aug 31, Sep 7 + const start = '2026-09-01'; + const end = '2026-09-13'; + const from = dayStart(start) as number; + const teams: Record = { dana: 'Store' }; + const teamOf = (login: string) => teams[login] ?? null; + const w = windowStats( + [ + // dana (a developer) opens and merges inside the first week: 2 days + { author: 'dana', project: 'alpha', opened: from, closed: from + 2 * DAY, merged: true }, + // kyle (not a developer) opens in the second week, merged 4 days later + { + author: 'kyle', + project: 'alpha', + opened: from + 7 * DAY, + closed: from + 11 * DAY, + merged: true, + }, + // kyle again, with no project label, still open + { author: 'kyle', project: null, opened: from + 8 * DAY, closed: null, merged: false }, + ], + start, + end, + { + teamOf, + reviews: [ + { reviewer: 'dana', author: 'kyle', at: from + 9 * DAY }, + { reviewer: 'dana', author: 'dana', at: from + DAY }, // her own PR: not a review + { reviewer: 'erin', author: 'dana', at: from + 3 * DAY }, + { reviewer: 'dana', author: 'kyle', at: from - DAY }, // before the window + ], + } + ); + + it('buckets opened and merged by developer or not, week by week from Monday', () => { + expect(w.weeks.map(x => x.week)).toEqual(['2026-08-31', '2026-09-07']); + expect(w.weeks[0].opened).toEqual({ developers: 1, non_developers: 0 }); + expect(w.weeks[1].opened).toEqual({ developers: 0, non_developers: 2 }); + expect(w.weeks[0].merged_by_project).toEqual({ alpha: 1 }); + expect(w.weeks[1].merged_by_project).toEqual({ alpha: 1 }); + }); + + it('gives the median days to merge and the people split per bucket', () => { + expect(w.totals.median_days_to_merge).toBe(3); + expect(w.projects.alpha).toMatchObject({ + developers: 1, + non_developers: 1, + first_opened: '2026-09-01', + last_closed: '2026-09-12', + }); + }); + + it('counts reviews given in the window on other people’s PRs, by whose PR', () => { + expect(w.people.dana).toMatchObject({ team: 'Store', reviews: 1, reviews_on_non_dev: 1 }); + // erin reviewed but opened nothing: she still gets a row + expect(w.people.erin).toMatchObject({ + team: null, + reviews: 1, + reviews_on_non_dev: 0, + opened: 0, + }); + expect(w.weeks[0].reviews).toEqual({ on_developers: 1, on_non_developers: 0 }); + expect(w.weeks[1].reviews).toEqual({ on_developers: 0, on_non_developers: 1 }); + }); +}); diff --git a/frontend-v2/src/model/retro.test.ts b/frontend-v2/src/model/retro.test.ts new file mode 100644 index 00000000..5c632750 --- /dev/null +++ b/frontend-v2/src/model/retro.test.ts @@ -0,0 +1,421 @@ +import { dayWords } from './projectData'; +import { describe, expect, it } from 'vitest'; +import { timeSpent, type Touch } from '../../../shared/model/retro'; +import type { Project } from '../../../shared/model/projects'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { + beforeWords, + chartWeeks, + finishedIn, + finishedOn, + groupRows, + lastWeek, + loadByPerson, + median, + mergeSpeed, + overloaded, + overloadLine, + peopleByProject, + projectLength, + quietWeeks, + retroCsv, + retroPlan, + retroRows, + spreadByPerson, + tooManyWords, + weekBars, + weeklyBy, + weekTitle, + weekWords, +} from './retro'; +import type { RetroData } from './retroData'; + +const DAY = 86400; +// Monday 2026-09-28, mid-afternoon UTC +const MON = Date.UTC(2026, 8, 28, 15) / 1000; +const touch = (login: string, number: number, day: number, owner: string): Touch => ({ + login, + at: MON + day * DAY, + repo: 'iFixit/ifixit', + number, + owner, +}); + +describe('timeSpent', () => { + it('splits each active day evenly across the PRs touched that day, week by week', () => { + const rows = timeSpent([ + // Monday: dana opens her PR 1 and stamps erin's PR 2, twice + touch('dana', 1, 0, 'dana'), + touch('dana', 2, 0, 'erin'), + touch('dana', 2, 0, 'erin'), + // Tuesday: only PR 1; the next Monday, PR 1 again + touch('dana', 1, 1, 'dana'), + touch('dana', 1, 7, 'dana'), + ]); + const of = (number: number, week: string) => + rows.find(r => r.number === number && r.week === week); + expect(of(1, '2026-09-28')).toMatchObject({ own: true, days: 1.5 }); + expect(of(2, '2026-09-28')).toMatchObject({ own: false, days: 0.5 }); + expect(of(1, '2026-10-05')).toMatchObject({ days: 1 }); + // a person's rows add up to their active days + expect(rows.reduce((sum, r) => sum + r.days, 0)).toBe(3); + }); + + it('counts only the people it’s told to', () => { + const rows = timeSpent( + [touch('dana', 1, 0, 'dana'), touch('renovate[bot]', 1, 0, 'dana')], + login => !login.endsWith('[bot]') + ); + expect(rows.map(r => r.login)).toEqual(['dana']); + }); +}); + +describe('Look back’s groups', () => { + const pr = (number: number, project: string | null, owner: string, merged: number | null) => ({ + repo: 'iFixit/ifixit', + number, + title: `PR ${number}`, + owner, + bot: false, + project, + state: merged ? ('closed' as const) : ('open' as const), + merged, + }); + const data: RetroData = { + start: '2026-09-28', + end: '2026-10-11', + counted: 'developers', + weeks: ['2026-09-28', '2026-10-05'], + people: ['dana', 'erin'], + prs: [ + pr(1, 'alpha', 'dana', MON + DAY), + pr(2, 'beta', 'erin', null), + pr(3, null, 'erin', null), + ], + rows: [ + [0, 0, 0, 1.5], + [0, 1, 0, 0.5], + [1, 1, 0, 1], + [1, 1, 1, 2], + [1, 2, 1, 1], + ], + }; + const rows = retroRows(data); + + it('reads the compact rows back with who wrote each PR', () => { + expect(rows.map(r => [r.login, r.pr.number, r.own])).toEqual([ + ['dana', 1, true], + ['dana', 2, false], + ['erin', 2, true], + ['erin', 2, true], + ['erin', 3, true], + ]); + }); + + it('adds up days, people and PRs, the most days first', () => { + const groups = groupRows(rows, r => r.pr.project ?? ''); + expect(groups.map(g => [g.key, g.days])).toEqual([ + ['beta', 3.5], + ['alpha', 1.5], + ['', 1], + ]); + expect(groups[0].people).toEqual([ + ['erin', 3], + ['dana', 0.5], + ]); + }); + + it('draws every week of the chart, the empty ones too, by each week’s Monday', () => { + // the chart starts a week before the data's first week, with nothing in it + const weeks = chartWeeks( + { start: '2026-09-21', end: '2026-10-11' }, + { start: '2026-09-28', end: '2026-10-11' } + ); + const weekly = weeklyBy(rows, r => r.pr.project ?? '', data, weeks); + expect(weekly.get('beta')).toEqual([0, 1.5, 2]); + expect(weekly.get('')).toEqual([0, 0, 1]); + }); + + it('says how many different projects each person touched in a week', () => { + // dana: alpha and beta in week one; erin: beta, then beta and an + // unfiled PR, which isn't a project + expect([...spreadByPerson(rows)]).toEqual([ + ['dana', 2], + ['erin', 1], + ]); + expect(median([3, 1, 2])).toBe(2); + expect(median([])).toBe(0); + }); + + it('says who worked on each filed project, and how', () => { + const by = peopleByProject(rows); + expect([...by.keys()].sort()).toEqual(['alpha', 'beta']); + expect(by.get('beta')).toEqual([ + { login: 'erin', days: 3, writing: 3 }, + { login: 'dana', days: 0.5, writing: 0 }, + ]); + }); + + it('counts each person’s projects, writing and reviewing, zeros included', () => { + const [dana, erin, finn] = loadByPerson(rows, ['dana', 'erin', 'finn']); + expect(dana).toMatchObject({ + days: 2, + reviewing: 0.5, + unfiled: 0, + wrote: 1, + reviewedOnly: 1, + reviewed: 1, + }); + expect(dana.projects.map(p => p.slug)).toEqual(['alpha', 'beta']); + // erin's unlabeled PR counts toward her days but no project + expect(erin).toMatchObject({ days: 4, reviewing: 0, unfiled: 1, wrote: 1, reviewedOnly: 0 }); + expect(finn).toMatchObject({ days: 0, projects: [], wrote: 0 }); + }); + + it('draws the overload line at twice the median, and never under 4', () => { + // the counts from Sep 30: the median is 2.5, so the line is 5 + expect(overloadLine([7, 6, 6, 6, 4, 4, 3, 3, 3, 2, 2, 1, 1, 1, 0, 0, 0, 0])).toBe(5); + expect(overloadLine([1, 1, 0])).toBe(4); + expect(overloadLine([])).toBe(4); + }); + + it('flags who wrote for the line or more, and no one when over a quarter would be', () => { + const load = (login: string, wrote: number) => ({ login, wrote }); + const team = [load('amy', 6), load('bob', 1), load('cy', 0), load('di', 2), load('ed', 1)]; + expect(overloaded(team, 4).map(l => l.login)).toEqual(['amy']); + // two of six is more than a quarter: the flag says nothing + expect(overloaded([...team, load('fay', 4)], 4)).toEqual([]); + expect(overloaded([], 4)).toEqual([]); + }); + + it('says how many cross when too many do to single out, never a bare zero', () => { + const load = (wrote: number) => ({ wrote }); + const team = [load(6), load(1), load(0), load(2), load(1)]; + expect(tooManyWords(team, 4)).toBeNull(); + expect(tooManyWords([...team, load(4)], 4)).toBe('2 of 6 wrote for 4 or more'); + expect(tooManyWords([], 4)).toBeNull(); + }); +}); + +describe('Look back’s project columns', () => { + it('finds the longest pause between the first and last busy week', () => { + expect(quietWeeks([0, 2, 0, 0, 1, 0, 3, 0])).toBe(2); + expect(quietWeeks([1, 1])).toBe(0); + expect(quietWeeks([0, 0])).toBe(0); + expect(lastWeek([0, 2, 0])).toBe(1); + expect(lastWeek([0, 0])).toBe(-1); + }); + + it('says how long a project ran: open at the range’s end, or done', () => { + const w = { first_opened: '2026-08-01', last_closed: '2026-09-15', backlog_end: 0 }; + expect(projectLength(w, '2026-09-30')).toEqual({ open: false, days: 45 }); + // the days gone by, as the Overview counts its age: not 61, the days touched + expect(projectLength({ ...w, backlog_end: 2 }, '2026-09-30')).toEqual({ + open: true, + days: 60, + }); + // still open: from its oldest open PR, as Decide says + expect(projectLength({ ...w, backlog_end: 2 }, '2026-09-30', '2026-09-10')).toEqual({ + open: true, + days: 20, + }); + expect(projectLength(null, '2026-09-30')).toBeNull(); + }); + + const plan = (over: Partial): RoadmapItem => ({ + id: 1, + name: 'Workbench', + project: 'workbench', + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-08-03', + weeks: 4, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: 1, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, + ...over, + }); + // the plan ends Sunday, Aug 30 + const at = (day: string) => Date.parse(`${day}T12:00:00Z`) / 1000; + + it('judges each plan’s outcome as of today, in the tab’s words', () => { + const today = '2026-09-30'; + expect(retroPlan(plan({ status: 'done', updated_at: at('2026-08-28') }), today).text).toBe( + 'Done on time' + ); + expect(retroPlan(plan({ status: 'done', updated_at: at('2026-09-10') }), today).text).toBe( + 'Done 2 weeks late' + ); + expect(retroPlan(plan({}), today).text).toBe('5 weeks overdue'); + expect(retroPlan(plan({ start: '2026-09-28' }), today).text).toBe('Ends Oct 25'); + expect(retroPlan(plan({ status: 'parked' }), today).kind).toBe('parked'); + expect(retroPlan(null, today).text).toBe('No plan'); + // only a hard end run past is owed a call; ongoing work has no end + expect(retroPlan(plan({ end_kind: 'soft' }), today)).toEqual({ + kind: 'open', + text: '5 weeks past its estimate', + }); + expect(retroPlan(plan({ end_kind: 'ongoing' }), today).text).toBe('Ongoing'); + }); + + it('dates a finish by when it was marked done, not by a later edit', () => { + const today = '2026-09-30'; + // done Aug 28, its notes edited Sep 10 + const edited = plan({ + status: 'done', + status_at: at('2026-08-28'), + updated_at: at('2026-09-10'), + }); + expect(retroPlan(edited, today).text).toBe('Done on time'); + expect(finishedIn([edited], [], { start: '2026-09-01', end: '2026-09-30' })).toEqual([]); + }); + + it('counts what finished in a range, once each, on time or late', () => { + const project = (slug: string, closed: string | null, reason = 'completed'): Project => ({ + slug, + name: slug, + repo: 'iFixit/projects', + number: 1, + state: closed ? 'closed' : 'open', + state_reason: closed ? reason : null, + ongoing: false, + parents: [], + lead: null, + target: null, + fields: { start: null, target: null, priority: null }, + created_at: null, + closed_at: closed ? `${closed}T10:00:00Z` : null, + }); + const done = finishedIn( + [plan({ status: 'done', updated_at: at('2026-09-10') })], + [ + // its plan already counts it + project('workbench', '2026-09-11'), + project('search', '2026-09-20'), + project('dropped-one', '2026-09-20', 'not_planned'), + project('older', '2026-07-01'), + ], + { start: '2026-09-01', end: '2026-09-30' } + ); + expect(done).toEqual([ + { key: 'workbench', name: 'Workbench', onTime: false }, + { key: 'search', name: 'search', onTime: null }, + ]); + }); + + it('counts a team’s finished plans as the ones it worked on', () => { + const done = [ + { key: 'alpha', name: 'Alpha', onTime: true }, + { key: 'gamma', name: 'Gamma', onTime: false }, + ]; + const pr = { + repo: 'r', + number: 1, + title: '', + owner: 'dana', + bot: false, + state: 'open' as const, + merged: null, + }; + const rows = [ + { login: 'dana', pr: { ...pr, project: 'alpha' }, week: 0, days: 1, own: true }, + ]; + expect(finishedOn(done, rows).map(f => f.key)).toEqual(['alpha']); + }); +}); + +describe('Look back’s words', () => { + it('compares a one-day range with the day before, not "the 1 days before"', () => { + expect(beforeWords(1)).toBe('day before'); + expect(beforeWords(30)).toBe('30 days before'); + }); + + it('says "half" of the merged PRs only when there are several', () => { + expect(mergeSpeed(1, 2)).toBe('2 days after it opened'); + // in the words a person says a time, as the project page does + expect(mergeSpeed(16, 0.3)).toBe('half of them within about 7 hours of opening'); + expect(mergeSpeed(0, null)).toBeNull(); + }); +}); + +describe('the weeks a chart draws', () => { + it('fills every Monday from the first day, pales the ones before the range, and counts cut-off days', () => { + // 90 days to Thursday Oct 1: the window starts Friday Jul 4 + const weeks = chartWeeks( + { start: '2026-07-04', end: '2026-10-01' }, + { start: '2026-09-25', end: '2026-10-01' } + ); + expect(weeks).toHaveLength(14); + expect(weeks[0]).toEqual({ week: '2026-06-29', days: 2, counted: 0, before: true }); + // Sep 21 to 27 holds the range's first three days: not before it, and + // only those three count + expect(weeks[12]).toEqual({ week: '2026-09-21', days: 7, counted: 3, before: false }); + expect(weeks[11].before).toBe(true); + expect(weeks[13]).toEqual({ week: '2026-09-28', days: 4, counted: 4, before: false }); + expect(weeks.map(weekTitle).slice(11)).toEqual([ + 'Week of Sep 14, before the range', + 'Week of Sep 21, 3 of 7 days in the range', + 'Week of Sep 28, 4 of 7 days', + ]); + }); + + it('draws the days before the range paler, the week the range starts in split in two', () => { + const weeks = chartWeeks( + { start: '2026-07-04', end: '2026-10-01' }, + { start: '2026-09-25', end: '2026-10-01' } + ).slice(11); + // the chart's days count 9, 28 and 6 a week; the range's own, 14 and 6 + const bars = weekBars([9, 28, 6], [0, 14, 6], weeks); + expect(bars).toEqual({ counted: [0, 14, 6], before: [9, 14, 0] }); + // without the range's own numbers, a week the range cuts counts whole + expect(weekBars([9, 28, 6], undefined, weeks)).toEqual({ + counted: [0, 28, 6], + before: [9, 0, 0], + }); + }); + + it('gives a week its year when it isn’t this one', () => { + const oct1 = Date.UTC(2026, 9, 1, 12) / 1000; + expect(weekWords('2025-09-29', oct1)).toBe('Sep 29, 2025'); + expect(weekWords('2026-09-28', oct1)).toBe('Sep 28'); + }); + + it('prints the year on any day outside this year, and only then', () => { + const oct1 = Date.UTC(2026, 9, 1, 12); + expect(dayWords('2025-10-17', { now: oct1 })).toBe('Oct 17, 2025'); + expect(dayWords('2026-10-17', { now: oct1 })).toBe('Oct 17'); + }); +}); + +describe('the CSV', () => { + it('writes a line per person, PR and week, defusing a title that reads as a formula', () => { + const pr = { + repo: 'iFixit/ifixit', + number: 7, + title: '=HYPERLINK("x"), then more', + owner: 'dana', + bot: false, + project: 'alpha', + state: 'open' as const, + merged: null, + }; + const csv = retroCsv( + [{ login: 'erin', pr, week: 0, days: 0.5, own: false }], + { weeks: ['2026-09-28'] }, + login => (login === 'erin' ? 'Store' : null), + slug => (slug === 'alpha' ? 'Alpha' : slug) + ); + expect(csv.split('\n')[1]).toBe( + '2026-09-28,erin,Store,reviewing,0.5,iFixit/ifixit#7,"\'=HYPERLINK(""x""), then more",Alpha' + ); + }); +}); diff --git a/frontend-v2/src/model/retro.ts b/frontend-v2/src/model/retro.ts new file mode 100644 index 00000000..dc2d1018 --- /dev/null +++ b/frontend-v2/src/model/retro.ts @@ -0,0 +1,571 @@ +import { n } from '../../../shared/format'; +import { + dayStart, + MISC_SLUG, + utcDay, + type Project, + type ProjectWindow, +} from '../../../shared/model/projects'; +import { + addWeeks, + endOf, + mondayOf, + planEnd, + planFor, + type RoadmapItem, +} from '../../../shared/model/roadmap'; +import { dayWords, type Range } from './projectData'; +import type { RetroData, RetroPr } from './retroData'; +import { durationWords } from './stage'; +import { NO_PLAN, pastEnd } from './words'; + +const DAY = 86400; + +/** + * Look back's numbers, from GET /retro-data's compact rows. Pure, so the + * view, its copy-as-text and the tests read the same sums. + */ + +/** One person's share of days on one PR in one week. */ +export interface RetroRow { + login: string; + pr: RetroPr; + /** index into RetroData.weeks */ + week: number; + days: number; + /** they wrote the PR: writing, not reviewing */ + own: boolean; +} + +export function retroRows(data: RetroData): RetroRow[] { + return data.rows.map(([person, pr, week, days]) => { + const login = data.people[person]; + const p = data.prs[pr]; + return { login, pr: p, week, days, own: p.owner.toLowerCase() === login.toLowerCase() }; + }); +} + +/** Rows grouped one way: the days, and who spent them on which PRs, most + * first. */ +export interface RetroGroup { + key: string; + days: number; + people: [string, number][]; + prs: [RetroPr, number][]; +} + +export function groupRows( + rows: readonly RetroRow[], + keyOf: (row: RetroRow) => string +): RetroGroup[] { + const groups = new Map< + string, + { days: number; people: Map; prs: Map } + >(); + for (const row of rows) { + const key = keyOf(row); + let g = groups.get(key); + if (!g) { + g = { days: 0, people: new Map(), prs: new Map() }; + groups.set(key, g); + } + g.days += row.days; + g.people.set(row.login, (g.people.get(row.login) ?? 0) + row.days); + g.prs.set(row.pr, (g.prs.get(row.pr) ?? 0) + row.days); + } + const most = (m: Map) => [...m].sort((a, b) => b[1] - a[1]); + return [...groups] + .map(([key, g]) => ({ + key, + days: g.days, + people: most(g.people), + prs: most(g.prs), + })) + .sort((a, b) => b.days - a.days || a.key.localeCompare(b.key)); +} + +/** + * A week as the charts draw it: its Monday, how many of its seven days the + * chart's days hold (fewer only at either end, as for this week so far), how + * many of those the picked range counts, and whether it ends before the + * range, which the charts draw paler. + */ +export interface ChartWeek { + week: string; + days: number; + /** all of `days` inside the range, none before it, and some for the + * week the range starts in, whose earlier days the charts draw paler */ + counted: number; + before: boolean; +} + +/** Days from one day to another, both counted; 0 when `to` comes first. */ +const daysFrom = (from: string, to: string) => + to < from ? 0 : Math.round(((dayStart(to) as number) - (dayStart(from) as number)) / DAY) + 1; + +/** One Monday's week against the days a chart covers and the picked range. + * With no days given, the week counts whole. */ +export function chartWeek(week: string, shown?: Range, picked?: Range): ChartWeek { + const sunday = utcDay((dayStart(week) as number) + 6 * DAY); + const from = shown && shown.start > week ? shown.start : week; + const to = shown && shown.end < sunday ? shown.end : sunday; + const days = daysFrom(from, to); + return { + week, + days, + counted: picked + ? daysFrom(picked.start > from ? picked.start : from, picked.end < to ? picked.end : to) + : days, + before: !!picked && sunday < picked.start, + }; +} + +/** + * "Week of Sep 28", and how much of it a bar counts: "4 of 7 days" where + * the chart's days stop, "5 of 7 days in the range" for the week the range + * starts in, and "before the range" for a paler week. + */ +export function weekTitle(w: ChartWeek): string { + const head = `Week of ${dayWords(w.week)}`; + if (w.before) return `${head}${w.days < 7 ? `, ${w.days} of 7 days` : ''}, before the range`; + if (w.counted < w.days) return `${head}, ${w.counted} of 7 days in the range`; + return w.days < 7 ? `${head}, ${w.days} of 7 days` : head; +} + +/** A week's Monday in words; `dayWords` adds the year when that isn't this + * one, so a year-long chart's first week can't read as this year's. */ +export function weekWords(week: string, now: number = Date.now() / 1000): string { + return dayWords(week, { now: now * 1000 }); +} + +/** A row's bars, week by week: what the picked range counts, drawn in full, + * and the days before the range, drawn paler. */ +export interface WeekBars { + counted: number[]; + before: number[]; +} + +/** + * Each week's number split at the picked range's start. `whole` counts every + * day the chart holds; `inRange` only the range's days (the range's own + * numbers), so the week the range starts in splits in two instead of + * counting its days before the range as the range's. Without `inRange`, that + * week counts whole. + */ +export function weekBars( + whole: readonly number[] | undefined, + inRange: readonly number[] | undefined, + weeks: readonly ChartWeek[] +): WeekBars { + const counted = weeks.map((w, i) => (w.before ? 0 : (inRange ?? whole)?.[i] ?? 0)); + const before = weeks.map((w, i) => + w.before + ? whole?.[i] ?? 0 + : w.counted < w.days + ? Math.max(0, (whole?.[i] ?? 0) - counted[i]) + : 0 + ); + return { counted, before }; +} + +/** Every week a chart draws for its days, the empty ones too, oldest first: + * the bars keep their place in time however few weeks had any work. */ +export function chartWeeks(shown: Range, picked: Range): ChartWeek[] { + const weeks: ChartWeek[] = []; + for (let w = mondayOf(shown.start); w <= shown.end; w = addWeeks(w, 1)) { + weeks.push(chartWeek(w, shown, picked)); + } + return weeks; +} + +/** Each group's days in each of `weeks`, zeros included. The rows come from + * `data` and land in the week their Monday names. */ +export function weeklyBy( + rows: readonly RetroRow[], + keyOf: (row: RetroRow) => string, + data: Pick, + weeks: readonly ChartWeek[] +): Map { + const at = new Map(weeks.map((w, i) => [w.week, i])); + const out = new Map(); + for (const row of rows) { + const i = at.get(data.weeks[row.week]); + if (i === undefined) continue; + const key = keyOf(row); + const list = out.get(key) ?? weeks.map(() => 0); + out.set(key, list); + list[i] += row.days; + } + return out; +} + +/** A filed project: a real one, not one-offs (misc) and not unlabeled. */ +const filedProject = (row: RetroRow) => + row.pr.project != null && row.pr.project !== MISC_SLUG ? row.pr.project : null; + +/** How spread out each person was: the median, over the weeks they worked, + * of how many different filed projects they touched that week. PRs with no + * project (and one-offs) don't count here; Not in a project says how much + * went to them. */ +export function spreadByPerson(rows: readonly RetroRow[]): Map { + const seen = new Map>>(); + for (const row of rows) { + const weeks = seen.get(row.login) ?? new Map>(); + seen.set(row.login, weeks); + const things = weeks.get(row.week) ?? new Set(); + weeks.set(row.week, things); + const slug = filedProject(row); + if (slug) things.add(slug); + } + return new Map( + [...seen].map(([login, weeks]) => [login, median([...weeks.values()].map(s => s.size))]) + ); +} + +/** One person's days on one project: all of them, and how many were + * writing (the rest were reviewing). */ +export interface ProjectWorker { + login: string; + days: number; + writing: number; +} + +/** Days per person per filed project. */ +function byPersonProject(rows: readonly RetroRow[]): Map> { + const out = new Map>(); + for (const row of rows) { + const slug = filedProject(row); + if (!slug) continue; + const mine = out.get(row.login) ?? new Map(); + out.set(row.login, mine); + const w = mine.get(slug) ?? { login: row.login, days: 0, writing: 0 }; + mine.set(slug, w); + w.days += row.days; + if (row.own) w.writing += row.days; + } + return out; +} + +/** Who worked on each filed project, most days first. */ +export function peopleByProject(rows: readonly RetroRow[]): Map { + const out = new Map(); + for (const projects of byPersonProject(rows).values()) { + for (const [slug, w] of projects) out.set(slug, [...(out.get(slug) ?? []), w]); + } + for (const list of out.values()) { + list.sort((a, b) => b.days - a.days || a.login.localeCompare(b.login)); + } + return out; +} + +/** One person's spread over a window: their days, how many went to + * reviewing and to PRs with no project, and every filed project they + * worked on, most days first. */ +export interface PersonLoad { + login: string; + days: number; + reviewing: number; + unfiled: number; + /** filed projects, with the person's days and writing days on each */ + projects: (ProjectWorker & { slug: string })[]; + /** filed projects they wrote on, and ones they only reviewed on */ + wrote: number; + reviewedOnly: number; + /** filed projects they reviewed on at all, writing or not */ + reviewed: number; +} + +/** Every login's load, in the order given, zeros included for anyone with no days. */ +export function loadByPerson(rows: readonly RetroRow[], logins: readonly string[]): PersonLoad[] { + const projects = byPersonProject(rows); + const totals = new Map(); + for (const row of rows) { + const t = totals.get(row.login) ?? { days: 0, reviewing: 0, unfiled: 0 }; + totals.set(row.login, t); + t.days += row.days; + if (!row.own) t.reviewing += row.days; + if (row.pr.project == null) t.unfiled += row.days; + } + return logins.map(login => { + const list = [...(projects.get(login) ?? new Map())] + .map(([slug, w]) => ({ ...w, slug })) + .sort((a, b) => b.days - a.days || a.slug.localeCompare(b.slug)); + const wrote = list.filter(p => p.writing > 0).length; + return { + login, + ...(totals.get(login) ?? { days: 0, reviewing: 0, unfiled: 0 }), + projects: list, + wrote, + reviewedOnly: list.length - wrote, + reviewed: list.filter(p => p.days - p.writing > 0).length, + }; + }); +} + +/** fewest filed projects they wrote for, in a window, that ever reads as + * overloaded */ +export const OVERLOAD_MIN = 4; + +/** + * How many filed projects someone wrote PRs for in a window make them + * overloaded: at least OVERLOAD_MIN, and at least twice the median across + * developers (zeros included). Reviewing doesn't count: a reviewer is spread + * by the job. Relative on purpose: while most PRs aren't filed yet, a fixed + * number would flag more people each week as filing catches up. + */ +export function overloadLine(counts: readonly number[]): number { + return Math.max(OVERLOAD_MIN, Math.ceil(2 * median(counts))); +} + +/** Who reads as overloaded: wrote for `line` or more filed projects, most + * first. Nobody when more than a quarter would: then the flag picks out no + * one, and the tile says how many cross (tooManyWords). */ +export function overloaded>( + loads: readonly T[], + line: number +): T[] { + if (tooManyWords(loads, line)) return []; + return loads + .filter(l => l.wrote >= line) + .sort((a, b) => b.wrote - a.wrote || a.login.localeCompare(b.login)); +} + +/** When too many cross the line for `overloaded` to single them out, how + * many do: "6 of 23 wrote for 4 or more", so a tile never reads as nobody. + * Null when the flag names them, or nobody crosses. */ +export function tooManyWords( + loads: readonly Pick[], + line: number +): string | null { + const over = loads.filter(l => l.wrote >= line).length; + return over * 4 > loads.length ? `${over} of ${loads.length} wrote for ${line} or more` : null; +} + +export function median(values: readonly number[]): number { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** The most weeks in a row with no days, between a row's first and last + * week with any: how long the work sat. 0 when it never paused. */ +export function quietWeeks(weekly: readonly number[]): number { + const first = weekly.findIndex(d => d > 0); + if (first < 0) return 0; + const last = lastWeek(weekly); + let best = 0; + let run = 0; + for (let i = first; i <= last; i++) { + run = weekly[i] > 0 ? 0 : run + 1; + best = Math.max(best, run); + } + return best; +} + +/** The index of a row's last week with any days; -1 with none. */ +export function lastWeek(weekly: readonly number[]): number { + for (let i = weekly.length - 1; i >= 0; i--) if (weekly[i] > 0) return i; + return -1; +} + +/** + * How long a project's PRs ran, as of the range's end: still open then, + * and for how many days since `openSince` (its oldest open PR, firstOpenDay) + * or else its first PR in the range opened; or done, + * and how many days from that first PR to its last merge or close. Null + * with no numbers for the range. It counts the days gone by, not the days + * touched, as the Overview counts a project's age, so the two say the same. + */ +export function projectLength( + w: Pick | null | undefined, + rangeEnd: string, + openSince?: string | null +): { open: boolean; days: number } | null { + if (!w?.first_opened) return null; + const from = dayStart(w.first_opened) as number; + if (w.backlog_end > 0) { + // still open: from its oldest open PR when known, the day every pane says + const since = (openSince && dayStart(openSince)) || from; + return { open: true, days: Math.round(((dayStart(rangeEnd) as number) - since) / DAY) }; + } + if (!w.last_closed) return null; + return { open: false, days: Math.round(((dayStart(w.last_closed) as number) - from) / DAY) }; +} + +/** How a project's plan turned out, judged today, for Look back: worst + * last, since a retro reads what finished first. */ +export type RetroPlanKind = + | 'on_time' + | 'late' + | 'past_end' + | 'open' + | 'parked' + | 'dropped' + | 'none'; +export const RETRO_PLAN_RANK: Record = { + on_time: 0, + late: 1, + past_end: 2, + open: 3, + parked: 4, + dropped: 5, + none: 6, +}; + +/** + * A plan's outcome in a few words: "Done on time", "Done 2 weeks late", + * "3 weeks past its end", "Ends Oct 11", parked, dropped, or "No plan". + * A plan's finish day is the day it was marked done (status_at), whatever + * edits came after; a plan saved before status_at existed falls back to its + * last change. Only a hard end passed is `past_end`, owed a call: a soft one + * passed says so as a plain fact, and ongoing work has no end to judge by. + */ +export function retroPlan( + plan: Pick< + RoadmapItem, + 'status' | 'start' | 'weeks' | 'end_kind' | 'updated_at' | 'status_at' + > | null, + today: string +): { kind: RetroPlanKind; text: string } { + if (!plan) return { kind: 'none', text: NO_PLAN }; + if (plan.end_kind === 'ongoing') { + if (plan.status === 'done') return { kind: 'on_time', text: 'Done' }; + if (plan.status === 'dropped' || plan.status === 'parked') { + return { kind: plan.status, text: plan.status === 'dropped' ? 'Dropped' : 'Parked' }; + } + return { kind: 'open', text: 'Ongoing' }; + } + const end = planEnd(plan); + const weeksAfter = (day: string) => + Math.ceil(((dayStart(day) as number) - (dayStart(end) as number)) / (7 * DAY)); + if (plan.status === 'done') { + // from when it was marked done, not its last edit + const at = plan.status_at ?? plan.updated_at; + const late = at == null ? 0 : weeksAfter(utcDay(at)); + return late > 0 + ? { kind: 'late', text: `Done ${n(late, 'week')} late` } + : { kind: 'on_time', text: 'Done on time' }; + } + if (plan.status === 'dropped') return { kind: 'dropped', text: 'Dropped' }; + if (plan.status === 'parked') return { kind: 'parked', text: 'Parked' }; + if (end < today) { + return { + kind: plan.end_kind === 'hard' ? 'past_end' : 'open', + text: pastEnd(weeksAfter(today), plan.end_kind), + }; + } + return { + kind: 'open', + text: plan.start > today ? `Starts ${dayWords(plan.start)}` : `Ends ${dayWords(end)}`, + }; +} + +/** A plan or project that finished in a range: on time by its plan's end, + * late, or null with no plan to judge it by. */ +export interface Finished { + key: string; + name: string; + onTime: boolean | null; +} + +/** + * What finished in a range: plans marked done in it (by the day they were + * marked so), and project issues closed as completed in it that no + * finished plan already counts. + */ +export function finishedIn( + plans: readonly RoadmapItem[], + projects: readonly Project[], + range: { start: string; end: string } +): Finished[] { + const inRange = (day: string) => day >= range.start && day <= range.end; + const out = new Map(); + for (const plan of plans) { + const at = plan.status_at ?? plan.updated_at; + if (plan.status !== 'done' || at == null) continue; + const day = utcDay(at); + if (!inRange(day)) continue; + const key = plan.project ?? `plan:${plan.id}`; + out.set(key, { key, name: plan.name, onTime: day <= (endOf(plan) ?? day) }); + } + for (const p of projects) { + if (p.state !== 'closed' || p.state_reason === 'not_planned' || !p.closed_at) continue; + const day = p.closed_at.slice(0, 10); + if (!inRange(day) || out.has(p.slug)) continue; + const plan = planFor(p.slug, plans); + out.set(p.slug, { + key: p.slug, + name: p.name, + // ongoing work has no end to finish late by + onTime: plan ? day <= (endOf(plan) ?? day) : null, + }); + } + return [...out.values()]; +} + +/** What finished, narrowed to the projects these rows spent days on: a + * team's or a person's finished plans are the ones they worked on. */ +export function finishedOn(finished: readonly Finished[], rows: readonly RetroRow[]): Finished[] { + const worked = new Set(rows.map(r => r.pr.project)); + return finished.filter(f => worked.has(f.key)); +} + +/** What a comparison compares with: "30 days before", or "day before" for + * one day, so a note reads "1 more than the day before". */ +export function beforeWords(rangeDays: number): string { + return rangeDays === 1 ? 'day before' : `${rangeDays} days before`; +} + +/** How quickly the merged PRs merged, said after their count ("16 merged, + * half of them within about 7 hours of opening"): half of several merged + * within the median, and one merged PR has only its own time. Null with + * none merged. */ +export function mergeSpeed(merged: number, medianDays: number | null): string | null { + if (!merged || medianDays == null) return null; + return merged === 1 + ? `${durationWords(medianDays)} after it opened` + : `half of them within ${durationWords(medianDays)} of opening`; +} + +/** + * A spreadsheet cell: quoted when it holds a comma, a quote or a line break, + * and a title that starts like a formula (=, +, -, @) gets a leading + * apostrophe, since a PR's title is anyone's text and a sheet would run it. + */ +function csvCell(value: string | number): string { + const text = + typeof value === 'string' && /^[=+\-@\t\r]/.test(value) ? `'${value}` : String(value); + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** The rows as CSV: one line per person, PR and week, so a sheet can split + * the days any way the page doesn't. */ +export function retroCsv( + rows: readonly RetroRow[], + data: Pick, + teamOf: (login: string) => string | null, + nameOf: (slug: string) => string +): string { + const head = [ + 'Week of', + 'Person', + 'Team', + 'Writing or reviewing', + 'Days', + 'PR', + 'Title', + 'Project', + ]; + const lines = rows.map(r => [ + data.weeks[r.week], + r.login, + teamOf(r.login) ?? '', + r.own ? 'writing' : 'reviewing', + r.days, + `${r.pr.repo}#${r.pr.number}`, + r.pr.title, + r.pr.project ? nameOf(r.pr.project) : '', + ]); + return [head, ...lines].map(l => l.map(csvCell).join(',')).join('\n') + '\n'; +} diff --git a/frontend-v2/src/model/retroData.ts b/frontend-v2/src/model/retroData.ts new file mode 100644 index 00000000..67096529 --- /dev/null +++ b/frontend-v2/src/model/retroData.ts @@ -0,0 +1,158 @@ +import { useEffect, useState } from 'react'; +import { isDummy, loadDummy } from '../backend/dummy'; +import { createMemoryStore } from '../storage'; +import { DUMMY_TEAMS } from '../backend/dummyProjects'; +import { epoch } from '../../../shared/format'; +import { issueKey } from '../../../shared/model/issueRef'; +import { dayStart, projectOf } from '../../../shared/model/projects'; +import { timeSpent, type Touch } from '../../../shared/model/retro'; +import { isSuffixBot } from '../../../shared/model/visibility'; +import { teamLookup, type Range } from './projectData'; +import { dummyPullLinks } from './workData'; + +/** A PR someone spent days on, as GET /retro-data lists it. */ +export interface RetroPr { + repo: string; + number: number; + title: string; + owner: string; + bot: boolean; + project: string | null; + state: 'open' | 'closed'; + /** epoch secs; null when not merged */ + merged: number | null; + /** epoch secs it closed, merged or not; null while open. Servers older + * than the field leave it out. */ + closed?: number | null; +} + +/** GET /retro-data: where people's days went in a range, week by week + * (lib/projects.js loadTimeSpent). Rows point into the lists by index. */ +export interface RetroData { + start: string; + end: string; + /** whose time: developers when there are teams, else everyone */ + counted: 'developers' | 'everyone'; + /** the Mondays of the weeks with any days, oldest first */ + weeks: string[]; + people: string[]; + prs: RetroPr[]; + /** [person, pr, week, days] */ + rows: [number, number, number, number][]; +} + +/** The dummy board's answer, from its fixture PRs: opened, merged and stamped. */ +async function dummyRetro({ start, end }: Range): Promise { + // a PR's project by the same rule as everywhere: its label, else the + // project whose issue it links + const [{ pulls, projectLabelPrefix }, linked] = await Promise.all([ + loadDummy(), + dummyPullLinks(), + ]); + const prefix = projectLabelPrefix ?? 'project:'; + const from = dayStart(start) ?? 0; + const to = (dayStart(end) ?? from) + 86400; + const touches: Touch[] = pulls.flatMap(p => { + const on = { repo: p.repo, number: p.number, owner: p.user.login }; + return [ + { login: p.user.login, at: epoch(p.created_at), ...on }, + ...(p.merged_at ? [{ login: p.user.login, at: epoch(p.merged_at), ...on }] : []), + ...[...p.status.allCR, ...p.status.allQA].map(sig => ({ + login: sig.data.user.login, + at: epoch(sig.data.created_at), + ...on, + })), + ].filter(t => t.at >= from && t.at < to); + }); + const teamOf = teamLookup(DUMMY_TEAMS); + // with no teams yet (a new install), everyone's days count, as the + // server counts them (lib/projects.js) + const teams = Object.values(DUMMY_TEAMS).some(members => members.length > 0); + const rows = timeSpent( + touches, + login => !isSuffixBot(login) && (!teams || teamOf(login) != null) + ); + const byKey = new Map(pulls.map(p => [`${p.repo}#${p.number}`, p])); + const people = [...new Set(rows.map(r => r.login))].sort(); + const weeks = [...new Set(rows.map(r => r.week))].sort(); + const prKeys = [...new Set(rows.map(r => `${r.repo}#${r.number}`))]; + return { + start, + end, + counted: teams ? 'developers' : 'everyone', + weeks, + people, + prs: prKeys.map(key => { + const p = byKey.get(key) as typeof pulls[number]; + return { + repo: p.repo, + number: p.number, + title: p.title, + owner: p.user.login, + bot: isSuffixBot(p.user.login), + project: projectOf(p.labels, prefix, linked[issueKey(p)]), + state: p.state === 'open' ? 'open' : 'closed', + merged: p.merged_at ? epoch(p.merged_at) : null, + closed: p.closed_at ? epoch(p.closed_at) : null, + }; + }), + rows: rows.map(r => [ + people.indexOf(r.login), + prKeys.indexOf(`${r.repo}#${r.number}`), + weeks.indexOf(r.week), + Math.round(r.days * 100) / 100, + ]), + }; +} + +// one fetch per range, reused for 5 minutes, as with /projects-data +const TTL_MS = 5 * 60_000; +const cache = new Map }>(); +// bumped by a Try again: a failed range is never cached, so every hook +// showing one asks again, and the ranges that loaded stay as they are +const tries = createMemoryStore({ n: 0 }); + +/** Ask again for every range whose load failed. */ +export function retryRetroData(): void { + tries.set({ n: tries.get().n + 1 }); +} + +/** Forget every loaded range and ask again on screen: the server's time + * spent depends on teams and issue links, which a save can change. */ +export function refreshRetroData(): void { + cache.clear(); + tries.set({ n: tries.get().n + 1 }); +} + +function load(range: Range): Promise { + if (isDummy()) return dummyRetro(range); + const query = new URLSearchParams({ start: range.start, end: range.end }); + return fetch(`/retro-data?${query}`) + .then(r => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null); +} + +/** A range's time spent: undefined while it loads, null if the fetch failed. */ +export function useRetroData(range: Range): RetroData | null | undefined { + const { n } = tries.useValue(); + const key = `${range.start}..${range.end}`; + const [got, setGot] = useState<{ key: string; data: RetroData | null }>(); + useEffect(() => { + let hit = cache.get(key); + if (!hit || Date.now() - hit.at > TTL_MS) { + hit = { at: Date.now(), data: load(range) }; + cache.set(key, hit); + } + let live = true; + hit.data.then(data => { + if (data == null) cache.delete(key); + if (live) setGot({ key, data }); + }); + return () => { + live = false; + }; + // keyed on the days, not the range object, which callers rebuild + // every render + }, [key, n]); + return got && got.key === key ? got.data : undefined; +} diff --git a/frontend-v2/src/model/reviewLanes.test.ts b/frontend-v2/src/model/reviewLanes.test.ts index 1729234f..f57d785d 100644 --- a/frontend-v2/src/model/reviewLanes.test.ts +++ b/frontend-v2/src/model/reviewLanes.test.ts @@ -446,3 +446,111 @@ describe('buildReviewLanes — why-line display names', () => { expect(lanes.whyQaNext(p)).toBe('Alice A is already testing it; it sinks below unclaimed QA'); }); }); + +describe('buildReviewLanes — projects', () => { + /** pull keys to the project standing the lanes read (model/standing.ts) */ + const standing = ( + parked: Record, + finishing: Record = {} + ) => ({ + parked: new Map(Object.entries(parked)), + finishing: new Map(Object.entries(finishing)), + }); + + it("sinks a parked project's PR below everyone's in the queue, above the bots, and says why", () => { + // a quick win waiting 3 days would lead, but its project is parked + const parked = dp({ number: 1, author: 'alice', weight: 'XS', ageDays: 3 }); + const fresh = dp({ number: 2, author: 'bob', weight: 'L', ageDays: 0 }); + const bot = dp({ number: 3, author: 'dependabot[bot]', ageDays: 1 }); + const lanes = buildReviewLanes( + input({ + pulls: [parked, fresh], + bots: [bot], + standing: standing({ 'org/repo#1': 'picker' }), + }) + ); + expect(lanes.queue).toEqual([fresh, parked, bot]); + expect(lanes.whyUpNext(parked)).toBe('Parked project: picker'); + }); + + it("sinks a teammate's parked PR below someone else's", () => { + const teammates = dp({ number: 1, author: 'alice' }); + const stranger = dp({ number: 2, author: 'bob' }); + const lanes = buildReviewLanes( + input({ + pulls: [teammates, stranger], + teams: [{ name: 'My team', members: ['alice'] }], + standing: standing({ 'org/repo#1': 'picker' }), + }) + ); + expect(lanes.queue).toEqual([stranger, teammates]); + }); + + it("sinks a parked project's PR in Needs QA and Ready to merge", () => { + const parked = standing({ 'org/repo#1': 'picker' }); + const qaParked = dp({ number: 1, author: 'alice', status: 'needs_qa', weight: 'XS' }); + const qaOther = dp({ number: 2, author: 'bob', status: 'needs_qa', weight: 'XL' }); + expect( + buildReviewLanes(input({ pulls: [qaParked, qaOther], standing: parked })).needsQa + ).toEqual([qaOther, qaParked]); + const readyParked = dp({ number: 1, author: 'alice', status: 'ready', ageDays: 9 }); + const readyOther = dp({ number: 2, author: 'bob', status: 'ready', ageDays: 1 }); + const lanes = buildReviewLanes(input({ pulls: [readyParked, readyOther], standing: parked })); + expect(lanes.ready).toEqual([readyOther, readyParked]); + expect(lanes.whyProject(readyParked, null)).toBe('Parked project: picker'); + }); + + it("sinks a parked project's PR within its word in Waiting on you, never out of it", () => { + const restampParked = dp({ + number: 1, + author: 'alice', + status: 'needs_recr', + recrBy: ['me'], + ageDays: 9, + }); + const restamp = dp({ number: 2, author: 'bob', status: 'needs_recr', recrBy: ['me'] }); + const requested = dp({ number: 3, author: 'carol', requestedReviewers: ['me'], ageDays: 20 }); + const lanes = buildReviewLanes( + input({ + pulls: [restampParked, restamp, requested], + standing: standing({ 'org/repo#1': 'picker' }), + }) + ); + // both re-stamps still come before the review request + expect(lanes.yourMove).toEqual([restamp, restampParked, requested]); + }); + + it('puts the PR that helps finish a plan first on a tie, and says so', () => { + // the same score to the day: the older one leads, unless the younger + // one is among the last open PRs of a plan in progress + const older = dp({ number: 1, author: 'alice', ageDays: 2.4 }); + const finisher = dp({ number: 2, author: 'bob', ageDays: 2.2 }); + const plain = buildReviewLanes(input({ pulls: [older, finisher] })); + expect(plain.queue).toEqual([older, finisher]); + const lanes = buildReviewLanes( + input({ + pulls: [older, finisher], + standing: standing({}, { 'org/repo#2': { slug: 'sync', left: 1 } }), + }) + ); + expect(lanes.queue).toEqual([finisher, older]); + expect(lanes.whyUpNext(finisher)).toBe( + 'Waiting 2d without a full CR. Helps finish sync: its last open PR' + ); + }); + + it('never puts it above a PR that waited a day longer', () => { + const older = dp({ number: 1, author: 'alice', ageDays: 3 }); + const finisher = dp({ number: 2, author: 'bob', ageDays: 2 }); + const lanes = buildReviewLanes( + input({ + pulls: [older, finisher], + standing: standing({}, { 'org/repo#2': { slug: 'sync', left: 2 } }), + }) + ); + expect(lanes.queue).toEqual([older, finisher]); + expect(lanes.whyProject(finisher, null)).toBe( + 'Helps finish sync: one of its last 2 open PRs' + ); + }); +}); diff --git a/frontend-v2/src/model/reviewLanes.ts b/frontend-v2/src/model/reviewLanes.ts index 95e17630..ca80a60c 100644 --- a/frontend-v2/src/model/reviewLanes.ts +++ b/frontend-v2/src/model/reviewLanes.ts @@ -1,4 +1,11 @@ -import { qaDone, weightRank, type DerivedPull, type Status } from '../../../shared/model/status'; +import { + isIterating, + qaDone, + STARVE_DAYS, + weightRank, + type DerivedPull, + type Status, +} from '../../../shared/model/status'; import type { PullData } from '../../../shared/types'; import { pullKey } from '../../../shared/format'; import { @@ -10,13 +17,14 @@ import { WAIT_WORD_RANK, } from './actions'; import { startHereReason } from './cheers'; -import { dealRank } from './deal'; +import { dealRank, dealScore } from './deal'; import { displayName } from './names'; import { matchesRegion } from './regions'; import { repoBlocks, type RepoBlock } from './repoBlocks'; import { claimFor, reviewRequestedFrom } from './reviewers'; import { myPeople, type PersonalTeam } from '../settings'; -import { crSort, teamFirst } from './sort'; +import { crScore, crSort, finishersFirst, sinkBy, teamFirst } from './sort'; +import type { PullStanding } from './standing'; /** * Review.tsx's lanes/pools, pulled out of the component so the bucketing @@ -77,6 +85,10 @@ export interface ReviewLanesInput { * a name instead of a login — defaults to {} so an unresolved login just * falls back to itself. */ names?: Readonly>; + /** how each PR's project stands (model/standing.ts): a parked project's + * PRs sink in every ranked lane, and the last PRs of a plan in progress + * go first on a tie. Absent on a board without projects. */ + standing?: PullStanding; } export interface ReviewLanes { @@ -132,8 +144,14 @@ export interface ReviewLanes { /** Needs QA's own per-card "why" line — that lane sorts by qaSort, not * deal-score, so it needs its own reasons rather than whyUpNext's */ whyQaNext: (p: DerivedPull) => string; + /** a lane's "why" line with what the PR's project did to its place: a + * parked project's sank it, the last PRs of a plan in progress won a tie. + * `base` is the lane's own reason; null when there's nothing to say */ + whyProject: (p: DerivedPull, base: string | null) => string | null; } +const NO_STANDING: PullStanding = { parked: new Map(), finishing: new Map() }; + /** Your own pulls contribute only their do-it-now verbs to the home lane. * "Undraft" always stays in My work (planning, not minutes). Getting QA is * different: when the team self-reviews, CR isn't the gate and lining up QA @@ -161,11 +179,26 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { repoPriority, ageWarnDays, names = {}, + standing = NO_STANDING, } = input; const team = new Set(myPeople(teams)); const others = pulls.filter(p => p.data.user.login !== me); + // A project's say in each ranked lane (model/standing.ts): a parked + // project's PRs sink, never hidden, the way an iterating PR sinks; and + // where two PRs tie, which each lane reads as its own ranking with age in + // whole days, the one that helps finish a plan in progress goes first. + const sunk = (p: DerivedPull) => standing.parked.has(pullKey(p.data)); + const finishes = (p: DerivedPull) => standing.finishing.has(pullKey(p.data)); + const days = (p: DerivedPull) => Math.round(p.ageDays); + const crRank = (list: DerivedPull[]) => + finishersFirst( + crSort(list, sunk), + p => `${sunk(p)}|${isIterating(p)}|${Math.round(crScore(p) * STARVE_DAYS)}|${days(p)}`, + finishes + ); + // Repo relevance is per-person (a web dev and a firmware dev share the // monorepo but little else) and inferred from the current board: the // repos you've authored or stamped on. An empty inference means we can't @@ -269,6 +302,17 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { weightRank(a.weight) - weightRank(b.weight) || b.ageDays - a.ageDays ); + // teammates lead the primary lane; a parked project's PRs sink below + // everything, theirs included + const qaRank = (list: DerivedPull[], pinTeam: boolean) => { + const pinned = (p: DerivedPull) => pinTeam && team.has(p.data.user.login); + const sorted = qaSort(list); + return finishersFirst( + sinkBy(pinTeam ? teamFirst(sorted, team) : sorted, p => Number(sunk(p))), + p => `${sunk(p)}|${pinned(p)}|${!!p.qaingLogin}|${p.weight}|${days(p)}`, + finishes + ); + }; // In your code regions: reviewable pulls (CR or QA pool) matching a region // you set in Settings, deduped across the two pools, surfaced as a @@ -280,7 +324,7 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { // isn't region news to you, even though qaPool itself doesn't otherwise // care about CR state. const regionSeen = new Set(); - const regionMatches = crSort( + const regionMatches = crRank( [...crPool, ...qaPool.filter(p => !p.crBy.includes(me))].filter(p => { const k = pullKey(p.data); if (regionSeen.has(k) || !matchesRegion(p, codeRegions)) return false; @@ -300,19 +344,33 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { // reachable but not in the way. Region matches stay in this queue too: // "In your code regions" above is a highlight, a copy of a slice, not an // extraction — the same non-exclusive relationship "Recently updated" and - // a claimed PR already have with their lanes. - const queue = teamFirst( - dealRank( - [ - ...nonStarved.filter(p => isPrimaryRepo(p.data.repo)), - ...crPool.filter(p => p.starved), - ...botReviewable, - ], - { me, pulls, deprioritize: isDemoted, warnDays: ageWarnDays } + // a claimed PR already have with their lanes. A parked project's PRs sink + // below every other person's, teammates' included, and above the bots. + const dealOpts = { me, pulls, deprioritize: isDemoted, warnDays: ageWarnDays }; + const classOf = (p: DerivedPull) => (isDemoted(p) ? 2 : sunk(p) ? 1 : 0); + const queue = finishersFirst( + sinkBy( + teamFirst( + dealRank( + [ + ...nonStarved.filter(p => isPrimaryRepo(p.data.repo)), + ...crPool.filter(p => p.starved), + ...botReviewable, + ], + dealOpts + ), + team + ), + classOf ), - team + // the score in days of waiting, as the why line counts them + p => + `${classOf(p)}|${team.has(p.data.user.login)}|${Math.round( + dealScore(p, dealOpts) * (ageWarnDays ?? STARVE_DAYS) + )}`, + finishes ); - const queueOther = crSort(nonStarved.filter(p => !isPrimaryRepo(p.data.repo))); + const queueOther = crRank(nonStarved.filter(p => !isPrimaryRepo(p.data.repo))); // Ready to merge is finishable work for anyone: fully signed off, green, // one button-press from done. It earns a real lane in Pick up next rather @@ -321,13 +379,23 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { // botsForReady (not `bots`) so this lane keeps showing merge-ready bot // PRs even when "Ignore bot PRs" has emptied `bots` out of the queue // tail and the bot fold below. - const ready = [ - ...others.filter(p => p.status === 'ready'), - ...botsForReady.filter(p => p.status === 'ready'), - ].sort((a, b) => b.ageDays - a.ageDays); + const ready = finishersFirst( + [ + ...others.filter(p => p.status === 'ready'), + ...botsForReady.filter(p => p.status === 'ready'), + ].sort((a, b) => Number(sunk(a)) - Number(sunk(b)) || b.ageDays - a.ageDays), + p => `${sunk(p)}|${days(p)}`, + finishes + ); - const needsQa = teamFirst(qaSort(qaPool.filter(p => isPrimaryRepo(p.data.repo))), team); - const needsQaOther = qaSort(qaPool.filter(p => !isPrimaryRepo(p.data.repo))); + const needsQa = qaRank( + qaPool.filter(p => isPrimaryRepo(p.data.repo)), + true + ); + const needsQaOther = qaRank( + qaPool.filter(p => !isPrimaryRepo(p.data.repo)), + false + ); // your live CR stamp is in, the PR just isn't fully signed off yet (another // reviewer owes a stamp, or a re-CR). Covers needs_recr too, so a PR you @@ -382,7 +450,16 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { const idx = DO_WORD_RANK.indexOf(word); return idx === -1 ? Number.POSITIVE_INFINITY : idx; }; - yourMove.sort((a, b) => doRankOf(a) - doRankOf(b) || b.ageDays - a.ageDays); + // a parked project's sink within their word's group, never out of it + yourMove.sort( + (a, b) => + doRankOf(a) - doRankOf(b) || Number(sunk(a)) - Number(sunk(b)) || b.ageDays - a.ageDays + ); + const yourMoveRanked = finishersFirst( + yourMove, + p => `${doRankOf(p)}|${sunk(p)}|${days(p)}`, + finishes + ); // "Waiting on others": your PRs and stamps sitting with someone else right now — // your own PRs waiting on a review/QA, plus a stamp you've already given @@ -407,13 +484,31 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { }; yoursWaiting.sort((a, b) => waitRankOf(a) - waitRankOf(b) || b.ageDays - a.ageDays); + // what a PR's project did to its place, said after the lane's own reason: + // a parked project's sank it, so that's the whole story; the last PRs of a + // plan in progress won a tie + const whyProject = (p: DerivedPull, base: string | null): string | null => { + const key = pullKey(p.data); + const parked = standing.parked.get(key); + if (parked) return `Parked project: ${parked}`; + const finishing = standing.finishing.get(key); + if (!finishing) return base; + const last = + finishing.left === 1 + ? `Helps finish ${finishing.slug}: its last open PR` + : `Helps finish ${finishing.slug}: one of its last ${finishing.left} open PRs`; + return base ? `${base}. ${last}` : last; + }; + // the per-card "why" behind the repo# door in the ranked lanes — the same // reason strings the dealt card's footnote and the Start-here toast use, // so every surface explains a pick in the same words - const whyUpNext = (p: DerivedPull) => - team.has(p.data.user.login) + const whyUpNext = (p: DerivedPull) => { + const base = team.has(p.data.user.login) ? `From ${displayName(names, p.data.user.login) ?? p.data.user.login}, on your team: teammates’ PRs lead your queue` : startHereReason(p, pulls, me, false, names); + return whyProject(p, base) ?? base; + }; // the queue's repo blocks (priority order, starved pierced out front) — // computed unconditionally, cheap; the render only reads it when a // priority is set @@ -423,14 +518,16 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { // sorted by qaSort (unclaimed-first, then lightest, then oldest) — reusing // whyUpNext's reciprocity/quick-win/urgency reasons here would describe a // ranking this lane doesn't use. - const whyQaNext = (p: DerivedPull) => - team.has(p.data.user.login) + const whyQaNext = (p: DerivedPull) => { + const base = team.has(p.data.user.login) ? `From ${displayName(names, p.data.user.login) ?? p.data.user.login}, on your team: teammates’ PRs lead this lane` : p.qaingLogin ? `${displayName(names, p.qaingLogin) ?? p.qaingLogin} is already testing it; it sinks below unclaimed QA` : p.weight === 'XS' || p.weight === 'S' ? `Nobody's testing it yet, a light one (${p.weight})` : `Nobody's testing it yet, waiting ${Math.max(1, Math.round(p.ageDays))}d`; + return whyProject(p, base) ?? base; + }; const boardIsQuiet = !yourMove.length && @@ -464,7 +561,7 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { const empty = !pulls.length && !bots.length && !closed.length && !botsForReady.length; return { - yourMove, + yourMove: yourMoveRanked, yoursWaiting, changed, queue, @@ -488,5 +585,6 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { empty, whyUpNext, whyQaNext, + whyProject, }; } diff --git a/frontend-v2/src/model/roadmap.test.ts b/frontend-v2/src/model/roadmap.test.ts new file mode 100644 index 00000000..1c8da52d --- /dev/null +++ b/frontend-v2/src/model/roadmap.test.ts @@ -0,0 +1,569 @@ +import { describe, expect, it } from 'vitest'; +import { ORIGIN_KEYS } from '../lens'; +import { + addWeeks, + blockersOf, + bucketOf, + checkRoadmapFields, + checkRoadmapUpdate, + endOf, + endShift, + healthStanding, + inProgress, + issuePace, + mondayOf, + moveBefore, + paceFinish, + periodPlan, + planEnd, + planFor, + planLately, + ROADMAP_ORIGINS, + updatesOwed, + updateStanding, + type EndKind, + type PlanLately, + type RoadmapItem, + type RoadmapUpdate, + waitsOnProblem, + weeksThrough, +} from '../../../shared/model/roadmap'; +import { dayStart } from '../../../shared/model/projects'; +import type { DerivedPull } from '../../../shared/model/status'; +import type { PullData } from '../../../shared/types'; + +describe('roadmap dates', () => { + it('moves any day to its week’s Monday and counts whole weeks from it', () => { + expect(mondayOf('2026-10-01')).toBe('2026-09-28'); // a Thursday + expect(mondayOf('2026-09-28')).toBe('2026-09-28'); + expect(mondayOf('2026-10-04')).toBe('2026-09-28'); // a Sunday + expect(addWeeks('2026-09-28', 2)).toBe('2026-10-12'); + expect(planEnd({ start: '2026-09-28', weeks: 2 })).toBe('2026-10-11'); + }); + + it('gives ongoing work no end, whatever its weeks say', () => { + expect(endOf({ start: '2026-09-28', weeks: 2, end_kind: 'soft' })).toBe('2026-10-11'); + expect(endOf({ start: '2026-09-28', weeks: 2, end_kind: 'ongoing' })).toBeNull(); + }); +}); + +describe('checkRoadmapFields', () => { + it('needs a name on a new item, trims it, and snaps the start to Monday', () => { + expect(checkRoadmapFields({}, { partial: false })).toEqual({ + error: 'a plan needs a name', + }); + expect( + checkRoadmapFields({ name: ' Checkout ', start: '2026-10-01' }, { partial: false }) + ).toEqual({ fields: { name: 'Checkout', start: '2026-09-28' } }); + }); + + it('lets an edit send any subset, and clears optional fields with null or empty', () => { + expect(checkRoadmapFields({ weeks: 6 }, { partial: true })).toEqual({ fields: { weeks: 6 } }); + expect(checkRoadmapFields({ lead: '', project: null }, { partial: true })).toEqual({ + fields: { lead: null, project: null }, + }); + expect(checkRoadmapFields({ origin: 'fire' }, { partial: true })).toEqual({ + fields: { origin: 'fire' }, + }); + expect(checkRoadmapFields({ origin: '' }, { partial: true })).toEqual({ + fields: { origin: null }, + }); + }); + + it('says what is wrong in words', () => { + const error = (input: unknown) => + (checkRoadmapFields(input, { partial: true }) as { error: string }).error; + expect(error({ weeks: 0 })).toMatch(/weeks/); + expect(error({ weeks: 2.5 })).toMatch(/weeks/); + expect(error({ status: 'someday' })).toMatch(/status/); + expect(error({ origin: 'boss' })).toMatch(/origin is one of asked, fire, chosen/); + expect(error({ project: 'Not A Slug' })).toMatch(/slug/); + expect(error({ lead: 'two words' })).toMatch(/login/); + expect(error({ start: 'next week' })).toMatch(/YYYY-MM-DD/); + expect(error([])).toMatch(/object/); + }); + + it('takes how firm the end is, and what done looks like as one line', () => { + expect(checkRoadmapFields({ end_kind: 'hard' }, { partial: true })).toEqual({ + fields: { end_kind: 'hard' }, + }); + expect( + (checkRoadmapFields({ end_kind: 'firm' }, { partial: true }) as { error: string }).error + ).toMatch(/end_kind is one of hard, soft, ongoing/); + // a pasted paragraph keeps its words, on one line + expect( + checkRoadmapFields( + { done_when: ' Both paths ship\n and the audit passes ' }, + { partial: true } + ) + ).toEqual({ fields: { done_when: 'Both paths ship and the audit passes' } }); + expect(checkRoadmapFields({ done_when: null }, { partial: true })).toEqual({ + fields: { done_when: '' }, + }); + expect( + ( + checkRoadmapFields({ done_when: 'x'.repeat(201) }, { partial: true }) as { + error: string; + } + ).error + ).toMatch(/200 characters/); + }); +}); + +describe('moveBefore', () => { + it('drops an id in front of another, or at the end', () => { + expect(moveBefore([1, 2, 3, 4], 4, 2)).toEqual([1, 4, 2, 3]); + expect(moveBefore([1, 2, 3, 4], 1, 4)).toEqual([2, 3, 1, 4]); + expect(moveBefore([1, 2, 3], 1, null)).toEqual([2, 3, 1]); + expect(moveBefore([1, 2, 3], 9, 1)).toEqual([1, 2, 3]); + }); +}); + +describe('updates', () => { + const DAY = 86400; + const now = dayStart('2026-09-30') as number; + const update = ( + daysAgo: number, + health: RoadmapUpdate['health'] = 'on_track' + ): RoadmapUpdate => ({ + id: 1, + item_id: 1, + health, + body: '', + plan_start: '2026-09-07', + plan_weeks: 4, + author: 'dana', + at: now - daysAgo * DAY, + }); + + it('checks the health and trims the words, which may be empty', () => { + expect(checkRoadmapUpdate({ health: 'at_risk', body: ' Slipping. ' })).toEqual({ + fields: { health: 'at_risk', body: 'Slipping.' }, + }); + expect(checkRoadmapUpdate({ health: 'on_track' })).toEqual({ + fields: { health: 'on_track', body: '' }, + }); + expect((checkRoadmapUpdate({ health: 'fine' }) as { error: string }).error).toMatch(/health/); + }); + + it('counts how far a plan’s end moved, later positive', () => { + const was = { start: '2026-09-07', weeks: 4 }; + expect(endShift(was, { start: '2026-09-07', weeks: 6 })).toBe(2); + expect(endShift(was, { start: '2026-08-31', weeks: 4 })).toBe(-1); + expect(endShift(was, was)).toBe(0); + }); + + it('owes an update only on work in progress, after two weeks', () => { + const active = { + status: 'active' as const, + start: '2026-09-07', + weeks: 4, + end_kind: 'hard' as const, + created_at: null, + }; + expect(healthStanding({ ...active, update: null }, now).kind).toBe('missing'); + expect(healthStanding({ ...active, start: '2026-09-21', update: null }, now).kind).toBe( + 'quiet' + ); + expect(healthStanding({ ...active, update: update(3) }, now).kind).toBe('current'); + expect(healthStanding({ ...active, update: update(15) }, now)).toMatchObject({ + kind: 'stale', + days: 15, + }); + // a plan that hasn't started, or finished work, owes nothing + expect(healthStanding({ ...active, status: 'planned', update: update(40) }, now).kind).toBe( + 'current' + ); + expect(healthStanding({ ...active, status: 'done', update: null }, now).kind).toBe('quiet'); + // work begun weeks ago but put on the roadmap three days ago owes nothing yet + expect( + healthStanding({ ...active, created_at: now - 3 * 86400, update: null }, now).kind + ).toBe('quiet'); + }); + + // Sep 7, 8 weeks: it ends Nov 1, a hard end + const going = { + status: 'active' as const, + start: '2026-09-07', + weeks: 8, + end_kind: 'hard' as EndKind, + created_at: null, + }; + const lately = (over: Partial = {}): PlanLately => ({ + merged: 2, + open: { ready: 0, hold: 0, review: 1, work: 1 }, + activityAt: now - DAY, + issues: null, + grew: 0, + medianAge: 5, + ...over, + }); + + it('owes nothing while its PRs merge, inside its end and its issues’ pace', () => { + expect(healthStanding({ ...going, update: null, lately: lately() }, now)).toEqual({ + kind: 'quiet', + vouch: { merged: 2, finish: null, endKind: 'hard' }, + }); + // the last update stays the word on it, with why none is owed now + expect(healthStanding({ ...going, update: update(20), lately: lately() }, now)).toEqual({ + kind: 'current', + update: update(20), + vouch: { merged: 2, finish: null, endKind: 'hard' }, + }); + // 2 open, 3 closed and 1 added in four weeks: done in four weeks, Oct 28 + const onPace = lately({ issues: { open: 2, closed: 3, added: 1 } }); + expect(healthStanding({ ...going, update: null, lately: onPace }, now)).toMatchObject({ + vouch: { finish: now + 28 * DAY }, + }); + }); + + it('owes one when its PRs didn’t merge, it’s past its end, or its issues finish late', () => { + const owes = (over: Partial, item: Partial = {}) => + healthStanding({ ...going, ...item, update: null, lately: lately(over) }, now).kind; + expect(owes({ merged: 0 })).toBe('missing'); + expect(owes({}, { weeks: 2 })).toBe('missing'); + // 4 open at one a week: Oct 28 is too late for an Oct 18 end + expect(owes({ issues: { open: 4, closed: 4, added: 0 } }, { weeks: 6 })).toBe('missing'); + // arriving as fast as they close never finishes + expect(owes({ issues: { open: 2, closed: 3, added: 3 } })).toBe('missing'); + // no pace to run on (none closed) leaves the merges to vouch + expect(owes({ issues: { open: 2, closed: 0, added: 2 } })).toBe('quiet'); + expect(healthStanding({ ...going, update: update(20), lately: null }, now).kind).toBe( + 'stale' + ); + }); + + it('owes one when its open PRs pile up behind a merge: 3 or more that grew by 3 or aged', () => { + const owes = (over: Partial) => + healthStanding({ ...going, update: null, lately: lately(over) }, now).kind; + const three = { ready: 1, hold: 0, review: 1, work: 1 }; + // grew by 3 in two weeks, or half of them open over 30 days + expect(owes({ open: three, grew: 3 })).toBe('missing'); + expect(owes({ open: three, medianAge: 30.5 })).toBe('missing'); + // a smaller rise, or a median of 30 days on the dot, still vouches + expect(owes({ open: three, grew: 2, medianAge: 30 })).toBe('quiet'); + // under 3 open, however fast they came, the merges still vouch + expect(owes({ open: { ready: 0, hold: 0, review: 1, work: 1 }, grew: 5 })).toBe('quiet'); + }); + + it('holds the numbers to a hard end only: a soft one is an estimate, ongoing has none', () => { + const owes = (end_kind: EndKind, weeks: number, over: Partial = {}) => + healthStanding({ ...going, end_kind, weeks, update: null, lately: lately(over) }, now); + // Sep 7 for 2 weeks ended Sep 20 + expect(owes('hard', 2).kind).toBe('missing'); + expect(owes('soft', 2)).toEqual({ + kind: 'quiet', + vouch: { merged: 2, finish: null, endKind: 'soft' }, + }); + // 4 open at one a week, done Oct 28: late for a hard Oct 18 end, not a soft one + const slow = { issues: { open: 4, closed: 4, added: 0 } }; + expect(owes('hard', 6, slow).kind).toBe('missing'); + expect(owes('soft', 6, slow).kind).toBe('quiet'); + // issues arriving as fast as they close never finish, unless it never ends + const never = { issues: { open: 2, closed: 3, added: 3 } }; + expect(owes('soft', 8, never).kind).toBe('missing'); + expect(owes('ongoing', 2, never)).toEqual({ + kind: 'quiet', + vouch: { merged: 2, finish: null, endKind: 'ongoing' }, + }); + }); + + it('never vouches over a lead’s at risk or off track: their word stands until a new one', () => { + for (const health of ['at_risk', 'off_track'] as const) { + expect( + healthStanding({ ...going, update: update(20, health), lately: lately() }, now) + ).toEqual({ kind: 'stale', update: update(20, health), days: 20 }); + } + }); + + it('judges a plan past its end, and its issues’ finish, by the team’s day, not UTC’s', () => { + const plan = { ...going, update: null, lately: lately() }; + // 9pm on Sunday Nov 1 in California, its last day, is Monday in UTC + const evening = Date.UTC(2026, 10, 2, 5) / 1000; + expect(healthStanding(plan, evening, '2026-11-01').kind).toBe('quiet'); + expect(healthStanding(plan, evening, '2026-11-02').kind).toBe('missing'); + // 9pm on Oct 4 there: its issues done in four weeks, on its last day + const paced = { ...plan, lately: lately({ issues: { open: 2, closed: 3, added: 1 } }) }; + const oct4 = Date.UTC(2026, 9, 5, 4) / 1000; + expect(healthStanding(paced, oct4, '2026-10-04').kind).toBe('quiet'); + }); + + it('says where a plan’s updates stand in one word, as the API does', () => { + const vouch = { merged: 2, finish: null, endKind: 'hard' as const }; + expect(updateStanding({ kind: 'missing' })).toBe('owed'); + expect(updateStanding({ kind: 'stale', update: update(20), days: 20 })).toBe('owed'); + expect(updateStanding({ kind: 'quiet', vouch })).toBe('vouched'); + expect(updateStanding({ kind: 'current', update: update(20), vouch })).toBe('vouched'); + expect(updateStanding({ kind: 'current', update: update(3) })).toBe('current'); + expect(updateStanding({ kind: 'quiet' })).toBeNull(); + }); + + it('reads a plan still marked planned as in progress once its PRs moved after its start', () => { + const planned = { status: 'planned' as const, start: '2026-09-07', lately: lately() }; + expect(inProgress(planned)).toBe(true); + expect(inProgress({ ...planned, lately: lately({ activityAt: now - 30 * DAY }) })).toBe( + false + ); + expect(inProgress({ ...planned, lately: lately({ activityAt: null }) })).toBe(false); + expect(inProgress({ ...planned, lately: null })).toBe(false); + expect(inProgress({ ...planned, status: 'parked' })).toBe(false); + // so it owes updates as one marked in progress does + expect( + healthStanding({ ...going, ...planned, update: null, lately: lately({ merged: 0 }) }, now) + .kind + ).toBe('missing'); + }); + + it('runs a project’s issues at the last four weeks’ pace', () => { + const issues = [ + { state: 'open', closedAt: null, attachedAt: now - 3 * DAY }, + { state: 'open', closedAt: null, attachedAt: null }, + { state: 'done', closedAt: now - 2 * DAY, attachedAt: now - 60 * DAY }, + { state: 'dropped', closedAt: now - 40 * DAY, attachedAt: now - 60 * DAY }, + ]; + expect(issuePace(issues, now)).toEqual({ open: 2, closed: 1, added: 1 }); + // 3 open, one fewer a week: three weeks + expect(paceFinish({ open: 3, closed: 5, added: 1 }, now)).toBe(now + 21 * DAY); + expect(paceFinish({ open: 3, closed: 2, added: 2 }, now)).toBe(Infinity); + // too few moved to call it a pace + expect(paceFinish({ open: 3, closed: 1, added: 1 }, now)).toBeNull(); + expect(paceFinish({ open: 3, closed: 0, added: 0 }, now)).toBeNull(); + expect(paceFinish({ open: 0, closed: 4, added: 0 }, now)).toBeNull(); + }); +}); + +describe('updatesOwed', () => { + const NOW = dayStart('2026-09-30') as number; + const DAY = 86400; + const item = (id: number, over: Partial): RoadmapItem => ({ + id, + name: `Item ${id}`, + project: null, + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-06-01', + weeks: 30, + end_kind: 'hard', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, + ...over, + }); + const update = (daysAgo: number) => ({ + id: 1, + item_id: 1, + health: 'on_track' as const, + body: '', + plan_start: '2026-06-01', + plan_weeks: 30, + author: 'x', + at: NOW - daysAgo * DAY, + }); + + it('lists each lead’s overdue plans, the most owed first, no lead last', () => { + const owed = updatesOwed( + [ + item(1, { lead: 'dana', update: update(20) }), + item(2, { lead: 'erin', update: update(30) }), + item(3, { lead: 'erin' }), + item(4, { lead: 'dana', update: update(3) }), + item(5, {}), + item(6, { lead: 'finn', status: 'planned' }), + ], + NOW + ); + expect(owed.map(o => [o.lead, o.owed.map(x => [x.item.id, x.kind, x.days])])).toEqual([ + [ + 'erin', + [ + [3, 'missing', 121], + [2, 'stale', 30], + ], + ], + ['dana', [[1, 'stale', 20]]], + [null, [[5, 'missing', 121]]], + ]); + }); + + it('leaves out a plan its numbers vouch for, the way every view does', () => { + const lately = { + merged: 3, + open: { ready: 0, hold: 0, review: 0, work: 1 }, + grew: 0, + medianAge: 4, + }; + const owed = updatesOwed( + [ + item(1, { + lead: 'dana', + update: update(20), + lately: { ...lately, activityAt: NOW, issues: null }, + }), + item(2, { lead: 'dana', update: update(20) }), + ], + NOW + ); + expect(owed.flatMap(o => o.owed.map(x => x.item.id))).toEqual([2]); + }); +}); + +describe('bucketOf', () => { + it('puts work in progress and started plans now, the next quarter next, the rest later', () => { + const today = '2026-09-30'; + expect(bucketOf({ status: 'active', start: '2026-12-07' }, today)).toBe('now'); + expect(bucketOf({ status: 'planned', start: '2026-09-28' }, today)).toBe('now'); + expect(bucketOf({ status: 'planned', start: '2026-12-28' }, today)).toBe('next'); + expect(bucketOf({ status: 'planned', start: '2027-01-04' }, today)).toBe('later'); + expect(bucketOf({ status: 'done', start: '2026-09-28' }, today)).toBeNull(); + }); +}); + +describe('waits on', () => { + const item = (id: number, over: Partial = {}): RoadmapItem => ({ + id, + name: `Item ${id}`, + project: null, + team: null, + lead: null, + status: 'planned', + origin: null, + start: '2026-09-07', + weeks: 4, + end_kind: 'soft', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, + ...over, + }); + + it('picks the plan that speaks for a project: the first under way, or the latest call', () => { + const all = [ + item(1, { project: 'a', status: 'done', updated_at: 100 }), + item(2, { project: 'a', status: 'active' }), + item(3, { project: 'b', status: 'done', updated_at: 100 }), + item(4, { project: 'b', status: 'dropped', updated_at: 200 }), + ]; + expect(planFor('a', all)?.id).toBe(2); + expect(planFor('b', all)?.id).toBe(4); + expect(planFor('c', all)).toBeNull(); + }); + + it('refuses itself, unknown ids, and loops, however long', () => { + const all = [item(1), item(2, { waits_on: [1] }), item(3, { waits_on: [2] })]; + expect(waitsOnProblem(3, [1], all)).toBeNull(); + expect(waitsOnProblem(null, [3], all)).toBeNull(); + expect(waitsOnProblem(1, [1], all)).toMatch(/itself/); + expect(waitsOnProblem(1, [9], all)).toMatch(/no plan 9/); + expect(waitsOnProblem(1, [3], all)).toMatch(/loop/); + }); + + it('clashes when it starts before what it waits on ends, or that was dropped', () => { + const all = [ + item(1, { start: '2026-09-07', weeks: 4 }), // ends Oct 4 + item(2, { status: 'done' }), + item(3, { status: 'dropped' }), + ]; + const clash = (start: string, waits_on: number[]) => + blockersOf({ start, waits_on }, all).map(b => [b.item.id, b.clash]); + expect(clash('2026-09-28', [1])).toEqual([[1, true]]); + expect(clash('2026-10-05', [1])).toEqual([[1, false]]); + expect(clash('2026-09-07', [2, 3, 9])).toEqual([ + [2, false], + [3, true], + ]); + }); + + it('clashes with ongoing work it waits on, which never ends', () => { + const all = [item(1, { status: 'active', end_kind: 'ongoing' })]; + expect(blockersOf({ start: '2027-06-07', waits_on: [1] }, all)[0].clash).toBe(true); + }); +}); + +describe('planLately', () => { + const DAY = 86400; + const now = dayStart('2026-09-30') as number; + const iso = (daysAgo: number) => new Date((now - daysAgo * DAY) * 1000).toISOString(); + const open = (daysAgo: number) => + ({ + status: 'needs_cr', + cryo: false, + externalBlock: false, + conflict: false, + changesRequestedBy: [], + data: { created_at: iso(daysAgo), status: {} }, + } as unknown as DerivedPull); + const gone = (openedDaysAgo: number, closedDaysAgo: number, merged: boolean) => + ({ + created_at: iso(openedDaysAgo), + closed_at: iso(closedDaysAgo), + merged_at: merged ? iso(closedDaysAgo) : null, + } as PullData); + + it('counts how many more are open than two weeks ago, and how old they are', () => { + const lately = planLately( + { + // three opened in the last two weeks, one long before + open: [open(2), open(5), open(9), open(40)], + // one merged and one closed of those open two weeks ago, and one + // opened and merged inside the two weeks, which nets out + merged: [gone(20, 3, true), gone(6, 1, true)], + closed: [gone(30, 4, false)], + lastActivity: now - DAY, + }, + null, + now + ); + // 4 open now; 1 + 2 were open then + expect(lately.grew).toBe(1); + expect(lately.medianAge).toBe(7); + expect(lately.merged).toBe(2); + expect(lately.open.review).toBe(4); + expect( + planLately({ open: [], merged: [], closed: [], lastActivity: null }, null, now) + ).toMatchObject({ grew: 0, medianAge: null }); + }); +}); + +describe('weeksThrough', () => { + it('counts through the week holding the end, one week at least and two years at most', () => { + // Oct 31 is a Saturday, in the week of Oct 26: five weeks from Sep 28 + expect(weeksThrough('2026-09-28', '2026-10-31')).toBe(5); + expect(planEnd({ start: '2026-09-28', weeks: 5 })).toBe('2026-11-01'); + expect(weeksThrough('2026-09-28', '2026-09-28')).toBe(1); + expect(weeksThrough('2026-09-28', '2026-08-01')).toBe(1); + expect(weeksThrough('2026-09-28', '2031-01-01')).toBe(104); + }); +}); + +describe('periodPlan', () => { + it('fills this or next month or quarter in whole weeks', () => { + const today = '2026-09-30'; // a Wednesday in the last week of Q3 + expect(periodPlan('month', 'this', today)).toEqual({ start: '2026-09-28', weeks: 1 }); + expect(periodPlan('month', 'next', today)).toEqual({ start: '2026-09-28', weeks: 5 }); + expect(periodPlan('quarter', 'next', today)).toEqual({ start: '2026-09-28', weeks: 14 }); + expect(periodPlan('quarter', 'this', '2026-08-12')).toEqual({ + start: '2026-08-10', + weeks: 8, + }); + // December's next month is January of the next year + expect(periodPlan('month', 'next', '2026-12-10').start).toBe('2026-12-28'); + }); +}); + +describe('origins', () => { + it('are the same in the URL as in the model, plus not said', () => { + expect([...ORIGIN_KEYS]).toEqual([...ROADMAP_ORIGINS, 'unsaid']); + }); +}); diff --git a/frontend-v2/src/model/roadmapData.test.ts b/frontend-v2/src/model/roadmapData.test.ts new file mode 100644 index 00000000..49b78934 --- /dev/null +++ b/frontend-v2/src/model/roadmapData.test.ts @@ -0,0 +1,315 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; + +const item = (id: number, over: Partial = {}): RoadmapItem => ({ + id, + name: `Item ${id}`, + project: null, + team: null, + lead: null, + status: 'planned', + origin: null, + start: '2026-09-28', + weeks: 4, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, + ...over, +}); + +/** A fetch whose replies the test hands out one call at a time. */ +function scriptedFetch() { + const pending: ((json: unknown, status?: number) => void)[] = []; + const fetch = vi.fn( + () => + new Promise(resolve => { + pending.push((json, status = 200) => + resolve({ status, redirected: false, json: async () => json } as Response) + ); + }) + ); + vi.stubGlobal('fetch', fetch); + return { + /** answer the oldest call still waiting, or the `which`th of them */ + answer: (json: unknown, status?: number, which = 0) => + pending.splice(which, 1)[0]?.(json, status), + }; +} + +// a fresh module per test, so each starts with an empty roadmap +const load = async () => { + vi.resetModules(); + return import('./roadmapData'); +}; +const settled = () => new Promise(resolve => setTimeout(resolve, 0)); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('the roadmap store', () => { + it('ignores a load that went out before a save and answers after it', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ items: [item(1)] }); + await first; + const stale = data.loadRoadmap(); + const save = data.updateRoadmapItem(1, { weeks: 6 }); + answer({ items: [item(1)] }); // the load, still saying 4 weeks + answer({ item: item(1, { weeks: 6 }) }); // the save + await Promise.all([stale, save]); + expect(data.readRoadmap().items?.[0].weeks).toBe(6); + }); + + it('shows a new item once, even when a load brought it first', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const create = data.createRoadmapItem({ name: 'Item 2' }); + await settled(); + const reload = data.loadRoadmap(); + answer({ items: [item(1), item(2)] }, 200, 1); // the load, which already has it + await reload; + answer({ item: item(2) }, 201); // then the create + await create; + expect(data.readRoadmap().items?.map(i => i.id)).toEqual([1, 2]); + }); + + it('fetches the whole roadmap after an add before the first load, rather than a list of one', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const create = data.createRoadmapItem({ name: 'Item 2' }); + answer({ item: item(2) }, 201); + await create; + // every other plan would read as missing, so nothing shows yet + expect(data.readRoadmap().items).toBeNull(); + answer({ items: [item(1), item(2)] }); // the load it asked for + await settled(); + expect(data.readRoadmap().items?.map(i => i.id)).toEqual([1, 2]); + }); + + it('sends an Undo’s times and shows them at once, so the plan reads as never decided', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ items: [item(1, { status: 'done', updated_at: 5000, status_at: 5000 })] }); + await first; + const undo = { updated_at: 2000, status_at: 1000 }; + const save = data.updateRoadmapItem(1, { status: 'active' }, { undo }); + // on screen before the server answers, with the old times, not now + expect(data.readRoadmap().items?.[0]).toMatchObject({ status: 'active', ...undo }); + const sent = JSON.parse(String(vi.mocked(fetch).mock.calls[1][1]?.body)); + expect(sent).toEqual({ status: 'active', undo }); + answer({ item: item(1, { status: 'active', ...undo }) }); + expect(await save).toBe(true); + }); + + it('takes a removed item off what others wait on, and puts it all back on a refusal', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ items: [item(1), item(2, { waits_on: [1] })] }); + await first; + const remove = data.removeRoadmapItem(1); + expect(data.readRoadmap().items).toEqual([item(2)]); + answer({ error: 'nope' }, 500); + expect(await remove).toBe(false); + expect(data.readRoadmap().items?.[1].waits_on).toEqual([1]); + expect(data.readRoadmap().problem).toMatch(/nope/); + }); + + it('does not bring a removed plan back when a reload answered before the delete committed', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ items: [item(1), item(2)] }); + await first; + const remove = data.removeRoadmapItem(1); + const reload = data.loadRoadmap(); // went out after the optimistic remove + answer({ items: [item(1), item(2)] }, 200, 1); // answers first, still listing it + await reload; + answer({ ok: true }); // the delete commits + await remove; + // the dropped answer is not on screen, and the load asked again after + expect(data.readRoadmap().items?.map(i => i.id)).toEqual([2]); + answer({ items: [item(2)] }); + await settled(); + expect(data.readRoadmap().items?.map(i => i.id)).toEqual([2]); + }); + + it('puts a removed item back in its place, and what waited on it with the next load', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ items: [item(1), item(2, { waits_on: [1] }), item(3)] }); + await first; + const remove = data.removeRoadmapItem(1); + answer({ ok: true }); + await remove; + const restore = data.restoreRoadmapItem(1); + const [path, init] = vi.mocked(fetch).mock.calls[2]; + expect([path, init?.method, JSON.parse(String(init?.body))]).toEqual([ + '/roadmap/1', + 'PATCH', + { restore: true }, + ]); + answer({ item: item(1) }); + expect(await restore).toBe(true); + expect(data.readRoadmap().items?.map(i => i.id)).toEqual([1, 2, 3]); + // the load it asks for brings back what waited on it + answer({ items: [item(1), item(2, { waits_on: [1] }), item(3)] }); + await settled(); + expect(data.readRoadmap().items?.[1].waits_on).toEqual([1]); + }); + + it('takes back an update just posted, and the one before is the latest again', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const update = (id: number) => ({ + id, + item_id: 1, + health: 'on_track' as const, + body: '', + plan_start: '2026-09-28', + plan_weeks: 4, + author: 'alice', + at: id, + }); + const first = data.loadRoadmap(); + answer({ items: [item(1, { update: update(7) })] }); + await first; + const back = data.takeBackRoadmapUpdate(1, 7); + const [path, init] = vi.mocked(fetch).mock.calls[1]; + expect([path, init?.method]).toEqual(['/roadmap/1/updates/7', 'DELETE']); + answer({ update: update(6) }); + expect(await back).toEqual({ update: update(6) }); + expect(data.readRoadmap().items?.[0].update?.id).toBe(6); + // a refusal comes back in words, and changes nothing + const theirs = data.takeBackRoadmapUpdate(1, 6); + answer({ error: 'only the person who posted an update can take it back' }, 403); + expect(await theirs).toEqual({ + error: 'Couldn’t take the update back: only the person who posted an update can take it back.', + }); + expect(data.readRoadmap().items?.[0].update?.id).toBe(6); + }); +}); + +describe('what fills itself in', () => { + const lately = (activityAt: number | null) => ({ + merged: 1, + open: { ready: 0, hold: 0, review: 1, work: 0 }, + activityAt, + issues: null, + grew: 0, + medianAge: 4, + }); + const monday = Date.parse('2026-09-28T00:00:00Z') / 1000; + + it('shows a plan still marked planned as in progress once its PRs moved after its start', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ + items: [ + item(1, { project: 'a', lately: lately(monday + 3600) }), + item(2, { project: 'b', lately: lately(monday - 3600) }), + item(3, { project: 'c', status: 'parked', lately: lately(monday + 3600) }), + ], + }); + await first; + expect(data.readRoadmap().items?.map(i => i.status)).toEqual(['active', 'planned', 'parked']); + }); + + it('hands back the status a plan was saved with, for Undo', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ + items: [ + item(1, { project: 'a', lately: lately(monday + 3600) }), + item(2, { status: 'parked' }), + ], + }); + await first; + const [moved, parked] = data.readRoadmap().items as RoadmapItem[]; + expect(moved.status).toBe('active'); + expect(data.storedStatus(moved)).toBe('planned'); + expect(data.storedStatus(parked)).toBe('parked'); + }); + + it('places a new plan by its issue’s Priority, above the first lower one under way', async () => { + const { placedByPriority } = await load(); + const fields = (priority: string | null) => ({ start: null, target: null, priority }); + const projects = [ + { slug: 'high', fields: fields('high') }, + { slug: 'low', fields: fields('low') }, + { slug: 'none', fields: fields(null) }, + ]; + const order = [ + item(1), + item(2, { project: 'none' }), + item(3, { project: 'low', status: 'done' }), + item(4, { project: 'low' }), + item(5, { project: 'high' }), + ]; + // past the plans that say nothing and the one that's finished + expect(placedByPriority(order, 5, projects)).toEqual([1, 2, 3, 5, 4]); + // none lower, or none said: where it is + expect(placedByPriority(order, 4, projects)).toEqual([1, 2, 3, 4, 5]); + expect(placedByPriority(order, 2, projects)).toEqual([1, 2, 3, 4, 5]); + }); + + it('reads what each project did lately the way the server does', async () => { + const { latelyFrom } = await load(); + const now = monday + 2 * 86400; + const iso = (daysAgo: number) => new Date((now - daysAgo * 86400) * 1000).toISOString(); + const pull = (status: string) => ({ + status, + cryo: false, + externalBlock: false, + conflict: false, + changesRequestedBy: [], + data: { created_at: iso(4), status: {} }, + }); + const merged = (daysAgo: number) => ({ + created_at: iso(30), + closed_at: iso(daysAgo), + merged_at: iso(daysAgo), + }); + const group = (slug: string, open: unknown[], mergedPrs: unknown[]) => ({ + slug, + open, + merged: mergedPrs, + closed: [], + lastActivity: now - 3600, + }); + const today = { + live: [group('a', [pull('needs_cr'), pull('dev_block')], [merged(2), merged(16)])], + quiet: [group('b', [], [])], + } as unknown as Parameters[0]; + const open = { state: 'open', closedAt: null, attachedAt: now - 86400 }; + const attached = new Map([ + ['a', [open]], + ['b', [open]], + ]); + const facts = latelyFrom(today, attached, new Set(['b']), now); + expect(facts.get('a')).toEqual({ + merged: 1, + open: { ready: 0, hold: 0, review: 1, work: 1 }, + activityAt: now - 3600, + issues: { open: 1, closed: 0, added: 1 }, + // two opened four days ago, one of those open before merged since + grew: 1, + medianAge: 4, + }); + // work with no end has no finish to forecast + expect(facts.get('b')?.issues).toBeNull(); + }); +}); diff --git a/frontend-v2/src/model/roadmapData.ts b/frontend-v2/src/model/roadmapData.ts new file mode 100644 index 00000000..5d140bc7 --- /dev/null +++ b/frontend-v2/src/model/roadmapData.ts @@ -0,0 +1,625 @@ +import { useEffect } from 'react'; +import { isDummy, loadDummy } from '../backend/dummy'; +import { DUMMY_PROJECTS, DUMMY_ROADMAP, DUMMY_ROADMAP_UPDATES } from '../backend/dummyProjects'; +import { createMemoryStore } from '../storage'; +import { + checkRoadmapFields, + inProgress, + isUnderWay, + issuePace, + mondayOf, + checkRoadmapUpdate, + moveBefore, + planLately, + waitsOnProblem, + type PlanLately, + type RoadmapFields, + type RoadmapHealth, + type RoadmapItem, + type RoadmapUpdate, +} from '../../../shared/model/roadmap'; +import { buildToday, dayStart, utcDay, type Project } from '../../../shared/model/projects'; +import { derive } from '../../../shared/model/status'; +import { isBotLogin } from '../../../shared/model/visibility'; +import { dummyWorkInputs } from './workData'; + +/** + * The roadmap's state on the board: the items (null until the first load), + * and the last problem saving, in words. Changes show at once and are rolled + * back if the server says no, so dragging a bar never waits on a round trip. + * Other people's changes arrive on the next load: every minute while the + * roadmap is open, and whenever the window regains focus. + */ +interface RoadmapState { + items: RoadmapItem[] | null; + loadFailed: boolean; + problem: string | null; +} + +const store = createMemoryStore({ items: null, loadFailed: false, problem: null }); + +/** Items in the roadmap's order: by priority, the older first on a tie. */ +export const byPriority = (items: readonly RoadmapItem[]) => + [...items].sort((a, b) => a.priority - b.priority || a.id - b.id); + +/** + * A plan as the board shows it: one still marked planned whose PRs moved + * after its start is in progress by the numbers (shared/model/roadmap.ts + * inProgress), so every view says so and nobody has to flip it. What's + * stored stays as it was, so no one's name goes on a change they didn't make. + */ +const shown = (item: RoadmapItem): RoadmapItem & { marked?: RoadmapItem['status'] } => + item.status === 'planned' && inProgress(item) + ? // `marked` keeps what it was saved as, for Decide's weekly list + { ...item, status: 'active', marked: 'planned' } + : item; + +/** The status a plan was saved with: what Undo writes back, not the + * In progress that `shown` read off its PRs. */ +export const storedStatus = (item: RoadmapItem): RoadmapItem['status'] => + (item as { marked?: RoadmapItem['status'] }).marked ?? item.status; + +type Reply = { status: number; json: Record }; + +interface Api { + list: () => Promise; + create: (fields: Partial) => Promise; + /** `restate`: a status sent as it already was is a call made again */ + update: ( + id: number, + fields: Partial, + restate?: boolean, + undo?: UndoTimes + ) => Promise; + remove: (id: number) => Promise; + /** a removed item back as it was: Remove's Undo */ + restore: (id: number) => Promise; + reorder: (ids: number[]) => Promise; + updates: (id: number) => Promise; + postUpdate: (id: number, fields: UpdateFields) => Promise; + /** an update its author just posted, taken back */ + removeUpdate: (id: number, updateId: number) => Promise; +} + +type UpdateFields = { health: RoadmapHealth; body: string }; + +/** The times an Undo puts back with the fields, so the plan reads as if the + * call it takes back never happened (stamped now, that call would count as + * answered after a reload). */ +export interface UndoTimes { + updated_at: number; + status_at: number | null; +} + +function liveApi(): Api { + const send = async (method: string, path: string, body?: unknown): Promise => { + const res = await fetch(path, { + method, + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + // a dead session redirects toward GitHub's sign-in page, which isn't JSON + const json = (await res.json().catch(() => ({}))) as Record; + return { status: res.redirected ? 401 : res.status, json }; + }; + return { + list: () => send('GET', '/roadmap'), + create: fields => send('POST', '/roadmap', fields), + update: (id, fields, restate, undo) => + send('PATCH', `/roadmap/${id}`, { + ...fields, + ...(restate ? { restate } : {}), + ...(undo ? { undo } : {}), + }), + remove: id => send('DELETE', `/roadmap/${id}`), + restore: id => send('PATCH', `/roadmap/${id}`, { restore: true }), + reorder: ids => send('PUT', '/roadmap/order', { ids }), + updates: id => send('GET', `/roadmap/${id}/updates`), + postUpdate: (id, fields) => send('POST', `/roadmap/${id}/updates`, fields), + removeUpdate: (id, updateId) => send('DELETE', `/roadmap/${id}/updates/${updateId}`), + }; +} + +/** GitHub's Priority field's options, most urgent first, as the server ranks + * them (lib/roadmap.js) */ +const PRIORITIES = ['urgent', 'high', 'medium', 'low']; +const rankOf = (priority: string | null | undefined) => + PRIORITIES.indexOf((priority ?? '').toLowerCase()); + +/** + * The order of plan ids with plan `id` placed by its project issue's + * Priority, as the server places a new one (lib/roadmap.js createItem): + * just above the first plan under way whose own issue says a lower + * priority. Left where it is when its issue says none, or none is lower. + */ +export function placedByPriority( + order: readonly RoadmapItem[], + id: number, + projects: readonly Pick[] +): number[] { + const priorityOf = (slug: string | null) => projects.find(p => p.slug === slug)?.fields.priority; + const ids = order.map(i => i.id); + const rank = rankOf(priorityOf(order.find(i => i.id === id)?.project ?? null)); + const below = + rank >= 0 && + order.find(i => i.id !== id && isUnderWay(i.status) && rankOf(priorityOf(i.project)) > rank); + return below ? moveBefore(ids, id, below.id) : ids; +} + +/** + * What each project did lately, by slug, as the server sends it with each + * plan (lib/roadmap.js latelyBySlug, both through shared planLately): from + * Today, its merges, its open PRs by stage, how many more are open than two + * weeks ago and how old they are, and its newest activity; and its issues' + * pace from its attached issues, which work with no end (`ongoing`) goes + * without. + */ +export function latelyFrom( + today: Pick, 'live' | 'quiet'>, + attached: ReadonlyMap[0][number][]>, + ongoing: ReadonlySet, + now: number +): Map { + const out = new Map(); + for (const g of [...today.live, ...today.quiet]) { + const issues = attached.get(g.slug); + const pace = issues && !ongoing.has(g.slug) ? issuePace(issues, now) : null; + out.set(g.slug, planLately(g, pace, now)); + } + return out; +} + +// the dummy board's facts, kept a minute as the server keeps its own +let dummyFacts: { at: number; bySlug: Map } | null = null; + +/** The dummy board's PRs and issues read the way the server reads its own: + * Today from the fixture's PRs, each in its project by the links too, and + * the issues from the work model's twin. */ +async function dummyLately(plans: readonly RoadmapItem[]): Promise> { + if (dummyFacts && Date.now() - dummyFacts.at < 60_000) return dummyFacts.bySlug; + const now = Date.now() / 1000; + const [{ pulls, bots = [], projectLabelPrefix }, { attached, linked }] = await Promise.all([ + loadDummy(), + dummyWorkInputs(plans), + ]); + const people = pulls.filter(p => !isBotLogin(p.user.login, new Set(bots))); + const today = buildToday( + DUMMY_PROJECTS, + people.filter(p => p.state === 'open').map(p => derive(p, undefined, now)), + people.filter(p => p.state !== 'open'), + projectLabelPrefix ?? 'project:', + now, + linked + ); + const ongoing = new Set(DUMMY_PROJECTS.filter(p => p.ongoing).map(p => p.slug)); + dummyFacts = { at: Date.now(), bySlug: latelyFrom(today, attached, ongoing, now) }; + return dummyFacts.bySlug; +} + +/** The dummy board's stand-in for the server: the same checks, a table in + * memory, gone on reload. */ +function dummyApi(): Api { + const rows = DUMMY_ROADMAP.map(item => ({ ...item })); + let nextId = Math.max(0, ...rows.map(r => r.id)) + 1; + let updates = DUMMY_ROADMAP_UPDATES.map(u => ({ ...u })); + // removed plans keep their rows and updates until Undo puts them back, + // left off what others wait on meanwhile, as on the server + const removed = new Set(); + const live = () => + rows + .filter(r => !removed.has(r.id)) + .map(r => ({ ...r, waits_on: r.waits_on.filter(other => !removed.has(other)) })); + const rowOf = (id: number) => rows.find(r => r.id === id && !removed.has(id)); + const ok = (json: Record, status = 200) => Promise.resolve({ status, json }); + const bad = (error: string, status = 400) => ok({ error }, status); + // stamped at each write, the way the server does: the Decide queue + // compares it with PR activity and updates + const touch = () => ({ + updated_by: 'danielbeardsley', + updated_at: Math.floor(Date.now() / 1000), + }); + // copies, as a server's JSON would be: handing out its own rows let a + // later write change rows the page already held, under React's feet; + // each with what its project did lately, as the server sends it + const sent = async (items: readonly RoadmapItem[]) => { + const bySlug = await dummyLately(live()); + return items.map(r => ({ + ...r, + waits_on: r.waits_on.filter(other => !removed.has(other)), + lately: (r.project && bySlug.get(r.project)) || null, + })); + }; + return { + list: async () => ok({ items: await sent(byPriority(live())) }), + create: async fields => { + const checked = checkRoadmapFields(fields, { partial: false }); + if ('error' in checked) return bad(checked.error); + const f = checked.fields; + const loop = f.waits_on && waitsOnProblem(null, f.waits_on, live()); + if (loop) return bad(loop); + // its project's issue says when it starts, when nobody did + const issue = DUMMY_PROJECTS.find(p => p.slug === f.project)?.fields; + const start = + issue?.start && dayStart(issue.start) != null ? issue.start : utcDay(Date.now() / 1000); + const item: RoadmapItem = { + id: nextId++, + name: f.name ?? '', + project: f.project ?? null, + team: f.team ?? null, + lead: f.lead ?? null, + status: f.status ?? 'planned', + origin: f.origin ?? null, + start: f.start ?? mondayOf(start), + weeks: f.weeks ?? 4, + // an estimate, as on the server, until someone commits + end_kind: f.end_kind ?? 'soft', + done_when: f.done_when ?? '', + notes: f.notes ?? '', + waits_on: f.waits_on ?? [], + priority: Math.max(-1, ...rows.map(r => r.priority)) + 1, + ...touch(), + created_at: Math.floor(Date.now() / 1000), + status_at: Math.floor(Date.now() / 1000), + update: null, + }; + rows.push(item); + // and where it goes, by its Priority + placedByPriority(byPriority(live()), item.id, DUMMY_PROJECTS).forEach((id, i) => { + const row = rows.find(r => r.id === id); + if (row) row.priority = i; + }); + return ok({ item: (await sent([item]))[0] }, 201); + }, + update: async (id, fields, restate, undo) => { + const checked = checkRoadmapFields(fields, { partial: true }); + if ('error' in checked) return bad(checked.error); + const loop = + checked.fields.waits_on && waitsOnProblem(id, checked.fields.waits_on, live()); + if (loop) return bad(loop); + const row = rowOf(id); + if (!row) return bad('no such roadmap item', 404); + // when it stopped is when its status changed (or a call restated + // it), not its last edit + const moved = + checked.fields.status != null && (checked.fields.status !== row.status || !!restate); + Object.assign(row, checked.fields, touch()); + if (undo) Object.assign(row, undo); + else if (moved) row.status_at = row.updated_at; + return ok({ item: (await sent([row]))[0] }); + }, + remove: id => { + if (!rowOf(id)) return bad('no such roadmap item', 404); + removed.add(id); + return ok({ ok: true }); + }, + restore: async id => { + const row = rows.find(r => r.id === id); + if (!row || !removed.delete(id)) return bad('no removed roadmap item by that id', 404); + return ok({ item: (await sent([row]))[0] }); + }, + updates: id => ok({ updates: updates.filter(u => u.item_id === id).reverse() }), + postUpdate: (id, fields) => { + const checked = checkRoadmapUpdate(fields); + if ('error' in checked) return bad(checked.error); + const row = rowOf(id); + if (!row) return bad('no such roadmap item', 404); + const update: RoadmapUpdate = { + id: Math.max(0, ...updates.map(u => u.id)) + 1, + item_id: id, + ...checked.fields, + plan_start: row.start, + plan_weeks: row.weeks, + author: touch().updated_by, + at: Math.floor(Date.now() / 1000), + }; + updates.push(update); + row.update = update; + return ok({ update }, 201); + }, + removeUpdate: (id, updateId) => { + const gone = updates.find(u => u.id === updateId && u.item_id === id); + if (!gone) return bad('no such update on that item', 404); + if (gone.author !== touch().updated_by) { + return bad('only the person who posted an update can take it back', 403); + } + updates = updates.filter(u => u !== gone); + // kept oldest first, so the item's latest is its last + const update = updates.filter(u => u.item_id === id).at(-1) ?? null; + const row = rows.find(r => r.id === id); + if (row) row.update = update; + return ok({ update }); + }, + reorder: async ids => { + ids.forEach((id, i) => { + const row = rows.find(r => r.id === id); + if (row) row.priority = i; + }); + return ok({ items: await sent(byPriority(live())) }); + }, + }; +} + +/** + * Failures on demand for the dummy board: `?fail=load` fails the roadmap's + * loads and `?fail=save` its writes, so a failed load or save can be + * designed and checked there like any other state. + */ +function failing(inner: Api): Api { + const fail = new URLSearchParams(window.location.search).get('fail'); + const down = () => Promise.resolve({ status: 503, json: { error: 'the server didn’t answer' } }); + if (fail === 'load') return { ...inner, list: down, updates: down }; + if (fail === 'save') + return { + ...inner, + create: down, + update: down, + remove: down, + restore: down, + reorder: down, + postUpdate: down, + removeUpdate: down, + }; + return inner; +} + +/** The dummy board has no server to say projectsChanged after a write, so + * its roadmap says it here, and the dummy backend hands it on: parking a + * project on Decide then reaches the review board, as it would live. */ +function announcing(inner: Api): Api { + const said = + (call: (...args: A) => Promise) => + async (...args: A) => { + const reply = await call(...args); + if (reply.status < 300) window.dispatchEvent(new Event('pd:projectsChanged')); + return reply; + }; + return { + ...inner, + create: said(inner.create), + update: said(inner.update), + remove: said(inner.remove), + reorder: said(inner.reorder), + }; +} + +const api = isDummy() ? announcing(failing(dummyApi())) : liveApi(); + +function problemOf(reply: Reply, doing: string): string { + if (reply.status === 401) return 'Your sign-in expired. Reload the page to sign in again.'; + const error = typeof reply.json.error === 'string' ? reply.json.error : null; + return error ? `Couldn’t ${doing}: ${error}.` : `Couldn’t ${doing}. Try again in a minute.`; +} + +// Counts writes, so a load that went out before one and answers after it +// can't put the old plan back on screen; the next load brings the new one. +let writes = 0; +// Removals still waiting on the server. A load answered meanwhile may have +// read before the DELETE committed and still list the plan, so it's dropped +// and asked for again once the last removal settles. +let removing = 0; +let droppedWhileRemoving = false; + +export async function loadRoadmap(): Promise { + const seen = writes; + try { + const reply = await api.list(); + if (reply.status !== 200) throw new Error(String(reply.status)); + if (seen !== writes) return; + if (removing > 0) { + droppedWhileRemoving = true; + return; + } + const items = (reply.json.items as RoadmapItem[]).map(shown); + store.set({ ...store.get(), items, loadFailed: false }); + } catch { + if (seen === writes) store.set({ ...store.get(), loadFailed: true }); + } +} + +/** Change the items on screen now; hand back a way to put them back. */ +function optimistic(change: (items: RoadmapItem[]) => RoadmapItem[]): () => void { + writes++; + const before = store.get().items; + store.set({ ...store.get(), items: change(before ?? []), problem: null }); + return () => store.set({ ...store.get(), items: before }); +} + +function settle(reply: Reply, doing: string, undo: () => void): boolean { + if (reply.status >= 200 && reply.status < 300) return true; + undo(); + store.set({ ...store.get(), problem: problemOf(reply, doing) }); + return false; +} + +/** Add an item: at the bottom, or where its project issue's Priority puts it. + * Resolves to it, or null when the server said no. */ +export async function createRoadmapItem( + fields: Partial +): Promise { + writes++; + const reply = await api.create(fields).catch((): Reply => ({ status: 0, json: {} })); + // nothing was shown early, so there's nothing to take back + if (!settle(reply, 'add the plan', () => undefined)) return null; + const item = shown(reply.json.item as RoadmapItem); + const before = store.get().items; + // never loaded (still loading, or the load failed): a list of just this + // one would read as the whole roadmap, every other project with no plan, + // so the full list is fetched instead + if (before == null) { + void loadRoadmap(); + return item; + } + // a load that already had it mustn't make it show twice + const others = before.filter(i => i.id !== item.id); + store.set({ ...store.get(), items: byPriority([...others, item]) }); + // placed above others by its Priority, which moved their places too + if (others.some(i => i.priority >= item.priority)) void loadRoadmap(); + return item; +} + +export async function updateRoadmapItem( + id: number, + fields: Partial, + { restate = false, undo }: { restate?: boolean; undo?: UndoTimes } = {} +): Promise { + // stamped now, as the server will: a decision shows as made right away + const now = Math.floor(Date.now() / 1000); + const takeBack = optimistic(items => + items.map(i => + i.id === id + ? { + ...i, + ...fields, + updated_at: now, + ...(fields.status && (fields.status !== i.status || restate) + ? { status_at: now } + : {}), + ...(undo ?? {}), + } + : i + ) + ); + const reply = await api + .update(id, fields, restate, undo) + .catch((): Reply => ({ status: 0, json: {} })); + if (!settle(reply, 'save the plan', takeBack)) return false; + const saved = shown(reply.json.item as RoadmapItem); + store.set({ + ...store.get(), + items: (store.get().items ?? []).map(i => (i.id === id ? saved : i)), + }); + return true; +} + +/** Take an item off the roadmap. The server keeps it, so restoreRoadmapItem + * can put it back. */ +export async function removeRoadmapItem(id: number): Promise { + // the server leaves it off what other items wait on; so does the screen + const undo = optimistic(items => + items + .filter(i => i.id !== id) + .map(i => + i.waits_on.includes(id) ? { ...i, waits_on: i.waits_on.filter(x => x !== id) } : i + ) + ); + removing++; + const reply = await api.remove(id).catch((): Reply => ({ status: 0, json: {} })); + removing--; + const ok = settle(reply, 'remove the plan', undo); + if (removing === 0 && droppedWhileRemoving) { + droppedWhileRemoving = false; + void loadRoadmap(); + } + return ok; +} + +/** Put a removed item back as it was (Remove's Undo): its updates and its + * place come with it, and what waited on it with the load that follows. */ +export async function restoreRoadmapItem(id: number): Promise { + writes++; + const reply = await api.restore(id).catch((): Reply => ({ status: 0, json: {} })); + // nothing was shown early, so there's nothing to take back + if (!settle(reply, 'put the plan back', () => undefined)) return false; + const item = shown(reply.json.item as RoadmapItem); + const others = (store.get().items ?? []).filter(i => i.id !== id); + store.set({ ...store.get(), items: byPriority([...others, item]) }); + void loadRoadmap(); + return true; +} + +/** Put the items in this order, top first; resolves to whether it saved. If + * someone else changed the roadmap meanwhile, the server's list wins and + * the person hears why. */ +export async function reorderRoadmap(ids: number[]): Promise { + const undo = optimistic(items => + byPriority(items.map(i => ({ ...i, priority: ids.indexOf(i.id) }))) + ); + const reply = await api.reorder(ids).catch((): Reply => ({ status: 0, json: {} })); + if (reply.status === 409) { + store.set({ + ...store.get(), + items: (reply.json.items as RoadmapItem[]).map(shown), + problem: + 'Someone else just added or removed a plan, so your new order wasn’t saved. This is the order now; try again.', + }); + return false; + } + if (!settle(reply, 'save the new order', undo)) return false; + store.set({ ...store.get(), items: (reply.json.items as RoadmapItem[]).map(shown) }); + return true; +} + +/** An item's updates, newest first; null when they couldn't be loaded. */ +export async function loadRoadmapUpdates(id: number): Promise { + const reply = await api.updates(id).catch((): Reply => ({ status: 0, json: {} })); + return reply.status === 200 ? (reply.json.updates as RoadmapUpdate[]) : null; +} + +/** Post an update. Once the server has it, the item's latest update changes + * everywhere the roadmap shows; a refusal comes back in words for the form. */ +export async function postRoadmapUpdate( + id: number, + fields: UpdateFields +): Promise<{ update: RoadmapUpdate } | { error: string }> { + writes++; + const reply = await api.postUpdate(id, fields).catch((): Reply => ({ status: 0, json: {} })); + if (reply.status !== 201) return { error: problemOf(reply, 'post the update') }; + const update = reply.json.update as RoadmapUpdate; + store.set({ + ...store.get(), + items: (store.get().items ?? []).map(i => (i.id === id ? { ...i, update } : i)), + }); + return { update }; +} + +/** Take back an update just posted (its Undo): the one before it is the + * item's latest again everywhere the roadmap shows. A refusal comes back in + * words. */ +export async function takeBackRoadmapUpdate( + id: number, + updateId: number +): Promise<{ update: RoadmapUpdate | null } | { error: string }> { + writes++; + const reply = await api.removeUpdate(id, updateId).catch((): Reply => ({ status: 0, json: {} })); + if (reply.status !== 200) return { error: problemOf(reply, 'take the update back') }; + const update = (reply.json.update as RoadmapUpdate | null) ?? null; + store.set({ + ...store.get(), + items: (store.get().items ?? []).map(i => (i.id === id ? { ...i, update } : i)), + }); + return { update }; +} + +/** The roadmap as the board has it now, outside React. */ +export function readRoadmap(): RoadmapState { + return store.get(); +} + +export function dismissRoadmapProblem(): void { + store.set({ ...store.get(), problem: null }); +} + +// One refresh for however many views show the roadmap: the first to mount +// loads it and starts the timer, the last to unmount stops it. +let watchers = 0; +let timer: number | undefined; +const reload = () => void loadRoadmap(); + +function watch(): () => void { + if (watchers++ === 0) { + void loadRoadmap(); + timer = window.setInterval(reload, 60_000); + window.addEventListener('focus', reload); + } + return () => { + if (--watchers > 0) return; + window.clearInterval(timer); + window.removeEventListener('focus', reload); + }; +} + +/** The roadmap, loaded now and kept fresh while any view shows it. */ +export function useRoadmap(): RoadmapState { + useEffect(watch, []); + return store.useValue(); +} diff --git a/frontend-v2/src/model/roadmapTime.test.ts b/frontend-v2/src/model/roadmapTime.test.ts new file mode 100644 index 00000000..1b857622 --- /dev/null +++ b/frontend-v2/src/model/roadmapTime.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + columnsFor, + commitEnds, + parseZoom, + quarterOf, + shiftZoom, + zoomAround, + zoomKey, + type Zoom, +} from './roadmapTime'; + +const z = (key: string) => parseZoom(key) as Zoom; + +describe('zoom keys', () => { + it('read and write quarters and months, and refuse anything else', () => { + expect(parseZoom('2026-Q4')).toEqual({ kind: 'quarter', year: 2026, index: 3 }); + expect(parseZoom('2026-10')).toEqual({ kind: 'month', year: 2026, index: 9 }); + expect(zoomKey(z('2026-10'))).toBe('2026-10'); + for (const bad of ['2026-Q5', '2026-13', '2026-1', 'Q4', '', null]) { + expect(parseZoom(bad)).toBeNull(); + } + }); + + it('find the period a day falls in', () => { + expect(zoomKey(zoomAround('quarter', new Date(2026, 8, 30)))).toBe('2026-Q3'); + expect(zoomKey(zoomAround('month', new Date(2026, 8, 30)))).toBe('2026-09'); + }); + + it('step across year ends both ways, and find a month’s quarter', () => { + expect(zoomKey(shiftZoom(z('2026-Q4'), 1))).toBe('2027-Q1'); + expect(zoomKey(shiftZoom(z('2026-01'), -1))).toBe('2025-12'); + expect(zoomKey(quarterOf(z('2026-11')))).toBe('2026-Q4'); + }); +}); + +describe('columnsFor', () => { + const now = new Date(2026, 8, 30); + + it('spreads a zoomed quarter over its three months, each one a zoom further in', () => { + const cols = columnsFor('quarter', parseZoom('2026-Q4'), now); + expect(cols.map(c => [c.start, c.end, c.zoom])).toEqual([ + ['2026-10-01', '2026-11-01', '2026-10'], + ['2026-11-01', '2026-12-01', '2026-11'], + ['2026-12-01', '2027-01-01', '2026-12'], + ]); + }); + + it('cuts a zoomed month into its weeks at the month’s edges', () => { + // October 2026 starts on a Thursday and ends on a Saturday + const cols = columnsFor('quarter', parseZoom('2026-10'), now); + expect(cols.map(c => [c.start, c.end])).toEqual([ + ['2026-10-01', '2026-10-05'], + ['2026-10-05', '2026-10-12'], + ['2026-10-12', '2026-10-19'], + ['2026-10-19', '2026-10-26'], + ['2026-10-26', '2026-11-01'], + ]); + expect(cols.every(c => c.zoom === null)).toBe(true); + }); + + it('starts the wide views a period back, each column a zoom in', () => { + const quarters = columnsFor('quarter', null, now); + expect(quarters[0]).toMatchObject({ start: '2026-04-01', zoom: '2026-Q2' }); + expect(quarters).toHaveLength(4); + const months = columnsFor('month', null, now); + expect(months[0]).toMatchObject({ start: '2026-08-01', zoom: '2026-08' }); + expect(months).toHaveLength(7); + }); +}); + +describe('commitEnds', () => { + it('skips a period ending within a week, and crosses the year', () => { + expect(commitEnds('2026-09-29').map(c => [c.label, c.end])).toEqual([ + ['End of Oct', '2026-10-31'], + ['End of Nov', '2026-11-30'], + ['End of Q4', '2026-12-31'], + ['End of Q1 2027', '2027-03-31'], + ]); + // a month next year says its year too + expect(commitEnds('2026-12-01').map(c => c.label)).toEqual([ + 'End of Q4', + 'End of Jan 2027', + 'End of Q1 2027', + ]); + // mid-month, the month and quarter at hand still count, and December + // goes by its quarter's name + expect(commitEnds('2026-11-10').map(c => [c.label, c.end])).toEqual([ + ['End of Nov', '2026-11-30'], + ['End of Q4', '2026-12-31'], + ['End of Q1 2027', '2027-03-31'], + ]); + expect(commitEnds('2026-11-10')[1].through).toBe('the end of Q4'); + }); + + it('puts a target still ahead first, however near, and never one already passed', () => { + expect(commitEnds('2026-10-01', '2026-10-21').map(c => [c.label, c.through])).toEqual([ + ['Its target date, Oct 21', 'its target date, Oct 21'], + ['End of Oct', 'the end of Oct'], + ['End of Nov', 'the end of Nov'], + ['End of Q4', 'the end of Q4'], + ['End of Q1 2027', 'the end of Q1 2027'], + ]); + // due today still counts; a month ending the same day gives way to it + expect(commitEnds('2026-10-31', '2026-10-31')[0]).toMatchObject({ target: true }); + expect(commitEnds('2026-10-01', '2026-10-31').map(c => c.end)).toEqual([ + '2026-10-31', + '2026-11-30', + '2026-12-31', + '2027-03-31', + ]); + expect(commitEnds('2026-10-01', '2026-09-26').some(c => c.target)).toBe(false); + // a target next year says its year, as the ends do + expect(commitEnds('2026-12-01', '2027-01-15')[0].label).toBe('Its target date, Jan 15 2027'); + }); +}); diff --git a/frontend-v2/src/model/roadmapTime.ts b/frontend-v2/src/model/roadmapTime.ts new file mode 100644 index 00000000..1c03b6e0 --- /dev/null +++ b/frontend-v2/src/model/roadmapTime.ts @@ -0,0 +1,203 @@ +import { ZOOM_KEY } from '../lens'; +import { dateOf, dayOf } from './days'; + +/** + * The roadmap timeline's columns: four quarters or seven months across, or, + * zoomed in, one quarter's months or one month's weeks filling the width. + * A zoom is kept in the URL as its key, "2026-Q4" or "2026-10". + */ + +export interface Column { + start: string; + /** the first day after it */ + end: string; + label: string; + /** the zoom key that fills the width with this column; null at the finest */ + zoom: string | null; +} + +export interface Zoom { + kind: 'quarter' | 'month'; + year: number; + /** the quarter (0 to 3) or the month (0 to 11) */ + index: number; +} + +export function parseZoom(key: string | null | undefined): Zoom | null { + const m = key ? ZOOM_KEY.exec(key) : null; + if (!m) return null; + return m[2] + ? { kind: 'quarter', year: Number(m[1]), index: Number(m[2]) - 1 } + : { kind: 'month', year: Number(m[1]), index: Number(m[3]) - 1 }; +} + +export function zoomKey(z: Zoom): string { + return z.kind === 'quarter' + ? `${z.year}-Q${z.index + 1}` + : `${z.year}-${String(z.index + 1).padStart(2, '0')}`; +} + +/** The same kind of period, `by` of them later (earlier when negative). */ +export function shiftZoom(z: Zoom, by: number): Zoom { + const per = z.kind === 'quarter' ? 4 : 12; + const at = z.year * per + z.index + by; + return { kind: z.kind, year: Math.floor(at / per), index: ((at % per) + per) % per }; +} + +/** The quarter or month a day falls in. */ +export function zoomAround(kind: Zoom['kind'], day: Date): Zoom { + const month = day.getMonth(); + return { + kind, + year: day.getFullYear(), + index: kind === 'quarter' ? Math.floor(month / 3) : month, + }; +} + +/** The quarter a zoomed month is in. */ +export function quarterOf(z: Zoom): Zoom { + return z.kind === 'quarter' + ? z + : { kind: 'quarter', year: z.year, index: Math.floor(z.index / 3) }; +} + +const firstDay = (z: Zoom) => new Date(z.year, z.kind === 'quarter' ? z.index * 3 : z.index, 1); + +export function zoomWords(z: Zoom): string { + return z.kind === 'quarter' + ? `Q${z.index + 1} ${z.year}` + : firstDay(z).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); +} + +const monthKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + +export function columnsFor(scale: 'month' | 'quarter', zoom: Zoom | null, now: Date): Column[] { + if (zoom?.kind === 'quarter') { + const first = firstDay(zoom); + return [0, 1, 2].map(i => { + const a = new Date(first.getFullYear(), first.getMonth() + i, 1); + return { + start: dayOf(a), + end: dayOf(new Date(a.getFullYear(), a.getMonth() + 1, 1)), + label: a.toLocaleDateString(undefined, { month: 'long' }), + zoom: monthKey(a), + }; + }); + } + if (zoom?.kind === 'month') { + // its weeks, cut at the month's edges: the 1st to its Sunday, then + // Monday to Sunday, the last one ending with the month + const first = firstDay(zoom); + const end = new Date(first.getFullYear(), first.getMonth() + 1, 1); + const columns: Column[] = []; + for (let a = first; a < end; ) { + const toMonday = (8 - a.getDay()) % 7 || 7; + const monday = new Date(a.getFullYear(), a.getMonth(), a.getDate() + toMonday); + const b = monday < end ? monday : end; + columns.push({ + start: dayOf(a), + end: dayOf(b), + label: a.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + zoom: null, + }); + a = b; + } + return columns; + } + const y = now.getFullYear(); + const m = now.getMonth(); + if (scale === 'month') { + // seven months from last month, so a little of the past sits beside the plan + return Array.from({ length: 7 }, (_, i) => { + const a = new Date(y, m - 1 + i, 1); + return { + start: dayOf(a), + end: dayOf(new Date(y, m + i, 1)), + label: a.toLocaleDateString(undefined, { + month: 'short', + year: i === 0 || a.getMonth() === 0 ? 'numeric' : undefined, + }), + zoom: monthKey(a), + }; + }); + } + // four quarters from last quarter: past this year, plans run thin + const q = Math.floor(m / 3) * 3; + return Array.from({ length: 4 }, (_, i) => { + const a = new Date(y, q - 3 + i * 3, 1); + const quarter = Math.floor(a.getMonth() / 3) + 1; + return { + start: dayOf(a), + end: dayOf(new Date(y, q + i * 3, 1)), + label: `Q${quarter} ${a.getFullYear()}`, + zoom: `${a.getFullYear()}-Q${quarter}`, + }; + }); +} + +/** An end a plan can commit to. */ +export interface CommitEnd { + /** its words on a button: "End of Oct", "Its target date, Oct 21" */ + label: string; + /** the day it runs through, YYYY-MM-DD */ + end: string; + /** its words after "by" in a sentence: "the end of Oct", "its target + * date, Oct 21" */ + through: string; + /** the project's own target, not a month's or a quarter's end */ + target?: boolean; +} + +/** + * The ends of the coming months and quarters a plan can commit to: the next + * two of each that are at least a week out, so a commitment is never to a + * period that is all but over, in date order. Their labels are the tab's one + * set of words for them, in Decide's calls and the roadmap's chooser and + * editor: "End of Oct", "End of Q4", with the year whenever it isn't this + * one ("End of Q1 2027"). A project's `target` (its issue's Target date or + * its milestone's due day) comes first while it's still ahead, however + * near: "Its target date, Oct 21", in place of a month or quarter ending + * the same day. A target already passed is a miss, not an end to offer. + */ +export function commitEnds(today: string, target?: string | null): CommitEnd[] { + const t = dateOf(today); + const soon = dayOf(new Date(t.getFullYear(), t.getMonth(), t.getDate() + 7)); + const year = (last: Date) => + last.getFullYear() === t.getFullYear() ? '' : ` ${last.getFullYear()}`; + const ends = (months: number, label: (last: Date) => string) => { + const out: CommitEnd[] = []; + const first = Math.floor(t.getMonth() / months) * months; + for (let k = 1; out.length < 2; k++) { + const last = new Date(t.getFullYear(), first + k * months, 0); + const words = label(last); + // "End of Q4" reads "the end of Q4" + const through = `the ${words.replace(/^End/, 'end')}`; + if (dayOf(last) >= soon) out.push({ label: words, end: dayOf(last), through }); + } + return out; + }; + // a month that ends a quarter goes by the quarter's name + const byEnd = new Map( + [ + ...ends( + 1, + last => `End of ${last.toLocaleDateString(undefined, { month: 'short' })}${year(last)}` + ), + ...ends(3, last => `End of Q${Math.floor(last.getMonth() / 3) + 1}${year(last)}`), + ].map(c => [c.end, c]) + ); + const sorted = [...byEnd.values()].sort((a, b) => a.end.localeCompare(b.end)); + if (!target || target < today) return sorted; + const due = dateOf(target); + // "Oct 21", with its year in another one, as the ends say theirs + const day = due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + year(due); + return [ + { + label: `Its target date, ${day}`, + end: target, + through: `its target date, ${day}`, + target: true, + }, + ...sorted.filter(c => c.end !== target), + ]; +} diff --git a/frontend-v2/src/model/settings.test.ts b/frontend-v2/src/model/settings.test.ts new file mode 100644 index 00000000..a7c739c8 --- /dev/null +++ b/frontend-v2/src/model/settings.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + checkDecideRotation, + checkDeveloperTeams, + decideTurn, +} from '../../../shared/model/settings'; + +describe('checkDeveloperTeams', () => { + it('trims names and logins, and counts a login listed twice in a team once', () => { + expect(checkDeveloperTeams({ ' Store ': ['dana', ' erin', 'Dana'] })).toEqual({ + teams: { Store: ['dana', 'erin'] }, + }); + expect(checkDeveloperTeams(null)).toEqual({ teams: null }); + }); + + it('refuses what it can’t count: a login on two teams, a bad login, a bad shape', () => { + const error = (input: unknown) => (checkDeveloperTeams(input) as { error: string }).error; + expect(error({ Store: ['dana'], FixBot: ['DANA'] })).toMatch(/both Store and FixBot/); + expect(error({ Store: ['not a login'] })).toMatch(/isn't a GitHub login/); + expect(error({ Store: 'dana' })).toMatch(/list/); + expect(error(['Store'])).toMatch(/object/); + expect(error({ '': ['dana'] })).toMatch(/team name/); + }); +}); + +describe('the Decide rotation', () => { + it('takes logins in turn order, trimmed, each once; none is nobody', () => { + expect(checkDecideRotation([' dana', 'erin', 'Dana'])).toEqual({ logins: ['dana', 'erin'] }); + expect(checkDecideRotation([])).toEqual({ logins: null }); + expect(checkDecideRotation(null)).toEqual({ logins: null }); + expect(checkDecideRotation('dana')).toHaveProperty('error'); + expect(checkDecideRotation(['two words'])).toHaveProperty('error'); + }); + + it('gives each a week in turn, from the first Monday, the same any day of the week', () => { + const rotation = { logins: ['dana', 'erin', 'finn'], from: '2026-09-28' }; + expect(decideTurn(rotation, '2026-09-28')).toBe('dana'); + expect(decideTurn(rotation, '2026-10-04')).toBe('dana'); + expect(decideTurn(rotation, '2026-10-05')).toBe('erin'); + expect(decideTurn(rotation, '2026-10-19')).toBe('dana'); + // before it started, the turns run backward the same way + expect(decideTurn(rotation, '2026-09-21')).toBe('finn'); + expect(decideTurn(null, '2026-10-05')).toBeNull(); + }); +}); diff --git a/frontend-v2/src/model/settingsData.ts b/frontend-v2/src/model/settingsData.ts new file mode 100644 index 00000000..a3bdd86f --- /dev/null +++ b/frontend-v2/src/model/settingsData.ts @@ -0,0 +1,93 @@ +import { isDummy } from '../backend/dummy'; +import { utcDay } from '../../../shared/model/projects'; +import { mondayOf } from '../../../shared/model/roadmap'; +import { + checkDecideRotation, + checkDeveloperTeams, + checkOngoingProjects, + type DeveloperTeams, +} from '../../../shared/model/settings'; +import { + refreshProjectsData, + markDummyOngoing, + setDummyRotation, + setDummyTeams, +} from './projectData'; + +/** + * Saving the developer teams from the People view: PATCH /settings, which + * checks them with the same shared code, then every window loads again, + * since each developer count changes with the teams. Null goes back to + * config.js's. The dummy board keeps them in memory instead. + */ +export async function saveDeveloperTeams( + teams: DeveloperTeams | null +): Promise<{ ok: true } | { error: string }> { + const checked = checkDeveloperTeams(teams); + if ('error' in checked) return checked; + if (isDummy()) { + setDummyTeams(checked.teams); + refreshProjectsData(); + return { ok: true }; + } + return patchSettings({ developer_teams: checked.teams }, 'the teams'); +} + +/** + * Saving who takes turns running Decide, a week each, the first of them + * this week; null for nobody. The dummy board keeps it in memory. + */ +export async function saveDecideRotation( + logins: string[] | null +): Promise<{ ok: true } | { error: string }> { + const checked = checkDecideRotation(logins); + if ('error' in checked) return checked; + if (isDummy()) { + const from = mondayOf(utcDay(Date.now() / 1000)); + setDummyRotation(checked.logins && { logins: checked.logins, from }); + refreshProjectsData(); + return { ok: true }; + } + return patchSettings({ decide_rotation: checked.logins }, 'who runs Decide'); +} + +/** + * Mark one project ongoing (no end, so Decide never asks it for a first + * plan), or with false take that back. The server changes its list one + * project at a time, so two quick clicks keep each other. The dummy board + * keeps the list in memory. + */ +export async function setOngoing( + slug: string, + ongoing: boolean +): Promise<{ ok: true } | { error: string }> { + const checked = checkOngoingProjects([slug]); + if ('error' in checked) return checked; + if (isDummy()) { + markDummyOngoing(slug, ongoing); + refreshProjectsData(); + return { ok: true }; + } + return patchSettings({ ongoing_project: { slug, ongoing } }, 'whether it’s ongoing'); +} + +/** PATCH /settings, then load every window again. */ +async function patchSettings( + body: Record, + what: string +): Promise<{ ok: true } | { error: string }> { + const res = await fetch('/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => null); + if (!res || res.redirected || res.status === 401) { + return { error: 'Your sign-in expired. Reload the page to sign in again.' }; + } + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { error?: string }; + return { error: json.error ?? `Couldn’t save ${what}. Try again in a minute.` }; + } + refreshProjectsData(); + return { ok: true }; +} diff --git a/frontend-v2/src/model/sort.test.ts b/frontend-v2/src/model/sort.test.ts index 864da9bd..a551eec8 100644 --- a/frontend-v2/src/model/sort.test.ts +++ b/frontend-v2/src/model/sort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { DerivedPull } from '../../../shared/model/status'; -import { crScore, crSort, teamFirst } from './sort'; +import { crScore, crSort, finishersFirst, sinkBy, teamFirst } from './sort'; function fake(over: { ageDays?: number; @@ -50,6 +50,30 @@ describe('crScore / crSort', () => { expect(sorted[1]).toBe(a); expect(sorted[2]).toBe(b); }); + + it("sinks a parked project's pull below a heavier one, never out of the list", () => { + const parkedXs = fake({ weight: 'XS', ageDays: 10 }); + const freshL = fake({ weight: 'L', ageDays: 0 }); + expect(crSort([parkedXs, freshL], p => p === parkedXs)).toEqual([freshL, parkedXs]); + }); +}); + +describe('sinkBy', () => { + it('moves the higher ranks down and keeps each rank in its order', () => { + const [a, b, c, d] = [1, 2, 3, 4].map(n => fake({ ageDays: n })); + expect(sinkBy([a, b, c, d], p => Number(p === a || p === c))).toEqual([b, d, a, c]); + }); +}); + +describe('finishersFirst', () => { + it('lifts a pull that finishes a plan to the front of its tie, no further', () => { + const [a, b, c, d] = [1, 2, 3, 4].map(n => fake({ ageDays: n })); + // a ranks above its own tie; b and c tie, and c would finish a plan + const tie = (p: DerivedPull) => (p === b || p === c ? 'tie' : String(p.ageDays)); + expect(finishersFirst([a, b, c, d], tie, p => p === c)).toEqual([a, c, b, d]); + // a finisher alone in its run stays where the ranking put it + expect(finishersFirst([a, b, c, d], tie, p => p === d)).toEqual([a, b, c, d]); + }); }); function withAuthor(login: string): DerivedPull { diff --git a/frontend-v2/src/model/sort.ts b/frontend-v2/src/model/sort.ts index c410feb7..89e12024 100644 --- a/frontend-v2/src/model/sort.ts +++ b/frontend-v2/src/model/sort.ts @@ -22,10 +22,12 @@ export function crScore(p: DerivedPull): number { return weight - (oneFromDone ? 1.5 : 0) - ageCredit; } -/** best next review first; actively-iterating pulls sink (demoted, never hidden) */ -export function crSort(pulls: DerivedPull[]): DerivedPull[] { +/** best next review first; actively-iterating pulls sink (demoted, never + * hidden), and a parked project's (`sinks`, model/standing.ts) below them */ +export function crSort(pulls: DerivedPull[], sinks?: (p: DerivedPull) => boolean): DerivedPull[] { return [...pulls].sort( (a, b) => + Number(!!sinks?.(a)) - Number(!!sinks?.(b)) || Number(isIterating(a)) - Number(isIterating(b)) || crScore(a) - crScore(b) || b.ageDays - a.ageDays || @@ -48,3 +50,41 @@ export function teamFirst(pulls: DerivedPull[], team: ReadonlySet): Deri ...pulls.filter(p => !team.has(p.data.user.login)), ]; } + +/** Lowest `rank` first, each rank keeping the order it had (sort is + * stable): how a lane sinks a parked project's pulls, or a bot's, to its + * bottom without reordering anything else. */ +export function sinkBy(pulls: DerivedPull[], rank: (p: DerivedPull) => number): DerivedPull[] { + return [...pulls].sort((a, b) => rank(a) - rank(b)); +} + +/** + * Within each run of neighbors that tie (the same `tie` key: the lane's + * own ranking with age read in whole days), the pulls that would finish a + * plan go first, every run otherwise in its order. A tie-break only: no + * pull passes one that ranks above it for its own reasons, or leaves its + * lane. + */ +export function finishersFirst( + pulls: DerivedPull[], + tie: (p: DerivedPull) => string, + finishes: (p: DerivedPull) => boolean +): DerivedPull[] { + // most lanes hold none, and a tie key can cost a score + if (!pulls.some(finishes)) return pulls; + const out: DerivedPull[] = []; + let run: DerivedPull[] = []; + let runKey = ''; + const flush = () => { + out.push(...run.filter(finishes), ...run.filter(p => !finishes(p))); + run = []; + }; + for (const p of pulls) { + const key = tie(p); + if (run.length && key !== runKey) flush(); + runKey = key; + run.push(p); + } + flush(); + return out; +} diff --git a/frontend-v2/src/model/stage.test.ts b/frontend-v2/src/model/stage.test.ts new file mode 100644 index 00000000..122010a9 --- /dev/null +++ b/frontend-v2/src/model/stage.test.ts @@ -0,0 +1,405 @@ +import { describe, expect, it } from 'vitest'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import type { DerivedPull, Status } from '../../../shared/model/status'; +import type { IssuePull, ProjectWork } from '../../../shared/model/work'; +import type { PullData } from '../../../shared/types'; +import { + addedLater, + dateWords, + durationWords, + holderWords, + issueForecast, + issueStanding, + lateWords, + plannedAt, + prStage, + withBoardStates, +} from './stage'; + +/** A DerivedPull with only the fields the stage rules read. */ +function dp( + number: number, + status: Status, + o: Partial<{ + author: string; + conflict: boolean; + changesRequestedAt: number | null; + headPushedAt: number | null; + deployBlockedBy: string[]; + externalBlock: boolean; + cryo: boolean; + ageDays: number; + qaingLogin: string | null; + recrBy: string[]; + /** someone took it to review, at this epoch second */ + claim: { login: string; at: number | null }; + }> = {} +): DerivedPull { + const asked = o.changesRequestedAt != null; + return { + data: { + repo: 'iFixit/ifixit', + number, + user: { login: o.author ?? 'dana' }, + // a claim is a review request the reviewer made of themselves + requested_reviewers: o.claim ? [o.claim.login] : [], + review_requests: o.claim ? [{ ...o.claim, self: true }] : [], + status: { + unstamped_reviewers: asked + ? [{ login: 'erin', state: 'CHANGES_REQUESTED', date: o.changesRequestedAt }] + : [], + }, + }, + status, + conflict: o.conflict ?? false, + changesRequestedBy: asked ? ['erin'] : [], + headPushedAt: o.headPushedAt ?? null, + deployBlockedBy: o.deployBlockedBy ?? [], + devBlockedBy: [], + externalBlock: o.externalBlock ?? false, + cryo: o.cryo ?? false, + ageDays: o.ageDays ?? 1, + ci: 'passing', + qaingLogin: o.qaingLogin ?? null, + recrBy: o.recrBy ?? [], + reqaBy: [], + engagedNoStamp: [], + } as unknown as DerivedPull; +} + +const pr = (number: number, state: IssuePull['state'] = 'open'): IssuePull => ({ + repo: 'iFixit/ifixit', + number, + title: `PR ${number}`, + author: 'dana', + createdAt: 0, + state, +}); + +describe('prStage', () => { + it('reads a PR’s state as a product manager would', () => { + expect(prStage(dp(1, 'deploy_block'))).toBe('hold'); + expect(prStage(dp(1, 'ready', { cryo: true }))).toBe('hold'); + expect(prStage(dp(1, 'needs_cr', { externalBlock: true }))).toBe('hold'); + expect(prStage(dp(1, 'draft'))).toBe('work'); + expect(prStage(dp(1, 'dev_block'))).toBe('work'); + expect(prStage(dp(1, 'ci_red'))).toBe('work'); + expect(prStage(dp(1, 'unmergeable', { conflict: true }))).toBe('work'); + expect(prStage(dp(1, 'needs_cr'))).toBe('review'); + expect(prStage(dp(1, 'needs_recr'))).toBe('review'); + expect(prStage(dp(1, 'needs_qa'))).toBe('review'); + expect(prStage(dp(1, 'ready'))).toBe('ready'); + expect(prStage(dp(1, 'ci_pending'))).toBe('ready'); + // a clean PR waiting on the one it's stacked on + expect(prStage(dp(1, 'unmergeable'))).toBe('ready'); + // signed off but in conflict, while CI runs: the board says Rebase + expect(prStage(dp(1, 'ci_pending', { conflict: true }))).toBe('work'); + }); + + it('gives a PR back to its author while asked-for changes wait on them', () => { + expect(prStage(dp(1, 'needs_cr', { changesRequestedAt: 100 }))).toBe('work'); + // pushed since: the reviewer's turn again + expect(prStage(dp(1, 'needs_cr', { changesRequestedAt: 100, headPushedAt: 200 }))).toBe( + 'review' + ); + // on a re-review too: a stamp went stale, then changes were asked for + expect(prStage(dp(1, 'needs_recr', { changesRequestedAt: 100 }))).toBe('work'); + expect(prStage(dp(1, 'needs_recr', { changesRequestedAt: 100, headPushedAt: 200 }))).toBe( + 'review' + ); + }); +}); + +describe('issueStanding', () => { + const board = new Map([ + [1, dp(1, 'ready')], + [2, dp(2, 'needs_cr')], + [3, dp(3, 'draft')], + [4, dp(4, 'deploy_block')], + ]); + const live = (ref: { number: number }) => board.get(ref.number); + + it('takes the stage of its least finished open PR', () => { + expect(issueStanding({ state: 'open', prs: [pr(1), pr(2)] }, live).stage).toBe('review'); + expect(issueStanding({ state: 'open', prs: [pr(1), pr(4)] }, live).stage).toBe('hold'); + const mixed = issueStanding({ state: 'open', prs: [pr(1), pr(4), pr(2), pr(3)] }, live); + expect([mixed.stage, mixed.pull?.data.number]).toEqual(['work', 3]); + // an open PR the board can't read counts as being worked on + expect(issueStanding({ state: 'open', prs: [pr(1), pr(9)] }, live)).toEqual({ + stage: 'work', + pull: null, + }); + }); + + it('says when its PRs merged with the issue still open, or no PR does it yet', () => { + expect(issueStanding({ state: 'open', prs: [pr(8, 'merged')] }, live).stage).toBe('merged'); + expect(issueStanding({ state: 'open', prs: [pr(8, 'closed')] }, live).stage).toBe('none'); + expect(issueStanding({ state: 'open', prs: [] }, live).stage).toBe('none'); + }); + + it('leaves a closed issue done or dropped, whatever its PRs say', () => { + expect(issueStanding({ state: 'done', prs: [pr(3)] }, live).stage).toBe('done'); + expect(issueStanding({ state: 'dropped', prs: [] }, live).stage).toBe('dropped'); + }); +}); + +describe('holderWords', () => { + it('names who holds a PR, and how long it’s been open once that’s past the warning', () => { + expect(holderWords(dp(1, 'draft', { author: 'mlahargou' }))).toBe( + 'mlahargou is working on it' + ); + expect(holderWords(dp(1, 'ready', { author: 'mlahargou' }))).toBe( + 'mlahargou is working on it' + ); + const turns = new Map([['iFixit/ifixit#2', 'erin']]); + expect(holderWords(dp(2, 'needs_cr'), { turns })).toBe('erin’s turn to review'); + expect(holderWords(dp(3, 'needs_cr'), { turns })).toBe('needs a reviewer'); + expect(holderWords(dp(3, 'needs_qa'))).toBe('needs a tester'); + // someone commented with no stamp: a sentence, like its siblings + const engaged = (logins: string[]) => + ({ ...dp(4, 'needs_cr'), engagedNoStamp: logins } as DerivedPull); + expect(holderWords(engaged(['mlahargou']))).toBe('mlahargou is looking at it'); + expect(holderWords(engaged(['ardelato', 'kate']))).toBe('ardelato, kate are looking at it'); + // a named person holds it before anyone's turn does + expect(holderWords(dp(3, 'needs_qa', { qaingLogin: 'k0rvus' }), { turns })).toBe( + 'k0rvus is testing it' + ); + expect(holderWords(dp(2, 'needs_recr', { recrBy: ['djmetzle', 'caphene'] }), { turns })).toBe( + 'waiting on djmetzle, caphene to look again' + ); + expect(holderWords(dp(4, 'deploy_block', { deployBlockedBy: ['sctice'] }))).toBe( + 'deploy hold by sctice' + ); + expect(holderWords(dp(5, 'ready', { cryo: true }))).toBe('paused'); + expect(holderWords(dp(1, 'draft', { ageDays: 38.6 }), { ageWarnDays: 14 })).toBe( + 'dana is working on it, PR open 38 days' + ); + expect(holderWords(dp(1, 'draft', { ageDays: 3 }), { ageWarnDays: 14 })).toBe( + 'dana is working on it' + ); + }); + + it('names who took a PR to review, as its row does', () => { + const now = Date.now() / 1000; + const turns = new Map([['iFixit/ifixit#2', 'erin']]); + expect( + holderWords(dp(2, 'needs_cr', { claim: { login: 'bob', at: now - 60 } }), { turns }) + ).toBe('bob is reading it'); + expect(holderWords(dp(2, 'needs_cr', { claim: { login: 'bob', at: now - 3 * 3600 } }))).toBe( + 'bob claimed it 3h ago' + ); + }); + + it('drops code review’s holders once code review is met', () => { + // a change request left over after enough others stamped it + expect(holderWords(dp(3, 'needs_qa', { changesRequestedAt: 100 }))).toBe('needs a tester'); + }); + + it('starts a line with a capital, but never changes a login', () => { + const turns = new Map([['iFixit/ifixit#2', 'andyg0808']]); + expect(holderWords(dp(1, 'draft', { author: 'mlahargou' }), { line: true })).toBe( + 'mlahargou is working on it' + ); + expect(holderWords(dp(3, 'needs_cr'), { line: true })).toBe('Needs a reviewer'); + expect(holderWords(dp(2, 'needs_cr'), { turns, line: true })).toBe( + 'andyg0808’s turn to review' + ); + expect(holderWords(dp(3, 'needs_qa', { qaingLogin: 'jrodger312' }), { line: true })).toBe( + 'jrodger312 is testing it' + ); + }); + + it('says nothing its own row shows, under that row', () => { + const onRow = { onRow: true, ageWarnDays: 14 }; + // the face is the author, the "external" flag says it, the rail its age + expect(holderWords(dp(1, 'draft', { ageDays: 38 }), onRow)).toBe(''); + expect(holderWords(dp(1, 'needs_cr', { externalBlock: true }), onRow)).toBe(''); + // who it waits on, and what holds it, the row doesn't say + const turns = new Map([['iFixit/ifixit#2', 'erin']]); + expect(holderWords(dp(2, 'needs_cr', { ageDays: 38 }), { ...onRow, turns })).toBe( + 'erin’s turn to review' + ); + expect(holderWords(dp(5, 'ready', { cryo: true }), onRow)).toBe('paused'); + }); +}); + +const DAY = 86400; +const plan = (o: Partial): RoadmapItem => + ({ + id: 1, + project: 'p', + status: 'active', + start: '2026-08-03', + weeks: 4, + status_at: null, + updated_at: null, + created_at: null, + ...o, + } as RoadmapItem); +// noon UTC on a day, as epoch secs +const at = (day: string) => Date.parse(`${day}T12:00:00Z`) / 1000; + +describe('lateWords', () => { + it('says a PR opened after its plan ended', () => { + const plans = [plan({})]; + // the plan's last day is Aug 30 + expect(lateWords(at('2026-08-30'), plans)).toBeNull(); + expect(lateWords(at('2026-09-02'), plans)).toBe('opened after the end date'); + expect(lateWords(null, plans)).toBeNull(); + }); + + it('says it opened after the plan was marked done, not after it ended', () => { + const done = plan({ status: 'done', status_at: at('2026-09-10') }); + expect(lateWords(at('2026-09-05'), [done])).toBe('opened after the end date'); + expect(lateWords(at('2026-09-12'), [done])).toBe('opened after it was marked done'); + expect(lateWords(at('2026-09-12'), [{ ...done, status: 'dropped' }])).toBe( + 'opened after it was dropped' + ); + }); + + it('goes by the project’s closed issue when no plan says it’s finished', () => { + const issue = { as: 'done' as const, at: at('2026-08-20') }; + expect(lateWords(at('2026-08-25'), [], issue)).toBe('opened after it was marked done'); + expect(lateWords(at('2026-08-25'), [plan({ weeks: 8 })], issue)).toBe( + 'opened after it was marked done' + ); + // a close time nobody knows says nothing + expect(lateWords(at('2026-08-25'), [], { as: 'done', at: 0 })).toBeNull(); + }); +}); + +describe('addedLater', () => { + it('counts from the plan’s start, or from when it was made if that’s later', () => { + const started = plannedAt(plan({})); + expect(started).toBe(Date.parse('2026-08-03T00:00:00Z') / 1000); + expect(plannedAt(plan({ created_at: at('2026-08-10') }))).toBe(at('2026-08-10')); + expect(addedLater({ attachedAt: at('2026-08-01') }, started)).toBe(false); + expect(addedLater({ attachedAt: at('2026-08-04') }, started)).toBe(true); + expect(addedLater({ attachedAt: null }, started)).toBe(false); + expect(addedLater({ attachedAt: at('2026-08-04') }, null)).toBe(false); + }); +}); + +describe('issueForecast', () => { + const now = at('2026-10-01'); + const ago = (n: number) => now - n * DAY; + const open = (attached: number | null = 60) => ({ + state: 'open' as const, + closedAt: null, + attachedAt: attached == null ? null : ago(attached), + }); + const closed = (daysAgo: number) => ({ + state: 'done' as const, + closedAt: ago(daysAgo), + attachedAt: ago(60), + }); + const day = (secs: number) => dateWords(secs, now); + + it('runs the open issues at the last four weeks’ pace, closes less adds', () => { + // 4 closed, 3 added: one fewer every four weeks, so 2 open take 8 weeks + const issues = [ + ...[1, 2, 3, 4].map(closed), + open(), + open(5), + open(10), + // added in the window and already dropped + { state: 'dropped' as const, closedAt: ago(2), attachedAt: ago(20) }, + ]; + // the dropped one counts as closed too: 5 closed, 3 added, 3 open + expect(issueForecast(issues, null, now)?.text).toBe( + `5 closed, 3 added in four weeks: done around ${day(now + 6 * 7 * DAY)}` + ); + // a close before the four weeks doesn't set the pace + expect( + issueForecast([closed(40), closed(3), closed(4), closed(5), open()], null, now)?.text + ).toBe(`3 closed, none added in four weeks: done around ${day(now + 2 * 7 * DAY)}`); + }); + + it('says when the target falls before it', () => { + const three = [closed(3), closed(4), closed(5)]; + expect(issueForecast([...three, open()], '2026-10-05', now)?.text).toMatch( + /done around .+, after the target$/ + ); + expect(issueForecast([...three, open()], '2026-12-31', now)?.text).not.toMatch(/target/); + }); + + it('gives no day when issues arrive as fast as they close, or nothing moves', () => { + expect(issueForecast([closed(3), closed(4), open(3), open(5)], null, now)?.text).toBe( + '2 closed, 2 added in four weeks: issues arrive as fast as they close' + ); + expect(issueForecast([closed(3), open(3), open(9)], null, now)?.text).toBe( + '1 closed, 2 added in four weeks: issues arrive faster than they close' + ); + expect(issueForecast([open(), open(null)], null, now)?.text).toBe( + 'no issue closed or added in four weeks' + ); + // nothing open: no forecast + expect(issueForecast([closed(3)], null, now)).toBeNull(); + }); + + it('only counts with too few closes and adds to read a pace from', () => { + // one closed and one added in four weeks is no pace + const thin = issueForecast([closed(3), open(3)], null, now); + expect(thin?.text).toBe('1 closed, 1 added in four weeks'); + expect(thin?.short).toBeUndefined(); + expect(issueForecast([closed(3), open()], null, now)?.text).toBe( + '1 closed, none added in four weeks' + ); + }); + + it('only counts when nothing has closed yet, with no pace to compare', () => { + // a project whose issues all just arrived isn't losing ground + expect(issueForecast([open(3), open(9)], null, now)?.text).toBe( + 'none closed, 2 added in four weeks' + ); + }); +}); + +describe('dateWords', () => { + const now = at('2026-10-01'); + + it('says a day as one phrase, with its year only when it isn’t this one', () => { + const thisYear = dateWords(at('2026-09-21'), now); + expect(thisYear).not.toMatch(/2026| /); + expect(thisYear).toMatch(/21/); + const before = dateWords(at('2024-03-03'), now); + expect(before).toMatch(/2024/); + expect(before).not.toMatch(/ /); + }); +}); + +describe('durationWords', () => { + it('says a median the way a person would, never "0.3 days"', () => { + expect(durationWords(0.01)).toBe('under an hour'); + expect(durationWords(0.04)).toBe('about an hour'); + expect(durationWords(0.3)).toBe('about 7 hours'); + expect(durationWords(6.1)).toBe('6 days'); + expect(durationWords(27.6)).toBe('28 days'); + }); +}); + +describe('withBoardStates', () => { + const ref = (number: number, state: IssuePull['state']) => ({ ...pr(number, state) }); + const page = { + issues: [ + { ref: { repo: 'iFixit/ifixit', number: 50 }, prs: [ref(1, 'open'), ref(2, 'open')] }, + ], + unlinked: [ref(3, 'closed'), ref(4, null)], + } as unknown as ProjectWork; + const board = new Map([[3, dp(3, 'needs_cr')]]); + const gone = new Map([ + [1, { merged_at: '2026-10-01T00:00:00Z' } as PullData], + [2, { merged_at: null } as unknown as PullData], + ]); + + it('reads each PR’s state off the board, newer than the page’s', () => { + const fresh = withBoardStates( + page, + r => board.get(r.number), + r => gone.get(r.number) + ); + // merged and closed since the page loaded; reopened; and one the board never read + expect(fresh.issues[0].prs.map(p => p.state)).toEqual(['merged', 'closed']); + expect(fresh.unlinked.map(p => p.state)).toEqual(['open', null]); + }); +}); diff --git a/frontend-v2/src/model/stage.ts b/frontend-v2/src/model/stage.ts new file mode 100644 index 00000000..69584ccb --- /dev/null +++ b/frontend-v2/src/model/stage.ts @@ -0,0 +1,303 @@ +import { ago, n, pullKey } from '../../../shared/format'; +import type { ClosedIssue } from '../../../shared/model/decide'; +import { dayStart } from '../../../shared/model/projects'; +import { issuePace, paceFinish, type RoadmapItem } from '../../../shared/model/roadmap'; +import { prStage, type PrStage } from '../../../shared/model/stage'; +import { CR_INCOMPLETE, type DerivedPull } from '../../../shared/model/status'; +import { + afterEndOf, + planOfWork, + type AttachedIssue, + type IssuePull, + type IssueRef, + type ProjectIssue, + type ProjectWork, +} from '../../../shared/model/work'; +import type { PullData } from '../../../shared/types'; +import { parked, STALE_CLAIM_SECS } from './actions'; +import { dayOf } from './days'; +import { claimFor, requestedReviewers } from './reviewers'; +import { days } from './words'; + +export { prStage, type PrStage }; + +/** + * Where work stands, in a product manager's words rather than the board's + * CR and QA ones. An open PR is ready to merge, on hold, waiting on review, + * or in development. An open issue is as far along as its least finished + * open PR; with none open, either its PRs merged and the issue is still + * open, or no PR does it yet. Closing an issue is a person's call, so a + * closed one is done or dropped whatever its PRs say. + */ + +export type IssueStage = PrStage | 'merged' | 'none' | 'done' | 'dropped'; + +export const STAGE_WORDS: Record = { + ready: 'Ready to merge', + hold: 'On hold', + review: 'Waiting on review', + // not "being worked on": the tab says that of a project whose PRs moved + // lately (words.ts BEING_WORKED_ON), which a PR waiting on review is too + work: 'In development', + merged: 'PRs merged, issue still open', + none: 'No PR yet', + done: 'Done', + dropped: 'Dropped', +}; + +/** The order a project's page lists them: nearest to shipping first, then + * the work not started, then the closed. */ +export const STAGE_ORDER: readonly IssueStage[] = [ + 'ready', + 'hold', + 'review', + 'work', + 'merged', + 'none', + 'done', + 'dropped', +]; + +// an issue is as far along as its least finished open PR +const LEAST_FINISHED: readonly PrStage[] = ['work', 'review', 'hold', 'ready']; + +/** An issue's stage, and the open PR that sets it (the least finished). */ +export interface IssueStanding { + stage: IssueStage; + /** null when no open PR the board can read sets it */ + pull: DerivedPull | null; +} + +export function issueStanding( + issue: Pick, + live: (ref: IssueRef) => DerivedPull | undefined +): IssueStanding { + if (issue.state !== 'open') return { stage: issue.state, pull: null }; + let least: IssueStanding | null = null; + for (const pr of issue.prs) { + const p = live(pr); + if (!p && pr.state !== 'open') continue; + // an open PR the board can't read is still being worked on, as far as anyone knows + const stage = p ? prStage(p) : 'work'; + const rank = LEAST_FINISHED.indexOf(stage); + if (!least || rank < LEAST_FINISHED.indexOf(least.stage as PrStage)) { + least = { stage, pull: p ?? null }; + } + } + if (least) return least; + return { stage: issue.prs.some(pr => pr.state === 'merged') ? 'merged' : 'none', pull: null }; +} + +/** + * Who holds an open PR right now, in a few words: its author while it's + * in development or ready to merge, whose turn it is to review, or what + * holds it. Says how long it's been open once that's past `ageWarnDays`, + * the board's own age warning. With `line`, the words start a line: they + * take a capital, unless they start with a login, which is never changed. + * With `onRow`, the words sit under the PR's own row, so they leave out + * what the row already shows: its author (the face), an outside block (its + * "external" flag) and its age (the rail); with nothing left, they're + * empty. + */ +export function holderWords( + p: DerivedPull, + opts: { + turns?: ReadonlyMap; + ageWarnDays?: number; + line?: boolean; + onRow?: boolean; + } = {} +): string { + // the board's own words, which a line's start capitalizes + const own = (words: string) => (opts.line ? words[0].toUpperCase() + words.slice(1) : words); + const stage = prStage(p); + let who: string; + if (stage === 'hold') { + if (parked(p)) who = own('paused'); + else if (p.externalBlock) who = opts.onRow ? '' : own('blocked outside the repo'); + else + who = own( + p.deployBlockedBy.length + ? `deploy hold by ${p.deployBlockedBy.join(', ')}` + : 'deploy hold' + ); + } else if (stage === 'review') { + who = reviewWaits(p, opts.turns?.get(pullKey(p.data)) ?? null, own); + } else { + who = opts.onRow ? '' : `${p.data.user.login} is working on it`; + } + const age = Math.floor(p.ageDays); + return !opts.onRow && opts.ageWarnDays != null && age >= opts.ageWarnDays + ? `${who}, PR open ${days(age)}` + : who; +} + +/** + * Who a PR waiting on review waits on, in the order the board's own notes + * weigh it (actions.ts reviewerNote): someone testing it now, the people + * asked to look again, the ones a review was asked of, someone looking, + * whose turn it is; only then nobody yet. `own` marks the board's words, + * as against a login. + */ +function reviewWaits(p: DerivedPull, turn: string | null, own: (words: string) => string): string { + const list = (logins: string[]) => logins.join(', '); + if (p.status === 'needs_qa') { + if (p.qaingLogin) return `${p.qaingLogin} is testing it`; + if (p.reqaBy.length) return own(`waiting on ${list(p.reqaBy)} to test again`); + } + // the rest of code review's; once it's met, a leftover request for changes + // or a claim holds nobody up, as on the board + if (CR_INCOMPLETE.includes(p.status)) { + if (p.status === 'needs_recr' && p.recrBy.length) { + return own(`waiting on ${list(p.recrBy)} to look again`); + } + // changes were asked for and the author has pushed since + if (p.changesRequestedBy.length) { + return own(`waiting on ${list(p.changesRequestedBy)} to look again`); + } + // someone took it to review, in the row's words + const claim = claimFor(p.data); + if (claim) { + return claim.at != null && Date.now() / 1000 - claim.at > STALE_CLAIM_SECS + ? `${claim.login} claimed it ${ago(claim.at)} ago` + : `${claim.login} is reading it`; + } + } + const asked = requestedReviewers(p); + if (asked.length) return own(`waiting on ${list(asked)}`); + if (p.engagedNoStamp.length) { + return `${list(p.engagedNoStamp)} ${ + p.engagedNoStamp.length === 1 ? 'is' : 'are' + } looking at it`; + } + if (turn) return `${turn}’s turn to review`; + return own(p.status === 'needs_qa' ? 'needs a tester' : 'needs a reviewer'); +} + +/** + * Why a PR stands out against its project's plan, in words for its line: + * it opened after the plan was marked done or dropped (or, with no plan + * that says so, after the project's issue was closed), or after the plan's + * last planned week. Null when neither, or when its opening isn't known. + */ +export function lateWords( + createdAt: number | null, + plans: readonly RoadmapItem[], + closed: ClosedIssue | null = null +): string | null { + if (createdAt == null) return null; + const plan = planOfWork(plans, createdAt); + // when it was called finished: its plan's say first, then its issue's + const mark = + plan && (plan.status === 'done' || plan.status === 'dropped') + ? { as: plan.status, at: plan.status_at ?? plan.updated_at } + : closed; + if (mark?.at && createdAt > mark.at) { + return mark.as === 'done' ? 'opened after it was marked done' : 'opened after it was dropped'; + } + return plan && createdAt >= afterEndOf(plan) ? 'opened after the end date' : null; +} + +/** When a plan took effect: its first day, or when it was put on the + * roadmap if that came later (an update is owed counting from the same + * moment). Issues attached after it were added along the way. */ +export function plannedAt(plan: Pick): number { + return Math.max(dayStart(plan.start) ?? 0, plan.created_at ?? 0); +} + +/** An issue attached after its project's plan took effect, as opposed to + * the work planned; false with no plan to tell by. */ +export const addedLater = (issue: Pick, planned: number | null) => + planned != null && issue.attachedAt != null && issue.attachedAt > planned; + +/** + * The day a time fell on here, the way the tab says a day: "Sep 21", with + * its year when that isn't this one ("Mar 3, 2024"). One unbreakable + * phrase, so a narrow line never leaves "Sep" at its end and "21" on the + * next. + */ +export function dateWords(at: number, now: number = Date.now() / 1000): string { + const date = new Date(at * 1000); + const thisYear = date.getFullYear() === new Date(now * 1000).getFullYear(); + return date + .toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: thisYear ? undefined : 'numeric', + }) + .replace(/ /g, '\u00a0'); +} + +/** A median time in the words a person says it: hours under a day ("about + * 7 hours"), whole days from there ("6 days"), never "0.3 days". */ +export function durationWords(d: number): string { + const hours = Math.round(d * 24); + if (hours < 1) return 'under an hour'; + if (hours === 1) return 'about an hour'; + return hours < 24 ? `about ${n(hours, 'hour')}` : days(Math.round(d)); +} + +/** + * A rough finish for a project's issues: how many closed (done or + * dropped) and how many were added over the last four weeks set a pace, + * and the open ones at that pace give a day, or the words that there's no + * end in sight. With none closed there's no pace to compare, and with + * fewer than PACE_MIN closes and adds together too little to read one, so + * it only counts. `due` (YYYY-MM-DD) adds "after the target" to a day past it. + * `how` says how it's worked out. Null when no issue is open. The pace is + * the one the roadmap's rule weighs (shared/model/roadmap.ts paceFinish), so + * the page and the plan's update say the same day. + */ +/** the fewest issues closed and added in four weeks that set a pace */ +export const PACE_MIN = 3; + +export function issueForecast( + issues: readonly Pick[], + due: string | null, + now: number = Date.now() / 1000 +): { text: string; how: string; eta?: number; tally?: string; short?: string } | null { + const pace = issuePace(issues, now); + const { open, closed, added } = pace; + if (!open) return null; + const how = `In the last four weeks ${closed} of its issues closed (done or dropped) and ${added} were added; ${open} are open. A rough guide: it assumes that pace holds.`; + if (!closed && !added) return { text: 'no issue closed or added in four weeks', how }; + const tally = `${closed || 'none'} closed, ${added || 'none'} added in four weeks`; + // one close and one add is no pace: "as fast as they close" from n=1 + // reads as a verdict + if (closed + added < PACE_MIN) return { text: tally, how }; + // a young project's issues all arrived lately: "faster than they close" + // would say it's losing ground before anything had a chance to close + const eta = paceFinish(pace, now); + if (eta == null) return { text: tally, how }; + if (eta === Infinity) { + const words = closed === added ? 'as fast as' : 'faster than'; + const short = `issues arrive ${words} they close`; + return { text: `${tally}: ${short}`, how, tally, short }; + } + // the milestone's day as GitHub means it, the same day the target shows + const late = due != null && dayOf(new Date(eta * 1000)) > due.slice(0, 10); + const short = `done around ${dateWords(eta, now)}${late ? ', after the target' : ''}`; + return { text: `${tally}: ${short}`, how, eta, tally, short }; +} + +/** + * A project's page with each PR's state as the board knows it now. The page + * is read once, when it opens; the board hears each merge and close as it + * happens, so a PR that merged since then reads as merged, not open. + */ +export function withBoardStates( + page: ProjectWork, + live: (ref: IssueRef) => DerivedPull | undefined, + known: (ref: IssueRef) => PullData | undefined +): ProjectWork { + const now = (pr: IssuePull): IssuePull => { + const gone = live(pr) ? undefined : known(pr); + const state = live(pr) ? 'open' : gone ? (gone.merged_at ? 'merged' : 'closed') : pr.state; + return state === pr.state ? pr : { ...pr, state }; + }; + return { + ...page, + issues: page.issues.map(issue => ({ ...issue, prs: issue.prs.map(now) })), + unlinked: page.unlinked.map(now), + }; +} diff --git a/frontend-v2/src/model/standing.test.ts b/frontend-v2/src/model/standing.test.ts new file mode 100644 index 00000000..eba48fa6 --- /dev/null +++ b/frontend-v2/src/model/standing.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import type { DerivedPull } from '../../../shared/model/status'; +import { pullStanding, type ProjectStanding } from './standing'; + +/** An open PR with just what pullStanding reads: its key and labels. */ +function pr(number: number, labels: string[] = []): Pick { + return { + data: { repo: 'org/repo', number, labels: labels.map(title => ({ title })) }, + } as unknown as Pick; +} + +const standing = (over: Partial = {}): ProjectStanding => ({ + pull_links: {}, + plans: {}, + ...over, +}); + +describe('pullStanding', () => { + it("names a parked project's PRs, by label or by the issue they link", () => { + const s = pullStanding( + standing({ + pull_links: { 'org/repo#2': ['picker'] }, + plans: { picker: { status: 'parked', in_progress: false } }, + }), + [pr(1, ['project:picker']), pr(2), pr(3)], + 'project:' + ); + expect([...s.parked]).toEqual([ + ['org/repo#1', 'picker'], + ['org/repo#2', 'picker'], + ]); + expect(s.finishing.size).toBe(0); + }); + + it('files a PR by its label before a link, as the Projects tab does', () => { + const s = pullStanding( + standing({ + pull_links: { 'org/repo#1': ['picker'] }, + plans: { picker: { status: 'parked', in_progress: false } }, + }), + [pr(1, ['project:sync'])], + 'project:' + ); + expect(s.parked.size).toBe(0); + }); + + it('marks the last two open PRs of a plan in progress, not a third', () => { + const plans = { sync: { status: 'active' as const, in_progress: true } }; + const two = pullStanding( + standing({ plans }), + [pr(1, ['project:sync']), pr(2, ['project:sync'])], + 'project:' + ); + expect([...two.finishing]).toEqual([ + ['org/repo#1', { slug: 'sync', left: 2 }], + ['org/repo#2', { slug: 'sync', left: 2 }], + ]); + const three = pullStanding( + standing({ plans }), + [pr(1, ['project:sync']), pr(2, ['project:sync']), pr(3, ['project:sync'])], + 'project:' + ); + expect(three.finishing.size).toBe(0); + }); + + it('finishes nothing for a plan not under way, or work filed as one-offs', () => { + const s = pullStanding( + standing({ + plans: { + later: { status: 'planned', in_progress: false }, + misc: { status: 'active', in_progress: true }, + }, + }), + [pr(1, ['project:later']), pr(2, ['project:misc'])], + 'project:' + ); + expect(s.finishing.size).toBe(0); + }); + + it('says nothing before the standing loads, or with no projects', () => { + const parked = { plans: { picker: { status: 'parked' as const, in_progress: false } } }; + expect(pullStanding(null, [pr(1, ['project:picker'])], 'project:').parked.size).toBe(0); + expect(pullStanding(standing(parked), [pr(1, ['project:picker'])], null).parked.size).toBe(0); + }); +}); diff --git a/frontend-v2/src/model/standing.ts b/frontend-v2/src/model/standing.ts new file mode 100644 index 00000000..0e860bb3 --- /dev/null +++ b/frontend-v2/src/model/standing.ts @@ -0,0 +1,140 @@ +import { useEffect } from 'react'; +import { pullKey } from '../../../shared/format'; +import { issueKey } from '../../../shared/model/issueRef'; +// the small module, so the review board doesn't load the projects model +import { MISC_SLUG, projectOf } from '../../../shared/model/projectLabel'; +import type { PullLinks } from '../../../shared/model/projects'; +import type { RoadmapStatus } from '../../../shared/model/roadmap'; +import type { DerivedPull } from '../../../shared/model/status'; +import { isDummy } from '../backend/dummy'; +import { backend } from '../backend/socket'; +import { createMemoryStore } from '../storage'; + +/** + * What the review board knows of projects (GET /project-standing): which + * projects each PR links, and how each project's plan stands, so a parked + * project's PRs sink in their lanes and the last PRs of a plan under way + * break ties upward. Loaded once a session and again whenever the server + * says projects changed; the dummy board builds the same from its fixtures. + */ +export interface ProjectStanding { + /** the projects each PR links, by issueKey of the PR (projects.ts PullLinks) */ + pull_links: PullLinks; + /** the plan that speaks for each project (roadmap.ts planFor), by slug; + * in_progress is the derived In progress (roadmap.ts inProgress) */ + plans: Record; +} + +/** how long a burst of changes on the server settles before one refetch, + * as on the Projects tab */ +const SETTLE_MS = 2000; + +const store = createMemoryStore<{ standing: ProjectStanding | null }>({ standing: null }); + +/** The dummy board's twin of the endpoint: its PRs' links as /projects-data + * sends them, and its roadmap as the board shows it (a plan read In progress + * from its PRs already says active there). Loaded only on the dummy board, + * so the fixtures stay out of the review board's bundle. */ +async function dummyStanding(): Promise { + const [{ dummyPullLinks }, { loadRoadmap, readRoadmap }, { planFor }] = await Promise.all([ + import('./workData'), + import('./roadmapData'), + import('../../../shared/model/roadmap'), + ]); + await loadRoadmap(); + const items = readRoadmap().items ?? []; + const plans: ProjectStanding['plans'] = {}; + for (const slug of new Set(items.flatMap(i => (i.project ? [i.project] : [])))) { + const plan = planFor(slug, items); + if (plan) plans[slug] = { status: plan.status, in_progress: plan.status === 'active' }; + } + return { pull_links: await dummyPullLinks(), plans }; +} + +async function load(): Promise { + const standing = isDummy() + ? await dummyStanding().catch(() => null) + : await fetch('/project-standing') + .then(r => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null); + // a failed load keeps what was there: the board just doesn't sink or + // raise anything it can't vouch for + if (standing) store.set({ standing }); +} + +let started = false; +function start() { + if (started) return; + started = true; + void load(); + let wait: ReturnType | null = null; + backend.onProjectsChanged(() => { + if (wait) clearTimeout(wait); + wait = setTimeout(() => void load(), SETTLE_MS); + }); +} + +/** The standing, null until it loads; `prefix` null (no projects on this + * server) never asks for it. */ +export function useProjectStanding(prefix: string | null): ProjectStanding | null { + useEffect(() => { + if (prefix) start(); + }, [prefix]); + return store.useValue().standing; +} + +/** A PR's project by the Projects tab's rule: its label, else the project + * whose issue it links (projectLabel.ts projectOf). */ +export function projectOfPull( + p: Pick, + prefix: string, + standing: ProjectStanding | null +): string | null { + return projectOf(p.data.labels, prefix, standing?.pull_links[issueKey(p.data)]); +} + +/** the most open PRs a project under way can have for each to count as one + * of its last: finishing them brings the plan in */ +export const FINISH_MAX_OPEN = 2; + +/** What the review lanes read off the standing, by pullKey. */ +export interface PullStanding { + /** open PRs whose project's plan is parked: they sink in their lanes */ + parked: ReadonlyMap; + /** open PRs that are among the last FINISH_MAX_OPEN of a plan in + * progress: they go first when they'd otherwise tie. `left` counts the + * project's open PRs, this one included. */ + finishing: ReadonlyMap; +} + +export const NO_STANDING: PullStanding = { parked: new Map(), finishing: new Map() }; + +/** + * Each open PR's project and how it stands. `open` is every person's open + * PR whatever the filters narrow, as every Projects view counts them, so a + * filtered-out PR still counts toward its project's last ones. + */ +export function pullStanding( + standing: ProjectStanding | null, + open: readonly Pick[], + prefix: string | null +): PullStanding { + if (!standing || !prefix) return NO_STANDING; + const keysOf = new Map(); + for (const p of open) { + const slug = projectOfPull(p, prefix, standing); + if (slug == null || slug === MISC_SLUG) continue; + keysOf.set(slug, [...(keysOf.get(slug) ?? []), pullKey(p.data)]); + } + const parked = new Map(); + const finishing = new Map(); + for (const [slug, keys] of keysOf) { + const plan = standing.plans[slug]; + for (const key of keys) { + if (plan?.status === 'parked') parked.set(key, slug); + else if (plan?.in_progress && keys.length <= FINISH_MAX_OPEN) + finishing.set(key, { slug, left: keys.length }); + } + } + return { parked, finishing }; +} diff --git a/frontend-v2/src/model/teamLoad.test.ts b/frontend-v2/src/model/teamLoad.test.ts new file mode 100644 index 00000000..789919ba --- /dev/null +++ b/frontend-v2/src/model/teamLoad.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { teamLoad } from './teamLoad'; + +const today = '2026-09-30'; +const plan = (id: number, over: Partial): RoadmapItem => ({ + id, + name: `Plan ${id}`, + project: null, + team: 'Store', + lead: null, + status: 'active', + origin: null, + start: '2026-09-07', + weeks: 8, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, + ...over, +}); +/** a project's PRs: how many are open, and how many days since any moved */ +const work = (slug: string, open: number, quietDays = 0, openSince = '2026-09-01') => ({ + slug, + name: slug, + team: 'Store', + open, + openSince, + lastActivity: { at: 0, days: quietDays, pr: { repo: 'iFixit/ifixit', number: 1, title: '' } }, +}); +const names = (plans: RoadmapItem[], items: ReturnType[]) => + teamLoad('Store', plans, items, today).map(w => w.name); + +describe('teamLoad', () => { + it('counts the Store lane as the dummy board has it: done and parked work still moving too', () => { + // the roadmap counted 5 and Decide 3: Akeneo is done and Store picker + // parked, but both still have PRs open that moved this week + const plans = [ + plan(2, { name: 'Shopify sync', project: 'shopify-sync' }), + // no project and not started: nothing to staff yet + plan(4, { name: 'Checkout redesign', status: 'planned', start: '2026-10-19' }), + plan(8, { name: 'Akeneo 4', project: 'akeneo-4', status: 'done' }), + // planned for later, but its PRs are already open + plan(11, { name: 'Newsletter promo', project: 'newsletter', status: 'planned' }), + plan(12, { name: 'Store picker', project: 'store-picker', status: 'parked' }), + plan(13, { name: 'Type refresh', project: 'type-refresh' }), + ]; + const items = [ + work('shopify-sync', 4), + work('akeneo-4', 1), + work('newsletter', 2), + work('store-picker', 2), + work('type-refresh', 3), + ]; + expect(names(plans, items)).toEqual([ + 'Shopify sync', + 'Akeneo 4', + 'Newsletter promo', + 'Store picker', + 'Type refresh', + ]); + }); + + it('leaves out done or parked work whose PRs stopped, and plans with none open', () => { + const plans = [ + plan(1, { name: 'Parked, quiet', project: 'a', status: 'parked' }), + plan(2, { name: 'Done, all merged', project: 'b', status: 'done' }), + plan(3, { name: 'Under way, none open', project: 'c' }), + plan(4, { name: 'Under way, stalled', project: 'd' }), + ]; + // a stalled plan under way still holds its people; a parked one doesn't + expect(names(plans, [work('a', 2, 9), work('b', 0), work('c', 0), work('d', 1, 30)])).toEqual( + ['Under way, stalled'] + ); + }); + + it('counts a project once, for the plan under way, wherever its finished phase sits', () => { + const plans = [ + plan(1, { name: 'Phase one', project: 'a', status: 'done' }), + plan(2, { name: 'Phase two', project: 'a' }), + plan(3, { name: 'Phase three', project: 'a', status: 'planned' }), + ]; + expect(names(plans, [work('a', 3)])).toEqual(['Phase two']); + }); + + it('counts a plan with no project in its weeks, once marked in progress', () => { + const plans = [ + plan(1, { name: 'Running' }), + plan(2, { name: 'Not marked', status: 'planned' }), + plan(3, { name: 'Ended', start: '2026-08-03', weeks: 4 }), + plan(4, { name: 'Upkeep', start: '2026-08-03', weeks: 4, end_kind: 'ongoing' }), + ]; + expect(names(plans, [])).toEqual(['Running', 'Upkeep']); + }); + + it('puts work with no plan after the plans, a dropped plan being none', () => { + const plans = [plan(1, { name: 'Dropped', project: 'a', status: 'dropped' })]; + const items = [ + work('a', 1, 0, '2026-09-20'), + work('older', 1, 0, '2026-08-01'), + { ...work('theirs', 1), team: 'Other' }, + ]; + expect(names(plans, items)).toEqual(['older', 'a']); + }); +}); diff --git a/frontend-v2/src/model/teamLoad.ts b/frontend-v2/src/model/teamLoad.ts new file mode 100644 index 00000000..bac9d6f6 --- /dev/null +++ b/frontend-v2/src/model/teamLoad.ts @@ -0,0 +1,62 @@ +import { endOf, isUnderWay, planFor, type RoadmapItem } from '../../../shared/model/roadmap'; +import type { PortfolioItem } from './portfolio'; + +/** One piece of a team's work being worked on. */ +export interface InFlight { + name: string; + slug: string | null; + /** its plan, when the roadmap has it */ + id: number | null; +} + +/** how recently a done or parked project's PRs must have moved to still + * take someone's time this week */ +const MOVING_DAYS = 7; + +/** + * What a team's developers have on this week, in priority order: the one + * count the roadmap's team lanes (their words, and the line where the people + * run out) and Decide's team sentence share, so the two can't disagree. + * First its plans, by the roadmap's order, one per project: a plan under way + * whose project has PRs open (with no project, one marked in progress and in + * its weeks), and a done or parked plan whose PRs are still open and moved + * in the last week, since those PRs take people's time too. Then its + * projects with PRs open and no plan (a dropped plan is none), the + * longest-running first. Past the team's developers, the rest have nobody + * left to staff them. + */ +export function teamLoad( + team: string, + plans: readonly RoadmapItem[], + items: readonly (Pick & + Partial>)[], + today: string +): InFlight[] { + const bySlug = new Map(items.map(i => [i.slug, i])); + const kept = plans.filter(p => p.status !== 'dropped'); + const onRoadmap = new Set(kept.flatMap(p => (p.project ? [p.project] : []))); + // a project's PRs count once, for the plan that speaks for it + // (roadmap.ts planFor): the first under way, or else its latest call + const speaker = new Map([...onRoadmap].map(slug => [slug, planFor(slug, kept)?.id])); + const going = (p: RoadmapItem) => { + // ongoing work has no end to run out at + if (!p.project) + return p.status === 'active' && p.start <= today && (endOf(p) ?? today) >= today; + const work = bySlug.get(p.project); + if (!work?.open || speaker.get(p.project) !== p.id) return false; + return isUnderWay(p.status) || (work.lastActivity?.days ?? MOVING_DAYS) < MOVING_DAYS; + }; + const planned = [...kept] + .sort((a, b) => a.priority - b.priority || a.id - b.id) + .filter(p => p.team === team && going(p)) + .map(p => ({ name: p.name, slug: p.project, id: p.id })); + const loose = items + .filter(i => i.team === team && i.open > 0 && !onRoadmap.has(i.slug)) + .sort( + (a, b) => + (a.openSince ?? today).localeCompare(b.openSince ?? today) || + a.name.localeCompare(b.name) + ) + .map(i => ({ name: i.name, slug: i.slug, id: null })); + return [...planned, ...loose]; +} diff --git a/frontend-v2/src/model/updateDraft.test.ts b/frontend-v2/src/model/updateDraft.test.ts new file mode 100644 index 00000000..2af5b983 --- /dev/null +++ b/frontend-v2/src/model/updateDraft.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import type { EndKind, PlanLately } from '../../../shared/model/roadmap'; +import { dayWords } from './projectData'; +import { draftUpdate } from './updateDraft'; + +const DAY = 86400; +// local noon, since the draft reads days as the reader's own +const NOW = new Date(2026, 8, 30, 12).getTime() / 1000; +// Sep 7 for 8 weeks: it ends Nov 1 +const plan = (lately: Partial | null, weeks = 8, end_kind: EndKind = 'hard') => ({ + start: '2026-09-07', + weeks, + end_kind, + lately: lately && { + merged: 2, + open: { ready: 1, hold: 0, review: 4, work: 3 }, + activityAt: NOW - DAY, + issues: null, + grew: 0, + medianAge: 6, + ...lately, + }, +}); + +describe('draftUpdate', () => { + it('says what merged, where the open PRs stand, and when its issues finish', () => { + // 4 open, one fewer a week: done Oct 28, by its Nov 1 end + expect(draftUpdate(plan({ issues: { open: 4, closed: 5, added: 1 } }), NOW)).toEqual({ + health: 'on_track', + body: + '2 PRs merged in the last 14 days, and 8 are open: 1 ready to merge, ' + + '4 waiting on review and 3 in development. At this pace its 4 open issues are ' + + `done around ${dayWords('2026-10-28')}, by its ${dayWords('2026-11-01')} end.`, + }); + }); + + it('is at risk when its issues finish after its end, or never at this pace', () => { + const late = draftUpdate(plan({ issues: { open: 8, closed: 5, added: 1 } }), NOW); + expect(late?.health).toBe('at_risk'); + expect(late?.body).toMatch(/done around .+, after its .+ end\.$/); + const never = draftUpdate(plan({ issues: { open: 3, closed: 2, added: 4 } }), NOW); + expect(never).toMatchObject({ health: 'at_risk' }); + expect(never?.body).toMatch( + /Its issues arrive faster than they close: 2 closed and 4 added in four weeks\.$/ + ); + }); + + it('is at risk when its open PRs stalled, and off track past its end with PRs open', () => { + const stalled = draftUpdate(plan({ merged: 0, activityAt: NOW - 25 * DAY }), NOW); + expect(stalled).toEqual({ + health: 'at_risk', + body: + 'No PRs merged in the last 14 days, and 8 are open: 1 ready to merge, ' + + '4 waiting on review and 3 in development. No PR activity for 25 days.', + }); + // Sep 7 for 2 weeks ended Sep 20 + expect(draftUpdate(plan({}, 2), NOW)).toMatchObject({ + health: 'off_track', + body: expect.stringMatching(/It’s 2 weeks overdue, .+\.$/), + }); + }); + + it('says a soft end passed without calling it off track, and ongoing work has none', () => { + expect(draftUpdate(plan({}, 2, 'soft'), NOW)).toMatchObject({ + health: 'on_track', + body: expect.stringMatching(/It’s 2 weeks past its estimate, .+\.$/), + }); + // finishing after an estimate isn't at risk + const late = draftUpdate(plan({ issues: { open: 8, closed: 5, added: 1 } }, 8, 'soft'), NOW); + expect(late?.health).toBe('on_track'); + expect(late?.body).toMatch(/after its .+ soft end\.$/); + expect(draftUpdate(plan({}, 2, 'ongoing'), NOW)?.body).toMatch( + /It’s ongoing, with no end date\.$/ + ); + }); + + it('names the pile of open PRs a merge couldn’t vouch for', () => { + expect(draftUpdate(plan({ grew: 4 }), NOW)?.body).toMatch( + /3 in development\. That’s 4 more open than 14 days ago\. / + ); + expect(draftUpdate(plan({ medianAge: 41.5 }), NOW)?.body).toMatch( + /Half have been open 41 days or more\. / + ); + expect(draftUpdate(plan({ grew: 3, medianAge: 35 }), NOW)?.body).toMatch( + /That’s 3 more open than 14 days ago, and half have been open 35 days or more\. / + ); + // nothing said under 3 open + const two = { ready: 0, hold: 0, review: 1, work: 1 }; + expect(draftUpdate(plan({ open: two, grew: 2 }), NOW)?.body).not.toMatch(/more open/); + }); + + it('says its end when there’s no pace to tell by, and needs numbers to draft from', () => { + const quiet = plan({ merged: 1, open: { ready: 0, hold: 0, review: 0, work: 0 } }); + expect(draftUpdate(quiet, NOW)?.body).toBe( + `1 PR merged in the last 14 days, and none are open. It ends ${dayWords('2026-11-01')}.` + ); + expect(draftUpdate(plan(null), NOW)).toBeNull(); + }); + + it('writes plain words: no dashes', () => { + const cases = [ + plan({ issues: { open: 4, closed: 5, added: 1 } }), + plan({ merged: 0, activityAt: NOW - 25 * DAY }), + plan({}, 2), + ]; + for (const p of cases) expect(draftUpdate(p, NOW)?.body).not.toMatch(/[–—-]/); + }); +}); + +describe('draftUpdate days', () => { + it('judges the finish on the reader’s local day, as the page does', () => { + // four weeks on from a Sunday night, so the finish lands late on the + // plan's last day, which is already the next day in UTC out west + const now = new Date(2026, 9, 4, 23, 30).getTime() / 1000; + const draft = draftUpdate(plan({ issues: { open: 4, closed: 5, added: 1 } }), now); + expect(draft?.body).toMatch(/, by its /); + expect(draft?.health).toBe('on_track'); + }); +}); diff --git a/frontend-v2/src/model/updateDraft.ts b/frontend-v2/src/model/updateDraft.ts new file mode 100644 index 00000000..86cbb854 --- /dev/null +++ b/frontend-v2/src/model/updateDraft.ts @@ -0,0 +1,109 @@ +import { n } from '../../../shared/format'; +import { STALL_DAYS } from '../../../shared/model/decide'; +import { dayStart } from '../../../shared/model/projects'; +import { dayOf } from './days'; +import { + endOf, + paceFinish, + PILE_AGE_DAYS, + PILE_GROWTH, + PILE_MIN, + UPDATE_DUE_DAYS, + type RoadmapHealth, + type RoadmapItem, +} from '../../../shared/model/roadmap'; +import type { PrStage } from '../../../shared/model/stage'; +import { dayWords } from './projectData'; +import { STAGE_WORDS } from './stage'; +import { andList, days, LAST_14_DAYS, noPrActivity, pastEnd } from './words'; + +const DAY = 86400; + +/** the order a project's open PRs are counted in, nearest to shipping + * first, as its page counts them */ +const STAGES: readonly PrStage[] = ['ready', 'hold', 'review', 'work']; + +/** + * An update drafted from a plan's numbers (its `lately`), for its lead to + * post as it is or change first. How it's going: off track once it runs + * past a hard end with PRs open; at risk when its issues, at their pace, + * never finish or finish after a hard end, or its open PRs stalled for + * STALL_DAYS; else on track. A soft end is an estimate, so passing it is + * said but changes nothing, and ongoing work has no end to say. Then a + * sentence of what merged and where its open PRs stand (and how they pile + * up, when that's why an update is owed), and one of what's left. Null with + * no numbers to draft from. + */ +export function draftUpdate( + item: Pick, + now: number +): { health: RoadmapHealth; body: string } | null { + const lately = item.lately; + if (!lately) return null; + const end = endOf(item); + const hard = item.end_kind === 'hard'; + const today = dayOf(new Date(now * 1000)); + const open = STAGES.reduce((sum, s) => sum + lately.open[s], 0); + const ended = !!end && today > end; + const idle = lately.activityAt == null ? null : Math.floor((now - lately.activityAt) / DAY); + const stalled = open > 0 && idle != null && idle >= STALL_DAYS; + const pace = end ? lately.issues : null; + const finish = pace ? paceFinish(pace, now) : null; + const after = + !!end && finish != null && finish !== Infinity && dayOf(new Date(finish * 1000)) > end; + const health: RoadmapHealth = + hard && ended && open > 0 + ? 'off_track' + : finish === Infinity || (hard && after) || stalled + ? 'at_risk' + : 'on_track'; + + const merged = lately.merged + ? `${n(lately.merged, 'PR')} merged in the ${LAST_14_DAYS}` + : `No PRs merged in the ${LAST_14_DAYS}`; + const stages = STAGES.filter(s => lately.open[s]).map( + s => `${lately.open[s]} ${STAGE_WORDS[s].toLowerCase()}` + ); + const prs = open + ? `${merged}, and ${open} ${open === 1 ? 'is' : 'are'} open: ${andList(stages)}.` + : `${merged}, and none are open.`; + // the pile a merge can't vouch for (roadmap.ts vouchFor), said in words + const grew = open >= PILE_MIN && lately.grew >= PILE_GROWTH; + const aged = open >= PILE_MIN && (lately.medianAge ?? 0) > PILE_AGE_DAYS; + const more = `That’s ${lately.grew} more open than ${days(UPDATE_DUE_DAYS)} ago`; + const half = `half have been open ${Math.floor(lately.medianAge ?? 0)} days or more`; + const pile = + grew && aged + ? ` ${more}, and ${half}.` + : grew + ? ` ${more}.` + : aged + ? ` ${half.charAt(0).toUpperCase()}${half.slice(1)}.` + : ''; + + let left: string; + if (!end) { + left = stalled ? `${noPrActivity(idle as number)}.` : 'It’s ongoing, with no end date.'; + } else if (ended) { + const weeks = Math.ceil(((dayStart(today) as number) - (dayStart(end) as number)) / DAY / 7); + left = hard + ? `It’s ${pastEnd(weeks)}, ${dayWords(end)}.` + : `It’s ${pastEnd(weeks, 'soft')}, ${dayWords(end)}.`; + } else if (stalled) { + left = `${noPrActivity(idle as number)}.`; + } else if (pace && finish === Infinity) { + const words = pace.closed === pace.added ? 'as fast as' : 'faster than'; + left = `Its issues arrive ${words} they close: ${pace.closed} closed and ${pace.added} added in four weeks.`; + } else if (pace && finish != null) { + const issues = `${n(pace.open, 'open issue')} ${pace.open === 1 ? 'is' : 'are'}`; + left = `At this pace its ${issues} done around ${dayWords(dayOf(new Date(finish * 1000)))}, ${ + after ? 'after' : 'by' + } its ${dayWords(end)}${hard ? '' : ' soft'} end.`; + } else if (pace?.open) { + const issues = `${n(pace.open, 'issue')} ${pace.open === 1 ? 'is' : 'are'}`; + left = `${issues} still open, and it ends ${dayWords(end)}.`; + } else { + left = `It ends ${dayWords(end)}.`; + } + return { health, body: `${prs}${pile} ${left}` }; +} diff --git a/frontend-v2/src/model/words.test.ts b/frontend-v2/src/model/words.test.ts new file mode 100644 index 00000000..4a3c78a8 --- /dev/null +++ b/frontend-v2/src/model/words.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { andList, devDays } from './words'; + +describe('andList', () => { + it('joins names the way a sentence does', () => { + expect(andList([])).toBe(''); + expect(andList(['dana'])).toBe('dana'); + expect(andList(['dana', 'sam'])).toBe('dana and sam'); + expect(andList(['dana', 'sam', 'lee'])).toBe('dana, sam and lee'); + }); +}); + +describe('devDays', () => { + it('keeps a tenth under 10 days and whole days above', () => { + expect(devDays(2.46)).toBe(2.5); + expect(devDays(12.46)).toBe(12); + }); +}); diff --git a/frontend-v2/src/model/words.ts b/frontend-v2/src/model/words.ts new file mode 100644 index 00000000..d861f845 --- /dev/null +++ b/frontend-v2/src/model/words.ts @@ -0,0 +1,88 @@ +import { n } from '../../../shared/format'; +import type { EndKind } from '../../../shared/model/roadmap'; + +/** + * The Projects tab's words for the facts several views show, kept in one + * place so one fact reads the same on every view (DESIGN.md: one concept, + * one word). Use these rather than writing the words again; a new shared + * fact gets its word here first. + */ + +/** a project with no plan on the roadmap, as a plain fact */ +export const NO_PLAN = 'No plan'; +/** one with no plan that Decide asks to plan: owed */ +export const NEEDS_A_PLAN = 'Needs a plan'; +/** one with no plan, small enough to ship without */ +export const NO_PLAN_NEEDED = 'No plan needed'; +/** the action that gives it one */ +export const PLAN_IT = 'Plan it'; +/** a plan whose lead owes an update: the last one is older than the update rhythm */ +export const UPDATE_DUE = 'Update due'; +/** a plan under way that has had no update since it started */ +export const NO_UPDATE_YET = 'No update yet'; +/** a plan still running after its end date, in a sentence: a promised end + * is overdue, an estimate only passed */ +export const pastEnd = (weeks: number, kind: EndKind = 'hard') => + kind === 'hard' ? `${n(weeks, 'week')} overdue` : `${n(weeks, 'week')} past its estimate`; +/** the same on a timeline bar, where a sentence won't fit */ +export const pastEndMark = (weeks: number, kind: EndKind = 'hard') => + kind === 'hard' ? `${weeks} wk overdue` : `${weeks} wk past`; +/** the board's recent window for merged and closed PRs */ +export const LAST_14_DAYS = 'last 14 days'; +/** PRs with no project label */ +export const NOT_IN_A_PROJECT = 'Not in a project'; +/** a length of time in a sentence: "42 days" */ +export const days = (count: number) => n(count, 'day'); +/** the same in a table cell or a row's meta line, the board's own form: "42d" */ +export const daysShort = (count: number) => `${count}d`; + +/** a plan's status while it runs (status `active`): only ever a plan's word */ +export const IN_PROGRESS = 'In progress'; +/** PRs moving on a project in a window (open, or merged or closed in it): + * Decide's phrase for the fact, so it never borrows a plan's "In progress" */ +export const BEING_WORKED_ON = 'being worked on'; +/** where a plan's work came from, when nobody has said */ +export const NOT_SAID = 'Not said'; +/** the board's bucket for small work that isn't worth a project */ +export const ONE_OFFS = 'Small work, no project'; +/** someone on more projects than the overload line (model/retro.ts) */ +export const OVERLOADED = 'overloaded'; +/** a project whose issues are all closed, as a label */ +export const ALL_ISSUES_CLOSED = 'All issues closed'; +/** every view's export of what it shows, as plain text for a meeting */ +export const COPY_AS_TEXT = 'Copy as text'; +/** the choice of a plan's end, on Decide and in the roadmap's editor */ +export const COMMIT_THROUGH = 'Promise to finish by'; +/** the call for upkeep with no finish line */ +export const ONGOING = 'Mark ongoing'; +/** how firm a plan's end is, as its editor offers it */ +export const END_WORD: Record = { + hard: 'Promised', + soft: 'Estimate', + ongoing: 'Ongoing', +}; +/** the same in a sentence about the plan: "Aug 31 to Oct 25, 8 weeks, an estimated end" */ +export const END_IN_A_SENTENCE: Record = { + hard: 'a promised end', + soft: 'an estimated end', + ongoing: 'ongoing', +}; +/** what each kind of end means, said beside the choice and in a hover */ +export const END_MEANS: Record = { + hard: 'A promise: Decide asks about it once it passes.', + soft: 'An estimate: shown on the roadmap, never asked about.', + ongoing: 'Upkeep with no end date: nothing asks when it finishes.', +}; +/** a plan's one line on what finished looks like */ +export const DONE_WHEN = 'Done when'; +/** a project's target, as a fact: "Target Oct 21" */ +export const targetOn = (day: string) => `Target date ${day}`; +/** a target that passed with work still open, said as what's owed */ +export const missedTarget = (day: string) => `Missed its ${day} target date`; +/** nobody's PRs moved: "No PR activity for 21 days" */ +export const noPrActivity = (count: number) => `No PR activity for ${days(count)}`; +/** names in a sentence, in the order given: "a", "a and b", "a, b and c" */ +export const andList = (names: readonly string[]) => + names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; +/** developer-days as every table prints them: a tenth under 10, whole above */ +export const devDays = (d: number) => (d < 10 ? Math.round(d * 10) / 10 : Math.round(d)); diff --git a/frontend-v2/src/model/work.test.ts b/frontend-v2/src/model/work.test.ts new file mode 100644 index 00000000..0c148855 --- /dev/null +++ b/frontend-v2/src/model/work.test.ts @@ -0,0 +1,561 @@ +import { describe, expect, it } from 'vitest'; +import { decideQueue } from '../../../shared/model/decide'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { + afterEndOf, + bodyLinks, + issueCounts, + issueQuery, + itemState, + parseIssueRef, + planOfWork, + planWork, + projectWork, + type AttachedIssue, + type IssueHit, + type IssueRef, + type WorkPull, +} from '../../../shared/model/work'; + +const at = (day: string) => Date.parse(`${day}T12:00:00Z`) / 1000; +const NOW = at('2026-09-30'); +const ref = (number: number, repo = 'iFixit/ifixit'): IssueRef => ({ repo, number }); +const key = (number: number) => `ifixit/ifixit#${number}`; + +const plan = (id: number, over: Partial = {}): RoadmapItem => ({ + id, + name: `Plan ${id}`, + project: 'workbench', + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-05-18', + weeks: 15, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: at('2026-05-18'), + update: null, + ...over, +}); + +const pull = (number: number, day: string, over: Partial = {}): WorkPull => ({ + repo: 'iFixit/ifixit', + number, + title: `PR ${number}`, + author: 'dana', + createdAt: at(day), + mergedAt: null, + closedAt: null, + state: 'open', + links: [], + ...over, +}); +const merged = (day: string): Partial => ({ + state: 'closed', + mergedAt: at(day), + closedAt: at(day), +}); + +const issue = (number: number, over: Partial = {}): AttachedIssue => ({ + ref: ref(number), + title: `Issue ${number}`, + state: 'open', + closedAt: null, + author: 'sam', + createdAt: at('2026-09-01'), + via: ['label'], + attachedAt: null, + addedBy: null, + ...over, +}); + +const hit = (number: number, over: Partial = {}): IssueHit => ({ + ...ref(number), + title: `Issue ${number}`, + state: 'open', + author: 'sam', + createdAt: at('2026-09-01'), + ...over, +}); + +describe('parseIssueRef', () => { + it('reads the ways people paste an issue', () => { + expect(parseIssueRef('iFixit/ifixit#63681')).toEqual(ref(63681)); + expect( + parseIssueRef(' https://github.com/iFixit/ifixit/issues/64797#issuecomment-1 ') + ).toEqual(ref(64797)); + expect(parseIssueRef('#12', 'iFixit/ops')).toEqual(ref(12, 'iFixit/ops')); + // a bare number needs a repo to mean anything + expect(parseIssueRef('#12')).toBeNull(); + expect(parseIssueRef('the launch epic')).toBeNull(); + }); +}); + +describe('bodyLinks', () => { + it('reads the issues a PR body links on purpose, not the ones it mentions in passing', () => { + const body = [ + 'Parts of #62502, #62503 and iFixit/ops#12', + 'Fixes: https://github.com/iFixit/ifixit/issues/64170', + 'connects to #9', + // named again: still one link + 'Closes #62502', + 'Unlike #63000, this keeps the old header. See #1.', + 'Not part of the #63000 audit', + ].join('\n'); + expect(bodyLinks(body, 'iFixit/ifixit')).toEqual([ + ref(62502), + ref(62503), + ref(12, 'iFixit/ops'), + ref(64170), + ref(9), + ]); + expect(bodyLinks(null, 'iFixit/ifixit')).toEqual([]); + }); +}); + +describe('itemState', () => { + it('counts not planned and duplicate as dropped, any other close as done', () => { + expect(itemState('OPEN', null)).toBe('open'); + expect(itemState('closed', 'completed')).toBe('done'); + expect(itemState('CLOSED', 'NOT_PLANNED')).toBe('dropped'); + expect(itemState('closed', 'duplicate')).toBe('dropped'); + expect(itemState('closed', null)).toBe('done'); + }); +}); + +describe('planOfWork', () => { + it('gives work to the latest plan that had started when it arrived', () => { + const launch = plan(1, { start: '2026-05-18', weeks: 15 }); + const feedback = plan(2, { start: '2026-09-21', weeks: 12 }); + expect(planOfWork([feedback, launch], at('2026-08-01'))?.id).toBe(1); + expect(planOfWork([feedback, launch], at('2026-09-22'))?.id).toBe(2); + // before every plan: the first one + expect(planOfWork([feedback, launch], at('2026-01-05'))?.id).toBe(1); + expect(planOfWork([], at('2026-01-05'))).toBeNull(); + }); + + it('skips a plan marked done or dropped before the PR opened, while another runs', () => { + const launch = plan(1, { start: '2026-07-06', weeks: 6 }); + // started Sep 7, dropped the next day + const extra = plan(2, { + start: '2026-09-07', + status: 'dropped', + updated_at: at('2026-09-08'), + }); + expect(planOfWork([launch, extra], at('2026-09-14'))?.id).toBe(1); + // opened before the drop: it was that plan's + expect(planOfWork([launch, extra], at('2026-09-07'))?.id).toBe(2); + // every started plan stopped: the latest still takes it, as work after it + const done = plan(3, { start: '2026-07-06', status: 'done', updated_at: at('2026-08-20') }); + expect(planOfWork([done], at('2026-09-14'))?.id).toBe(3); + }); + + it('skips a parked plan too, so an earlier one can’t take the work after a later', () => { + const launch = plan(1, { start: '2026-07-06', weeks: 6 }); + // started Aug 3, parked Aug 20 + const extra = plan(2, { + start: '2026-08-03', + status: 'parked', + updated_at: at('2026-08-20'), + }); + expect(planOfWork([launch, extra], at('2026-09-14'))?.id).toBe(1); + // parked Jul 20, then a plan from Aug 3 done Sep 1: the work after is the done plan's + const parked = plan(3, { + start: '2026-07-06', + status: 'parked', + updated_at: at('2026-07-20'), + }); + const done = plan(4, { start: '2026-08-03', status: 'done', updated_at: at('2026-09-01') }); + expect(planOfWork([parked, done], at('2026-09-14'))?.id).toBe(4); + }); + + it('goes by when a plan’s status changed, not by a later edit', () => { + const launch = plan(1, { start: '2026-07-06', weeks: 6 }); + // dropped Aug 20, its notes edited Sep 20 + const extra = plan(2, { + start: '2026-08-03', + status: 'dropped', + status_at: at('2026-08-20'), + updated_at: at('2026-09-20'), + }); + expect(planOfWork([launch, extra], at('2026-09-14'))?.id).toBe(1); + }); +}); + +describe('issueCounts', () => { + it('counts each state and takes the last close it knows', () => { + expect( + issueCounts([ + issue(1), + issue(2, { state: 'done', closedAt: at('2026-09-20') }), + issue(3, { state: 'dropped', closedAt: at('2026-09-25') }), + // closed, time not known: counted, but it can't be the last close + issue(4, { state: 'done' }), + ]) + ).toEqual({ total: 4, open: 1, done: 2, dropped: 1, lastClosedAt: at('2026-09-25') }); + expect(issueCounts([])).toEqual({ + total: 0, + open: 0, + done: 0, + dropped: 0, + lastClosedAt: null, + }); + }); +}); + +describe('planWork', () => { + // the launch ran to 2026-08-30; its feedback round began 2026-09-21 + const launch = plan(1, { start: '2026-05-18', weeks: 15 }); + const feedback = plan(2, { start: '2026-09-21', weeks: 12 }); + // done on 2026-09-01: a PR in the week after is its tail, one later a comeback + const tool = plan(3, { + project: 'tool', + status: 'done', + start: '2026-08-03', + weeks: 4, + updated_at: at('2026-09-01'), + }); + const work = planWork({ + plans: [launch, feedback, tool, plan(4, { project: null })], + attached: new Map([['workbench', [issue(100)]]]), + pulls: new Map([ + [ + 'workbench', + [ + pull(1, '2026-06-01', merged('2026-06-02')), + pull(2, '2026-09-05'), + pull(3, '2026-09-22'), + pull(4, '2026-09-01', merged('2026-09-03')), + ], + ], + ['tool', [pull(10, '2026-09-05'), pull(11, '2026-09-10')]], + ]), + unlabeled: [ + // no label: in the project whose issue it links, as the server + // reads it off the issue's side + pull(5, '2026-09-23', { links: [ref(100)] }), + pull(6, '2026-09-24'), + // links nothing attached anywhere: no project's + pull(7, '2026-09-24', { links: [ref(999)] }), + ], + linked: { [key(5)]: ['workbench'], [key(6)]: ['workbench'] }, + links: new Map([[key(100), [ref(5), ref(6)]]]), + }); + const ids = (prs: WorkPull[]) => prs.map(p => p.number); + + it('gives each PR to the plan running when it opened', () => { + expect(work.map(w => [w.planId, w.openPulls])).toEqual([ + [1, 1], + [2, 3], + [3, 2], + [4, 0], + ]); + }); + + it('lists the PRs that opened after a plan’s end, oldest first', () => { + expect(ids(work[0].afterEnd)).toEqual([4, 2]); + expect(ids(work[1].afterEnd)).toEqual([]); + }); + + it('finds nothing after the end of ongoing work, which has none', () => { + expect(afterEndOf({ start: '2026-08-03', weeks: 1, end_kind: 'ongoing' })).toBe(Infinity); + }); + + it('counts the PRs that opened more than a week after a plan was marked done', () => { + expect(ids(work[2].afterEnd)).toEqual([10, 11]); + expect(ids(work[2].afterDone)).toEqual([11]); + expect(ids(work[0].afterDone)).toEqual([]); + }); + + it('counts a PR that links two projects’ issues once, in the first, as every view does', () => { + const [first, second] = planWork({ + plans: [plan(1, { project: 'alpha' }), plan(2, { project: 'beta' })], + attached: new Map(), + pulls: new Map(), + unlabeled: [pull(8, '2026-09-24')], + linked: { [key(8)]: ['alpha', 'beta'] }, + links: new Map(), + }); + expect([first.openPulls, second.openPulls]).toEqual([1, 0]); + }); + + it('counts from when it was marked done, not from a later edit', () => { + // done Sep 1, its notes edited Sep 20 + const edited = { ...tool, status_at: at('2026-09-01'), updated_at: at('2026-09-20') }; + const [late] = planWork({ + plans: [edited], + attached: new Map(), + pulls: new Map([['tool', [pull(11, '2026-09-10')]]]), + links: new Map(), + }); + expect(ids(late.afterDone)).toEqual([11]); + }); + + it('leaves the work after a done plan to it, so Decide still reopens it', () => { + // parked Jul 20; a later plan was done Sep 1, and a PR opened Sep 14 + const parked = plan(5, { + start: '2026-07-06', + status: 'parked', + updated_at: at('2026-07-20'), + }); + const done = plan(6, { start: '2026-08-03', status: 'done', updated_at: at('2026-09-01') }); + const after = planWork({ + plans: [parked, done], + attached: new Map(), + pulls: new Map([['workbench', [pull(20, '2026-09-14', merged('2026-09-15'))]]]), + links: new Map(), + }); + expect(after.map(w => [w.planId, ids(w.afterDone)])).toEqual([ + [5, []], + [6, [20]], + ]); + const rows = decideQueue({ + live: [ + { + slug: 'workbench', + firstOpened: null, + lastActivity: at('2026-09-15'), + open: 0, + prs: 1, + due: null, + }, + ], + items: [parked, done], + planCounts: new Map( + after.map(w => [ + w.planId, + { + openPulls: w.openPulls, + afterEnd: w.afterEnd.length, + afterDone: w.afterDone.length, + }, + ]) + ), + today: '2026-09-30', + now: NOW, + }); + expect(rows.map(r => [r.item?.id, r.reasons.map(x => x.kind)])).toEqual([[6, ['reopened']]]); + }); +}); + +describe('projectWork', () => { + const page = projectWork( + { + plans: [], + attached: new Map([ + [ + 'workbench', + [ + issue(100), + issue(101, { state: 'done', closedAt: at('2026-09-20'), via: ['hand'] }), + issue(102, { state: 'dropped' }), + ], + ], + ]), + pulls: new Map([ + [ + 'workbench', + [ + pull(200, '2026-09-25', { links: [ref(100)] }), + pull(201, '2026-09-26', merged('2026-09-28')), + // merged long ago: off the page + pull(202, '2026-07-01', merged('2026-07-05')), + pull(203, '2026-09-27', { links: [ref(300)] }), + // merged, and newer than the open ones: it still lists after them + pull(209, '2026-09-29', merged('2026-09-29')), + pull(204, '2026-09-29', { links: [ref(301)] }), + pull(205, '2026-09-10', { + ...merged('2026-09-12'), + // the project's own issue, a PR, an issue closed long + // ago, one closed lately, and one #203 links too + links: [ref(1), ref(206), ref(302), ref(303), ref(300)], + }), + ], + ], + ]), + unlabeled: [pull(206, '2026-09-24', { links: [ref(101)] })], + linked: { [key(206)]: ['workbench'] }, + links: new Map([ + // from the issue's side: #200 again, and PRs the body doesn't say + [key(100), [ref(200), ref(150)]], + [key(101), [ref(208)]], + ]), + knownPulls: new Map([ + [key(208), pull(208, '2026-09-01', { ...merged('2026-09-02'), title: 'Elsewhere' })], + ]), + knownIssues: new Map([ + [key(301), hit(301, { title: 'Show the queue' })], + [key(302), hit(302, { state: 'done', closedAt: at('2026-08-01') })], + [key(303), hit(303, { state: 'done', closedAt: at('2026-09-15') })], + ]), + notIssues: new Set([key(1)]), + }, + 'workbench', + NOW + ); + const numbers = (list: { number: number }[]) => list.map(p => p.number); + + it('lists each issue with the PRs that link it from either side, newest first', () => { + expect(page.issues.map(i => [i.ref.number, numbers(i.prs)])).toEqual([ + [100, [200, 150]], + [101, [206, 208]], + [102, []], + ]); + // a PR the board never read shows by its number alone + expect(page.issues[0].prs[1]).toEqual({ + ...ref(150), + title: null, + author: null, + createdAt: null, + state: null, + }); + expect(page.issues[1].prs[1]).toMatchObject({ title: 'Elsewhere', state: 'merged' }); + // a PR the board knows that isn't this project's says so, for the counts + expect(page.issues[1].prs.map(p => [p.number, p.outside ?? false])).toEqual([ + [206, false], + [208, true], + ]); + }); + + it('lists its PRs that link none of its issues: open ones first, then closed lately', () => { + expect(numbers(page.unlinked)).toEqual([204, 203, 209, 201]); + }); + + it('suggests the issues its recent PRs link that aren’t attached', () => { + // in the order the PRs linking them opened: #205 (Sep 10) first + expect(page.suggested.map(s => [s.number, s.title, numbers(s.linkedBy)])).toEqual([ + [303, 'Issue 303', [205]], + // never seen by the board: just its number + [300, '', [205, 203]], + [301, 'Show the queue', [204]], + ]); + // one that joins on its own is dated from when its first PR opened + expect(page.suggested.map(s => [s.number, s.linkedAt])).toEqual([ + [303, at('2026-09-10')], + [300, at('2026-09-10')], + [301, at('2026-09-29')], + ]); + }); + + it('counts its issues', () => { + expect(page.counts).toEqual({ + total: 3, + open: 1, + done: 1, + dropped: 1, + lastClosedAt: at('2026-09-20'), + }); + }); + + it('says which other projects have each issue, by label or by hand', () => { + const shared = projectWork( + { + plans: [], + attached: new Map([ + ['workbench', [issue(100), issue(101)]], + ['printing', [issue(100), issue(300, { via: ['hand'] })]], + ['labels', [issue(100, { via: ['label', 'hand'] })]], + ]), + pulls: new Map([ + ['workbench', [pull(200, '2026-09-25', { links: [ref(300), ref(301)] })]], + ]), + links: new Map(), + }, + 'workbench', + NOW + ); + expect(shared.issues.map(i => [i.ref.number, i.alsoIn])).toEqual([ + [100, ['labels', 'printing']], + [101, []], + ]); + // #300 is printing's: suggested here, never joining on its own, and + // the PR doing it says where it is + expect(shared.suggested.map(s => [s.number, s.alsoIn, s.joins])).toEqual([ + [300, ['printing'], false], + [301, [], true], + ]); + expect(shared.unlinked.map(p => [p.number, p.elsewhere])).toEqual([ + [200, [{ ref: ref(300), projects: ['printing'] }]], + ]); + }); + + it('lets an issue join on its own from a PR here by its label or its issues, alone', () => { + const page = projectWork( + { + plans: [], + attached: new Map([ + ['workbench', [issue(100), issue(110, { via: ['link'], linkedBy: ref(200) })]], + ['printing', [issue(500)]], + ]), + pulls: new Map([ + // its label, linking an issue taken off it too + ['workbench', [pull(200, '2026-09-25', { links: [ref(100), ref(301), ref(304)] })]], + ['printing', [pull(400, '2026-09-25', { links: [ref(500), ref(302)] })]], + ]), + unlabeled: [ + // here only by #110, which a link brought: it brings no more + pull(201, '2026-09-26', { links: [ref(110), ref(303)] }), + // here by #100, but printing's PR links #302 too + pull(202, '2026-09-26', { links: [ref(100), ref(302)] }), + ], + linked: { [key(201)]: ['workbench'], [key(202)]: ['workbench'] }, + links: new Map([ + [key(100), [ref(200), ref(202)]], + [key(110), [ref(201)]], + ]), + removed: new Map([['workbench', new Set([key(304)])]]), + }, + 'workbench', + NOW + ); + expect(page.suggested.map(s => [s.number, s.joins])).toEqual([ + [301, true], + [303, false], + [302, false], + ]); + // one a link brought is an issue like any other, with its PRs + expect(page.issues.map(i => [i.ref.number, i.via, numbers(i.prs)])).toEqual([ + [100, ['label'], [202, 200]], + [110, ['link'], [201]], + ]); + }); + + it('lists nothing for a project with no issues and no PRs', () => { + const empty = projectWork( + { plans: [], attached: new Map(), pulls: new Map(), links: new Map() }, + 'nothing', + NOW + ); + expect(empty).toEqual({ + issues: [], + unlinked: [], + suggested: [], + counts: { total: 0, open: 0, done: 0, dropped: 0, lastClosedAt: null }, + }); + }); +}); + +describe('issueQuery', () => { + it('tells an issue link, a number and words apart', () => { + expect(issueQuery('https://github.com/iFixit/ifixit/issues/64797')).toEqual({ + kind: 'ref', + ref: ref(64797), + }); + expect(issueQuery(' #64797 ')).toEqual({ kind: 'number', number: 64797 }); + expect(issueQuery('workbench feedback')).toEqual({ + kind: 'words', + words: 'workbench feedback', + }); + expect(issueQuery('w')).toBeNull(); + expect(issueQuery(' ')).toBeNull(); + // past GitHub's largest issue number: not a number to look up + expect(issueQuery('99999999999')).toBeNull(); + }); +}); diff --git a/frontend-v2/src/model/workData.ts b/frontend-v2/src/model/workData.ts new file mode 100644 index 00000000..3483ed65 --- /dev/null +++ b/frontend-v2/src/model/workData.ts @@ -0,0 +1,302 @@ +import { useEffect, useState } from 'react'; +import { isDummy, loadDummy } from '../backend/dummy'; +import { + DUMMY_ATTACHED, + DUMMY_ISSUES, + DUMMY_LINKS, + DUMMY_PROJECTS, +} from '../backend/dummyProjects'; +import { epoch } from '../../../shared/format'; +import { MISC_SLUG, projectOf, type PullLinks } from '../../../shared/model/projects'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { isSuffixBot } from '../../../shared/model/visibility'; +import { + issueKey, + planWork, + projectCounts, + projectWork, + type AttachedIssue, + type IssueCounts, + type IssueRef, + type PlanWork, + type WorkInputs, + type WorkPull, +} from '../../../shared/model/work'; +import { createMemoryStore, readSessionStorage, writeSessionStorage } from '../storage'; + +/** + * Every plan's PRs by the dates, and how every project's issues stand + * (shared/model/work.ts): GET /work-data on a real board, and the dummy + * board's fixtures run through the same model. + */ + +/** Bumped when an issue is added to a project or taken off, so the work + * and every project page load again. */ +export const workVersion = createMemoryStore({ n: 0 }); + +/** an issue Remove can take off: one added by hand or joined by a link. One + * with the project's label stays until the label comes off on GitHub. */ +export const removable = (issue: AttachedIssue) => !issue.via.includes('label'); + +/** A row of the dummy board's stand-in for the server's project_issues + * table: an issue added by hand or joined by a link, and when it was taken + * off, its row kept so a link never brings it back. */ +type DummyRow = AttachedIssue & { removedAt: number | null }; + +// one set per bench, so ?state=fresh starts with none of the full bench's +const DUMMY_ROWS_KEY = `pd2.dummy.projectIssues${ + typeof location !== 'undefined' && new URLSearchParams(location.search).get('state') + ? `:${new URLSearchParams(location.search).get('state')}` + : '' +}`; + +/** The dummy board's project_issues rows, by slug: the fixtures' own + * issues added by hand to start, so Remove takes them off as it would a + * real one. They last the browser tab, as the server's table outlasts a + * reload, so a Remove still holds after one. */ +export const dummyRows: Map = (() => { + try { + const kept = JSON.parse(readSessionStorage(DUMMY_ROWS_KEY) ?? 'null'); + if (Array.isArray(kept)) return new Map(kept as [string, DummyRow[]][]); + } catch { + // a row from an older shape: start over from the fixtures + } + const byHandOnly = (issue: AttachedIssue) => issue.via.length === 1 && issue.via[0] === 'hand'; + return new Map( + Object.entries(DUMMY_ATTACHED) + .map(([slug, list]): [string, DummyRow[]] => [ + slug, + list.filter(byHandOnly).map(issue => ({ ...issue, removedAt: null })), + ]) + .filter(([, list]) => list.length > 0) + ); +})(); + +/** Keep the dummy board's rows for the next load of this tab. */ +export function saveDummyRows(): void { + writeSessionStorage(DUMMY_ROWS_KEY, JSON.stringify([...dummyRows])); +} + +/** The dummy board's issues by slug, the fixtures' labeled ones and the + * rows not taken off, each once; and the ones taken off. */ +function dummyAttached(): { + attached: Map; + removed: Map>; +} { + const attached = new Map(); + for (const slug of new Set([...Object.keys(DUMMY_ATTACHED), ...dummyRows.keys()])) { + const byKey = new Map(); + const labeled = (DUMMY_ATTACHED[slug] ?? []).filter(issue => issue.via.includes('label')); + const live = (dummyRows.get(slug) ?? []).filter(row => row.removedAt == null); + for (const issue of [...labeled, ...live]) { + const was = byKey.get(issueKey(issue.ref)); + byKey.set( + issueKey(issue.ref), + was + ? { + ...was, + via: [...new Set([...was.via, ...issue.via])], + addedBy: was.addedBy ?? issue.addedBy, + linkedBy: was.linkedBy ?? issue.linkedBy, + } + : issue + ); + } + if (byKey.size) attached.set(slug, [...byKey.values()]); + } + const removed = new Map( + [...dummyRows].map(([slug, rows]): [string, Set] => [ + slug, + new Set(rows.filter(row => row.removedAt != null).map(row => issueKey(row.ref))), + ]) + ); + return { attached, removed }; +} + +/** Which projects each PR links, the way the server reads it (lib/work.js + * pullLinksFrom): every project with an issue it links, or whose own + * issue it links, sorted. */ +function dummyPullLinksFrom( + attached: ReadonlyMap, + own: ReadonlyMap, + links: ReadonlyMap +): PullLinks { + const projectsOf = new Map>(); + const add = (ref: IssueRef, slug: string) => + projectsOf.set(issueKey(ref), new Set([...(projectsOf.get(issueKey(ref)) ?? []), slug])); + for (const [slug, issues] of attached) for (const issue of issues) add(issue.ref, slug); + for (const [slug, ref] of own) add(ref, slug); + const out: Record = {}; + for (const [issue, prs] of links) { + const slugs = projectsOf.get(issue); + if (!slugs) continue; + for (const pr of prs) { + out[issueKey(pr)] = [...new Set([...(out[issueKey(pr)] ?? []), ...slugs])].sort(); + } + } + return out; +} + +/** + * What the dummy board's work is built from, the way the server builds it + * (lib/work.js workInputs), just after a sync: each load lets the issues + * its projects' PRs link join on their own, as the server's hourly sync + * does (joinLinkedIssues), and never one taken off. + */ +export async function dummyWorkInputs(plans: readonly RoadmapItem[]): Promise { + const { pulls, projectLabelPrefix } = await loadDummy(); + const prefix = projectLabelPrefix ?? 'project:'; + const linksOf = new Map(Object.entries(DUMMY_LINKS).map(([k, v]) => [k.toLowerCase(), v])); + const byProject = new Map(); + const unlabeled: WorkPull[] = []; + const knownPulls = new Map(); + for (const p of pulls) { + if (isSuffixBot(p.user.login)) continue; + const pull: WorkPull = { + repo: p.repo, + number: p.number, + title: p.title, + author: p.user.login, + createdAt: epoch(p.created_at), + mergedAt: p.merged_at ? epoch(p.merged_at) : null, + closedAt: p.closed_at ? epoch(p.closed_at) : null, + state: p.state === 'open' ? 'open' : 'closed', + links: linksOf.get(issueKey(p)) ?? [], + }; + knownPulls.set(issueKey(pull), pull); + // misc holds unsorted work, not a project: a misc PR joins the + // project whose issue it links, as one with no label does + const slug = projectOf(p.labels, prefix); + if (slug && slug !== MISC_SLUG) byProject.set(slug, [...(byProject.get(slug) ?? []), pull]); + else if (pull.links.length) unlabeled.push(pull); + } + // the PRs that link each issue, as the issue's side reads them + const links = new Map(); + for (const [pr, issues] of linksOf) { + const at = pr.lastIndexOf('#'); + const ref = knownPulls.get(pr) ?? { repo: pr.slice(0, at), number: Number(pr.slice(at + 1)) }; + for (const issue of issues) { + const k = issueKey(issue); + links.set(k, [...(links.get(k) ?? []), { repo: ref.repo, number: ref.number }]); + } + } + const ownIssues = new Map(DUMMY_PROJECTS.map(p => [p.slug, { repo: p.repo, number: p.number }])); + const inputs = (): WorkInputs => { + const { attached, removed } = dummyAttached(); + return { + plans, + attached, + pulls: byProject, + unlabeled, + linked: dummyPullLinksFrom(attached, ownIssues, links), + links, + knownPulls, + knownIssues: new Map(DUMMY_ISSUES.map(hit => [issueKey(hit), hit])), + notIssues: new Set(DUMMY_PROJECTS.map(p => issueKey(p))), + ownIssues, + removed, + }; + }; + const before = inputs(); + const slugs = new Set([ + ...byProject.keys(), + ...unlabeled.map(pr => before.linked?.[issueKey(pr)]?.[0]).filter(s => s != null), + ]); + const now = Math.floor(Date.now() / 1000); + let joined = false; + for (const slug of slugs) { + for (const s of projectWork(before, slug, now).suggested) { + // one the board has never seen is one GitHub would have to name + if (!s.joins || !s.title) continue; + const ref = { repo: s.repo, number: s.number }; + dummyRows.set(slug, [ + ...(dummyRows.get(slug) ?? []), + { + ref, + title: s.title, + state: s.state, + closedAt: s.closedAt ?? null, + author: s.author, + createdAt: s.createdAt, + via: ['link'], + // its pace counts from when the PR that brought it opened, as + // on the server; when it joined stays beside it + attachedAt: s.linkedAt, + joinedAt: now, + addedBy: null, + linkedBy: s.linkedBy[0], + removedAt: null, + }, + ]); + joined = true; + } + } + if (!joined) return before; + saveDummyRows(); + return inputs(); +} + +/** Which projects each of the dummy board's PRs links, as /projects-data + * sends it, once its issues have joined. */ +export async function dummyPullLinks(): Promise { + return (await dummyWorkInputs([])).linked ?? {}; +} + +/** Every plan's PRs by the dates, by plan id, and how every project's + * issues stand, by slug. */ +export interface WorkData { + plans: ReadonlyMap; + projects: ReadonlyMap; +} + +const toData = (body: { plans: PlanWork[]; projects: Record }): WorkData => ({ + plans: new Map(body.plans.map(p => [p.planId, p])), + projects: new Map(Object.entries(body.projects)), +}); + +async function load(plans: readonly RoadmapItem[]): Promise { + if (isDummy()) { + const inputs = await dummyWorkInputs(plans); + return toData({ + plans: planWork(inputs), + projects: Object.fromEntries(projectCounts(inputs)), + }); + } + return fetch('/work-data') + .then(r => (r.ok ? (r.json() as Promise[0]>) : null)) + .then(body => body && toData(body)) + .catch(() => null); +} + +/** + * The work: undefined while it loads, null if the fetch failed. Pass the + * roadmap's plans (null while they load). + */ +export function useWorkData(plans: readonly RoadmapItem[] | null): WorkData | null | undefined { + const [got, setGot] = useState(undefined); + const { n: version } = workVersion.useValue(); + // what the plans' PRs depend on: each plan's project, dates, whether it + // ends at all, status, and when its status changed (a done plan's late + // PRs count from then) + const key = plans + ?.map( + p => + `${p.id}:${p.project}:${p.start}:${p.weeks}:${p.end_kind}:${p.status}:${ + p.status_at ?? p.updated_at ?? '' + }` + ) + .join(','); + useEffect(() => { + if (!plans) return; + let live = true; + void load(plans).then(data => { + if (live) setGot(data); + }); + return () => { + live = false; + }; + // keyed on what the work depends on, not the list object, which the + // roadmap rebuilds on every change + }, [key, version]); + return plans ? got : undefined; +} diff --git a/frontend-v2/src/store.test.ts b/frontend-v2/src/store.test.ts index f60be63b..f5d3a414 100644 --- a/frontend-v2/src/store.test.ts +++ b/frontend-v2/src/store.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { pullKey } from '../../shared/format'; import type { PullData } from '../../shared/types'; -import { isSnoozed, refreshAllText, type SnoozeRecord } from './store'; +import { isNewBuild, isSnoozed, refreshAllText, type SnoozeRecord } from './store'; // isSnoozed reads only repo/number/updated_at and a few status counts, so a // minimal pull is enough; the outer cast keeps the fixture to what it touches. @@ -192,3 +192,12 @@ describe('refreshAllText', () => { expect(done(0, 0, 2)).toBe('up to date · couldn’t read 2 repos'); }); }); + +describe('isNewBuild', () => { + it('flags only a different build than the first one seen', () => { + expect(isNewBuild('a', 'a')).toBe(false); + expect(isNewBuild('a', 'b')).toBe(true); + expect(isNewBuild(undefined, 'b')).toBe(false); + expect(isNewBuild('a', undefined)).toBe(false); + }); +}); diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 4ec706e0..a7d9655a 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -22,12 +22,18 @@ export interface Snapshot { pulls: DerivedPull[]; /** bot logins beyond the `[bot]` suffix, from server config (initialize) */ extraBots: ReadonlySet; + /** the label prefix that files a PR into a project, from server config + * (initialize); null when the server isn't set up for projects, which + * hides the Projects tab */ + projectLabelPrefix: string | null; /** merged/closed in the last 14 days (the server's retention window) */ closed: PullData[]; me: string; connection: ConnectionState; /** first initialize payload has arrived: [] means empty, not loading */ initialized: boolean; + /** a reconnect found the server running a different build than this tab loaded */ + updated: boolean; /** the /token fetch failed: session expired or server down */ authFailed: boolean; /** epoch secs of the last payload from the server; 0 until one arrives */ @@ -52,10 +58,13 @@ let repoSpecs: RepoSpec[] = []; // reference invalidates the derive cache so weights re-resolve when it arrives. let weightLabels: ReadonlyMap = new Map(); let extraBots: ReadonlySet = new Set(); +let projectLabelPrefix: string | null = null; let me = ''; let connection: ConnectionState = 'connecting'; let initialized = false; let authFailed = false; +let firstBuild: string | undefined; +let updated = false; let lastPayloadAt = 0; const listeners = new Set<() => void>(); @@ -196,13 +205,20 @@ export function isSnoozed( return true; } +/** a later initialize naming a different build than the first one this tab saw */ +export function isNewBuild(first: string | undefined, now: string | undefined): boolean { + return first != null && now != null && first !== now; +} + let snapshot: Snapshot = { pulls: [], extraBots, + projectLabelPrefix, closed: [], me, connection, initialized, + updated, authFailed, lastPayloadAt, lastSeen, @@ -257,9 +273,11 @@ function publish() { (a, b) => (Date.parse(b.closed_at ?? '') || 0) - (Date.parse(a.closed_at ?? '') || 0) ), extraBots, + projectLabelPrefix, me, connection, initialized, + updated, authFailed, lastPayloadAt, lastSeen, @@ -303,6 +321,9 @@ function start() { // server-owned config rides with the board (see shared/types) weightLabels = parseWeightLabels(payload.weightLabels); extraBots = new Set(payload.bots ?? []); + projectLabelPrefix = payload.projectLabelPrefix || null; + firstBuild ??= payload.build; + updated = isNewBuild(firstBuild, payload.build); for (const p of payload.pulls) raw.set(pullKey(p), p); initialized = true; // a press that ended while this board was away mustn't stay stuck diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 01bcafc4..e6bb49a5 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -14,6 +14,9 @@ pulldasher's night-shift habit, derived from the same gray ramp. Retheme by swapping --brand* and the badge pairs. ── */ :root { + /* native controls (a checkbox's empty box, scrollbars, date fields) follow + the theme; .dark flips it */ + color-scheme: light; --canvas: #f9fafb; --surface: #ffffff; --muted: #f3f4f6; @@ -56,6 +59,7 @@ } .dark { + color-scheme: dark; --canvas: #030712; --surface: #101828; --muted: #1e2939; @@ -137,11 +141,15 @@ body { /* Tailwind v4 ships no button cursor at all (native arrow); the app's convention is the hand on everything clickable, declared once instead of - remembered 27 times. Disabled buttons keep the arrow. */ -button:not(:disabled), -summary, -label:has(> input:not(:disabled)) { - cursor: pointer; + remembered 27 times. Disabled buttons keep the arrow. In the base layer, + so a utility wins where a control means more than a click: the roadmap's + bars drag (cursor-grab), and that's what the cursor has to say. */ +@layer base { + button:not(:disabled), + summary, + label:has(> input:not(:disabled)) { + cursor: pointer; + } } /* extend a small control's tap target past 24px without moving any layout — @@ -150,13 +158,18 @@ label:has(> input:not(:disabled)) { rows, so a larger bleed would overlap in the gap and steal each other's clicks (the star vs. its Mute/Only neighbors). -4px still lifts the smallest control we use it on (a 16px avatar) to the 24px target floor. */ -.hit { - position: relative; -} -.hit::after { - content: ""; - position: absolute; - inset: -4px; +@layer components { + /* in a layer, so a control that also says absolute or fixed (a Close in + a panel's corner) gets it: unlayered, this position: relative beat + every Tailwind utility and pulled such controls back into the flow */ + .hit { + position: relative; + } + .hit::after { + content: ""; + position: absolute; + inset: -4px; + } } /* an internally-scrollable strip (the mobile lens nav) reads as a swipeable @@ -169,11 +182,19 @@ label:has(> input:not(:disabled)) { display: none; } -/* shared focus ring (house: border-ring + ring/50 at 3px) */ -:is(select, input, button, summary, a):focus-visible { +/* shared focus ring: a solid 2px brand ring, at least 3:1 against both + themes' surfaces (the old 50% gray glow measured about 1.5:1, and on text + buttons it was the only sign of focus). A summary is a full-width band + inside a clipped card, so its ring sits inside its own edge. Anything + focusable gets it: a textarea, a chart, a drag grip, a heading a jump + lands on, so no focus anywhere falls back to the browser's own ring. */ +:is(select, input, textarea, button, summary, a, [tabindex]):focus-visible { outline: none; - border-color: var(--ring); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent); + border-color: var(--brand); + box-shadow: 0 0 0 2px var(--brand); +} +summary:focus-visible { + box-shadow: inset 0 0 0 2px var(--brand); } @layer components { @@ -349,15 +370,19 @@ label:has(> input:not(:disabled)) { left: 0; bottom: 0; width: 100%; - height: 2px; + height: 8px; pointer-events: none; + /* drawn 2px tall by scaling an 8px band from the row's bottom edge, so + the hover grows it by transform instead of animating height */ + transform: scaleY(0.25); + transform-origin: bottom; /* the growing tip rounds like every other surface on the board; the left end stays square — it's a measurement from the row's edge, a ruler fill, and it tucks under the lane card's own rounded corner */ border-radius: 0 999px 999px 0; - /* only the height animates open; the global prefers-reduced-motion + /* only the scale animates open; the global prefers-reduced-motion rule at the bottom of this file already zeroes this out */ - transition: height 150ms var(--ease-quart); + transition: transform 150ms var(--ease-quart); } /* the line's own door: .pd-age-track is the row-relative track (bits.tsx @@ -382,7 +407,7 @@ label:has(> input:not(:disabled)) { part of that answer (its popover still needs the band's own hover) */ .pd-row:hover .pd-age-line, .pd-age-hit:focus-visible .pd-age-line { - height: 8px; + transform: none; } /* row flags: quiet colored labels, not badges — amber for act-on-it, @@ -968,3 +993,16 @@ input[type="checkbox"] { border: 1px solid var(--secondary); padding: 3px 8px; } + +/* The Projects date range calendar (react-day-picker). Its stylesheet takes + colors and sizes from --rdp-* variables, which RangeCalendar.tsx points at + the tokens above; these rules undo the two sizes it hard-codes, so picked + days and month names stay at the board's text size. */ +.pd-calendar .rdp-selected { + font-size: inherit; + font-weight: 600; +} +.pd-calendar .rdp-month_caption { + font-size: 13px; + font-weight: 600; +} diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index c62947bd..28e4b982 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -186,6 +186,9 @@ interface LiveToast extends Toast { * the `cheers` setting; when off, the baseline still primes so flipping it on * mid-session doesn't replay the backlog. */ +/** a stable empty default, so the effect below doesn't re-run each render */ +const NO_PARKED: ReadonlyMap = new Map(); + export function useToasts( pulls: DerivedPull[], me: string, @@ -211,7 +214,9 @@ export function useToasts( names: Readonly> = {}, /** pull keys the viewer snoozed in Review — passed to evaluateCheers so a * snoozed pull stops firing start-here / your-turn cheers. */ - snoozed: ReadonlySet = new Set() + snoozed: ReadonlySet = new Set(), + /** open PRs whose project is parked: start-here puts them last */ + parked: ReadonlyMap = NO_PARKED ) { const [toasts, setToasts] = useState([]); const [history, setHistory] = useState([]); @@ -348,6 +353,7 @@ export function useToasts( snoozed, ready, names, + parked, }, baseline.current ); @@ -368,7 +374,7 @@ export function useToasts( return t; }); if (on) push(bound); - }, [ready, pulls, turns, closed, me, push, onQuickWins, onClaimTurn, names, snoozed]); + }, [ready, pulls, turns, closed, me, push, onQuickWins, onClaimTurn, names, snoozed, parked]); // pre-built one-shot toasts from the caller (e.g. the shipped catch-up), // deduped by dedupeKey so the same logical toast never re-fires on a later diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index da2d81c7..c93488ee 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -1,7 +1,29 @@ +import { useMemo } from 'react'; import type { DerivedPull } from '../../../shared/model/status'; import { rowWord } from '../model/actions'; import type { PullData } from '../../../shared/types'; -import { pullKey } from '../../../shared/format'; +import { n, pullKey } from '../../../shared/format'; +import { closedIssues, decideProjects, decideQueue } from '../../../shared/model/decide'; +import { buildToday } from '../../../shared/model/projects'; +import { dayOf } from '../model/days'; +import { + isYours, + portfolioItems, + stageWord, + withCalls, + type PortfolioItem, +} from '../model/portfolio'; +import { + DEFAULT_RANGE, + ongoingSlugs, + resolveRange, + teamLookup, + useProjectsData, + type Range, +} from '../model/projectData'; +import { useRoadmap } from '../model/roadmapData'; +import { useWorkData } from '../model/workData'; +import { LAST_14_DAYS } from '../model/words'; import { claimFor } from '../model/reviewers'; import { EmptyState } from '../components/bits'; import { Fold, Lane, laneShown, RestGroup, Truncated } from '../components/Lane'; @@ -16,17 +38,26 @@ import { ClosedRow } from '../components/ClosedRow'; * word-group sub-headers render — lane and header can't disagree (splitting * on authorMove once put a brand "Chase CR" header inside "Waiting on * others", because the two models diverge on edge cases like external - * blocks). + * blocks). Under them, your projects, in the Projects tab's own words. */ export function MyWork({ pulls, closed, opts, + prefix, + allPulls, + allClosed, }: { pulls: DerivedPull[]; closed: PullData[]; opts: RowOptions; + /** the project label prefix; null when the server isn't set up for projects */ + prefix: string | null; + /** every person's open and recently closed PRs, whatever the filters + * narrow, as every Projects view counts them */ + allPulls: DerivedPull[]; + allClosed: PullData[]; }) { const me = opts.me; const mine = pulls.filter(p => p.data.user.login === me); @@ -35,13 +66,27 @@ export function MyWork({ const move = mine.filter(p => kindOf(p) === 'do').sort(byUrgency); const waiting = mine.filter(p => kindOf(p) !== 'do').sort(byUrgency); const shipped = closed.filter(p => p.user.login === me); + // a project you lead is yours with no PR of your own open + const projects = prefix && ( + + ); if (!mine.length && !shipped.length) { return ( - + <> + + {projects && {projects}} + ); } @@ -68,26 +113,179 @@ export function MyWork({
)} - {shipped.length > 0 && ( - - - - {shipped.map(p => ( - - ))} - - - - )} + + {projects} + + + {shipped.map(p => ( + + ))} + + + ); } + +/** A project of yours in My work: the Projects tab's item, and whether its + * word is a call that's yours. */ +interface YourProject { + item: PortfolioItem; + /** its word names a call its lead owes, and you lead it */ + owed: boolean; +} + +/** + * Your projects as the Projects tab reads them (portfolio.ts isYours, the + * Overview's Yours): the projects you lead or have a PR in, open or merged + * in the last 14 days, each with its Plan column's word. Null until the + * projects and the roadmap load. + */ +function useYourProjects( + me: string, + prefix: string, + allPulls: DerivedPull[], + allClosed: PullData[] +): YourProject[] | null { + // the window the Projects tab opens on, so one fetch serves both + const data = useProjectsData(resolveRange(DEFAULT_RANGE) as Range); + const { items: plans } = useRoadmap(); + const work = useWorkData(plans); + return useMemo(() => { + if (!data || !plans) return null; + const now = Date.now(); + const today = buildToday( + data.projects, + allPulls, + allClosed, + prefix, + now / 1000, + data.pull_links + ); + const ongoing = ongoingSlugs(data); + // Decide's questions, asked as decideRows asks them + // (views/projects/Decide.tsx), so each word names the call Decide puts first + const asked = decideQueue({ + live: decideProjects(today), + items: plans, + closed: closedIssues(data.projects), + planCounts: new Map( + [...(work?.plans ?? [])].map(([id, w]) => [ + id, + { + openPulls: w.openPulls, + afterEnd: w.afterEnd.length, + afterDone: w.afterDone.length, + }, + ]) + ), + issues: work?.projects, + ongoing, + today: dayOf(new Date(now)), + now: now / 1000, + }); + const items = withCalls( + portfolioItems( + data.projects, + today, + {}, + teamLookup(data.teams), + now, + plans, + work?.projects ?? null, + ongoing + ), + asked, + now + ); + return ( + items + .filter(item => isYours(item, me)) + .map(item => ({ + item, + owed: item.planCell.warn && item.lead?.toLowerCase() === me.toLowerCase(), + })) + // a call that's yours first, then where the most is open + .sort( + (a, b) => + Number(b.owed) - Number(a.owed) || + b.item.open - a.item.open || + a.item.name.localeCompare(b.item.name) + ) + ); + }, [data, plans, work, allPulls, allClosed, prefix, me]); +} + +/** + * The fold under your PRs: one line per project of yours, its name, its one + * word (amber only when the call is yours: an update you owe as its lead, a + * question Decide asks of a plan you lead) and its open PRs, each opening + * the project's page on the Projects tab. It rests closed like the board's + * other folds, and opens on its own when it holds a call of yours or your + * lanes are clear. + */ +function YourProjects({ + me, + prefix, + allPulls, + allClosed, + onProject, + lanesClear, +}: { + me: string; + prefix: string; + allPulls: DerivedPull[]; + allClosed: PullData[]; + onProject?: (slug: string) => void; + lanesClear: boolean; +}) { + const yours = useYourProjects(me, prefix, allPulls, allClosed); + if (!yours) return null; + return ( + y.owed)} + > + {yours.map(({ item, owed }) => ( + + ))} + + ); +} diff --git a/frontend-v2/src/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx new file mode 100644 index 00000000..aa5f1077 --- /dev/null +++ b/frontend-v2/src/views/Projects.tsx @@ -0,0 +1,319 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import type { DerivedPull } from '../../../shared/model/status'; +import type { PullData } from '../../../shared/types'; +import { closedIssues } from '../../../shared/model/decide'; +import { buildToday, MISC_SLUG, type Today } from '../../../shared/model/projects'; +import { backend } from '../backend/socket'; +import { EmptyState, Segmented } from '../components/bits'; +import { Icon } from '../components/Icon'; +import type { RowOptions } from '../components/Row'; +import { + DEFAULT_RANGE, + ongoingSlugs, + previousRange, + refreshProjectsData, + resolveRange, + teamLookup, + useProjectsData, + type Range, +} from '../model/projectData'; +import { useWorkData, workVersion } from '../model/workData'; +import { portfolioItems } from '../model/portfolio'; +import { loadRoadmap, useRoadmap } from '../model/roadmapData'; +import { ONE_OFFS } from '../model/words'; + +/** how long a burst of changes on the server settles before one refetch */ +const CHANGE_SETTLE_MS = 2000; +import { DateRangePicker } from './projects/DateRangePicker'; +import { Decide, decideRows, useKeptCalls } from './projects/Decide'; +import { Overview } from './projects/Overview'; +import { People } from './projects/People'; +import { ProjectPage } from './projects/ProjectPage'; +import { Retro } from './projects/Retro'; +import { Roadmap } from './projects/Roadmap'; +import { switchView, type Navigate, type ProjectsNav } from './projects/parts'; + +export type { ProjectsNav } from './projects/parts'; + +const NO_TODAY: Today = { live: [], quiet: [], misc: [], unsorted: [], doubleLabeled: [] }; + +/** The way back from a project's page, named for where it goes. */ +const BACK_WORDS: Record = { + overview: 'All projects', + decide: 'Decide', + roadmap: 'Roadmap', + people: 'People', + retro: 'Look back', +}; + +/** + * The Projects tab, for the person who plans the work: an overview of every + * project (headline numbers, charts, and one sortable list), the roadmap + * (the plan, dragged into shape, with the PRs' real activity drawn over it), + * and the people (who is on what, and whether review keeps up). A job outside + * Pulldasher files each PR with a project label and keeps one GitHub issue per + * project; the roadmap is Pulldasher's own. Today is built here from the live + * socket pulls, so it moves with the board; the project issues and the + * history come from /projects-data. + */ +export function Projects({ + allPulls, + allClosed, + prefix, + nav, + navigate, + opts, + me, +}: { + /** people's open PRs, and their PRs closed in the last 14 days, whatever + * the filter bar narrows */ + allPulls: DerivedPull[]; + allClosed: PullData[]; + /** the project label prefix; null when the server isn't set up for projects */ + prefix: string | null; + nav: ProjectsNav; + navigate: Navigate; + opts: RowOptions; + me: string; +}) { + // a person clicked anywhere on this tab opens their row on People, so the + // click stays in Projects and sets no filter that would change its counts + const onPersonHere = useCallback( + (login: string) => navigate({ ...switchView('people'), who: login }, { push: true }), + [navigate] + ); + // a change made on the server (anyone's call, an issue added, a sync) + // reaches this board: what it shows is fetched again in the background, + // once a burst of changes settles, keeping the old numbers up meanwhile + useEffect(() => { + let wait: ReturnType | null = null; + const off = backend.onProjectsChanged(() => { + if (wait) clearTimeout(wait); + wait = setTimeout(() => { + refreshProjectsData(); + workVersion.set({ n: workVersion.get().n + 1 }); + void loadRoadmap(); + }, CHANGE_SETTLE_MS); + }); + return () => { + if (wait) clearTimeout(wait); + off(); + }; + }, []); + // this tab shows hidden PRs anyway, so its rows don't offer to hide one + const tabOpts = useMemo( + () => ({ ...opts, noHide: true, onPerson: onPersonHere }), + [opts, onPersonHere] + ); + const rangeKey = resolveRange(nav.range) ? nav.range : DEFAULT_RANGE; + const range = resolveRange(rangeKey) as Range; + const data = useProjectsData(prefix ? range : null); + // the same days just before the range, for every "compared with" line + const prev = useProjectsData(prefix ? previousRange(range) : null); + // every view counts every PR, whatever the filter bar narrows: a project + // whose PRs are filtered out would otherwise look quiet or finished, and a + // click on a face would rewrite the planner's numbers. A PR with no + // project label counts where the issues it links put it, as on the server. + const today = useMemo( + () => + prefix + ? buildToday( + data?.projects ?? [], + allPulls, + allClosed, + prefix, + Date.now() / 1000, + data?.pull_links + ) + : NO_TODAY, + [data, allPulls, allClosed, prefix] + ); + const teamOf = useMemo(() => teamLookup(data?.teams ?? {}), [data]); + const { items: plans } = useRoadmap(); + // each plan's PRs by the dates, each project's issues, and which + // projects run with no end + const work = useWorkData(plans); + const ongoing = useMemo(() => ongoingSlugs(data), [data]); + const items = useMemo( + () => + portfolioItems( + data?.projects ?? [], + today, + data?.window.projects ?? {}, + teamOf, + Date.now(), + plans ?? [], + work?.projects ?? null, + ongoing + ), + [data, today, teamOf, plans, work, ongoing] + ); + const closedProjects = useMemo(() => closedIssues(data?.projects ?? []), [data]); + // the calls owed, counted on the tab so they're seen from every view + const decisions = useMemo( + () => (plans ? decideRows(today, plans, closedProjects, work, ongoing) : null), + [today, plans, closedProjects, work, ongoing] + ); + // a call made this visit stays with its row (a receipt and Undo) on + // Decide and on the project's page; the badge counts what's still owed + const kept = useKeptCalls(decisions ?? []); + const owedCalls = decisions ? kept.owed : null; + const views: [ProjectsNav['view'], string][] = [ + ['overview', 'Overview'], + ['decide', 'Decide'], + ['roadmap', 'Roadmap'], + ['people', 'People'], + ['retro', 'Look back'], + ]; + const nameOf = useMemo(() => { + const names = new Map(items.map(i => [i.slug, i.name])); + names.set(MISC_SLUG, ONE_OFFS); + return (slug: string) => names.get(slug) ?? slug; + }, [items]); + + if (!prefix) { + return ( + + ); + } + const rangePicker = ( + navigate({ range: key })} + /> + ); + // one toolbar for the tab: the way back or the view switch, and the date + // range picker wherever the page shows numbers for a range + const toolbar = ( +
+ {nav.project ? ( + // named for where it goes: the view the page was opened from + + ) : ( + navigate(switchView(view))} + /> + )} + {/* the Overview and Decide are about now, and the roadmap has its + own months and quarters; the range is for looking back. A + project's page puts it beside the numbers it sets. */} + {!nav.project && (nav.view === 'people' || nav.view === 'retro') && rangePicker} +
+ ); + return ( + <> + {toolbar} + {nav.project ? ( + // a project's page counts all of its PRs, whatever the filter bar + // narrows elsewhere, as Decide does + i.slug === nav.project)} + plans={plans} + ongoingSaved={data?.ongoing ?? []} + opts={tabOpts} + onPerson={onPersonHere} + asks={kept.owed.filter(row => row.slug === nav.project)} + calls={kept.all.filter(row => row.slug === nav.project)} + rangePicker={rangePicker} + /> + ) : nav.view === 'people' ? ( + + ) : nav.view === 'decide' ? ( + + ) : nav.view === 'retro' ? ( + + ) : nav.view === 'roadmap' ? ( + + ) : ( + + )} + + ); +} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 346dde5a..03079469 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -3,6 +3,7 @@ import { pullKey, shortRepo } from '../../../shared/format'; import { useNames } from '../model/names'; import { matchedRegions } from '../model/regions'; import { buildReviewLanes } from '../model/reviewLanes'; +import type { PullStanding } from '../model/standing'; import { useSettings } from '../settings'; import { clearSnoozes, isFresh, isSnoozed, markAllSeen, usePulldasher } from '../store'; import type { PullData } from '../../../shared/types'; @@ -31,6 +32,7 @@ export function Review({ botsForReady: allBotsForReady, closed, opts, + standing, }: { pulls: DerivedPull[]; bots: DerivedPull[]; @@ -40,6 +42,9 @@ export function Review({ botsForReady: DerivedPull[]; closed: PullData[]; opts: RowOptions; + /** how each PR's project stands (model/standing.ts), so a parked + * project's PRs sink and a plan's last ones win ties */ + standing?: PullStanding; }) { const me = opts.me; const { selfReview, teams, codeRegions, repoPriority, repoQueueCap } = useSettings(); @@ -50,7 +55,7 @@ export function Review({ // loop lives here, so the quieting gesture belongs here — every other // lens still shows the pull. Snoozed rows collect in their own section at // the bottom of the board instead of vanishing into Settings. - const { snoozed } = usePulldasher(); + const { snoozed, projectLabelPrefix } = usePulldasher(); // allBotsForReady can hold a bot allBots no longer does ("Ignore bot PRs" // pulled it out of the human-review surfaces) — folded in here too, deduped // by key, so snoozing a bot straight off the Ready-to-merge lane still @@ -101,8 +106,20 @@ export function Review({ repoPriority, ageWarnDays: opts.ageWarnDays, names, + standing, }); const queueOpts = { ...opts, rankReason: lanes.whyUpNext }; + // lanes ranked by age or by the word you owe say only what a PR's project + // did to its place + const projectOpts = { ...opts, rankReason: (p: DerivedPull) => lanes.whyProject(p, null) }; + // what a project does to a lane's order, said in each lane's explanation + // on a board that has projects + const projectRule = (sink: string, tie: string) => + projectLabelPrefix && ( +

+ {sink}. {tie}, the one that helps finish a plan in progress goes first. +

+ ); // bots/shipped stay reachable even when no human PRs need review if (lanes.empty) { @@ -140,11 +157,15 @@ export function Review({ Bot PRs land here too once they’re fully green: they only ship when a human merges them. Longest-waiting first.

+ {projectRule( + 'A parked project’s PRs sink to the bottom', + 'Between two that have waited the same days' + )} } pulls={lanes.ready} cap={6} - opts={opts} + opts={projectOpts} /> {lanes.yourMove.length > 0 && ( + {projectRule( + 'A parked project’s PRs sink to the bottom of their group', + 'Between two open the same days' + )} } pulls={[]} @@ -176,7 +201,7 @@ export function Review({ > @@ -273,9 +298,10 @@ export function Review({ hideRegionMark: true, rankReason: p => { const r = matchedRegions(p, codeRegions); - return r.length - ? `It touches ${r.join(', ')}, a code region you flagged` - : null; + return lanes.whyProject( + p, + r.length ? `It touches ${r.join(', ')}, a code region you flagged` : null + ); }, }} /> @@ -308,6 +334,10 @@ export function Review({ sink to each block’s bottom. The per-repo cap folds the rest behind “+N more” so one busy repo can’t take the whole screen.

+ {projectRule( + 'A parked project’s PRs sink below everyone else’s, above the bot PRs', + 'When two PRs rank the same with age in whole days' + )}

PRs you claim stay in the queue and also appear in Waiting on you.

Starving PRs and PRs in your code regions still show up in their repo block @@ -368,6 +398,10 @@ export function Review({ your work, and small quick wins, lightest first. Bot PRs (dependency bumps) sink to the bottom.

+ {projectRule( + 'A parked project’s PRs sink below everyone else’s, above the bot PRs', + 'When two PRs rank the same with age in whole days' + )}

PRs you claim stay in the queue and also appear in Waiting on you.

PRs in your code regions still show up in the queue below; the call-out @@ -396,6 +430,10 @@ export function Review({ or conflicted. PRs nobody is testing yet lead; within that, lighter tests first, then oldest.

+ {projectRule( + 'A parked project’s PRs sink to the bottom', + 'When two tie, counting age in whole days' + )} } pulls={lanes.needsQa} diff --git a/frontend-v2/src/views/projects/BacklogSection.tsx b/frontend-v2/src/views/projects/BacklogSection.tsx new file mode 100644 index 00000000..3ecdd826 --- /dev/null +++ b/frontend-v2/src/views/projects/BacklogSection.tsx @@ -0,0 +1,150 @@ +import type { ReactNode } from 'react'; +import { n } from '../../../../shared/format'; +import type { WindowCounts } from '../../../../shared/model/projects'; +import { LoadFailed } from '../../components/bits'; +import { GroupHeader } from '../../components/Lane'; +import { + chartWindow, + dayWords, + previousRange, + rangeDays, + rangeWords, + refreshProjectsData, + useProjectsData, + type Range, +} from '../../model/projectData'; +import { beforeWords, mergeSpeed } from '../../model/retro'; +import { durationWords } from '../../model/stage'; +import { StatsCard } from '../stats/parts'; +import { ChartSlot, FlowWeeksChart, OpenPrsChart } from './lazyCharts'; +import { versus } from './parts'; + +/** a project's numbers for days it had no PRs in */ +const NONE: WindowCounts = { + backlog_start: 0, + backlog_end: 0, + opened: 0, + merged: 0, + closed: 0, + median_age_start_days: null, + median_age_end_days: null, + median_days_to_merge: null, + developers: 0, + non_developers: 0, +}; + +/** + * The range's PRs in one sentence, each count against the same number of + * days just before: "Sep 2 to Oct 1: 47 PRs opened (4 more than the 30 days + * before), 16 merged (2 fewer), half of them within about 7 hours of + * opening, and none closed without merging." Then how old the open ones + * were, in the same words for a time. + */ +function rangeSentence(w: WindowCounts, prev: WindowCounts | undefined, range: Range): string { + // the first comparison says against what; the second, only how far + const first = prev ? ` (${versus(w.opened, prev.opened, beforeWords(rangeDays(range)))})` : ''; + const d = prev ? w.merged - prev.merged : 0; + const second = prev ? ` (${d > 0 ? `${d} more` : d < 0 ? `${-d} fewer` : 'the same'})` : ''; + const speed = mergeSpeed(w.merged, w.median_days_to_merge); + const flow = `${rangeWords(range)}: ${w.opened ? n(w.opened, 'PR') : 'no PRs'} opened${first}, ${ + w.merged || 'none' + } merged${second}${speed ? `, ${speed},` : ''} and ${ + w.closed || 'none' + } closed without merging.`; + if (w.median_age_end_days == null) return flow; + return w.median_age_start_days != null + ? `${flow} The open PRs’ median age went from ${durationWords( + w.median_age_start_days + )} to ${durationWords(w.median_age_end_days)}.` + : `${flow} The open PRs’ median age ended at ${durationWords(w.median_age_end_days)}.`; +} + +/** + * Is the backlog growing: the PRs open at the end of each day, and what + * arrived and what merged each week, over at least 90 days ending on the + * range's last day with the days before the range shown lighter, then the range's + * numbers in one sentence under the charts. Every PR, or one project's (by + * its label) with `slug`. Look back and a project's page both show it, so + * the question reads the same wherever it's asked. + */ +export function BacklogSection({ + range, + slug = null, + title, + id, + headerExtra, + everyone = false, +}: { + range: Range; + /** one project's PRs; every PR without */ + slug?: string | null; + /** "Is the backlog growing?", or "Is its backlog growing?" on a project's page */ + title: string; + id?: string; + /** the header's right end: a project page's range picker */ + headerExtra?: ReactNode; + /** the page around it counts only some people's days: say this counts every PR */ + everyone?: boolean; +}) { + const shown = chartWindow(range); + const flow = useProjectsData(shown, slug); + // the range's own numbers, whose weeks split the week the range starts in + const now = useProjectsData(range, slug); + // the days before come unscoped, as the tab already holds them; a + // project's numbers are in them by its slug + const prev = useProjectsData(previousRange(range)); + const before = prev + ? slug + ? prev.window.projects[slug] ?? NONE + : prev.window.totals + : undefined; + // nothing open on any day and nothing opened: one line, not two empty charts + const empty = + !!flow && + !flow.window.days.some(day => day.backlog > 0) && + !flow.window.weeks.some(w => w.opened.developers + w.opened.non_developers > 0); + return ( +
+ + + {flow === null ? ( + + ) : empty ? ( +

+ No PRs were open, opened or merged from {rangeWords(shown)}. +

+ ) : ( +
+ + {flow && } + + + {/* waits for the range's weeks too, so its first week + doesn't redraw from whole to split */} + {flow && now !== undefined && ( + + )} + + {now && ( +

+ {rangeSentence(now.window.totals, before, range)} +

+ )} +
+ )} +
+
+ ); +} diff --git a/frontend-v2/src/views/projects/DateRangePicker.tsx b/frontend-v2/src/views/projects/DateRangePicker.tsx new file mode 100644 index 00000000..fe326828 --- /dev/null +++ b/frontend-v2/src/views/projects/DateRangePicker.tsx @@ -0,0 +1,110 @@ +import { lazy, Suspense, useEffect, useId, useState, type KeyboardEvent } from 'react'; +import { CalendarDays, Check, ChevronDown } from 'lucide-react'; +import { Icon } from '../../components/Icon'; +import { Popover } from '../../components/Popover'; +import { RANGE_PRESETS, rangeName, rangeWords, type Range } from '../../model/projectData'; + +// the calendar and its stylesheet load the first time the panel opens +const RangeCalendar = lazy(() => import('./RangeCalendar')); + +/** + * The date range control, shaped like the one in any analytics tool: one + * button that names the range and its days, opening the presets beside a + * two-month calendar. A preset applies on click; a range picked on the + * calendar applies with Apply. A custom range's button says its dates, even + * on a phone, where "Custom range" alone would say nothing. + */ +export function DateRangePicker({ + rangeKey, + range, + onChange, +}: { + rangeKey: string; + range: Range; + onChange: (key: string) => void; +}) { + // remounting the Popover is how a pick closes it (LensMenu does the same): + // its children get no close callback + const [picks, setPicks] = useState(0); + const pick = (key: string) => { + onChange(key); + setPicks(p => p + 1); + }; + // the remount takes the focused preset or Apply with it: hand focus back + // to the button, which now names the range just picked + const id = useId(); + useEffect(() => { + if (picks) document.getElementById(id)?.focus(); + }, [picks, id]); + // the panel sits at the end of the page, so a Tab past its last control + // (or Shift+Tab before its first) would wander off to whatever the page + // ends with and leave it open: it closes instead, back on the button + const tabOut = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + const stops = [ + ...e.currentTarget.querySelectorAll('button, input, [tabindex]'), + ].filter(el => el.tabIndex >= 0 && !el.matches(':disabled')); + if (e.target !== (e.shiftKey ? stops[0] : stops.at(-1))) return; + e.preventDefault(); + setPicks(p => p + 1); + }; + const custom = !RANGE_PRESETS.some(([key]) => key === rangeKey); + return ( + ( + + )} + > +
+
    + {RANGE_PRESETS.map(([key, label]) => ( +
  • + +
  • + ))} +
+
+ }> + pick(`${r.start}..${r.end}`)} /> + +
+
+
+ ); +} diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx new file mode 100644 index 00000000..366b8d2f --- /dev/null +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -0,0 +1,2704 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react'; +import { Check } from 'lucide-react'; +import { epoch, issueUrl, n } from '../../../../shared/format'; +import { + compareRows, + decideProjects, + decideQueue, + DECIDE_MIN_PRS, + STALL_DAYS, + type ClosedIssue, + type DecideReason, + type DecideRow, +} from '../../../../shared/model/decide'; +import { firstOpenDay, projectSlugs, type Today } from '../../../../shared/model/projects'; +import { decideTurn, type DecideRotation } from '../../../../shared/model/settings'; +import { + addWeeks, + healthStanding, + HEALTH_WORD, + isUnderWay, + mondayOf, + planEnd, + weeksThrough, + type RoadmapFields, + type RoadmapItem, + type RoadmapOrigin, + type Vouch, +} from '../../../../shared/model/roadmap'; +import type { DerivedPull } from '../../../../shared/model/status'; +import { + issueKey, + type IssueRef, + type ProjectIssue, + type ProjectWork, +} from '../../../../shared/model/work'; +import type { PullData } from '../../../../shared/types'; +import { + ClosedBadge, + EmptyState, + FactLink, + LoadFailed, + PrimaryButton, + QuietButton, + Segmented, + textInputClass, +} from '../../components/bits'; +import { Icon } from '../../components/Icon'; +import { ClosedRow } from '../../components/ClosedRow'; +import { Fold, GroupHeader, Rows, SubDoor, Truncated } from '../../components/Lane'; +import { Row, type RowOptions } from '../../components/Row'; +import { useRowKeys } from '../../components/useRowKeys'; +import { mainTeam, type PortfolioItem } from '../../model/portfolio'; +import { teamLoad, type InFlight } from '../../model/teamLoad'; +import { dayOf, dayWords } from '../../model/projectData'; +import { changeProjectIssue, useProjectWork } from '../../model/projectWork'; +import { commitEnds, type CommitEnd } from '../../model/roadmapTime'; +import { saveDecideRotation, setOngoing } from '../../model/settingsData'; +import { + ALL_ISSUES_CLOSED, + andList, + BEING_WORKED_ON, + COMMIT_THROUGH, + COPY_AS_TEXT, + DONE_WHEN, + IN_PROGRESS, + LAST_14_DAYS, + missedTarget, + NEEDS_A_PLAN, + noPrActivity, + ONGOING, + pastEnd, + PLAN_IT, + targetOn, +} from '../../model/words'; +import type { WorkData } from '../../model/workData'; +import { + createRoadmapItem, + dismissRoadmapProblem, + loadRoadmap, + readRoadmap, + removeRoadmapItem, + updateRoadmapItem, + useRoadmap, + storedStatus, +} from '../../model/roadmapData'; +import { createMemoryStore, readSessionStorage, writeSessionStorage } from '../../storage'; +import { + ByPrs, + openPageAt, + openPlan, + ORIGIN_OPTIONS, + targetWords, + type Navigate, + type ProjectsNav, +} from './parts'; +import { PLAN_STATUS_WORD, vouchWords, when } from './roadmapHealth'; + +/** The decisions owed now, worst first, for this view, the tab's label and + * the Overview's tile. */ +export function decideRows( + today: Today, + items: readonly RoadmapItem[], + closed: ReadonlyMap, + work: WorkData | null | undefined = null, + ongoing: ReadonlySet = new Set() +): DecideRow[] { + return decideQueue({ + live: decideProjects(today), + items, + closed, + planCounts: new Map( + [...(work?.plans ?? [])].map(([id, w]) => [ + id, + { + openPulls: w.openPulls, + afterEnd: w.afterEnd.length, + afterDone: w.afterDone.length, + }, + ]) + ), + issues: work?.projects, + ongoing, + today: dayOf(new Date()), + now: Date.now() / 1000, + }); +} + +const lowerFirst = (s: string) => s.charAt(0).toLowerCase() + s.slice(1); + +/** What "Mark ongoing" is for, said where each section that offers it + * explains itself, since a title on the button is missed by touch and + * screen readers. */ +const ONGOING_HELP = `${ONGOING} is for upkeep with no finish line. For work that ends but can’t be sized, promise a month, and Decide checks in when it ends.`; + +/** The queue's sections, worst first, as decide.ts ranks them: the reasons + * each holds, its title, and what lands there and in what order, for the + * title's hover. A row sits in the first section any of its reasons names. */ +const SECTIONS: { + kinds: DecideReason['kind'][]; + title: string; + more: string[]; +}[] = [ + { + kinds: ['reopened'], + title: 'Done or dropped, still worked on', + more: [ + 'Marked done or dropped, on the roadmap or by closing its issue, but a week after that a PR is still open, or a new PR opened more than a week after it was marked.', + ONGOING_HELP, + ], + }, + { + kinds: ['issue_closed'], + title: 'Issue closed, plan still going', + more: [ + 'Its issue was closed after the plan last changed, and the plan still says it’s going.', + ], + }, + { + kinds: ['moving'], + title: 'Parked, still worked on', + more: [ + 'Its PRs had activity after it was parked: a push, a person’s comment, stamp or review, opening or merging.', + ], + }, + { + kinds: ['off_track'], + title: 'Off track', + more: ['Its latest update says off track, and the plan hasn’t changed since.'], + }, + { + kinds: ['missed'], + title: 'Missed its target date', + more: [ + 'The target date on its issue passed with PRs still open, and the plan hasn’t changed since.', + ], + }, + { + kinds: ['over', 'ended'], + title: 'Overdue', + more: [ + 'Its promised end date has passed. An estimated end date is never asked about, and ongoing work has none. The ones still getting new PRs come first, then the longest overdue.', + ], + }, + { + kinds: ['issues_done'], + title: ALL_ISSUES_CLOSED, + more: [ + 'Every issue in the project is closed, and the plan hasn’t changed since the last one closed.', + ], + }, + { + kinds: ['stalled'], + title: 'Stalled', + more: [ + `PRs still open, ${lowerFirst( + noPrActivity(STALL_DAYS) + )} or more, and no decision in that time. Activity is real work: a push, a person’s comment, stamp or review, opening or merging. The oldest activity comes first.`, + ], + }, + { + kinds: ['at_risk'], + title: 'At risk', + more: ['Its latest update says at risk, and the plan hasn’t changed since.'], + }, + { + kinds: ['new'], + title: NEEDS_A_PLAN, + more: [ + `${DECIDE_MIN_PRS} or more PRs open or merged in the ${LAST_14_DAYS}, some still open, and no plan yet. Smaller work ships without one unless it stalls. The ones open longest come first.`, + ONGOING_HELP, + ], + }, +]; + +const sectionOf = (row: DecideRow) => + SECTIONS.findIndex(s => row.reasons.some(r => s.kinds.includes(r.kind))); + +/** The reason that put a row in its section: the one its question asks. */ +const primaryOf = (row: DecideRow) => + row.reasons.find(r => SECTIONS[sectionOf(row)].kinds.includes(r.kind)) ?? row.reasons[0]; + +/** A section's question asked of all its rows at once: "Park them for + * now?", "When will each finish?". */ +export const askAll = (question: string) => + question + .replace(/^(Is|When will) it\b/, '$1 each') + .replace(/ it\b/, ' them') + .replace(/the (plan|end date)\b/, 'the $1s'); + +/** The words that tell one answer from another, as the section's one click + * counts them: every target date is the same answer. */ +const answerKey = (call: Call | null) => + !call ? '' : call.kind !== 'commit' ? call.kind : call.target ? 'target' : call.end; + +/** + * The question a section asks once, in its header, when two or more rows + * ask it with the same suggested answer and have nothing else to say: each + * row is then one line, its name, lead and the one fact. Null keeps the + * cards. + */ +export function sharedAsk( + rows: readonly DecideRow[], + projectOf: (row: DecideRow) => Pick | undefined, + today: string +): string | null { + if (rows.length < 2) return null; + const keys = new Set(); + const questions = new Set(); + for (const row of rows) { + if (row.reasons.length !== 1) return null; + const [reason] = row.reasons; + const { question, call } = askOf(reason); + // a done_when or an issues link is a second line the card keeps + if (call === 'done' && row.item?.done_when) return null; + if (reason.kind === 'issues_done' && reason.open > 0 && row.slug) return null; + const answer = answerOf(row, projectOf(row), today); + if (!answer) return null; + keys.add(answerKey(answer)); + questions.add(question); + } + return keys.size === 1 && questions.size === 1 ? [...questions][0] : null; +} + +const rowKey = (row: DecideRow) => `${row.slug ?? ''}:${row.item?.id ?? ''}`; +const kindsOf = (row: DecideRow) => row.reasons.map(r => r.kind).join(','); +const nameOf = (row: DecideRow, project?: Pick) => + row.item?.name ?? project?.name ?? row.slug ?? 'A plan'; + +const closedAs = (as: 'done' | 'dropped') => (as === 'done' ? 'completed' : 'not planned'); +const upperFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); +/** A fact mid-line: "no PR activity for 268 days", but "PRs open since". */ +const midLine = (s: string) => (/^[A-Z][a-z]/.test(s) ? lowerFirst(s) : s); + +/** The calls a row can get. */ +export type Call = + | ({ kind: 'commit' } & CommitEnd) + | { kind: 'park' } + | { kind: 'done' } + | { kind: 'drop' } + | { kind: 'ongoing' }; + +/** The question that asks a plan to end later: its answers are only ends + * after the one it has. */ +const NEW_END = 'Move the end date?'; + +/** The question a reason asks, and the call that answers yes. */ +export function askOf(reason: DecideReason): { + question: string; + call: Call['kind']; +} { + switch (reason.kind) { + case 'new': + // answered under "Promise to finish by": "End of Oct" + return { question: 'When will it finish?', call: 'commit' }; + case 'stalled': + return { question: 'Park it for now?', call: 'park' }; + case 'over': + case 'missed': + case 'off_track': + case 'at_risk': + return { question: NEW_END, call: 'commit' }; + case 'moving': + return { question: 'Restart it?', call: 'commit' }; + case 'ended': + return { question: 'Is it done?', call: 'done' }; + case 'issue_closed': + return reason.as === 'done' + ? { question: 'Mark the plan done too?', call: 'done' } + : { question: 'Drop the plan too?', call: 'drop' }; + case 'reopened': + return reason.as === 'done' + ? { question: 'Is it still done?', call: 'done' } + : { question: 'Is it still dropped?', call: 'drop' }; + case 'issues_done': + return reason.done + ? { question: 'Is it done?', call: 'done' } + : { question: 'Drop it?', call: 'drop' }; + } +} + +/** The ends a row's plan can commit to: its project's target first while + * that's ahead, then the coming months and quarters, none before the plan + * starts. An end that leaves a plan under way with the hard end it has + * would only stamp it changed, so it isn't one, while the same end on a + * soft one commits to its estimate; a row asking "Move the end date?" offers only the + * ends that move it later, and ongoing work, with no end, any of them. */ +export function endsFor( + row: DecideRow, + project: Pick | undefined, + today: string +): CommitEnd[] { + const target = project?.target?.due_on?.slice(0, 10) ?? null; + const plan = row.item; + if (!plan) return commitEnds(today, target); + const later = row.reasons.length > 0 && askOf(primaryOf(row)).question === NEW_END; + const endless = plan.end_kind === 'ongoing'; + return commitEnds(today, target).filter(c => { + if (c.end < plan.start) return false; + const weeks = weeksThrough(plan.start, c.end); + if (later) return endless || weeks > plan.weeks; + return weeks !== plan.weeks || !isUnderWay(plan.status) || plan.end_kind !== 'hard'; + }); +} + +/** + * The call a row's suggested answer makes, safe to take without reading the + * rest: the one its question asks, a commit going through its target while + * that's ahead, else through the nearest end, never one that ends its plan + * sooner. Null when there's none to outline: a row nobody asked about (a + * plan under its project's page), or a plan already running past every end + * offered. + */ +export function answerOf( + row: DecideRow, + project: Pick | undefined, + today: string +): Call | null { + if (!row.reasons.length) return null; + const asked = askOf(primaryOf(row)).call; + if (asked !== 'commit') return { kind: asked }; + const plan = row.item; + const end = endsFor(row, project, today).find( + c => !plan || plan.end_kind === 'ongoing' || weeksThrough(plan.start, c.end) >= plan.weeks + ); + return end ? { kind: 'commit', ...end } : null; +} + +const VERBS: Record = { + commit: ['Promise', 'Promised'], + park: ['Park', 'Parked'], + done: ['Mark', 'Marked'], + drop: ['Drop', 'Dropped'], + ongoing: ['Mark', 'Marked'], +}; + +/** + * What one click on a section does to its rows, or did, in the day each + * row's own button says: "Promise all 52 by Oct 31", "Promised 52 by Oct + * 31". Rows whose answers differ say each one and how many ("Promise 5 by + * Oct 31 and mark + * 3 done"), and rows going through their own target dates + * count together. + */ +export function bulkWords(made: readonly Call[], tense: 'do' | 'did'): string { + const groups = new Map(); + for (const call of made) { + const key = call.kind === 'commit' ? (call.target ? 'target' : call.end) : call.kind; + const group = groups.get(key) ?? { call, count: 0 }; + group.count++; + groups.set(key, group); + } + const all = groups.size === 1 && tense === 'do' ? 'all ' : ''; + let said = ''; + const parts = [...groups.values()].map(({ call, count }, i) => { + const verb = VERBS[call.kind][tense === 'do' ? 0 : 1]; + const tail = + call.kind === 'commit' + ? ` by ${ + !call.target ? dayWords(call.end) : count > 1 ? 'their target dates' : call.through + }` + : call.kind === 'done' || call.kind === 'ongoing' + ? ` ${call.kind}` + : ''; + // "Promise 40 by the end of Oct and 12 by their target dates" + const head = verb === said ? '' : `${i ? verb.toLowerCase() : verb} `; + said = verb; + return `${head}${all}${count}${tail}`; + }); + return andList(parts); +} + +/** The suggested answer in a verb that says what it does, given where the + * plan already stands: "Keep it done" on a plan that's done. */ +export function answerWords(call: Call, plan: RoadmapItem | null): string { + const now = plan?.status; + switch (call.kind) { + case 'commit': { + const day = dayWords(call.end); + return !plan + ? `Promise to finish by ${day}` + : now === 'parked' || now === 'done' || now === 'dropped' + ? `Restart, finishing by ${day}` + : `Move the end date to ${day}`; + } + case 'park': + return now === 'parked' ? 'Keep it parked' : 'Park'; + case 'done': + return now === 'done' ? 'Keep it done' : 'Mark done'; + case 'drop': + return now === 'dropped' ? 'Keep it dropped' : 'Drop'; + case 'ongoing': + return 'Mark ongoing'; + } +} + +/** + * The facts behind a reason, in words. `bare` leaves out what the reason's + * section title already says, for a row under that title. + */ +function reasonFacts(reason: DecideReason, item: RoadmapItem | null, bare = false): string { + switch (reason.kind) { + case 'new': { + const since = reason.since ? `PRs open since ${dayWords(reason.since)}` : 'PRs open'; + return bare ? since : `${since}, and no plan yet`; + } + case 'stalled': + return noPrActivity(reason.days); + case 'over': + // short form everywhere: the PRs still open go without saying + return `${upperFirst(pastEnd(reason.weeks))}${ + reason.since ? `, ${n(reason.since, 'new PR')} since` : '' + }`; + case 'ended': { + // under its title the PRs are said already: "though 4 opened since" + const after = reason.since + ? `, though ${bare && item ? reason.since : n(reason.since, 'PR')} opened since` + : ''; + return bare && item + ? `Was due ${dayWords(planEnd(item))}. No PRs are open${after}` + : `${upperFirst(pastEnd(reason.weeks))}, and no PRs are open${after}`; + } + case 'missed': + // under "Missed its target date", the target is the fact; elsewhere the + // miss is what's owed + return bare + ? `${targetOn(dayWords(reason.due))}, with ${n(reason.open, 'PR')} still open` + : `${missedTarget(dayWords(reason.due))}, with ${n(reason.open, 'PR')} open`; + case 'off_track': + case 'at_risk': { + const u = item?.update; + const said = u?.body ? `: ${u.body.split('\n')[0]}` : ''; + if (!bare) return `${HEALTH_WORD[reason.kind]}, says ${u?.author ?? 'its lead'}${said}`; + return u ? `Update from ${u.author}, ${when(u.at)}${said}` : 'Its latest update says so'; + } + case 'issue_closed': { + const plan = item ? PLAN_STATUS_WORD[item.status].toLowerCase() : 'going'; + const closed = `closed as ${closedAs(reason.as)} on ${dayWords(reason.on)}`; + return bare + ? `${upperFirst(closed)}, and the plan says ${plan}` + : `Its issue was ${closed}, but the plan still says ${plan}`; + } + case 'reopened': { + const still = [ + reason.open ? `${n(reason.open, 'PR is', 'PRs are')} still open` : null, + reason.late ? `${n(reason.late, 'PR')} opened more than a week later` : null, + ] + .filter(Boolean) + .join(' and '); + if (reason.by === 'issue') + return `Its issue was closed as ${closedAs(reason.as)}, but ${still}`; + const on = item?.status_at ? ` ${when(item.status_at)}` : ''; + return `Marked ${reason.as} on the roadmap${on}, but ${still}`; + } + case 'moving': { + const at = item?.status_at ?? item?.updated_at; + return bare && at ? `Parked ${when(at)}` : 'Parked, but its PRs have had activity since'; + } + case 'issues_done': { + const tally = reason.done + ? `${reason.done} done${reason.dropped ? `, ${reason.dropped} dropped` : ''}` + : `all ${reason.dropped} dropped`; + const open = reason.open ? `, but ${n(reason.open, 'PR is', 'PRs are')} still open` : ''; + return bare + ? `${upperFirst(tally)}${open}` + : `All its issues are closed (${tally})${open}`; + } + } +} + +/** A sentence ended once: a reason that quotes an update ending in its own + * stop keeps that one ("can land. Move the end date?", never "land.. Move + * the end date?"). */ +const stop = (words: string) => (/[.!?…]$/.test(words) ? words : `${words}.`); + +/** Why a row is here and the question it asks, as two pieces, for a page + * that sets them apart (the question in amber, after every reason's facts). */ +export function reasonParts( + reason: DecideReason, + item: RoadmapItem | null, + bare = false +): { facts: string; question: string } { + return { + facts: stop(reasonFacts(reason, item, bare)), + question: askOf(reason).question, + }; +} + +/** Why a row is here and the question it asks, in a sentence that stands + * alone (a project's page, where there's no section title). */ +export function reasonWords(reason: DecideReason, item: RoadmapItem | null): string { + const { facts, question } = reasonParts(reason, item); + return `${facts} ${question}`; +} + +/** A row under its section title, as plain text: the question its reason + * asks, then the other reasons in full. */ +function rowWords(row: DecideRow): string { + const primary = primaryOf(row); + return [ + `${stop(reasonFacts(primary, row.item, true))} ${askOf(primary).question}`, + ...row.reasons.filter(r => r !== primary).map(r => stop(reasonFacts(r, row.item))), + ].join(' '); +} + +/** Where a plan the row makes starts: its item's start, or else the week + * its first open PR opened. */ +function startOf(row: DecideRow, project: PortfolioItem | undefined, today: string): string { + if (row.item) return row.item.start; + // a Start date a person set on the project's issue wins, as everywhere + // a person's value does; else the work started when its first PR did + return mondayOf( + project?.project?.fields.start || (project?.group && firstOpenDay(project.group)) || today + ); +} + +const STATUS = { park: 'parked', done: 'done', drop: 'dropped' } as const; + +/** + * What a call writes to the roadmap: a change to the row's plan, or, for + * work with none, a new plan from its first open PR's week through this one, + * unless it commits further. A commit is the commitment that makes an end + * hard, so Decide asks about it once it passes. + */ +export function writeFor( + call: Exclude, + row: DecideRow, + project: PortfolioItem | undefined, + today: string +): + | { id: number; fields: Partial; restate: boolean } + | { id: null; fields: Partial } { + const start = startOf(row, project, today); + // a new end for a plan that hasn't started yet leaves it planned + const waiting = row.item?.status === 'planned' && start > today; + const fields: Partial = + call.kind === 'commit' + ? { + status: waiting ? 'planned' : 'active', + start, + weeks: weeksThrough(start, call.end), + end_kind: 'hard', + } + : { status: STATUS[call.kind] }; + if (row.item) { + // done or dropped on a plan already marked so says it again, which + // accepts the PRs that came after it, until a new one comes + const restate = fields.status === 'done' || fields.status === 'dropped'; + return { id: row.item.id, fields, restate }; + } + return { + id: null, + fields: { + name: nameOf(row, project), + project: row.slug, + // with no plan yet, its team is the one most of its developers are on + team: project?.team ?? null, + lead: project?.lead ?? null, + start, + weeks: weeksThrough(start, today), + ...fields, + }, + }; +} + +/** Whether "Mark ongoing" on a row gives its plan under way no end, rather + * than marking the project ongoing: new work has no plan, and work whose + * plan finished goes on without one. */ +const ongoingPlan = (row: DecideRow) => !!row.item && isUnderWay(row.item.status); + +/** + * What a call decided and what happens next, by decide.ts's rules: a plan + * comes back when it runs past its end, parked work when its PRs move, and + * finished or dropped work a week on (REOPEN_DAYS) if PRs are still open or + * new ones open, unless the work never ends. + */ +export function callWords( + call: Call, + row: DecideRow, + project: Pick | undefined, + today: string +): string { + switch (call.kind) { + case 'commit': + return `Promised to finish by ${call.through}. Decide asks again if it runs past that.`; + case 'park': + return 'Parked, so it stops counting in the weeks ahead. Decide asks again if its PRs move.'; + case 'ongoing': + return ongoingPlan(row) + ? 'Marked ongoing, with no end, so Decide never asks about one.' + : 'Marked ongoing, so Decide stops asking it for a plan or an end.'; + case 'done': + case 'drop': { + const did = call.kind === 'done' ? 'Marked done' : 'Dropped'; + if (!row.slug || project?.ongoing) return `${did}.`; + const week = dayWords(addWeeks(today, 1)); + return project && project.open > 0 + ? `${did}. Decide asks again if a PR is still open on ${week}, or a new one opens after.` + : `${did}. Decide asks again if a PR opens after ${week}.`; + } + } +} + +/** A call made on a row this visit, kept so the row stays in its place + * saying what was decided, here and on the project's page. */ +export interface Made { + /** the row as it stood when the call was made */ + row: DecideRow; + call: Call; + /** what was decided and what happens next */ + words: string; + /** made (saved, or saving), failed to save, or taken back with Undo */ + state: 'made' | 'failed' | 'undone'; + /** why the save, a later Undo, or where it came from didn't save */ + why?: string; + /** where the work came from, said after the call */ + origin: RoadmapOrigin | null; + /** tells this call from a later one on the same row */ + token: number; + /** made, or taken back, by its section's one click: Undo all takes it + * back, and the section says what happened rather than each row */ + batch?: boolean; +} + +// the calls made since the page loaded, so a trip to a project's page and +// back still shows them +const calls = createMemoryStore<{ made: ReadonlyMap }>({ + made: new Map(), +}); + +function setMade(key: string, made: Made | null): void { + const next = new Map(calls.get().made); + if (made) next.set(key, made); + else next.delete(key); + calls.set({ made: next }); +} + +/** Change a row's call, if it's still the one `token` made and in `state`. */ +function patchMade(key: string, token: number, state: Made['state'], patch: Partial): void { + const made = calls.get().made.get(key); + if (made?.token === token && made.state === state) setMade(key, { ...made, ...patch }); +} + +/** + * The queue with this visit's calls kept in place: a row called here stays, + * among `all` but not `owed`, until it comes back for a new reason. A row + * taken back with Undo is owed again, even where the undo itself counted as + * a change to the plan, unless it was never asked: work parked from a team's + * load. + */ +export function keepCalls( + queue: readonly DecideRow[], + made: ReadonlyMap +): { owed: DecideRow[]; all: DecideRow[]; settled: ReadonlyMap } { + const byKey = new Map(queue.map(row => [rowKey(row), row])); + const settled = new Map(); + const back: DecideRow[] = []; + for (const [key, m] of made) { + const again = byKey.get(key); + if (m.state !== 'made') { + if (!again && m.row.reasons.length) back.push(m.row); + } else if (!again || kindsOf(again) === kindsOf(m.row)) { + settled.set(key, m); + } + } + const owed = [...queue.filter(row => !settled.has(rowKey(row))), ...back].sort(compareRows); + const all = [...owed, ...[...settled.values()].map(m => m.row)].sort(compareRows); + return { owed, all, settled }; +} + +/** keepCalls on this visit's calls, for a view that lists Decide's rows. */ +export function useKeptCalls(queue: readonly DecideRow[]) { + const { made } = calls.useValue(); + return useMemo(() => keepCalls(queue, made), [queue, made]); +} + +/** + * keepCalls on the calls still owed (`owed`, Decide's queue with this + * visit's calls kept) and the ones made since this view opened: a view that + * answers in place keeps a row it answered there, with its receipt (`all`, + * `settled`), while a call made elsewhere before it opened is simply done. + */ +export function useCallsMadeHere(owed: readonly DecideRow[]) { + const { made } = calls.useValue(); + const [since] = useState(() => tokens); + return useMemo( + () => keepCalls(owed, new Map([...made].filter(([, m]) => m.token > since))), + [owed, made, since] + ); +} + +/** + * The row a project's strip answers under its plan, where Decide asks + * nothing of it: a call made there on this plan while it stands, so its + * receipt and Undo stay where the click was, even once the call has made + * the plan; else the plan, or, for work with no plan, the calls that start + * one. `kept` is the project's rows with this visit's calls kept. + */ +export function planRow( + slug: string, + plan: RoadmapItem | null, + kept: readonly DecideRow[] +): DecideRow { + return ( + kept.find(row => !row.reasons.length && (!row.item || row.item.id === plan?.id)) ?? { + slug, + item: plan, + reasons: [], + } + ); +} + +/** + * A plan's calls for a page that edits the plan as fields (the project + * page's Status and End): the call Decide suggests, the ends it offers, the + * receipt of a call made here, and the ways to make, retry, undo or clear + * one. The same calls and store as DecideCall, so Decide's row clears and + * its Undo works from either place. + */ +export function usePlanCalls(row: DecideRow, project?: PortfolioItem) { + const made = calls.useValue().made.get(rowKey(row)); + const mine = made && kindsOf(made.row) === kindsOf(row) ? made : undefined; + const receipt = mine?.state === 'made' || mine?.state === 'failed' ? mine : undefined; + const today = dayOf(new Date()); + return { + answer: answerOf(row, project, today), + ends: endsFor(row, project, today), + receipt, + make: (call: Call) => void makeCall(row, call, project), + retry: () => receipt && void makeCall(receipt.row, receipt.call, project), + undo: () => void undoCall(rowKey(row)), + clear: () => setMade(rowKey(row), null), + }; +} + +/** How many of a section's rows to draw before its "+ N more": enough for + * `owed` rows still owed, so the rows decided here ride along without + * taking the slots, and deciding never hides what's left. */ +export function capOwed( + rows: readonly DecideRow[], + decided: (row: DecideRow) => boolean, + owed: number +): number { + let left = owed; + const past = rows.findIndex(row => !decided(row) && --left < 0); + return past < 0 ? rows.length : past; +} + +// each row's writes run one after another, so an Undo or a "where it came +// from" waits for the call it follows instead of racing it; `after` holds +// a write back until another is done, so a section's calls go one by one. +// The promise it gives settles when the write is done, whatever happened. +const chains = new Map>(); +function afterRow( + key: string, + write: () => Promise, + after?: Promise +): Promise { + const next = Promise.all([chains.get(key), after]) + .then(write) + .catch(() => undefined); + chains.set(key, next); + return next; +} + +// the calls whose save landed, by token, with the plan they wrote: what +// Undo takes back +const landed = new Map(); +let tokens = 0; + +/** Why a save didn't land, after "Didn’t save.": the server's reason, or + * how to sign in again, or nothing for a plain failure. */ +export function whyNot(problem: string | null): string { + if (!problem) return ''; + const said = /^Couldn’t [^:.]+: (.+)$/.exec(problem); + if (said) return ` ${upperFirst(said[1])}`; + return problem.startsWith('Couldn’t') ? '' : ` ${problem}`; +} + +/** The roadmap's words for the save that just failed, taken off the page + * header: the row says it, where the click was. */ +function takeProblem(): string { + const { problem } = readRoadmap(); + dismissRoadmapProblem(); + return whyNot(problem); +} + +function makeCall( + row: DecideRow, + call: Call, + project: PortfolioItem | undefined, + batch?: { after: Promise } +): Promise { + const key = rowKey(row); + const token = ++tokens; + const today = dayOf(new Date()); + // shown as made at once, so nothing moves while it saves + setMade(key, { + row, + call, + words: callWords(call, row, project, today), + state: 'made', + origin: row.item?.origin ?? null, + token, + batch: !!batch, + }); + return afterRow( + key, + async () => { + let id: number | null = null; + let why: string | null = null; + if (call.kind === 'ongoing' && row.item && ongoingPlan(row)) { + if (await updateRoadmapItem(row.item.id, { end_kind: 'ongoing' })) id = row.item.id; + else why = takeProblem(); + } else if (call.kind === 'ongoing') { + const saved = await setOngoing(row.slug as string, true); + if ('error' in saved) why = whyNot(saved.error); + } else { + const write = writeFor(call, row, project, today); + if (write.id != null) { + const ok = await updateRoadmapItem(write.id, write.fields, { + restate: write.restate, + }); + if (ok) id = write.id; + else why = takeProblem(); + } else { + id = (await createRoadmapItem(write.fields))?.id ?? null; + if (id == null) why = takeProblem(); + } + } + if (why == null) landed.set(token, { id }); + else patchMade(key, token, 'made', { state: 'failed', why }); + }, + batch?.after + ); +} + +/** Take a call back: the plan as it was (its end's kind too), the new plan + * gone, or the project no longer ongoing. */ +function undoCall(key: string, batch?: { after: Promise }): Promise { + const made = calls.get().made.get(key); + if (!made) return Promise.resolve(); + // the row's own Undo speaks for itself, and takes it out of Undo all + setMade(key, { ...made, state: 'undone', why: undefined, batch: !!batch }); + return afterRow( + key, + async () => { + const saved = landed.get(made.token); + // a call that never saved has nothing to take back + if (!saved) return; + let why: string | null = null; + const was = made.row.item; + if (made.call.kind === 'ongoing' && !ongoingPlan(made.row)) { + const r = await setOngoing(made.row.slug as string, false); + if ('error' in r) why = whyNot(r.error); + } else if (was) { + const fields = { + status: storedStatus(was), + start: was.start, + weeks: was.weeks, + end_kind: was.end_kind, + origin: was.origin, + }; + // with the times the call replaced, so the plan reads as never + // decided and Decide asks again after a reload too + const undo = + was.updated_at != null + ? { updated_at: was.updated_at, status_at: was.status_at ?? null } + : undefined; + if (!(await updateRoadmapItem(was.id, fields, { undo }))) why = takeProblem(); + } else if (saved.id != null && !(await removeRoadmapItem(saved.id))) { + why = takeProblem(); + } + if (why == null) landed.delete(made.token); + else + patchMade(key, made.token, 'undone', { + state: 'made', + why: `Undo didn’t save.${why}`, + }); + }, + batch?.after + ); +} + +/** A section's calls, or their Undos, all shown at once and saved one after + * another through the same path as a row's own click. */ +function inTurn(each: ((after: Promise) => Promise)[]): void { + each.reduce>((after, write) => write(after), Promise.resolve()); +} + +/** Say where a decided plan's work came from. */ +function sayOrigin(key: string, origin: RoadmapOrigin | null): void { + const made = calls.get().made.get(key); + if (!made || made.state !== 'made') return; + setMade(key, { ...made, origin, why: undefined }); + afterRow(key, async () => { + const id = landed.get(made.token)?.id; + if (id == null || calls.get().made.get(key)?.state !== 'made') return; + if (!(await updateRoadmapItem(id, { origin }))) { + const why = `Where it came from didn’t save.${takeProblem()}`; + patchMade(key, made.token, 'made', { origin: made.origin, why }); + } + }); +} + +/** Left and right, Home and End move the focus among a group's buttons, so + * the group is one Tab stop: the button with tabIndex 0. */ +function arrowsAmong(e: ReactKeyboardEvent): void { + const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + if (!step && e.key !== 'Home' && e.key !== 'End') return; + const buttons = [...e.currentTarget.querySelectorAll('button')]; + const at = buttons.indexOf(document.activeElement as HTMLElement); + const to = + e.key === 'Home' + ? 0 + : e.key === 'End' + ? buttons.length - 1 + : (at + step + buttons.length) % buttons.length; + e.preventDefault(); + buttons[to]?.focus(); +} + +/** + * The calls a row can get, one click each, and once one is made, what was + * decided and what happens next, with Undo. The question the row's reason + * asks has one answer, the filled button marked Suggested. Works wherever a + * Decide row shows: this view, an Overview row and the roadmap's plan + * details. Where nothing is asked (a plan in an Overview row), the calls + * rest behind Change, or Plan it, and open with nothing suggested: a + * suggested answer there would be a call nobody asked for, one click away. + */ +export function DecideCall({ + row, + project, + describedBy, + change = 'Change', + opened = false, + compact = false, +}: { + row: DecideRow; + /** its project, for a new plan's start, team and lead, and for what + * happens next */ + project?: PortfolioItem; + /** the element that says why the row is here, read with its answer */ + describedBy?: string; + /** the words that open the calls on a plan nobody asked about: "Change + * the plan" where they sit away from the plan's own words */ + change?: string; + /** the calls open from the start, with no Cancel: a place someone opened + * to change the plan (the roadmap's plan details), where they're the point */ + opened?: boolean; + /** on a one-line row under a section that asks its question once: the + * answer an outlined button at the line's end, since the section's one + * click is the filled one */ + compact?: boolean; +}) { + const made = calls.useValue().made.get(rowKey(row)); + // a call made on the row as it stood; a row back for a new reason is asked afresh + const mine = made && kindsOf(made.row) === kindsOf(row) ? made : undefined; + const shown = mine?.state === 'made' || mine?.state === 'failed' ? mine : undefined; + const name = nameOf(row, project); + const rootRef = useRef(null); + const refocus = useRef(false); + // the height the row had when a click here changed it, kept as the least + // it has, so the rows below never slide under the pointer, a phone's + // wrapped lines included + const [held, setHeld] = useState(); + // opened from Change; it stays open through a call and its Undo + const [open, setOpen] = useState(opened); + const view = shown?.state ?? (row.reasons.length || open ? 'ask' : 'rest'); + const before = useRef(view); + // after a click here, focus lands on what replaced the button: what was + // decided, Try again, the row's answer, or Change again; a save or an + // Undo that fails later takes the focus only when it removed the focused + // control + useEffect(() => { + if (before.current === view) return; + before.current = view; + const lost = document.activeElement === document.body && view !== 'ask'; + if (!refocus.current && !lost) return; + refocus.current = false; + rootRef.current?.querySelector('[data-decide-focus]')?.focus(); + }, [view]); + const act = (fn: () => void) => () => { + refocus.current = true; + setHeld(rootRef.current?.offsetHeight); + fn(); + }; + // a call made here is read where the focus lands, so only what changes + // later is said here: a failure, an Undo, a part that didn't save. A + // section's one click says what it did for every row at once + const said = !mine + ? '' + : mine.state === 'made' + ? mine.why ?? '' + : mine.batch + ? '' + : mine.state === 'failed' + ? `Didn’t save the decision on ${name}.${mine.why ?? ''}` + : `Undid the decision on ${name}.`; + const live = ( + + {said} + + ); + if (view === 'rest') { + // on the plan's own line, after its words + return ( +
+ {live}{' '} + setOpen(true))} + > + {row.item ? change : PLAN_IT} + +
+ ); + } + return ( +
+ {live} + {shown?.state === 'failed' ? ( +

+ Didn’t save.{shown.why} + makeCall(shown.row, shown.call, project))} + aria-label={`Try the decision on ${name} again`} + > + Try again + + setMade(rowKey(row), null))} + aria-label={`Cancel the decision on ${name}`} + > + Cancel + +

+ ) : shown ? ( +
+ {/* one run of text, so Undo shares the words' last line and + the receipt is no taller than the calls it replaced */} + + + {/* the focus lands on what was decided, not on Undo, so an + extra Enter after a call, or j then Enter on a decided + row, never takes it back: Undo is the next Tab */} + + {shown.words} + {shown.why && ` ${shown.why}`} + {' '} + undoCall(rowKey(row)))} + aria-label={`Undo the decision on ${name}`} + > + Undo + + + {shown.call.kind !== 'ongoing' && ( + // optional, after the call: saying it alone never clears a row + sayOrigin(rowKey(row), o)} + /> + )} +
+ ) : ( + act(() => makeCall(row, call, project))()} + onCancel={row.reasons.length || opened ? undefined : act(() => setOpen(false))} + full={opened} + quiet={compact} + /> + )} +
+ ); +} + +/** Where a decided plan's work came from: optional, after the call, the + * same switch as the roadmap's editor. */ +function OriginRun({ + name, + origin, + onChange, +}: { + name: string; + origin: RoadmapOrigin | null; + onChange: (origin: RoadmapOrigin | null) => void; +}) { + return ( + + Where it came from + onChange(o === 'unsaid' ? null : o)} + ariaLabel={`Where ${name} came from`} + /> + + ); +} + +/** One call a row can get, in words. */ +interface Option { + call: Call; + label: string; + /** what it does to which row, for a screen reader */ + ariaLabel: string; +} + +/** The calls, before one is made, as real buttons in two labeled rows: the + * ends it can promise, then what else becomes of it. The row's answer is + * filled and says Suggested. With an answer, the strip starts as that one + * button and Other answers, which opens the two rows in place. One Tab + * stop, the answer or else the first call; the arrow keys reach the rest, + * and Escape is Cancel where there's one. */ +function CallStrip({ + row, + name, + project, + describedBy, + onCall, + onCancel, + full = false, + quiet = false, +}: { + row: DecideRow; + name: string; + /** its project, for its target */ + project: PortfolioItem | undefined; + describedBy?: string; + onCall: (call: Call) => void; + /** close it again, for calls opened from Change */ + onCancel?: () => void; + /** every call shown from the start, where changing the plan is the point */ + full?: boolean; + /** the answer outlined and unmarked: its section's header asks the + * question and holds the filled button */ + quiet?: boolean; +}) { + const [more, setMore] = useState(false); + const stripRef = useRef(null); + const day = dayOf(new Date()); + const answer = answerOf(row, project, day); + // ongoing answers new work, finished work that keeps going, and any plan + // under way that has an end: upkeep one click from its end + const ongoing = + (!!row.slug && + row.reasons.some( + r => r.kind === 'new' || (r.kind === 'reopened' && r.by === 'roadmap') + )) || + (ongoingPlan(row) && row.item?.end_kind !== 'ongoing'); + const dates: Option[] = endsFor(row, project, day).map(c => ({ + call: { kind: 'commit', ...c }, + label: c.label, + ariaLabel: `Promise ${name} by ${c.through}`, + })); + const others: Option[] = [ + { call: { kind: 'park' }, label: 'Park', ariaLabel: `Park ${name}` }, + { + call: { kind: 'done' }, + label: 'Mark done', + ariaLabel: `Mark ${name} done`, + }, + { call: { kind: 'drop' }, label: 'Drop', ariaLabel: `Drop ${name}` }, + ...(ongoing + ? [ + { + call: { kind: 'ongoing' } as Call, + label: ONGOING, + ariaLabel: `Mark ${name} ongoing`, + }, + ] + : []), + ]; + // a commit's answer is its target or the nearest end that doesn't cut + // the plan short + const first = + answer && + (answer.kind === 'commit' + ? dates.find(o => o.call.kind === 'commit' && o.call.end === answer.end) + : others.find(o => o.call.kind === answer.kind)); + // the strip's one Tab stop + const lead = first ?? dates[0] ?? others[0]; + // every call a button that looks like one, in two labeled rows: how + // long it runs, or what else becomes of it. The suggested answer is + // filled and says so; the rest are outlined, never bare words. + // opened from Other answers, a quiet row's answer says Suggested again + const marked = !quiet || more || full; + const button = (o: Option, words = o.label) => ( + + ); + // opened, the focus moves to the first of the answers it showed + const showMore = () => { + setMore(true); + requestAnimationFrame(() => + stripRef.current?.querySelector('button:not([data-decide-focus])')?.focus() + ); + }; + const group = (label: string, options: Option[]) => + options.length > 0 && ( +
+ {label} + {options.map(o => button(o))} +
+ ); + if (first && !full && !more) { + return ( +
+ {button(first, answerWords(first.call, row.item))} + +
+ ); + } + return ( +
{ + if (e.key !== 'Escape' || !onCancel) return arrowsAmong(e); + e.preventDefault(); + onCancel(); + }} + className="flex flex-col gap-2" + > + {group(COMMIT_THROUGH, dates)} + {group('Or instead', others)} + {onCancel && ( +
+ + Cancel + +
+ )} +
+ ); +} + +/** Its lead, the one fact a card shows beside its name; only the lead goes + * anywhere (People), and the row's name opens the project. A lead guessed + * from PRs is followed by the word guessed, unless its section says it once + * for every row. */ +function RowLead({ + row, + project, + onPerson, + sayGuessed = true, + noRepo = false, +}: { + row: DecideRow; + project: PortfolioItem | undefined; + onPerson?: (login: string) => void; + sayGuessed?: boolean; + /** no projects repo, so no issue to assign */ + noRepo?: boolean; +}) { + const lead = row.item?.lead ?? project?.lead ?? null; + if (!lead) return null; + const byPrs = sayGuessed && guessedLead(row, project); + return ( + <> + {onPerson ? ( + onPerson(lead)} + title={`See ${lead} on People`} + > + {lead} + + ) : ( + {lead} + )} + {byPrs && ( + + + + )} + + ); +} + +/** A lead nobody named: the plan names none, and the project's is the one + * with the most PRs. */ +const guessedLead = (row: DecideRow, project: PortfolioItem | undefined) => + !row.item?.lead && !!project?.lead && !!project.leadByPrs; + +/** The rest of what a decision turns on, for the reason's hover: team, + * people and size, target date. */ +function factsTitle( + row: DecideRow, + project: PortfolioItem | undefined, + team: string | null +): string { + const people = project ? [...project.developers, ...project.nonDevelopers] : []; + // a missed target is the row's reason already + const target = + project?.target && !row.reasons.some(r => r.kind === 'missed') ? project.target : null; + return [ + team, + people.length ? people.join(', ') : null, + project?.open ? n(project.open, 'open PR') : null, + project?.merged ? `${project.merged} merged in the ${LAST_14_DAYS}` : null, + target && targetOn(targetWords(target)), + ] + .filter(Boolean) + .join(' · '); +} + +/** The row a trip to a project's page or a plan left from, kept for this + * tab, so coming back lands on it rather than the top of the list. */ +const BACK_TO = 'pd2.decide.backTo'; + +/** On coming back to the list, scroll to the row the trip left from and + * put the focus on its answer. */ +function useBackToRow(ready: boolean) { + useEffect(() => { + if (!ready || !readSessionStorage(BACK_TO)) return; + // a frame later: the tab scrolls to the top on a change of view + // (app.tsx), after this view's own effects + const frame = requestAnimationFrame(() => { + const key = readSessionStorage(BACK_TO); + writeSessionStorage(BACK_TO, ''); + const row = [...document.querySelectorAll('[data-decide-row]')].find( + r => r.dataset.decideRow === key + ); + if (!row) return; + row.scrollIntoView({ block: 'center' }); + row.querySelector('[data-decide-focus]')?.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + }, [ready]); +} + +function DecideRowView({ + row, + decided, + project, + team, + nav, + navigate, + onPerson, + asked = false, + sayGuessed = true, + noRepo = false, +}: { + row: DecideRow; + /** a call was made on it here */ + decided: boolean; + /** its section asks the question once, so the row is one line: name, + * lead, the one fact and its answer */ + asked?: boolean; + /** its section says "Leads guessed" once instead */ + sayGuessed?: boolean; + noRepo?: boolean; + project: PortfolioItem | undefined; + team: string | null; + nav: ProjectsNav; + navigate: Navigate; + onPerson?: (login: string) => void; +}) { + const nameId = useId(); + const whyId = useId(); + const { item } = row; + const primary = primaryOf(row); + const key = rowKey(row); + // a trip from the row remembers it, for the way back + const go = (patch: Partial) => { + writeSessionStorage(BACK_TO, key); + navigate(patch); + }; + const openProject = () => + row.slug ? go({ project: row.slug }) : item && go(openPlan(nav, item.id)); + // plain words: the name is the row's one way out, to the project, whose + // page links to the plan + const reason = (r: DecideReason, bare: boolean) => stop(reasonFacts(r, item, bare)); + return ( + // a decided row keeps every line it had, so the rows below never slide + // under the pointer between two clicks; it steps down to the quiet ink + // and its question loses the amber, rather than fading below what + // can be read +
+
+ + + {asked && ( + <> + + · + {midLine(reasonFacts(primary, item, true))} + {/* the header asks it once; the row's answer is read with it */} + {askOf(primary).question} + + + + )} +
+ {!asked && ( + <> +

+ {reason(primary, true)} {/* the one thing owed on the row, until it's answered */} + {askOf(primary).question} + {primary.kind === 'issues_done' && primary.open > 0 && row.slug && ( + <> + {' '} + openPageAt(go, row.slug as string, 'project-unlinked')} + > + Add issues for those PRs on its page + + + )} + {/* "Is it done?" checked against what the plan said done looks like */} + {item?.done_when && askOf(primary).call === 'done' && ( + + {DONE_WHEN}: {item.done_when} + + )} +

+ {row.reasons + .filter(r => r !== primary) + .map(r => ( +

+ {reason(r, false)} +

+ ))} + + + )} +
+ ); +} + +/** + * One click for a whole section, in its header: every row still owed gets + * the call its suggested answer makes, all shown at once and saved one after + * another, each row keeping its own receipt and Undo (a failure stays in + * its row with Try again). Then what it did, with Undo all. Only for two + * rows or more, since a lone row's answer is the same click. + */ +function SectionCalls({ + title, + rows, + owed, + projectOf, + main = false, +}: { + title: string; + /** the section's rows, decided here or not */ + rows: readonly DecideRow[]; + /** the ones still owed a call */ + owed: readonly DecideRow[]; + projectOf: (row: DecideRow) => PortfolioItem | undefined; + /** the section's main action, filled: the header asks its question */ + main?: boolean; +}) { + const { made } = calls.useValue(); + const rootRef = useRef(null); + const refocus = useRef(false); + const [said, setSaid] = useState(''); + const day = dayOf(new Date()); + // its calls in a state, on the rows as they stood + const batch = (state: Made['state']) => + rows.flatMap(row => { + const m = made.get(rowKey(row)); + return m?.batch && m.state === state && kindsOf(m.row) === kindsOf(row) ? [m] : []; + }); + const standing = batch('made'); + const failed = batch('failed').length; + // a row with no answer to suggest waits for a person + const answers = owed.flatMap(row => { + const call = answerOf(row, projectOf(row), day); + return call ? [{ row, call }] : []; + }); + const view = standing.length ? 'did' : 'ask'; + const before = useRef(view); + // after a click here, focus lands on what it did, so an extra Enter never + // takes it all back; after Undo all, on the click again, or else on the + // section's first answer. Saves that all fail later take the focus only + // when they removed it, and give it to the click again + useEffect(() => { + if (before.current === view) return; + before.current = view; + if (!refocus.current && document.activeElement !== document.body) return; + refocus.current = false; + rootRef.current + ?.closest('[data-decide-section]') + ?.querySelector('[data-decide-focus]') + ?.focus(); + }, [view]); + const words = bulkWords( + answers.map(a => a.call), + 'do' + ); + const all = () => { + refocus.current = true; + setSaid(''); + inTurn(answers.map(a => after => makeCall(a.row, a.call, projectOf(a.row), { after }))); + }; + const undoAll = () => { + refocus.current = true; + setSaid(`Undid ${n(standing.length, 'decision')} under ${title}.`); + inTurn(standing.map(m => after => undoCall(rowKey(m.row), { after }))); + }; + // the rows' failures, said once for all of them + const news = failed + ? `${n(failed, 'decision')} under ${title} didn’t save. Each row says why, with Try again.` + : said; + return ( + + + {news} + + {standing.length > 0 && ( + + + + {bulkWords( + standing.map(m => m.call), + 'did' + )} + . + + + Undo all + + + )} + {answers.length > 1 && + (main ? ( + + ) : ( + + {words} + + ))} + + ); +} + +/** A team's work in flight against its developers, in the roadmap's words. + * Past what they can staff, it names the lowest in priority order, each + * with its Park: the roadmap's capacity line sends a planner here to park + * something, so here is where it's one click. */ +function TeamLoad({ + team, + developers, + load, + rowOf, + projectOf, + onOpen, +}: { + team: string; + developers: number; + load: InFlight[]; + /** the work's row on this list, or a row to park it by */ + rowOf: (work: InFlight) => DecideRow; + projectOf: (row: DecideRow) => PortfolioItem | undefined; + onOpen: (work: InFlight) => void; +}) { + // once one is parked the names hold still, so each keeps its place and + // its Undo while the count above them moves + const [held, setHeld] = useState(null); + if (!developers) return null; + const over = load.length - developers; + // the lowest in the order are the ones to park first + const lowest = held ?? (over > 0 ? load.slice(-Math.min(3, over)) : []); + return ( +
+

+ {team} has {n(load.length, 'project')} {BEING_WORKED_ON} this week, for{' '} + {n(developers, 'developer')} + {over > 0 ? ( + <> + , {over} more than they can staff. + + ) : ( + '.' + )} + {lowest.length > 0 && ' The lowest in priority order, the first to park:'} +

+ {lowest.length > 0 && ( +
    + {lowest.map(work => { + const row = rowOf(work); + return ( + onOpen(work)} + onPark={() => setHeld(lowest)} + /> + ); + })} +
+ )} +
+ ); +} + +/** One piece of a team's work with its Park, then what was decided and + * Undo, in place; the same call its row below makes, if it has one. */ +function ParkLine({ + name, + row, + project, + onOpen, + onPark, +}: { + name: string; + row: DecideRow; + project: PortfolioItem | undefined; + onOpen: () => void; + onPark: () => void; +}) { + const key = rowKey(row); + const made = calls.useValue().made.get(key); + const parked = made?.call.kind === 'park' ? made : undefined; + const state = parked && parked.state !== 'undone' ? parked.state : 'ask'; + const rootRef = useRef(null); + const refocus = useRef(false); + const before = useRef(state); + // after a click here, focus lands on what replaced the button, and a + // save that fails later takes it only from the words it removed + useEffect(() => { + if (before.current === state) return; + before.current = state; + const lost = document.activeElement === document.body && state !== 'ask'; + if (!refocus.current && !lost) return; + refocus.current = false; + rootRef.current?.querySelector('[data-park-focus]')?.focus(); + }, [state]); + const act = (fn: () => void) => () => { + refocus.current = true; + fn(); + }; + const park = act(() => { + onPark(); + makeCall(row, { kind: 'park' }, project); + }); + return ( +
  • + + {name} + + · + {state === 'made' ? ( + <> + + Parked. + + undoCall(key))} aria-label={`Undo parking ${name}`}> + Undo + + + ) : state === 'failed' ? ( + <> + Didn’t save.{parked?.why} + + Try again + + + ) : ( + + Park + + )} + + {state === 'failed' + ? `Didn’t park ${name}.${parked?.why ?? ''}` + : parked?.state === 'undone' + ? `Undid parking ${name}.` + : parked?.why ?? ''} + +
  • + ); +} + +/** + * Who runs Decide this week: with no product manager, the list needs a name + * on it, so people take turns a week each. Changing the turns starts them + * over from the first name this week. + */ +function RunsDecide({ + rotation, + day, + people, + onPerson, +}: { + rotation: DecideRotation | null; + day: string; + /** everyone on a team, to pick from */ + people: string[]; + onPerson?: (login: string) => void; +}) { + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const now = decideTurn(rotation, day); + const next = decideTurn(rotation, addWeeks(day, 1)); + const close = () => { + setDraft(null); + setError(null); + }; + const chosen = (draft ?? '').split(/[\s,]+/).filter(Boolean); + const save = async () => { + setSaving(true); + const saved = await saveDecideRotation(chosen.length ? chosen : null); + setSaving(false); + if ('error' in saved) setError(saved.error); + else close(); + }; + const person = (login: string) => + onPerson ? ( + onPerson(login)} title={`See ${login} on People`}> + {login} + + ) : ( + {login} + ); + if (draft == null) { + return ( +

    + {now ? ( + <> + {person(now)} runs Decide this week + {next && next !== now && <>, {person(next)} next week}. + + ) : ( + 'Nobody runs Decide yet.' + )}{' '} + setDraft(rotation?.logins.join(', ') ?? '')}> + {now ? 'Change the turns' : 'Name who takes turns'} + +

    + ); + } + const left = people.filter(login => !chosen.includes(login)); + return ( +
    { + e.preventDefault(); + void save(); + }} + onKeyDown={e => { + if (e.key === 'Escape') close(); + }} + > + + + + Save + + + Cancel + + + {left.length > 0 && ( +

    + Add + {left.map(login => ( + setDraft([...chosen, login].join(', '))} + aria-label={`Add ${login} to the turns`} + > + {login} + + ))} +

    + )} + {error && ( + + {error} + + )} +
    + ); +} + +const DAY = 86400; +/** how far back "this week" reaches, for what filled itself in */ +const WEEK_DAYS = 7; + +/** A plan as the board shows it, with `marked`: the status it was saved + * with, where the board reads another off its PRs (marked Planned, read In + * progress). roadmapData.ts shown is the place to set it; until it does, + * no plan lists as In progress by its PRs. */ +type ShownPlan = RoadmapItem & { marked?: RoadmapItem['status'] }; + +/** What filled itself in this week, kind by kind: each guess the board made + * from PRs, labels, links and activity, for the list at the end of Decide. */ +export interface FilledIn { + /** issues that joined a project this week by a link from one of its PRs, + * newest first */ + joined: { slug: string; issue: ProjectIssue }[]; + /** PRs opened this week, with no project label, that count in a project + * by a link to one of its issues (that issue, when its page says), + * newest first */ + linked: { + slug: string; + pull: DerivedPull | PullData; + issue: IssueRef | null; + }[]; + /** plans marked Planned that started this week and read In progress off + * their PRs */ + started: ShownPlan[]; + /** plans in progress whose numbers vouch for them, so their leads owe no + * update this week */ + vouched: { plan: RoadmapItem; vouch: Vouch }[]; +} + +export function filledIn({ + today, + prefix, + plans, + pages, + now, +}: { + today: Pick; + prefix: string; + plans: readonly ShownPlan[]; + /** the pages of the projects that have issues, by slug */ + pages: ReadonlyMap>; + /** epoch secs */ + now: number; +}): FilledIn { + const since = now - WEEK_DAYS * DAY; + const joined = [...pages] + .flatMap(([slug, page]) => + page.issues + // one a person added or labeled too is theirs, not a guess + // by when it joined (joinedAt), not when the PR that brought it + // opened, so a deploy week's first sync shows every join + .filter( + i => + i.via.length === 1 && + i.via[0] === 'link' && + (i.joinedAt ?? i.attachedAt ?? 0) >= since + ) + .map(issue => ({ slug, issue })) + ) + .sort( + (a, b) => + (b.issue.joinedAt ?? b.issue.attachedAt ?? 0) - + (a.issue.joinedAt ?? a.issue.attachedAt ?? 0) + ); + const byLink = (p: PullData, slug: string) => + epoch(p.created_at) >= since && !projectSlugs(p.labels, prefix).includes(slug); + const issueOf = (slug: string, p: PullData) => + pages.get(slug)?.issues.find(i => i.prs.some(pr => issueKey(pr) === issueKey(p)))?.ref ?? + null; + const linked = [...today.live, ...today.quiet] + .flatMap(g => [ + ...g.open + .filter(p => byLink(p.data, g.slug)) + .map(p => ({ + slug: g.slug, + pull: p, + issue: issueOf(g.slug, p.data), + })), + ...g.merged + .filter(p => byLink(p, g.slug)) + .map(p => ({ slug: g.slug, pull: p, issue: issueOf(g.slug, p) })), + ]) + .sort((a, b) => epoch(dataOf(b.pull).created_at) - epoch(dataOf(a.pull).created_at)); + const week = dayOf(new Date(since * 1000)); + const started = plans.filter( + p => p.marked === 'planned' && p.status === 'active' && p.start >= week + ); + const vouched = plans.flatMap(plan => { + const s = isUnderWay(plan.status) ? healthStanding(plan, now) : null; + const vouch = s?.kind === 'quiet' || s?.kind === 'current' ? s.vouch : undefined; + return vouch ? [{ plan, vouch }] : []; + }); + return { joined, linked, started, vouched }; +} + +/** A PR's own fields, open (as the board derives it) or closed. */ +const dataOf = (p: DerivedPull | PullData): PullData => ('data' in p ? p.data : p); + +/** One project's page, read for what filled itself in: the issues that + * joined it, and which issue each of its PRs links. It draws nothing. */ +function PageOf({ + slug, + plans, + onPage, +}: { + slug: string; + plans: readonly RoadmapItem[]; + onPage: (slug: string, page: ProjectWork) => void; +}) { + const page = useProjectWork(slug, plans); + useEffect(() => { + if (page) onPage(slug, page); + }, [page]); + return null; +} + +/** An issue that joined a project by a link, with its Remove, which takes it + * off for good: then what was done, where Remove was, with Undo. */ +function JoinedLine({ + slug, + issue, + name, + onHold, + navigate, +}: { + slug: string; + issue: ProjectIssue; + /** its project's name */ + name: string; + /** keep the line in place once it's off its project */ + onHold: () => void; + navigate: Navigate; +}) { + const [state, setState] = useState<'on' | 'removing' | 'off' | 'putting' | 'failed'>('on'); + const [error, setError] = useState(null); + const [said, setSaid] = useState(''); + const rootRef = useRef(null); + const refocus = useRef(false); + const number = `#${issue.ref.number}`; + // once it's saved, the focus lands on what replaced the button: what was + // done, or Remove again after an Undo + const saving = state === 'removing' || state === 'putting'; + useEffect(() => { + if (!refocus.current || saving) return; + refocus.current = false; + rootRef.current?.querySelector('[data-joined-focus]')?.focus(); + }, [state]); + const change = (add: boolean) => { + if (saving) return; + refocus.current = true; + setError(null); + setState(add ? 'putting' : 'removing'); + if (!add) onHold(); + void changeProjectIssue(slug, issue.ref, add).then(r => { + if ('error' in r) { + setError(r.error); + setState(add ? 'off' : 'failed'); + setSaid(r.error); + return; + } + setState(add ? 'on' : 'off'); + setSaid(add ? `Put ${number} back on ${name}.` : `Removed ${number} from ${name}.`); + }); + }; + const off = state === 'off' || state === 'putting'; + return ( +
  • + + {said} + + + + {number} ↗ + {' '} + {issue.title || number} joined{' '} + openPageAt(navigate, slug, 'project-issues')}>{name} + {issue.linkedBy && ( + <> + , linked by{' '} + + PR #{issue.linkedBy.number} ↗ + + + )} + .{' '} + {off ? ( + <> + + {error ?? 'Removed.'} + {' '} + change(true)} + aria-label={`Undo removing ${number} from ${name}`} + > + Undo + + + ) : ( + <> + {state === 'failed' && {error} } + change(false)} + aria-label={`Remove ${number} from ${name}`} + > + {state === 'failed' ? 'Try again' : 'Remove'} + + + )} + +
  • + ); +} + +/** + * What filled itself in this week, at the end of Decide: every guess the + * board made from PRs, labels, links and activity, in one place, each line + * a way to where it happened, so a person can check them all in half a + * minute and take a joined issue back off. Nothing shows in a week with no + * guesses. + */ +function FilledInSection({ + today, + prefix, + plans, + work, + items, + opts, + nav, + navigate, +}: { + today: Today; + prefix: string; + plans: readonly RoadmapItem[]; + work: WorkData | null | undefined; + items: readonly PortfolioItem[]; + opts: RowOptions; + nav: ProjectsNav; + navigate: Navigate; +}) { + const [pages, setPages] = useState>(new Map()); + const onPage = useCallback( + (slug: string, page: ProjectWork) => setPages(was => new Map(was).set(slug, page)), + [] + ); + // a line taken off its project stays, with Undo, though its page no + // longer lists it; put back, its page lists it again + const [held, setHeld] = useState>(new Map()); + // ponytail: one project page per project with issues, read when Decide + // opens; a list of the week's joins on /work-data once that's dozens + const withIssues = [...(work?.projects.keys() ?? [])]; + const found = filledIn({ + today, + prefix, + plans, + pages, + now: Date.now() / 1000, + }); + const keyOf = (j: FilledIn['joined'][number]) => `${j.slug} ${issueKey(j.issue.ref)}`; + const joined = [...found.joined]; + for (const j of held.values()) if (!joined.some(o => keyOf(o) === keyOf(j))) joined.push(j); + const nameOf = (slug: string) => items.find(i => i.slug === slug)?.name ?? slug; + const line = 'border-t border-secondary px-3.5 py-2 first:border-t-0'; + const total = joined.length + found.linked.length + found.started.length + found.vouched.length; + return ( + <> + {withIssues.map(slug => ( + + ))} + {total > 0 && ( +
    + +

    + What the board read off PRs, labels, links and activity in the last{' '} + {WEEK_DAYS} days rather than asking anyone: issues that joined a project + because its PRs link them, PRs that count in a project because they link + one of its issues, plans that read In progress because their PRs moved, + and updates nobody owes because the numbers vouch. +

    +

    + Each line opens where it happened. A joined issue that doesn’t belong + comes off with Remove, and no link brings it back. +

    + + } + /> + + +
      + {joined.map(j => ( + setHeld(was => new Map(was).set(keyOf(j), j))} + navigate={navigate} + /> + ))} +
    +
    + + {found.linked.map(({ slug, pull, issue }) => { + const note = ( + <> + Counts in{' '} + navigate({ project: slug })}> + {nameOf(slug)} + {' '} + by its link to {issue ? `#${issue.number}` : 'one of its issues'}, not + a label + + ); + const data = dataOf(pull); + return ( +
    + {'data' in pull ? ( + + ) : ( + <> + + {/* under its title, as a row's footnote is, past + stand-ins as wide as its badge and face */} +

    + + + + + {note} +

    + + )} +
    + ); + })} +
    + +
      + {found.started.map(plan => ( +
    • + + navigate(openPlan(nav, plan.id))}> + {plan.name} + {' '} + shows {IN_PROGRESS}: it was Planned to start {dayWords(plan.start)} + , and its PRs have moved since. + +
    • + ))} +
    +
    + +
      + {found.vouched.map(({ plan, vouch }) => ( +
    • + + + plan.project + ? navigate({ project: plan.project }) + : navigate(openPlan(nav, plan.id)) + } + > + {plan.name} + + {' · '} + {vouchWords(vouch)} + +
    • + ))} +
    +
    +
    +
    + )} + + ); +} + +/** The teams the switch offers: the configured ones in their own order, + * then any other a row names, by name; "No team" comes after these. */ +export function teamOrder(configured: readonly string[], named: readonly (string | null)[]) { + const others = named.filter((t): t is string => !!t && !configured.includes(t)); + return [...configured, ...[...new Set(others)].sort()]; +} + +/** + * Decide: the weekly triage a product manager would run, as a queue that + * empties. Every row is a project or plan that needs a call, says why, with + * the facts the call turns on, and asks one question. Each call (promise it + * by a month or quarter, park, mark done, drop) is one click that + * writes the roadmap; the row stays in place saying what was decided and + * what happens next, with Undo, so nothing vanishes unexplained. Worst + * first; a team's lead can take just theirs. + */ +export function Decide({ + today, + items, + closed, + teamOf, + teamMembers, + rotation, + work, + ongoing, + prefix, + opts, + nav, + navigate, + onPerson, + projectsRepo, +}: { + /** Today from every PR, not the filter bar's */ + today: Today; + /** the portfolio from the same, for names, leads, sizes and teams */ + items: PortfolioItem[]; + closed: ReadonlyMap; + teamOf: (login: string) => string | null; + teamMembers: Record; + /** who takes turns running this list; null for nobody */ + rotation: DecideRotation | null; + /** each plan's PRs by the dates, and each project's issues (model/workData.ts) */ + work: WorkData | null | undefined; + /** every project with no end, and the ones marked so on the board */ + ongoing: ReadonlySet; + /** the project label prefix, to tell a PR's label from its links */ + prefix: string; + /** how the board draws its PR rows */ + opts: RowOptions; + nav: ProjectsNav; + navigate: Navigate; + /** open a person on People */ + onPerson?: (login: string) => void; + /** the repo whose issues name projects; null when there's none */ + projectsRepo?: string | null; +}) { + const { items: plans, loadFailed } = useRoadmap(); + const { made } = calls.useValue(); + const [copied, setCopied] = useState(null); + // j and k land on each row's answer, or on what was decided once it is + useRowKeys('[data-decide-row]', '[data-decide-focus]'); + useBackToRow(!!plans); + if (!plans) { + return loadFailed ? ( + void loadRoadmap()} /> + ) : ( +

    Loading the roadmap…

    + ); + } + const day = dayOf(new Date()); + const bySlug = new Map(items.map(i => [i.slug, i])); + const projectOf = (row: DecideRow) => (row.slug ? bySlug.get(row.slug) : undefined); + // the team a row belongs to: its plan's, or the team most of its + // developers are on, as the roadmap's lanes split them + const teamOfRow = (row: DecideRow): string | null => { + const project = projectOf(row); + return row.item?.team ?? (project ? mainTeam(project, teamOf) : null); + }; + const kept = keepCalls(decideRows(today, plans, closed, work, ongoing), made); + const inTeam = (row: DecideRow) => !nav.team || (teamOfRow(row) ?? '(none)') === nav.team; + const owed = kept.owed.filter(inTeam); + const shown = kept.all.filter(inTeam); + const decidedHere = [...kept.settled.values()].filter(m => inTeam(m.row)); + const whose = !nav.team + ? '' + : nav.team === '(none)' + ? ' for work with no team' + : ` for ${nav.team}`; + const heading = owed.length + ? `${n(owed.length, 'decision')} to make` + : decidedHere.length + ? 'Nothing left to decide' + : 'Nothing to decide'; + const leadOf = (row: DecideRow) => { + const project = projectOf(row); + const lead = row.item?.lead ?? project?.lead; + if (!lead) return ''; + return !row.item?.lead && project?.leadByPrs ? ` (${lead}, guessed)` : ` (${lead})`; + }; + + // the list as plain text, for the weekly meeting's notes or a chat post: + // what's left, and the decisions made here + const copy = () => { + const left = SECTIONS.flatMap((section, index) => { + const inSection = owed.filter(row => sectionOf(row) === index); + if (!inSection.length) return []; + return [ + section.title, + ...inSection.map( + row => `- ${nameOf(row, projectOf(row))}${leadOf(row)}: ${rowWords(row)}` + ), + ]; + }); + const decided = decidedHere.length + ? [ + 'Decided', + ...decidedHere.map( + m => `- ${nameOf(m.row, projectOf(m.row))}${leadOf(m.row)}: ${m.words}` + ), + ] + : []; + const runner = decideTurn(rotation, day); + const text = [ + `${heading}${whose}, ${dayWords(day)}`, + ...(runner ? [`${runner} runs Decide this week`] : []), + ...left, + ...decided, + ].join('\n'); + const say = (words: string) => { + setCopied(words); + window.setTimeout(() => setCopied(null), 2000); + }; + if (!navigator.clipboard) return say('Couldn’t copy'); + navigator.clipboard.writeText(text).then( + () => say('Copied'), + () => say('Couldn’t copy') + ); + }; + + // each team's decisions still owed, for the team switch: "Store · 2", + // never a zero + const teams = teamOrder(Object.keys(teamMembers), kept.owed.map(teamOfRow)); + const owedBy = (team: string) => + kept.owed.filter(row => (teamOfRow(row) ?? '(none)') === team).length; + const counted = (label: string, count: number) => (count ? `${label} · ${count}` : label); + const people = [...new Set(Object.values(teamMembers).flat())].sort(); + // a team's load counts its work in flight; the row each one has here + // makes its Park the same call + const rowOf = (w: InFlight): DecideRow => + kept.all.find(row => rowKey(row) === `${w.slug ?? ''}:${w.id ?? ''}`) ?? { + slug: w.slug, + item: plans.find(p => p.id === w.id) ?? null, + reasons: [], + }; + return ( +
    +
    + 0 && ( + +

    + Each row is a decision the roadmap is owed. The sections run from what + costs most to leave alone to what costs least: work marked done, dropped + or parked that’s still being worked on, and plans their own issue + disagrees with; then plans that slipped past an update, a target date or + an end date; then plans whose issues are all closed; then stalled work, + and plans whose lead says at risk; then new work waiting for a plan. +

    +

    + {ALL_ISSUES_CLOSED} comes before at risk: that plan is probably done, but + until someone says so it still counts in its team’s load, while at risk + only warns that a plan might slip. +

    +

    + Each decision saves to the roadmap when you click it, and its row says + what happens next, with Undo. A section of two or more has one button + that gives every row its suggested answer, and then Undo all. +

    +

    + From the keyboard, j and k move between the rows, the left and right + arrow keys between a row’s answers, and Enter makes the decision. +

    +
    + ) + } + headerExtra={ + (owed.length > 0 || decidedHere.length > 0) && ( + + {copied ?? COPY_AS_TEXT} + + ) + } + /> + + {copied ?? ''} + + + {teams.length > 0 && ( +
    + owedBy(t) > 0 || nav.team === t) + .map((t): [string, string] => [t, counted(t, owedBy(t))]), + ...(owedBy('(none)') > 0 || nav.team === '(none)' + ? [['(none)', counted('No team', owedBy('(none)'))] as [string, string]] + : []), + ]} + onChange={t => navigate({ team: t || null })} + /> + {nav.team && nav.team !== '(none)' && ( + + w.slug + ? navigate({ project: w.slug }) + : w.id != null && navigate(openPlan(nav, w.id)) + } + /> + )} +
    + )} +
    + {/* the check is earned by clearing the list too, above the calls + that cleared it */} + {!owed.length && ( + <> + + {nav.team && kept.owed.length > 0 && ( +

    + navigate({ team: null })}> + Show all teams + +

    + )} + + )} + {SECTIONS.map((section, index) => { + const here = shown.filter(row => sectionOf(row) === index); + if (!here.length) return null; + const decided = (row: DecideRow) => kept.settled.has(rowKey(row)); + const owedHere = here.filter(row => !decided(row)); + // asked once in the header when every row asks it alike + const asked = sharedAsk(here, projectOf, day); + const leads = here.filter(row => row.item?.lead ?? projectOf(row)?.lead); + const guessed = + leads.length > 0 && leads.every(row => guessedLead(row, projectOf(row))); + return ( +
    + {/* no sub-line: the title says what lands here, its rule + is the header's hover and read before the rows */} +
    + + {asked && owedHere.length > 0 && ( + {askAll(asked)} + )} + {guessed && ( + + {leads.length > 1 ? 'Leads' : 'Lead'}{' '} + + + )} + + ) + } + headerExtra={ + + } + /> +
    +

    {section.more.join(' ')}

    + + + {here.map(row => ( + + ))} + + +
    + ); + })} + +
    + ); +} diff --git a/frontend-v2/src/views/projects/DecideCall.test.tsx b/frontend-v2/src/views/projects/DecideCall.test.tsx new file mode 100644 index 00000000..1b0b36bb --- /dev/null +++ b/frontend-v2/src/views/projects/DecideCall.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DecideReason } from '../../../../shared/model/decide'; +import type { RoadmapItem } from '../../../../shared/model/roadmap'; +import type { DecideRow } from '../../../../shared/model/decide'; +import { askAll, bulkWords, type Call, DecideCall, sharedAsk } from './Decide'; + +// React's act() warns unless the environment says it's a test +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const plan: RoadmapItem = { + id: 9, + name: 'Visual regression checks', + project: null, + team: 'FixBot', + lead: 'ardelato', + status: 'active', + origin: null, + start: '2026-09-07', + weeks: 6, + // a commitment, the end these tests ask about + end_kind: 'hard', + done_when: '', + priority: 8, + notes: '', + waits_on: [], + updated_by: null, + updated_at: null, + created_at: null, + update: null, +}; + +let host: HTMLDivElement | null = null; +afterEach(() => { + host?.remove(); + host = null; +}); + +function render(opened: boolean, reasons: DecideReason[] = [], compact = false): HTMLDivElement { + host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + + ) + ); + return host; +} + +describe('the calls on a plan nobody asked about', () => { + it('rest behind Change where they sit beside the plan’s words', () => { + const el = render(false); + expect(el.querySelector('[role="toolbar"]')).toBeNull(); + expect(el.textContent).toContain('Change'); + }); + + it('stand open, with no Cancel, where someone opened the plan to change it', () => { + const el = render(true); + expect(el.querySelector('[role="toolbar"]')?.getAttribute('aria-label')).toBe( + 'Decisions for Visual regression checks' + ); + expect(el.textContent).toContain('Mark done'); + expect(el.textContent).not.toContain('Cancel'); + }); +}); + +describe('the calls on a row Decide asks about', () => { + const done: DecideReason[] = [{ kind: 'issues_done', done: 2, dropped: 0, open: 0 }]; + const buttons = (el: HTMLElement) => [...el.querySelectorAll('button')].map(b => b.textContent); + + it('start as the suggested answer and Other answers', () => { + const el = render(false, done); + expect(buttons(el)).toEqual(['Mark doneSuggested', 'Other answers']); + }); + + it('open every answer in place from Other answers', () => { + const el = render(false, done); + const other = [...el.querySelectorAll('button')].find(b => b.textContent === 'Other answers'); + act(() => other?.click()); + expect(el.textContent).toContain('Promise to finish by'); + expect(buttons(el)).toEqual(expect.arrayContaining(['Park', 'Drop', 'Mark doneSuggested'])); + }); + + it('stand open where someone opened the plan to change it', () => { + const el = render(true, done); + expect(el.textContent).toContain('Or instead'); + expect(el.textContent).not.toContain('Other answers'); + }); +}); + +describe('a section whose rows all ask the same thing', () => { + const stalled = (slug: string, days: number): DecideRow => ({ + slug, + item: null, + reasons: [{ kind: 'stalled', days }], + }); + const none = () => undefined; + + it('asks once, in the plural, when every row has the same answer', () => { + const rows = [stalled('a', 268), stalled('b', 99)]; + expect(sharedAsk(rows, none, '2026-10-09')).toBe('Park it for now?'); + expect(askAll('Park it for now?')).toBe('Park them for now?'); + expect(askAll('When will it finish?')).toBe('When will each finish?'); + expect(askAll('Mark the plan done too?')).toBe('Mark the plans done too?'); + }); + + it('keeps the cards for one row, differing answers, or a row with more to say', () => { + expect(sharedAsk([stalled('a', 268)], none, '2026-10-09')).toBeNull(); + const done: DecideRow = { + slug: null, + item: plan, + reasons: [{ kind: 'issues_done', done: 2, dropped: 0, open: 0 }], + }; + expect(sharedAsk([stalled('a', 268), done], none, '2026-10-09')).toBeNull(); + const twice: DecideRow = { + ...stalled('b', 99), + reasons: [...stalled('b', 99).reasons, { kind: 'at_risk' }], + }; + expect(sharedAsk([stalled('a', 268), twice], none, '2026-10-09')).toBeNull(); + }); + + it('says the day the rows’ own buttons say', () => { + const oct: Call = { + kind: 'commit', + label: 'End of Oct', + end: '2026-10-31', + through: 'the end of Oct', + }; + expect(bulkWords(Array(7).fill(oct), 'do')).toBe('Promise all 7 by Oct 31'); + }); + + it('gives each row an outlined answer, unmarked, and every other answer a click away', () => { + const el = render(false, [{ kind: 'issues_done', done: 2, dropped: 0, open: 0 }], true); + const buttons = () => [...el.querySelectorAll('button')]; + expect(buttons().map(b => b.textContent)).toEqual(['Mark done', 'Other answers']); + expect(buttons()[0].className).not.toContain('bg-brand'); + expect(buttons()[0].getAttribute('aria-label')).toBe('Mark Visual regression checks done'); + act(() => buttons()[1].click()); + expect(buttons().map(b => b.textContent)).toEqual( + expect.arrayContaining(['Park', 'Drop', 'Mark doneSuggested']) + ); + }); +}); diff --git a/frontend-v2/src/views/projects/LoadChart.tsx b/frontend-v2/src/views/projects/LoadChart.tsx new file mode 100644 index 00000000..6ac9a370 --- /dev/null +++ b/frontend-v2/src/views/projects/LoadChart.tsx @@ -0,0 +1,454 @@ +import type { ReactNode } from 'react'; +import { n } from '../../../../shared/format'; +import { addWeeks, type RoadmapOrigin } from '../../../../shared/model/roadmap'; +import { FactLink, QuietButton } from '../../components/bits'; +import { dayWords } from '../../model/projectData'; +import { NOT_SAID } from '../../model/words'; + +// what a week counts: a project with a PR open at some point in it (merged +// that week counts too), not the lanes' 14 days of "being worked on" +const PRS_OPEN = 'with PRs open'; +import type { LoadWeek, OriginCounts } from '../../../../shared/model/load'; + +/** + * How loaded the weeks up to today were, on the roadmap's own time axis so + * each week's bar sits above the same weeks of every row. Each fact is said + * once: the headline beside the bars names what they count (blue on the + * roadmap, gray with no plan: the colors the counts under it are written + * in), and the dashed line, the one ruler that matters, says how many + * developers there are and leads to them. No y-axis: each bar's hover + * gives its number. The headline answers the one question: more projects + * than developers, said in amber. No bars after today: a plan list only + * knows what's decided, so any forecast drawn from it can only fall. The + * weeks ahead are said in words instead (Ahead), which the rows below can + * check. + */ + +const ON_PLAN = 'var(--brand)'; +const OFF_PLAN = 'color-mix(in oklab, var(--ink-3) 70%, transparent)'; + +const total = (w: LoadWeek) => w.onPlan + w.offPlan; + +/** Round tick values from 0 to `top`, at most four, for the y-axis. */ +export function yTicks(top: number): number[] { + const step = [1, 2, 5, 10, 20, 25, 50, 100, 200, 500].find(s => top / s <= 4) ?? 1000; + const ticks: number[] = []; + for (let v = step; v <= top; v += step) ticks.push(v); + return ticks; +} + +type OriginKey = RoadmapOrigin | 'unsaid'; +/** A count of plans from one origin, in words. */ +const ORIGIN_COUNT: Record string> = { + asked: c => `${c} asked for`, + fire: c => n(c, 'fire'), + chosen: c => n(c, 'team’s pick'), + unsaid: c => `${c} ${NOT_SAID.toLowerCase()}`, +}; +const ORIGIN_TITLE: Record = { + asked: 'Show only the plans asked for from above', + fire: 'Show only the plans that are fires to put out', + chosen: 'Show only the plans the team picked itself', + unsaid: 'Show only the plans with no word on where the work came from', +}; + +/** A plan and the day it starts or should end. */ +export interface PlanDay { + name: string; + day: string; +} + +/** The plans that start, and the plans that should end, in the next four + * weeks (Roadmap.tsx). */ +export interface Ahead { + starts: PlanDay[]; + ends: PlanDay[]; +} + +/** "Next 4 weeks: 2 start, 3 should end", each count a dotted + * underline whose hover names the plans and their days. */ +function AheadWords({ ahead }: { ahead: Ahead }) { + const { starts, ends } = ahead; + if (!starts.length && !ends.length) return <>Nothing starts or ends in the next 4 weeks; + const count = (list: PlanDay[], verb: string, words: string) => ( + `${p.name} ${verb} ${dayWords(p.day)}`).join('\n')} + className="underline decoration-dotted underline-offset-2" + > + {words} + + ); + const startWords = + starts.length > 0 && + count(starts, 'starts', `${starts.length} ${starts.length === 1 ? 'starts' : 'start'}`); + // plans go without saying on a roadmap + const endWords = ends.length > 0 && count(ends, 'should end', `${ends.length} should end`); + return ( + <> + Next 4 weeks: {startWords} + {startWords && endWords && ', '} + {endWords} + + ); +} + +/** With no plans yet, where to start: the projects Decide asks to plan. */ +function FirstPlan({ asked, onStart }: { asked: number; onStart: (() => void) | null }) { + if (!asked) return <>No plans yet, and nothing big enough to need one.; + return ( + <> + No plans yet. Start with the {asked} that {asked === 1 ? 'needs' : 'need'} one.{' '} + {onStart && Plan the first one} + + ); +} + +function weekWords(w: LoadWeek, developers: number): string { + const people = developers ? `, for ${n(developers, 'developer')}` : ''; + return `Week of ${dayWords(w.week)}: ${total(w)} ${PRS_OPEN}, ${w.onPlan} with a plan and ${ + w.offPlan + } with none${people}`; +} + +/** A count that filters the rows to what it counts, and back on a second + * click: it acts here, so it's a bordered chip, filled while pressed. The + * count stays quiet; the words take the color of the bars they count, so + * they label them without a legend. */ +function CountButton({ + active, + onClick, + title, + count, + tone = '', + children, +}: { + active: boolean; + onClick: () => void; + title: string; + count?: number; + /** the color of the bars it counts */ + tone?: string; + children: ReactNode; +}) { + return ( + + {count != null && {count} } + {children} + + ); +} + +export function LoadChart({ + weeks: all, + now, + developers, + at, + todayAt, + when, + ahead, + rowGrid, + picked, + onPick, + show, + onShow, + origin, + onOrigin, + onPeople, + firstPlan, +}: { + weeks: LoadWeek[]; + /** this week's load, whatever weeks the chart shows */ + now: LoadWeek; + /** everyone on a developer team; 0 when none are set up */ + developers: number; + /** a day's place across the track, 0 to 100 */ + at: (day: string) => number; + todayAt: number | null; + /** whether the shown weeks are all past, all to come, or today falls among them */ + when: 'past' | 'future' | 'both'; + /** what starts and should end in the next four weeks, said right of today */ + ahead: Ahead; + /** the roadmap's row grid, so the chart's track lines up with the rows' */ + rowGrid: string; + /** the week picked by clicking its bar, or null */ + picked: string | null; + onPick: (week: string | null) => void; + /** what the rows show, which the counts switch */ + show: 'all' | 'plan' | 'unplanned'; + onShow: (show: 'all' | 'plan' | 'unplanned') => void; + /** where the shown plans came from, picked from the counts; null for all */ + origin: OriginKey | null; + onOrigin: (origin: OriginKey | null) => void; + /** to the developer teams */ + onPeople: () => void; + /** with no plans yet: how many projects Decide asks to plan, and the way + * to plan the first of them (null when none is asked); null once a plan + * exists */ + firstPlan?: { asked: number; onStart: (() => void) | null } | null; +}) { + // only the weeks that happened: a projection from the plans can only fall + const weeks = all.filter(w => !w.projected); + // a picked week's numbers stand in for this week's + const shown = (picked && weeks.find(w => w.week === picked)) || now; + const inFlight = total(shown); + // the week the counts are about, in words + const weekName = picked ? `the week of ${dayWords(shown.week)}` : 'this week'; + // a count picks its week too, so its rows are the ones it counted + const narrow = (then: () => void) => { + if (picked !== shown.week) onPick(shown.week); + then(); + }; + const step = (by: number) => { + const i = weeks.findIndex(w => w.week === (picked ?? now.week)); + const next = weeks[Math.min(weeks.length - 1, Math.max(0, (i < 0 ? 0 : i) + by))]; + if (next) onPick(next.week); + }; + const top = Math.max(developers, ...weeks.map(total), 1) * 1.15; + const pct = (v: number) => `${(v / top) * 100}%`; + const each = developers ? inFlight / developers : null; + const over = developers ? inFlight - developers : 0; + return ( + // its own stacking context, so no label in it paints over the sticky + // headers above it +
    + {/* a week picked with the arrow keys is read out, as a click shows it */} + + {picked ? weekWords(shown, developers) : ''} + +
    + {/* the answer: how many projects, and how many more than + developers. It narrows the rows to its week; its hover fill + says it presses, and no border makes it a card in a card */} +
    + +
    + {/* the two halves of the count, in one line; each narrows the rows + to what it counts, in the week it counts them, so its number + and its rows agree. Where the plans came from is a closer look: + it shows once the rows are narrowed to the plans. */} + {/* with no plans, "0 have a plan" is always 0: the line waits for one */} +
    + + + show === 'plan' ? onShow('all') : narrow(() => onShow('plan')) + } + title={`Show only the plans on the roadmap ${weekName}`} + count={shown.onPlan} + > + {shown.onPlan === 1 ? 'has a plan' : 'have a plan'} + + + · + + + show === 'unplanned' ? onShow('all') : narrow(() => onShow('unplanned')) + } + title={`Show only the projects ${PRS_OPEN} with no plan ${weekName}`} + count={shown.offPlan} + tone="text-ink-3" + > + {shown.offPlan === 1 ? 'has none' : 'have none'} + + + (o ? narrow(() => onOrigin(o)) : onOrigin(null))} + weekName={weekName} + /> +
    +
    +
    + {/* today, in brand words over its line: the line marks it, so + no fill to make it the loudest thing here */} +
    + {todayAt != null && ( + + Today + + )} +
    +
    { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + step(e.key === 'ArrowRight' ? 1 : -1); + } else if (e.key === 'Escape') onPick(null); + }} + > + {weeks.map(w => { + const left = at(w.week); + const isPicked = w.week === picked; + return ( + // a week's column is the button that picks it; the chart + // itself takes the keys, so the weeks aren't 78 tab stops + + ); + })} + {developers > 0 && ( + // a plain reference: the headline says when it's crossed + <> + + {/* on the line, over the bars, so it keeps the card behind + it; the way to the teams on People */} + + {n(developers, 'developer')} + + + )} + {todayAt != null && ( + + )} + {/* the weeks ahead, in words the rows can check, in the room + right of today */} + {when !== 'past' && ( + + {/* on the card, so the developer line doesn't strike it */} + + {firstPlan ? : } + + + )} +
    +
    +
    + ); +} + +/** + * Which origins the roadmap count splits into: none at rest, since where the + * work came from is a closer look than the load; once the rows narrow to the + * plans, or to an origin, each with plans that week, and the picked one even + * at none, so it can be let go. Until some plan says, there's nothing to + * split. + */ +export function originsShown( + show: 'all' | 'plan' | 'unplanned', + origin: OriginKey | null, + counts: OriginCounts +): OriginKey[] { + const said = counts.asked + counts.fire + counts.chosen > 0; + if (!origin && (show !== 'plan' || !said)) return []; + return (Object.keys(ORIGIN_COUNT) as OriginKey[]).filter(k => counts[k] > 0 || k === origin); +} + +/** The plans on the roadmap split by where their work came from, each count + * a filter like the ones above it. */ +function OriginSplit({ + keys, + counts, + origin, + onOrigin, + weekName, +}: { + /** the origins to show (originsShown) */ + keys: OriginKey[]; + counts: OriginCounts; + origin: OriginKey | null; + onOrigin: (origin: OriginKey | null) => void; + /** the week the counts are about, in words */ + weekName: string; +}) { + if (!keys.length) return null; + return ( + + {keys.map(k => ( + onOrigin(origin === k ? null : k)} + title={`${ORIGIN_TITLE[k]}, ${weekName}`} + > + {ORIGIN_COUNT[k](counts[k])} + + ))} + + ); +} diff --git a/frontend-v2/src/views/projects/NowNextLater.tsx b/frontend-v2/src/views/projects/NowNextLater.tsx new file mode 100644 index 00000000..bbbabbc8 --- /dev/null +++ b/frontend-v2/src/views/projects/NowNextLater.tsx @@ -0,0 +1,222 @@ +import type { MouseEvent } from 'react'; +import { bucketOf, NEXT_WEEKS, type RoadmapItem } from '../../../../shared/model/roadmap'; +import { FactLink } from '../../components/bits'; +import { Fold, GroupHeader, Rows } from '../../components/Lane'; +import type { PortfolioItem } from '../../model/portfolio'; +import { n } from '../../../../shared/format'; +import { IN_PROGRESS } from '../../model/words'; +import { Dotted, planWarnings, restWords, SaidWords, type PlanCall } from './roadmapHealth'; + +const BUCKETS: ['now' | 'next' | 'later', string, string][] = [ + ['now', 'Now', `${IN_PROGRESS}, or its start week has come`], + ['next', 'Next', `Starts in the next ${NEXT_WEEKS} weeks`], + ['later', 'Later', `Starts more than ${NEXT_WEEKS} weeks from now`], +]; + +/** + * The month a plan's work starts, "starts in October" (with the year when it + * isn't this one): the layout is for the order, not the weeks. A week + * belongs to the month its Thursday falls in, so a plan starting the week of + * Sep 28 reads October. + */ +function monthWords(start: string, today: string): string { + const thursday = new Date(`${start}T00:00:00Z`); + thursday.setUTCDate(thursday.getUTCDate() + 3); + const sameYear = thursday.getUTCFullYear() === Number(today.slice(0, 4)); + return `starts in ${thursday.toLocaleDateString(undefined, { + month: 'long', + year: sameYear ? undefined : 'numeric', + timeZone: 'UTC', + })}`; +} + +/** A word on a card that does what it does on the timeline; the card's own + * click, which opens the plan, doesn't fire too. */ +const only = (then: () => void) => (e: MouseEvent) => { + e.stopPropagation(); + then(); +}; + +/** A plan as a card: its name, then the timeline row's own words at rest + * (Decide's question, its one amber mark, or a clash), the month it starts + * when that's ahead, and its lead. The rest is in its details on the + * timeline, which the card opens. */ +function Card({ + item, + all, + linked, + today, + call, + onOpen, + onPerson, +}: { + item: RoadmapItem; + all: RoadmapItem[]; + linked: PortfolioItem | undefined; + today: string; + /** the call Decide asks about it */ + call: PlanCall | null; + onOpen: () => void; + onPerson: (login: string) => void; +}) { + const bucket = bucketOf(item, today); + // the timeline's own warnings, in its words and with its one amber piece + const w = planWarnings( + item, + all, + today, + linked ? { live: linked.status === 'live', target: linked.target } : null, + undefined, + call + ); + const rest = restWords(w); + const lead = item.lead; + return ( + // the whole card opens the plan; the name is its keyboard door +
  • + +
    + + {[ + rest && ( + // plain words: the card and its name open the plan + + + + ), + bucket !== 'now' && {monthWords(item.start, today)}, + // no lead is said in the plan, not on every card + lead && ( + onPerson(lead))} + title={`Open ${lead}’s row on People`} + > + {lead} + + ), + ]} + +
    +
  • + ); +} + +/** + * The roadmap without dates, for readers who want the order and not the + * weeks: what's happening now, what's next, and what's later, each in + * priority order and each split by team when the roadmap is. Opening an item + * switches to the timeline with it open, since that's where its plan + * changes. + */ +export function NowNextLater({ + items, + all, + bySlug, + laneTitles, + today, + calls, + unplanned, + onOpen, + onPerson, + onTimeline, +}: { + /** the plans to show, as the find box narrows them, in priority order */ + items: RoadmapItem[]; + /** every plan, for what one waits on */ + all: RoadmapItem[]; + bySlug: Map; + /** team names when the roadmap is split into team lanes, else null */ + laneTitles: string[] | null; + today: string; + /** the calls Decide asks, by plan */ + calls: ReadonlyMap; + /** projects being worked on with no plan, which have no column here */ + unplanned: number; + /** a plan, open on the timeline */ + onOpen: (id: number) => void; + onPerson: (login: string) => void; + /** the timeline, where the projects with no plan are */ + onTimeline: () => void; +}) { + const cards = (list: RoadmapItem[]) => ( +
      + {list.map(item => ( + onOpen(item.id)} + onPerson={onPerson} + /> + ))} +
    + ); + return ( + <> +
    + {BUCKETS.map(([bucket, title, sub]) => { + const list = items.filter(i => bucketOf(i, today) === bucket); + const lanes = + laneTitles && + [...laneTitles, null] + .map(team => ({ team, list: list.filter(i => (i.team ?? null) === team) })) + .filter(lane => lane.list.length); + return ( +
    + + {list.length ? ( + + {lanes + ? lanes.map(lane => ( + + {cards(lane.list)} + + )) + : cards(list)} + + ) : ( +

    No plans.

    + )} +
    + ); + })} +
    + {/* without them, the columns would read as all the work */} + {unplanned > 0 && ( +

    + {n(unplanned, 'project')} with no plan {unplanned === 1 ? 'isn’t' : 'aren’t'} shown + here.{' '} + + See {unplanned === 1 ? 'it' : 'them'} on the timeline + +

    + )} + + ); +} diff --git a/frontend-v2/src/views/projects/Overview.tsx b/frontend-v2/src/views/projects/Overview.tsx new file mode 100644 index 00000000..32e0f2ec --- /dev/null +++ b/frontend-v2/src/views/projects/Overview.tsx @@ -0,0 +1,623 @@ +import { useEffect, useMemo, useRef, type ReactNode } from 'react'; +import { n } from '../../../../shared/format'; +import { STALL_DAYS, type DecideRow } from '../../../../shared/model/decide'; +import type { Today } from '../../../../shared/model/projects'; +import { FactLink, LoadFailed } from '../../components/bits'; +import { Fold, FoldRows, laneShown, RestGroup, SubDoor } from '../../components/Lane'; +import type { RowOptions } from '../../components/Row'; +import { DEFAULT_SORT } from '../../lens'; +import { + AGE_BUCKETS, + agoWords, + behindWords, + beingWorkedOn, + bucketDays, + bucketOf, + ENDS_SOON_DAYS, + IDLE_BUCKETS, + isYours, + lowerFirst, + sortItems, + upperFirst, + withCalls, + withWorkers, + type BucketDef, + type PortfolioItem, +} from '../../model/portfolio'; +import { dayOf, refreshProjectsData, type ProjectsData } from '../../model/projectData'; +import { median, OVERLOAD_MIN, peopleByProject } from '../../model/retro'; +import { retryRetroData } from '../../model/retroData'; +import { + andList, + BEING_WORKED_ON, + days, + LAST_14_DAYS, + noPrActivity, + NOT_IN_A_PROJECT, + ONE_OFFS, + OVERLOADED, +} from '../../model/words'; +import { StatsCard } from '../stats/parts'; +import type { Bucket } from './charts'; +import { useCallsMadeHere } from './Decide'; +import { BucketChart, ChartSlot } from './lazyCharts'; +import { switchView, Tile, type Navigate, type ProjectsNav } from './parts'; +import { AS_OPENED, Portfolio, Yours, type Workers } from './Portfolio'; +import { useWhoIsOnWhat, type WhoRow } from './WhoIsOnWhat'; + +const NO_ROWS: DecideRow[] = []; + +/** Scroll a section of the page into view, under the sticky header. */ +const scrollTo = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' }); + +/** Up to three names in a sentence, or the first two and how many more. */ +const names = (logins: string[]) => + andList(logins.length <= 3 ? logins : [...logins.slice(0, 2), `${logins.length - 2} more`]); + +/** + * The tiles: whether anything needs a look now, each opening what it + * counts. The owed come first: the calls Decide asks, and the people on + * too many projects, which wear the amber; the counts of stalls and slips + * are slices of those calls, so they stay ink, and on a phone only the owed + * tiles show. A tile that narrows the list is a toggle, picked while the + * list shows its chip. + */ +function Tiles({ + items, + today, + overloaded, + tooMany, + line, + decisions, + noTeams, + nav, + navigate, +}: { + items: PortfolioItem[]; + today: Today; + /** no teams are set, so everyone counts */ + noTeams: boolean; + /** undefined while the last 14 days load, null when they failed */ + overloaded: WhoRow[] | null | undefined; + /** how many cross the line when too many do to single out */ + tooMany: string | null; + line: number; + decisions: DecideRow[] | null; + nav: ProjectsNav; + navigate: Navigate; +}) { + const working = items.filter(beingWorkedOn); + const ages = working.map(i => i.ageDays).filter((d): d is number => d != null); + const middleAge = ages.length ? Math.round(median(ages)) : null; + const stalled = working + .filter(i => i.stalled) + .sort((a, b) => (b.lastActivity?.days ?? 0) - (a.lastActivity?.days ?? 0)); + // the list's own order, so the one named is the one it puts first + const behind = sortItems( + items.filter(i => i.behind), + DEFAULT_SORT + ); + const ending = items.filter(i => i.endsSoon); + // the open PRs of the projects the tile counts, so its rows' Open column + // adds up to the same number + const held = working.reduce((sum, i) => sum + i.open, 0); + const allOpen = + today.live.reduce((sum, g) => sum + g.open.length, 0) + + today.misc.length + + today.unsorted.length; + const behindNote = () => { + const [first] = behind; + const words = behindWords(first, dayOf(new Date())); + const more = behind.length > 1 ? `, and ${behind.length - 1} more` : ''; + return `${first.name}${words ? `, ${lowerFirst(words)}` : ''}${more}`; + }; + const newWork = decisions?.filter(d => d.reasons.some(r => r.kind === 'new')).length; + const stalledAsked = decisions?.filter(d => d.reasons.some(r => r.kind === 'stalled')).length; + const owedCalls = !!decisions?.length; + const overloadedNow = !!overloaded?.length; + // a tile opens exactly what it counts: no find or earlier pick on top + const toList = (patch: Partial) => { + navigate({ find: '', only: null, ...patch }); + scrollTo('all-projects'); + }; + // a second click on a picked tile lets the list go, as the chip's × does + const toggle = (only: string, status: string) => + nav.only === only ? navigate(AS_OPENED) : toList({ status, only }); + // quiet tiles give way on a phone, where the list is what matters + const onPhone = (owed: boolean, tile: ReactNode) => + owed ? tile :
    {tile}
    ; + return ( +
    + +
    + {onPhone( + owedCalls, + navigate(switchView('decide'))} + /> + )} + {onPhone( + overloadedNow, + r.login)) + : tooMany ?? `nobody on ${line} or more projects` + }` + } + warn={overloadedNow} + title={`${ + noTeams ? 'People' : 'Developers' + } who wrote PRs on ${line} or more different projects in the ${LAST_14_DAYS}. Reviewing a PR doesn’t count.${ + tooMany ? ' Too many to single out.' : '' + } Click to open People.`} + // the same 14 days the tile counts, narrowed to who it + // names, or sorted by it when too many cross to name + onClick={() => + navigate({ + ...switchView('people'), + range: '14d', + ...(tooMany + ? { only: null, psort: 'projects' } + : { only: 'overloaded' }), + }) + } + /> + )} + {onPhone( + false, + toList({ status: 'live' })} + /> + )} + {onPhone( + false, + d >= 90) + ? `${ages.filter(d => d >= 90).length} over 90 days` + : 'none over 90 days' + } + title={`How long the projects ${BEING_WORKED_ON} have been open, from each one’s oldest open PR: half are newer than this, half older. Click to list them oldest first.`} + onClick={() => toList({ status: 'live', sort: 'age' })} + /> + )} + {onPhone( + false, + + stalled.length + ? toggle('stalled', 'live') + : toList({ status: 'live', sort: 'last' }) + } + /> + )} + {onPhone( + false, + i.plan) + ? 'no plans yet' + : `${n(ending.length, 'plan')} ${ + ending.length === 1 ? 'is' : 'are' + } due in the next ${ENDS_SOON_DAYS} days` + } + title={`Projects overdue or past their target date with PRs still open, or whose latest update says off track with no new plan since. Click to list ${ + behind.length + ? 'them' + : ending.length + ? `the plans ending in the next ${ENDS_SOON_DAYS} days` + : 'every project, the soonest target first' + }.`} + picked={ + behind.length + ? nav.only === 'behind' + : ending.length + ? nav.only === 'ending' + : undefined + } + onClick={() => + behind.length + ? toggle('behind', 'all') + : ending.length + ? toggle('ending', 'all') + : toList({ status: 'all', sort: 'target' }) + } + /> + )} +
    + {/* what each tile counts, said once behind a door, for touch and + screen readers too: a title on each tile only reaches a mouse. + A phone shows only the owed tiles, and the views they open + explain them, so there it gives the list the line */} +
    + +

    + To decide: the decisions Decide asks for, such as a first plan, a new end date, + or work marked done or parked whose PRs still move. +

    +

    + {upperFirst(OVERLOADED)}: {noTeams ? 'people' : 'developers'} who wrote PRs on{' '} + {line} or more projects in the {LAST_14_DAYS}, twice the{' '} + {noTeams ? 'median' : 'developers’ median'} and never under {OVERLOAD_MIN}. + Reviewing a PR doesn’t count. When more than a quarter cross, none are singled + out and the tile says how many. +

    +

    + {upperFirst(BEING_WORKED_ON)}: an open PR or a merge in the {LAST_14_DAYS}, and + not parked, done or dropped, unless Decide asks about it because its PRs still + move. +

    +

    + Median time open: from each one’s oldest open PR. Stalled: open PRs, and{' '} + {lowerFirst(noPrActivity(STALL_DAYS))} or more. +

    +

    + Behind plan: overdue or past its target date with PRs open, or off track since + its plan last changed. +

    +

    Each tile opens what it counts.

    +
    +
    +
    +
    + ); +} + +/** The projects being worked on, counted into one chart's bars. */ +function bucketsOf(items: PortfolioItem[], chart: 'age' | 'idle', defs: BucketDef[]): Bucket[] { + // no amber bar: the stalled ones are calls the To decide tile counts + const buckets: Bucket[] = defs.map(d => ({ tick: d.tick, items: [] })); + for (const item of items) { + const span = bucketDays(item, chart); + if (span != null) buckets[bucketOf(span, defs)].items.push({ name: item.name, days: span }); + } + return buckets; +} + +/** + * How long the projects being worked on have been open, and when anyone + * last worked on them: one bar per bucket, and a click lists only that + * bar's projects in the table above. Parked projects nobody's working on + * are left out, and the line under each chart says so. + */ +function AgeCharts({ + items, + nav, + navigate, +}: { + items: PortfolioItem[]; + nav: ProjectsNav; + navigate: Navigate; +}) { + const working = items.filter(beingWorkedOn); + const parked = items.filter(i => i.stage === 'parked' && !beingWorkedOn(i)); + const pickedOf = (chart: 'age' | 'idle') => { + const bar = /^(age|idle)-(\d)$/.exec(nav.only ?? ''); + return bar && bar[1] === chart ? Number(bar[2]) : null; + }; + // the list sits above the charts, so a bar's pick brings it into view + const pick = (chart: 'age' | 'idle', i: number) => { + navigate({ + status: 'live', + find: '', + only: pickedOf(chart) === i ? null : `${chart}-${i}`, + }); + scrollTo('all-projects'); + }; + const span = (list: number[], chart: 'age' | 'idle') => { + if (!list.length) return ''; + const lo = Math.min(...list); + const hi = Math.max(...list); + if (chart === 'age') return `open ${lo === hi ? days(lo) : `${lo} to ${hi} days`}`; + return `last worked on ${ + lo === hi ? agoWords(lo) : `${lo ? lo : 'today'} to ${days(hi)} ago` + }`; + }; + const notCounted = (chart: 'age' | 'idle') => { + const range = span( + parked.map(i => bucketDays(i, chart)).filter((d): d is number => d != null), + chart + ); + const none = working.filter(i => bucketDays(i, chart) == null).length; + if (!parked.length && !none) return null; + const parts: ReactNode[] = []; + if (parked.length) { + parts.push( + + { + navigate({ status: 'parked', find: '', only: null }); + scrollTo('all-projects'); + }} + title="List the parked projects" + > + {n(parked.length, 'parked project')} + + {range ? `, ${range}` : ''} + + ); + } + if (none) parts.push(`${none} with only merges and nothing open`); + return ( +

    + Not counted: {parts.flatMap((part, i) => (i ? ['; ', part] : [part]))}. +

    + ); + }; + const unit = `Projects ${BEING_WORKED_ON}, ${LAST_14_DAYS}`; + const card = ( + chart: 'age' | 'idle', + title: string, + sub: string, + defs: BucketDef[], + ariaLabel: string + ) => ( + + {working.length ? ( +
    + + pick(chart, i)} + /> + + {notCounted(chart)} +
    + ) : ( +

    Nothing {BEING_WORKED_ON}.

    + )} +
    + ); + return ( +
    + {/* the cards' own headings sit under this one, not under the list's */} +

    {`Projects ${BEING_WORKED_ON}, by age and by last activity`}

    +
    + {card( + 'age', + 'How long projects have been open', + 'from each project’s oldest open PR', + AGE_BUCKETS, + `Projects ${BEING_WORKED_ON}, by how long they have been open` + )} + {card( + 'idle', + 'When projects were last worked on', + `${STALL_DAYS} days or more is stalled`, + IDLE_BUCKETS, + `Projects ${BEING_WORKED_ON}, by how long since anyone worked on them` + )} +
    +
    + ); +} + +/** + * The Overview: where the projects stand now. Your own projects come first, + * the same rows as the list's; the tiles say whether anything needs a look; + * then every project on one list, what's owed first; then how long the projects being worked on have been open and + * when they last moved; and the PRs outside any project close it out, + * since sorting them is someone's job too. Who is on what lives on People, + * where the Overloaded tile goes. Every number is as of now; Look back + * covers a range. + */ +export function Overview({ + today, + data, + prefix, + items, + teamOf, + nameOf, + nav, + navigate, + opts, + decisions, + me, + onPerson, +}: { + today: Today; + data: ProjectsData | null | undefined; + prefix: string; + items: PortfolioItem[]; + teamOf: (login: string) => string | null; + nameOf: (slug: string) => string; + nav: ProjectsNav; + navigate: Navigate; + opts: RowOptions; + decisions: DecideRow[] | null; + me: string; + onPerson: (login: string) => void; +}) { + const { rows, who, line, over, tooMany } = useWhoIsOnWhat(data?.teams, today, teamOf); + // who worked on each lately, and the calls Decide asks of each, which + // give every row's Plan cell its words and its one amber mark + const listed = useMemo( + () => withCalls(rows ? withWorkers(items, peopleByProject(rows)) : items, decisions), + [items, rows, decisions] + ); + // the calls owed, and the ones made in the list's rows since it opened + const calls = useCallsMadeHere(decisions ?? NO_ROWS); + // a row answered in place keeps its place, its tab and its receipt, as a + // row on Decide does: the list places it as it stood just before the + // call, while its cells say what's true now + const answered = new Set([...calls.settled.values()].map(m => m.row.slug)); + const stood = useRef(new Map()); + useEffect(() => { + for (const item of listed) if (!answered.has(item.slug)) stood.current.set(item.slug, item); + }); + const placeOf = (item: PortfolioItem) => + (answered.has(item.slug) && stood.current.get(item.slug)) || item; + // your projects, first, as the list places them + const yours = new Set( + listed + .map(placeOf) + .filter(i => isYours(i, me)) + .map(i => i.slug) + ); + const workers: Workers = rows ? 'loaded' : who === null ? 'failed' : 'loading'; + // what both lists draw their rows with + const list = { + items: listed, + placeOf, + calls: calls.all, + prefix, + workers, + nav, + navigate, + opts, + onPerson, + me, + noTeams: !!data && !Object.keys(data.teams ?? {}).length, + projectsRepo: data ? data.projects_repo : undefined, + }; + // who wrote for the line's projects or more, most first; reviewing isn't + // being on a project, and a line that would flag a quarter flags no one + // a failed projects load can't say who is a developer: "?" like a failed + // days load, not the loading dots + const overloaded = data === null ? null : who && over; + // the teams in their configured order, for the list's Group by Team + const teams = useMemo(() => Object.keys(data?.teams ?? {}), [data]); + // two-label PRs already sit in a project, so they aren't "outside" ones + const outside = today.misc.length + today.unsorted.length; + const allOpen = today.live.reduce((sum, g) => sum + g.open.length, 0) + outside; + return ( +
    + {data === null && ( + + )} + {who === null && ( + + )} + + + + + {outside + today.doubleLabeled.length > 0 && ( + + + + + + + + + + + + )} +
    + ); +} diff --git a/frontend-v2/src/views/projects/People.test.ts b/frontend-v2/src/views/projects/People.test.ts new file mode 100644 index 00000000..ecd413c9 --- /dev/null +++ b/frontend-v2/src/views/projects/People.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; +import type { PersonWindow } from '../../../../shared/model/projects'; +import { teamLookup } from '../../model/projectData'; +import type { PortfolioItem } from '../../model/portfolio'; +import { findFilter } from '../../model/portfolio'; +import type { RetroRow } from '../../model/retro'; +import { + listedTwice, + overloadedPeople, + peopleRows, + personMatches, + sortPeople, + tooManyOf, + type PersonRow, +} from './People'; +import { openingMonth } from './RangeCalendar'; +import { lastWeeks, type WhoRow } from './WhoIsOnWhat'; + +const load = (login: string, projects: number, days: number): WhoRow => ({ + login, + days, + reviewing: 0, + unfiled: 0, + projects: Array.from({ length: projects }, (_, i) => ({ + login, + slug: `p${i}`, + days: 1, + writing: 1, + last: null, + })), + wrote: projects, + reviewedOnly: 0, + reviewed: 0, + team: null, + open: 0, +}); +const stats = (over: Partial) => over as PersonWindow; + +describe('peopleRows', () => { + const teams = { Store: ['DanielBeardsley', 'zdmitchell'] }; + const data = { + teams, + window: { + people: { + danielbeardsley: stats({ opened: 3, reviews: 2 }), + BaseInfinity: stats({ opened: 4, reviews: 0 }), + }, + }, + } as unknown as Parameters[0]; + const rows = peopleRows(data, teamLookup(teams), [load('danielbeardsley', 5, 9)], 'Sterling'); + + it('keeps one row per person whatever case their login comes in', () => { + const dan = rows.filter(r => r.login.toLowerCase() === 'danielbeardsley'); + expect(dan).toHaveLength(1); + // the spelling their PRs use, their team from the typed list, and both + // halves of their numbers + expect(dan[0].login).toBe('danielbeardsley'); + expect(dan[0].team).toBe('Store'); + expect(dan[0].load?.projects).toHaveLength(5); + expect(dan[0].w?.opened).toBe(3); + }); + + it('lists idle developers, non-developers, and the person picked elsewhere', () => { + expect(rows.map(r => r.login).sort()).toEqual( + ['BaseInfinity', 'Sterling', 'danielbeardsley', 'zdmitchell'].sort() + ); + const picked = rows.find(r => r.login === 'Sterling'); + expect(picked).toMatchObject({ team: null, load: null, w: null }); + }); +}); + +describe('sortPeople', () => { + const row = (login: string, projects: number | null, open: number): PersonRow => ({ + login, + team: null, + load: projects == null ? null : load(login, projects, projects), + w: stats({ backlog_end: open, reviews: 0 }), + }); + const rows = [row('ann', 2, 1), row('bob', null, 5), row('cat', 0, 0), row('dan', 2, 3)]; + + it('puts the most first, ties to open PRs, and the uncounted after a zero', () => { + const sorted = sortPeople(rows, { key: 'projects', reversed: false }); + expect(sorted.map(r => r.login)).toEqual(['dan', 'ann', 'cat', 'bob']); + }); + + it('reverses only the column, not the tie-breaks', () => { + const sorted = sortPeople(rows, { key: 'projects', reversed: true }); + expect(sorted.map(r => r.login)).toEqual(['bob', 'cat', 'dan', 'ann']); + }); + + it('sorts names A to Z', () => { + const sorted = sortPeople(rows, { key: 'name', reversed: false }); + expect(sorted.map(r => r.login)).toEqual(['ann', 'bob', 'cat', 'dan']); + }); +}); + +describe('overloadedPeople', () => { + const row = (login: string, projects: number): PersonRow => ({ + login, + team: 'Store', + load: load(login, projects, projects), + w: null, + }); + + it('names the most projects first, and ties by login, as the Overview does', () => { + const rows = [ + row('zed', 4), + row('amy', 6), + row('bob', 4), + ...['c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'].map(l => row(l, 1)), + ]; + expect(overloadedPeople(rows, 4).map(r => r.login)).toEqual(['amy', 'bob', 'zed']); + }); + + it('counts projects they wrote for, not ones they only reviewed', () => { + const reviewer = { ...row('rae', 9), load: { ...load('rae', 9, 9), wrote: 1 } }; + const rows = [reviewer, ...['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(l => row(l, 1))]; + expect(overloadedPeople(rows, 4)).toEqual([]); + }); + + it('flags no one when more than a quarter would be, and says how many cross', () => { + const rows = [row('amy', 6), row('bob', 6), row('cal', 1)]; + expect(overloadedPeople(rows, 4)).toEqual([]); + expect(tooManyOf(rows, 4)).toBe('2 of 3 wrote for 4 or more'); + // non-developers, with no days, aren't counted in either number + expect(tooManyOf([...rows, { ...row('nia', 0), load: null }], 4)).toBe( + '2 of 3 wrote for 4 or more' + ); + expect(tooManyOf([row('amy', 6), ...['b', 'c', 'd'].map(l => row(l, 1))], 4)).toBeNull(); + }); + + it('sorts Wrote on by the projects they wrote for, not the ones they reviewed', () => { + const reviewer = { ...row('rae', 9), load: { ...load('rae', 9, 9), wrote: 1 } }; + const sorted = sortPeople([reviewer, row('amy', 3)], { key: 'projects', reversed: false }); + expect(sorted.map(r => r.login)).toEqual(['amy', 'rae']); + }); +}); + +describe('personMatches', () => { + const items = new Map([ + [ + 'webdriver', + { + slug: 'webdriver', + name: 'Deflake the webdriver tests', + lead: 'zdmitchell', + team: 'FixBot', + parents: ['ci'], + } as unknown as PortfolioItem, + ], + ]); + const dan: PersonRow = { + login: 'danielbeardsley', + team: 'Store', + load: { ...load('danielbeardsley', 0, 2), projects: [] }, + w: stats({ projects: ['webdriver'] }), + }; + const matches = (find: string) => personMatches(dan, findFilter(find), items); + + it('finds a person by login or team, and an empty find keeps everyone', () => { + expect(matches('')).toBe(true); + expect(matches('Beard')).toBe(true); + expect(matches('store')).toBe(true); + expect(matches('team:sto')).toBe(true); + expect(matches('lead:daniel')).toBe(true); + }); + + it('finds the people on a project the find names, by its name or its parent', () => { + expect(matches('webdriver')).toBe(true); + expect(matches('parent:ci')).toBe(true); + }); + + it('reads a login or a team as the person’s, not their projects’', () => { + // the project's lead and team aren't dan's + expect(matches('zdmitchell')).toBe(false); + expect(matches('fixbot')).toBe(false); + expect(matches('team:fixbot')).toBe(false); + expect(matches('lead:zd')).toBe(false); + }); +}); + +describe('lastWeeks', () => { + const row = (login: string, project: string | null, week: number): RetroRow => + ({ login, pr: { project }, week, days: 1, own: true } as unknown as RetroRow); + + it('keeps each person’s latest week on each project, whatever case their login comes in', () => { + const weeks = ['2026-09-07', '2026-09-14', '2026-09-21']; + const last = lastWeeks( + [row('Dan', 'sso', 2), row('dan', 'sso', 0), row('dan', 'akeneo', 1), row('dan', null, 2)], + weeks + ); + expect(last.get('dan sso')).toBe('2026-09-21'); + expect(last.get('dan akeneo')).toBe('2026-09-14'); + expect(last.size).toBe(2); + }); +}); + +describe('openingMonth', () => { + const day = (y: number, m: number, d: number) => new Date(y, m - 1, d); + + it('opens early in a month on the months before, which have days to pick', () => { + expect(openingMonth(day(2026, 10, 1), day(2026, 10, 1), 2)).toEqual(day(2026, 8, 1)); + expect(openingMonth(day(2026, 10, 1), day(2026, 10, 1), 1)).toEqual(day(2026, 9, 1)); + }); + + it('ends on the range’s own month once most of it has come', () => { + expect(openingMonth(day(2026, 10, 20), day(2026, 10, 20), 2)).toEqual(day(2026, 9, 1)); + }); + + it('ends on a past range’s month', () => { + expect(openingMonth(day(2025, 3, 15), day(2026, 10, 1), 2)).toEqual(day(2025, 2, 1)); + }); +}); + +describe('listedTwice', () => { + it('names a team typed on two rows, ignoring spaces around it', () => { + expect( + listedTwice([ + ['Store', 'a, b'], + [' Store ', 'c'], + ]) + ).toBe('Store'); + }); + it('lets different names and blank rows through', () => { + expect( + listedTwice([ + ['Store', 'a'], + ['', ''], + ['', 'x'], + ['Web', 'b'], + ]) + ).toBeNull(); + }); +}); diff --git a/frontend-v2/src/views/projects/People.tsx b/frontend-v2/src/views/projects/People.tsx new file mode 100644 index 00000000..63a1760b --- /dev/null +++ b/frontend-v2/src/views/projects/People.tsx @@ -0,0 +1,1442 @@ +import { + Fragment, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { Check, ChevronRight, Plus, X } from 'lucide-react'; +import { n } from '../../../../shared/format'; +import type { PersonWindow, Today, WeekPoint } from '../../../../shared/model/projects'; +import { mondayOf } from '../../../../shared/model/roadmap'; +import type { DeveloperTeams } from '../../../../shared/model/settings'; +import { + FactLink, + LoadFailed, + PrimaryButton, + QuietButton, + textInputClass, +} from '../../components/bits'; +import { Icon } from '../../components/Icon'; +import { Fold, foldDomId, GroupHeader, openFold, SubDoor } from '../../components/Lane'; +import { useRowKeys } from '../../components/useRowKeys'; +import { findFilter, type FindFields, type PortfolioItem } from '../../model/portfolio'; +import { + chartWindow, + dayWords, + DEFAULT_RANGE, + rangeDays, + rangeName, + rangeWords, + refreshProjectsData, + resolveRange, + teamLookup, + useProjectsData, + type ProjectsData, + type Range, +} from '../../model/projectData'; +import { + beforeWords, + OVERLOAD_MIN, + overloaded as overloadedOf, + tooManyWords, +} from '../../model/retro'; +import { retryRetroData } from '../../model/retroData'; +import { saveDeveloperTeams } from '../../model/settingsData'; +import { days, devDays, NOT_IN_A_PROJECT, OVERLOADED } from '../../model/words'; +import { PersonCell, StatsCard } from '../stats/parts'; +import { ChartSlot, SplitWeeksChart } from './lazyCharts'; +import { + NarrowChip, + openPlan, + readSort, + SortHeader, + switchView, + Tile, + deltaWords, + versus, + type Navigate, + type ProjectsNav, +} from './parts'; +import { useWhoIsOnWhat, type WhoRow } from './WhoIsOnWhat'; + +/** the non-developers' fold, which a tile opens */ +const NON_DEVS = 'people:non-developers'; + +/** "Overloaded", where the word starts a label */ +const OVERLOADED_LABEL = OVERLOADED.charAt(0).toUpperCase() + OVERLOADED.slice(1); + +/** Bring a part of the page into view, under the sticky headers. */ +const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' }); + +/** Where a jump lands a row in the list: under the app's header, the + * list's title (from sm up) and its 30px of column names, with a little + * room (`--stick` is set on the list's section; the card gives its folds + * the same margin). */ +const UNDER_HEADS = 'scroll-mt-[calc(var(--stick,0px)_+_38px)]'; + +/** + * The developer teams, and the way to change them: who counts as a developer, + * and on which team, for every developer count in the tab and the load line + * on the roadmap. Edited here, they replace the default teams for everyone; + * "Go back to the default teams" drops them. Either leaves a receipt with Undo, which + * saves the teams as they were, so neither asks twice. A login with no PR or + * stamp behind it is pointed out as it's typed, since a typo would quietly + * make someone a non-developer. Escape, like Cancel, puts the teams back as + * they were. + */ +function TeamsSection({ + teams, + from, + known, +}: { + teams: DeveloperTeams; + from: 'saved' | 'config'; + /** every login the range or the board has seen, in lowercase */ + known: ReadonlySet; +}) { + // [name, members as typed], one per team, while editing + const [draft, setDraft] = useState<[string, string][] | null>(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + // what the last save did, with the way back: the teams as they were, or + // config.js's (null) + const [receipt, setReceipt] = useState<{ + words: string; + failed?: boolean; + back?: DeveloperTeams | null; + }>(); + const editId = useId(); + const formRef = useRef(null); + // focus follows the editor: into its first field when it opens, and back + // to Edit teams when a save, Cancel or Escape closes it, never to the + // page's top + const editing = draft != null; + const wasEditing = useRef(editing); + useEffect(() => { + if (editing !== wasEditing.current) { + (editing + ? formRef.current?.querySelector('input') + : document.getElementById(editId) + )?.focus(); + } + wasEditing.current = editing; + }, [editing, editId]); + const names = Object.keys(teams); + const save = async (value: DeveloperTeams | null) => { + const back = from === 'saved' ? teams : null; + setSaving(true); + const result = await saveDeveloperTeams(value); + setSaving(false); + if ('error' in result) return setError(result.error); + setError(null); + setReceipt({ words: value ? 'Saved' : 'Back to the default teams', back }); + setDraft(null); + }; + const undo = async (back: DeveloperTeams | null) => { + const result = await saveDeveloperTeams(back); + setReceipt( + 'error' in result + ? { words: `Undo didn’t save. ${result.error}`, failed: true, back } + : { words: 'Put back as they were' } + ); + }; + const cancel = () => { + setDraft(null); + setError(null); + }; + const typed = (): DeveloperTeams => + Object.fromEntries( + (draft ?? []) + .filter(([name]) => name.trim()) + .map(([name, members]) => [name, members.split(/[\s,]+/).filter(Boolean)]) + ); + const setRow = (i: number, row: [string, string]) => + setDraft(d => (d ?? []).map((old, j) => (j === i ? row : old))); + return ( +
    + +

    + Who counts as a developer, and on which team. Everyone else is a non-developer. +

    +

    + The teams set every developer count in Projects and the developer line on the + roadmap. Edited here, they replace the default teams for everyone. +

    + + } + headerExtra={ + <> + {/* the receipt sits where focus comes back to, and is read + out; its Undo saves the teams as they were */} + {/* on the header's baseline, its words level with Undo's */} + + {receipt && !editing && ( + <> + {!receipt.failed && ( + + )} + {receipt.words} + + )} + + {receipt?.back !== undefined && !editing && ( + void undo(receipt.back ?? null)} + aria-label={`Undo: put back ${ + receipt.back ? 'the teams saved here' : 'the default teams' + }`} + > + Undo + + )} + {!editing && ( + { + setReceipt(undefined); + setDraft( + names.length ? names.map(t => [t, teams[t].join(', ')]) : [['', '']] + ); + }} + > + Edit teams + + )} + + } + /> + + {!draft ? ( + names.length ? ( +
      + {names.map(team => ( +
    • + {team} + + {' '} + · {n(teams[team].length, 'developer')} + + {teams[team].join(', ')} +
    • + ))} +
    + ) : ( +

    + No teams yet. Add them to split developers from everyone else. +

    + ) + ) : ( +
    { + e.preventDefault(); + // Object.fromEntries would merge same-named rows before + // the server's own check could see them + const twice = listedTwice(draft ?? []); + if (twice) return setError(`the team ${twice} is listed twice`); + void save(typed()); + }} + onKeyDown={e => { + if (e.key !== 'Escape') return; + e.preventDefault(); + cancel(); + }} + > + {draft.map(([name, members], i) => { + const unknown = members + .split(/[\s,]+/) + .filter(login => login && !known.has(login.toLowerCase())); + return ( +
    + setRow(i, [e.target.value, members])} + /> + {/* at least 60% wide, so on a phone it takes the line + under the name instead of squeezing beside it, and + as tall as its logins, so none hide below a line */} +