diff --git a/DESIGN.md b/DESIGN.md index 49c4d6e6..43471587 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -309,7 +309,7 @@ icon with a class bolted on. - **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 + roadmap's window and 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 @@ -328,6 +328,11 @@ icon with a class bolted on. - **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. + They land on a row's first target that can be pressed; a target that's + off (an answer waiting on its chooser) gives way to the row's first + control that isn't a field (its name: j typed in a select picks an + option), and a row with nowhere to land is stepped past, so j and k reach + every row from every row. - **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 @@ -387,6 +392,17 @@ icon with a class bolted on. 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". + - **A draft isn't work yet: never counted, always one click away in a + closed fold.** Draft PRs are common and mostly a prompt, so no number + on the tab or the API takes one (open counts, live vs quiet, idle days, + people, flags, health, Decide, the window, who is on what): a project + with only drafts is quiet. Each one waits in a closed "Drafts, not + counted" fold where someone would look: at the end of its project's + PRs, in its project's row, in its author's row on People, and under + the Overview's PRs outside projects for the rest. Under an issue it + stays where it is, saying "a draft, not counted", out of the issue's + numbers. Where a line says nothing is open, it names the drafts ("None + open", then "2 drafts, not counted"), so nobody reads it as nothing. - **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 @@ -538,10 +554,15 @@ icon with a class bolted on. - **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 +- **One range per view, 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 + applies on click; a hand-picked range applies with Apply. Look back's + range is the past days it measures (`range`, days after today blocked); + the Roadmap's is the window it draws (`window`, a preset like `ytd` or + whole months `2025-10..2026-09`, future days allowed, no 400-day cap). + Neither view shows the other's, and no key in the URL means the default, + so a link from before the picker opens as it did. 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 @@ -578,8 +599,8 @@ icon with a class bolted on. - **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 + waits on, by name ("Starts before Shopify sync is done"): a row number + reads differently in every grouping, so it would need a legend. 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 @@ -653,9 +674,40 @@ icon with a class bolted on. `?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. + the page. The window is picked with the date range button (This quarter, + Next 2 quarters, Next 6 months, Year to date, Last 12 months, This year, + or any months on the calendar); with none picked it's last quarter and the + next three (Quarters) or last month and the next six (Months). Each + preset is drawn in the unit its name says, whatever the month (This + quarter, Next 6 months, Year to date and Last 12 months in months; Next 2 + quarters and This year in quarters), so a bookmark keeps its layout. A + span picked on the calendar follows its length: months up to four months, + and up to a year that doesn't fall on whole quarters; whole quarters past + that, rounded out. A window is at most five years; a longer link is cut + to five and the URL says so. The Months and Quarters switch shows the + unit drawn: with no window picked it picks that default, and with one it + redraws the same window in that unit (out to whole quarters), so the lit + option does nothing and Now, next, later keeps the window for coming + back. Column names never collide and never drop a column's zoom button: + full names while they fit, then short ones ("Nov", "Q4" with the year + where it starts), then letters ("N"), then fewer names (a year in Q1's or + January's place). Only a zoomed month's cut-off weeks lose their name + under 8% of the width. Past seven month columns the Mondays' dates drop + out of the header, and past eight quarters the month names do; the lines + stay. ‹ and › step by the window's own length on every view; Today puts + back the default, and shows whenever today is outside the window or a + hand-picked span is on screen. A plan's edge chip moves the window to + start at its plan. A window wholly ahead asks for no history. A column + name zooms that quarter or month to the full width; the toolbar zooms + back out one step to a window holding it (your place is kept) and pages + to the period before or after, and the browser's Back does the same, + since the window and zoom are in the URL. Done plans that ended before the window rest in one closed + fold under the rows, "Done before Oct 2025 · 12", so a year of history + never buries the plan; ‹ brings each back into the rows as its weeks come + into view. The initiative lanes hold only work under way, so they have no + such fold. A done or dropped plan's details offer no Decide calls unless + Decide asks about it; a done plan Decide asks about is never folded, and a + week picked on the load chart narrows the fold by the chart's own rule. - **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, @@ -664,7 +716,9 @@ icon with a class bolted on. - 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 column or month name zooms in; Today comes back to the default + window, or while zoomed to this period + - a plan's chip at the timeline's edge moves the window to it - 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 @@ -758,9 +812,14 @@ icon with a class bolted on. 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". + ("Move the end date to the end of Oct", "Restart, finishing by the end of + Oct", "Promise to finish by the end of Oct", "Mark done", "Park", "Drop", + "Mark ongoing") so it answers the question alone; the expanded rows keep + the short "End of Oct". A promise says its end one way everywhere: the + buttons, the section's bulk button, the receipt and the accessible names + all read "by the end of Oct" (or "by its target date, Nov 1"). A button's + accessible name starts with the words it shows ("Mark done: Akeneo"), so + a voice user can say what they see. 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 @@ -770,7 +829,11 @@ icon with a class bolted on. 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 + words, with Undo the next Tab, so an extra Enter never undoes it. After + Undo, "Put X back as it was." holds the receipt's place, with nothing to + click in it, and the answers come back below, so a double-click on Undo + never answers again; the Roadmap's receipts and a project page's filing + do the same. 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 @@ -817,6 +880,138 @@ icon with a class bolted on. 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. +- **Initiatives come first, on the Roadmap and the Overview.** Both open + grouped by initiative (the one shared Group by), so the resting page is + the short ranked list a manager compares, not every plan. An initiative is + a name, a goal, one owner and its own rank; its dates are read off its + projects' plans, never kept by hand. Every project has one home: an + initiative, Keep the lights on, Not tracked, or none (Unsorted, shown only + when it holds a project, and asked about on Decide until it's empty). Not + tracked work is left out once, where the data comes in, so every count, + load, capacity line and question is tracked unless it names the full set + on purpose (a person's own list, one project, history); its rows close + the Roadmap and the Overview in a ledger that rests closed, with no rank + and nothing owed. Three amendments to the rules above, for initiative + lanes only (2026-10): + 1. **A lane holds what's under way.** An initiative lane holds its plans + planned or in progress, its live projects with no plan, and any plan + Decide asks about, whatever its status; its finished, dropped and + parked plans live on its page under "Done or dropped". Team lanes and + Group by None still draw every plan: Keep the lights on would + otherwise open to twice the rows. + 2. **Its grip sits in the gutter, left of the band.** The band keeps the + Fold contract (a click anywhere on it folds; rank, name, the rows it + holds, the chevron, and one ink phrase of dates and counts, "Jul 13 to + Nov 1 · 1 plan with an end date · 3 ongoing"), so the grip that ranks it is its + own control outside that target, with the arrow keys, the way a row's + grip works. Dropping a plan on another initiative files its project + there and leaves its priority alone; its keyboard twin is File + elsewhere, in the plan's details and on the project's page. A closed + initiative's lane takes no plan, as no chooser offers it. + 3. **Lanes rest closed unless they hold a Decide call.** Twenty closed + bands read as the table of contents a manager ranks on; twenty open + ones are the long page again. A link that opens a plan opens its lane + first, as a click on the band would. + + Inside a lane its rows are numbered by their place there (1, 2, 3), under + the band's own number; a row's priority on the whole roadmap is in its + grip's label and the number's hover, never a second number beside it. + "Priority" is the one word for order, for lanes and rows alike, on grips, + hovers and receipts; a receipt names the number its row shows ("Moved + Cache warmer to priority 1 in Site speed"). A band with no grip keeps + the grip's column, so every label starts at one x, and a row with no + number keeps the number's, so every name in a lane does too. An + initiative's band, on the Roadmap and the Overview alike, ends a long + name in an ellipsis, keeps its count, and on a phone puts its detail on + its own line. A closed initiative + says "Closed as done Sep 20" everywhere; an open one whose plans all + stopped says "Nothing under way since Sep 20". + "File" is the one word for putting a project in an initiative: the + opener ("File elsewhere"), the chooser, the button, the receipt ("Filed + in Release safety", "Filed Image CDN in FixBot chat") and its hover. + Filing into Not tracked is the one exception, with one receipt on every + view (Decide, a plan's details, a project's page): "Marked Not tracked, + so it counts nowhere. Decide asks again if it grows." Not tracked is + the home's name, capital N, in every answer and receipt ("Keep Not + tracked", "Still Not tracked?"). Under an Unsorted band a row's Plan + cell asks its question ("Which initiative is it in?"), not the band's + name again. + There is one chooser, one layout, on every view: "File in [select] + File Cancel". An Unsorted row's File is its answer; Track it, File + elsewhere, a plan's details and a project's page open it with Cancel; + Needs a plan shows it alone, since its plan answer carries the pick. + A plan with no project asks the same way: "Its project [select] Link". + An opener (Track it, File elsewhere) stays where it is on every view, + says it's open, and opens the chooser on the line below; a second click + on it closes it. With nothing offered chosen (a home that closed since), + the chooser says "Choose where it goes". Filing is offered once: while + a plan's Plan line asks where its project goes, its Initiative line + only says the home. + The chooser shows only while the project is filed nowhere; a filed + project's plan answer stays a plan answer. Filing from a plan's details + is a drop's keyboard twin: its receipt and Undo sit under the row in + its new lane, with the focus on the receipt's words. A question or a + clash in a row's words does the natural thing on a click: the question + opens the plan's details, the clash the plan it waits on, each with the + focus on the details' Plan line, so a second Enter answers nothing; the + answers are a Tab away. A question about a project with a plan under + way (Still Not tracked?) is said on that plan's row, and its lane opens. +- **Escape closes the innermost thing and gives the focus back to what + opened it**: a chooser (not the details around it), the answers Other + answers opened, an edit form. An answer strip's one Tab stop is its + first answer that can be pressed, so an answer waiting on an empty + chooser never hides Other answers from the keyboard. Opened, Other + answers puts the focus on its first row's label, not an answer, so a + second Enter (the keyboard's double-click) answers nothing. +- **After a click, a still pointer can't press what came under it** + (`components/settle.ts`). A click often changes what sits under the + pointer: a receipt replaces the answers, a row joins a section above, + Other answers opens its ends. In the parts of a view that move when the + initiatives feature answers a click (Decide, the Roadmap's lanes and + details, the project and initiative pages, the Overview; not the load + chart), a pointer click or a drop notes what's within 24 px of its + point and where it sits. For 1.6 s, until the pointer moves away or a + person scrolls (a wheel, the scrollbar, a touch pan, any key but a + modifier alone), a press there reaches nothing (and the control dims + with a small nudge; under reduced motion it dims and takes a dashed + outline in its own colour) only if it lands on a control that arrived + or moved since, or on the control just pressed within a double-click or + saying new words. Where a control sits is its centre and its size + before transforms, so a press's own scale is no move. The control just + pressed is judged first: a toggle or a grip takes every press, whatever + its press or its new label did to its box. A control that stayed put + takes every press: a toggle closes again, the next week, a neighbour, a + grip's drag. A press the browser counts as a double or triple click on + the control just pressed is one click, an opener's too (it stays open); + so a double or triple click is one click, and a press "to be sure" a + second later lands on nothing new. A pointer moved onto a control that + arrived means it once it rests there or a double-click's span has + passed, so a sloppy double-click's drift doesn't. A touch has no + pointer move between taps, so a tap on what came under a still finger + waits out the 1.6 s, by design: a finger can't wobble off and back. What the click + left (a receipt, Undo's words) gets the focus and is shown whole below + the sticky headers, wherever it went. Undo's held words ("Put X back as + it was.") stand where the receipt was until the pointer moves away, a + Tab, or the next action. A failed Undo says the server's reason and + keeps Try again. A run of lane moves, on one lane or several, keeps one + receipt whose Undo puts the whole order back. Every Undo is named by + what it takes back: "Undo: " and its receipt's words, without their + full stop; a failed one is "Try the Undo again: " and the same words. +- **One drag, one change.** A bar moves a plan's dates; its grip moves it + to another lane (files it, by initiative). A bar let go over another + lane moves the dates its preview showed and stays in its own lane, and + the receipt says the grip is what files it, since filing there too + would change its dates and its project's home at once (a drag that + drifts onto the next lane keeps its date change). +- **A question on the Roadmap is a row's amber words.** An initiative's + own question (Did it reach its goal?) sits on a row like a plan's, its + answers outlined at the row's end, not filled under a paragraph; the + board's guess still says Suggested, as it does on Decide. On a + phone a chooser's label goes above its select, as an answer row's does. + "Unsorted" is the one word for a project filed nowhere, on every band, + chooser, hover and split (the sentence form is "filed nowhere"). A plan + with no project can't be filed, so it sits in its own "No project yet" + lane, and Decide asks which project it is once it's active. - **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 diff --git a/app.js b/app.js index cace28fb..79f66035 100644 --- a/app.js +++ b/app.js @@ -17,7 +17,8 @@ import statsController from './controllers/stats.js'; import userNamesController from './controllers/user-names.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 initiativesController from './controllers/initiatives.js'; +import { API_ROUTES, PROJECT_WRITES, 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'; @@ -107,8 +108,6 @@ 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 () { @@ -130,6 +129,14 @@ 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); +// initiatives, the layer above projects: read like the roadmap, written +// behind canWrite, every route under /initiatives (PROJECT_WRITES) +app.get('/initiatives', initiativesController.list); +app.post('/initiatives', canWrite, initiativesController.create); +app.put('/initiatives/file', canWrite, initiativesController.file); +app.patch('/initiatives/:id', canWrite, initiativesController.update); +app.delete('/initiatives/:id', canWrite, initiativesController.remove); +app.post('/initiatives/:id/move', canWrite, initiativesController.move); app.get('/settings', settingsController.get); app.patch('/settings', canWrite, settingsController.update); app.post('/hooks/main', hooksController.main); diff --git a/bin/dump-schema b/bin/dump-schema index 4a7bf477..325aed4b 100755 --- a/bin/dump-schema +++ b/bin/dump-schema @@ -20,6 +20,8 @@ mysqldump "$@" \ project_issues \ issue_pull_links \ project_settings \ + initiatives \ + initiative_projects \ > $SCHEMA_FILE sed -i -e "s/CREATE TABLE/CREATE TABLE IF NOT EXISTS/" $SCHEMA_FILE diff --git a/config.example.js b/config.example.js index c916e1ca..d0e62066 100644 --- a/config.example.js +++ b/config.example.js @@ -140,6 +140,11 @@ module.exports = { developerTeams: { Store: ['login1', 'login2'], }, + // Repos whose projects are Not tracked until someone files them in an + // initiative: a project whose every labeled PR is in one of these + // leaves Decide, the counts and capacity. One PR in another repo ends + // it. Omit or [] for none. + // untrackedRepos: ['owner/ops'], // or just 'ops' }, // The usual MySQL stuff. Like every other MySQL webapp, basically. diff --git a/controllers/api-routes.js b/controllers/api-routes.js index 90e8b774..ab6cfd93 100644 --- a/controllers/api-routes.js +++ b/controllers/api-routes.js @@ -1,4 +1,5 @@ import apiController from './api.js'; +import initiativesController from './initiatives.js'; import projectsController from './projects.js'; import roadmapController, { canWrite } from './roadmap.js'; import settingsController from './settings.js'; @@ -13,11 +14,29 @@ import { ROADMAP_ORIGINS, ROADMAP_STATUSES, DECIDE_MIN_PRS, + GROWTH_FLOOR, + INITIATIVE_GOAL_MAX, + INITIATIVE_KINDS, + INITIATIVE_NAME_MAX, + INITIATIVE_STATUSES, STALL_DAYS, UPDATE_DUE_DAYS, WAITS_ON_MAX, } from '../shared/dist/index.js'; +/** Every write the Projects tab can see, board and API (app.js): each one + * that succeeds has open boards fetch again. */ +export const PROJECT_WRITES = [ + '/project-issues', + '/roadmap', + '/initiatives', + '/settings', + '/api/v1/project-issues', + '/api/v1/roadmap', + '/api/v1/initiatives', + '/api/v1/settings', +]; + /** * 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 @@ -47,7 +66,9 @@ export const API_ROUTES = [ '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', + '`double_labeled` holds the open PRs in two. Each project says its `initiative` (slug, or null for ' + + 'Unsorted), whether it is `tracked`, and whether it `grew` while not tracked. A project Not tracked because ' + + 'its PRs are all in projects.untrackedRepos has `initiative: null` with `tracked: false`', }, { method: 'get', @@ -122,15 +143,16 @@ export const API_ROUTES = [ `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`, + `age passed ${PILE_AGE_DAYS} days. Not-tracked projects owe none`, }, { 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)', + 'The decisions owed now, worst first, each with its project, roadmap item, `initiative` (its home, or ' + + 'the one asked about), and reasons (see `decide`); days are the team’s (config.js projects.timeZone, ' + + 'default America/Los_Angeles). Not-tracked projects are left out, but for the ones that grew', }, { method: 'get', @@ -139,7 +161,7 @@ export const API_ROUTES = [ 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)', + 'weeks back to 26 ahead). Not-tracked projects are left out', }, { method: 'get', @@ -152,7 +174,8 @@ export const API_ROUTES = [ '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)', + 'or null (none yet, none owed). `untracked`: its project is Not tracked, so it owes nothing and ' + + 'counting views leave it out', }, { method: 'post', @@ -216,6 +239,63 @@ export const API_ROUTES = [ '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/initiatives', + handlers: [initiativesController.list], + does: + 'Every initiative in rank order (top first): slug, name, goal, owner, kind (`initiative`, `upkeep` for ' + + 'Keep the lights on, `untracked` for Not tracked), status and `status_at`, the projects filed in it ' + + '(`projects`: each `{project, set_by, set_at, baseline_prs}`) and `span`, read off their plans: the earliest start, ' + + 'the latest end under way, how many plans under way have an end or none, and `end_kind` (soft, ongoing, ' + + 'or ended on `ended`). A project with no filing is Unsorted', + }, + { + method: 'post', + path: '/api/v1/initiatives', + handlers: [canWrite, initiativesController.create], + does: + 'Add an initiative, ranked last: {slug, name, goal?, owner?, kind?}; 409 when the slug is taken, or ' + + 'when one of kind upkeep or untracked already exists (there is one of each)', + }, + { + method: 'put', + path: '/api/v1/initiatives/file', + handlers: [canWrite, initiativesController.file], + does: + 'File a project: {project, initiative: or null for none, expect?, baseline_prs?, set_at?, undo?}. ' + + '`expect` is the home you saw (a slug or null); 409 with the current `home` when it changed. 409 when ' + + 'the initiative is closed, unless it is an undo (`undo: true`, or `set_at` sent). null is a 409 for a ' + + 'project Not tracked because its PRs are all in projects.untrackedRepos, unless it is an undo. ' + + 'Answers `project` as its label spells it, `before` and `home` (initiative, set_by, set_at, ' + + 'baseline_prs); undo by sending `before` back with its set_at, baseline_prs and `undo: true`. ' + + 'Into Not tracked with no baseline_prs, the count is its old baseline plus the PRs since (when it was ' + + 'already not tracked), else its PR count now', + }, + { + method: 'patch', + path: '/api/v1/initiatives/:id', + handlers: [canWrite, initiativesController.update], + does: + 'Change its name, goal, owner or status (open, done, dropped). Only kind `initiative` closes (409 for ' + + 'upkeep and untracked). Closing is a 409 naming the projects in it ' + + 'still going (`live`: a plan under way or open PRs). {status: "open", restate: true} is Keep open; ' + + '{undo: {updated_at, status_at}} puts back the times a change replaced', + }, + { + method: 'delete', + path: '/api/v1/initiatives/:id', + handlers: [canWrite, initiativesController.remove], + does: 'Delete an initiative that holds no projects (409 while it holds any), freeing its slug', + }, + { + method: 'post', + path: '/api/v1/initiatives/:id/move', + handlers: [canWrite, initiativesController.move], + does: + 'Move one initiative just above another: {before: }, or {before: null} for the bottom. Only ' + + 'initiatives’ ranks change, never a plan’s priority', + }, { method: 'get', path: '/api/v1/settings', @@ -267,6 +347,14 @@ export function apiIndex(req, res) { notes: 'up to 2000 characters', waits_on: `ids of other items this one waits on, at most ${WAITS_ON_MAX}, never a loop`, }, + initiative: { + slug: 'required on create, a label-style slug like site-speed; kept for good', + name: `required on create, up to ${INITIATIVE_NAME_MAX} characters`, + goal: `one line, up to ${INITIATIVE_GOAL_MAX} characters`, + owner: 'a GitHub login, or null: orders its projects and takes the calls for ones with no lead', + kind: { values: INITIATIVE_KINDS, means: 'set on create: upkeep is Keep the lights on, untracked is Not tracked' }, + status: INITIATIVE_STATUSES, + }, update: { health: ROADMAP_HEALTHS, body: 'up to 2000 characters, may be empty', @@ -288,13 +376,24 @@ export function apiIndex(req, res) { 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', + unfiled: `in no initiative, with ${DECIDE_MIN_PRS} or more PRs or a plan under way (asked once any initiative exists; a project asked \`new\` is filed from that call)`, + no_project: + 'an active plan with no project, which can’t be filed in an initiative until it has one (asked once any initiative exists); answer by PATCHing its `project`', + untracked_grew: `not tracked, and opened \`since\` PRs since it was set so: at least its count then (\`baseline\`) and ${GROWTH_FLOOR}`, + goal_reached: + 'no project of the open `initiative` has a plan under way or PRs open, every plan in it stopped 14 days ago or more, and no project with no plan merged lately; Keep open quiets it until a plan or filing in it changes', + reopened_in_closed: + 'its `initiative` is closed (`as` done or dropped), but it has open PRs or a plan under way again: the same rule that refuses a close. On the project’s own row when it has one', }, 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.`, + `a decision, and quiets a stall for ${STALL_DAYS} days. Initiative calls are writes to ` + + 'PUT /api/v1/initiatives/file (unfiled; untracked_grew: file it in the Not tracked initiative again ' + + 'to keep it, or elsewhere) or PATCH /api/v1/initiatives/:id (goal_reached: status done or dropped, ' + + 'or {status: "open", restate: true} to keep it open; reopened_in_closed: status open).', }, settings: { developer_teams: diff --git a/controllers/initiatives.js b/controllers/initiatives.js new file mode 100644 index 00000000..7f5fd544 --- /dev/null +++ b/controllers/initiatives.js @@ -0,0 +1,262 @@ +import pullManager from '../lib/pull-manager.js'; +import { respondOrError } from '../lib/controller-utils.js'; +import { loadProjects, projectSettings, todayFromBoard } from '../lib/projects.js'; +import { listItems } from '../lib/roadmap.js'; +import { loadPullLinks } from '../lib/work.js'; +import { + createInitiative, + deleteInitiative, + fileProject, + getInitiative, + listInitiatives, + moveInitiative, + serially, + updateInitiative, +} from '../lib/initiatives.js'; +import { undoTimes } from './roadmap.js'; +import { + checkInitiativeFields, + goingProjects, + initiativeSpan, + DEFAULT_PROJECT_PREFIX, + PROJECT_SLUG, +} from '../shared/dist/index.js'; + +/** 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; +} + +/** Initiatives as they're sent: each with its span, read off the plans of + * the projects in it (shared/model/initiatives.ts initiativeSpan). */ +async function sent(initiatives, items) { + const plans = items ?? (await listItems()); + return initiatives.map(i => ({ + ...i, + span: initiativeSpan( + i.projects.map(f => f.project), + plans + ), + })); +} + +/** + * The projects in an initiative still going (goingProjects: a plan under + * way, or PRs open on the board), by the same rule Decide asks by. An + * initiative closes only once none is left. + */ +async function liveProjects(initiative) { + const settings = projectSettings(); + const open = new Map(); + if (settings) { + const [projects, linked] = await Promise.all([loadProjects(settings), loadPullLinks(settings)]); + const today = todayFromBoard(pullManager.getPulls(), projects, settings.prefix, undefined, linked); + for (const g of today.live) open.set(g.slug, g.open.length); + } + return goingProjects( + initiative.projects.map(f => f.project), + await listItems(), + slug => open.get(slug) ?? 0 + ); +} + +/** A failed write's 500, logged. */ +function failed(res, what) { + return err => { + console.error(`initiative ${what} failed:`, err); + res.status(500).json({ error: `initiative ${what} failed` }); + }; +} + +export default { + /** + * GET /initiatives (session) and GET /api/v1/initiatives (Bearer) -- + * every initiative in rank order, with the projects filed in it and its + * span read off their plans. + */ + list: function (req, res) { + respondOrError( + res, + listInitiatives() + .then(all => sent(all)) + .then(initiatives => ({ initiatives })), + 'initiatives query failed' + ); + }, + + /** POST /initiatives {slug, name, goal?, owner?, kind?} -- a new one, + * ranked last. 201 with it; 409 when the slug is taken, or when a home + * of a special kind (Not tracked, upkeep) already exists: there's one of + * each. */ + create: function (req, res) { + const checked = checkInitiativeFields(req.body, { partial: false }); + if (checked.error) return res.status(400).json({ error: checked.error }); + const kind = checked.fields.kind ?? 'initiative'; + // one at a time, so two creates can't both pass the one-of-each check + serially(async () => { + const all = kind === 'initiative' ? [] : await listInitiatives(); + if (all.some(i => i.kind === kind)) return { special: true }; + return createInitiative(checked.fields, req.roadmapLogin); + }) + .then(async made => { + if (made.special) { + return res + .status(409) + .json({ error: `there is already a home of kind ${kind}: there's one of each` }); + } + if (made.taken) { + return res.status(409).json({ error: 'another initiative has that slug' }); + } + res.status(201).json({ initiative: (await sent([made]))[0] }); + }) + .catch(failed(res, 'create')); + }, + + /** + * PATCH /initiatives/:id {name?, goal?, owner?, status?} -- changes just + * those. Only an initiative of kind `initiative` closes (409 otherwise). Closing (done or dropped) is refused with 409 while a project + * in it has a plan under way or PRs open, naming them (`live`), so the + * person moves or finishes each first. `restate: true` with the status + * it has stamps the status anew (Keep open); `undo: {updated_at, + * status_at}` puts back the times a change replaced. + */ + update: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + const { restate, undo, ...body } = req.body ?? {}; + // only a well-formed undo, with the times it puts back, counts as one + const times = undoTimes(undo); + const checked = checkInitiativeFields(body, { partial: true }); + if (checked.error) return res.status(400).json({ error: checked.error }); + serially(async () => { + const was = await getInitiative(id); + if (!was) return res.status(404).json({ error: 'no such initiative' }); + const closing = checked.fields.status && checked.fields.status !== 'open'; + // Not tracked and upkeep are homes that never end: closed, they'd + // refuse every filing Decide makes into them + if (closing && was.kind !== 'initiative') { + return res.status(409).json({ error: `a home of kind ${was.kind} is never closed` }); + } + // an undo puts back the status a Reopen replaced: the project that + // made Decide ask is still going in it, so the check would refuse it + if (closing && was.status === 'open' && !times) { + const live = await liveProjects(was); + if (live.length) { + return res + .status(409) + .json({ error: 'move or finish the projects still going in it first', live }); + } + } + const changed = await updateInitiative(id, checked.fields, req.roadmapLogin, { + restate: restate === true, + undo: times, + }); + if (!changed) return res.status(404).json({ error: 'no such initiative' }); + res.json({ initiative: (await sent([changed]))[0] }); + }).catch(failed(res, 'update')); + }, + + /** DELETE /initiatives/:id -- only one with no projects in it (the undo + * of a create); 409 while it holds any. */ + remove: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + serially(() => deleteInitiative(id)) + .then(result => { + if (result.missing) return res.status(404).json({ error: 'no such initiative' }); + if (result.notEmpty) { + return res + .status(409) + .json({ error: 'it still holds projects: file them elsewhere first' }); + } + res.json({ ok: true }); + }) + .catch(failed(res, 'delete')); + }, + + /** POST /initiatives/:id/move {before} -- just above initiative + * `before`, or the bottom for null. Only initiatives' ranks change. */ + 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' }); + } + moveInitiative(id, before) + .then(async result => { + if (result.missing) return res.status(404).json({ error: 'no such initiative' }); + res.json({ initiatives: await sent(result.initiatives) }); + }) + .catch(failed(res, 'move')); + }, + + /** + * PUT /initiatives/file {project, initiative, expect?, baseline_prs?, + * set_at?} -- file a project in the initiative with that slug, or in none + * (null: Unsorted). `expect` is the home the person saw (a slug or null): + * 409 with the one it has now (`home`) when that's stale. Answers + * `before` and `home` (initiative, set_by, set_at, baseline_prs), so an + * undo sends `before` back with `set_at` and `baseline_prs`. A closed + * initiative is a 409, except for an undo (`undo: true` or `set_at`) + * putting a filing back. Unsorted is a 409 for a project Not tracked by + * its repos, except for an undo. + */ + file: function (req, res) { + const body = req.body ?? {}; + const { project, initiative, expect } = body; + if (typeof project !== 'string' || !PROJECT_SLUG.test(project)) { + return res.status(400).json({ error: 'send project, a project label’s slug' }); + } + const slug = v => v === null || (typeof v === 'string' && PROJECT_SLUG.test(v)); + if (!slug(initiative) || (expect !== undefined && !slug(expect))) { + return res + .status(400) + .json({ error: 'send initiative (and expect) as an initiative’s slug, or null for none' }); + } + // the column is an int unsigned; no project has near a million PRs + const count = v => v == null || (Number.isInteger(v) && v >= 0 && v <= 1e6); + const now = Math.floor(Date.now() / 1000); + const when = v => v == null || (Number.isInteger(v) && v > 0 && v <= now); + if (!count(body.baseline_prs) || !when(body.set_at)) { + return res.status(400).json({ + error: 'baseline_prs is a count of PRs, and set_at a time in the past in epoch seconds', + }); + } + const settings = projectSettings(); + serially(() => + fileProject(project, initiative, req.roadmapLogin, { + prefix: settings?.prefix ?? DEFAULT_PROJECT_PREFIX, + expect, + baseline_prs: body.baseline_prs ?? null, + set_at: body.set_at ?? null, + undo: body.undo === true, + untrackedRepos: settings?.untrackedRepos ?? [], + now, + }) + ) + .then(result => { + if (result.missing) return res.status(404).json({ error: 'no initiative has that slug' }); + if (result.closed) { + return res + .status(409) + .json({ error: 'that initiative is closed; reopen it before filing a project in it' }); + } + if (result.untracked) { + return res.status(409).json({ + error: 'its PRs are all in repos that are Not tracked, so it stays Not tracked until it’s filed in an initiative', + }); + } + if (result.conflict) { + return res + .status(409) + .json({ error: 'its home changed; here it is now', home: result.home }); + } + res.json({ project: result.project, before: result.before, home: result.home }); + }) + .catch(failed(res, 'filing')); + }, +}; diff --git a/controllers/projects.js b/controllers/projects.js index a8c89e2a..de18008a 100644 --- a/controllers/projects.js +++ b/controllers/projects.js @@ -10,6 +10,8 @@ import { todayFromBoard, } from '../lib/projects.js'; import { listItems } from '../lib/roadmap.js'; +import { trackedOnly } from '../lib/initiatives.js'; +import { trackedNow } from './roadmap.js'; import { attachIssue, detachIssue, @@ -83,13 +85,14 @@ function load(req, res) { 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]) => + return Promise.all([loadProjects(settings), loadPullLinks(settings), trackedNow()]).then( + ([projects, linked, tracked]) => loadWindow(settings, window.start, window.end, only, linked).then( ({ spans, reviews }) => ({ settings, projects, linked, + tracked, stats: windowStats(spans, window.start, window.end, { teamOf: settings.teamOf, reviews, @@ -103,9 +106,11 @@ function load(req, res) { * 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. + * history) by slug. Each says its initiative (its home's slug, or null), + * whether it's tracked, and whether it grew while not tracked + * (lib/initiatives.js tracking). */ -function projectRecords(projects, today, stats, prefix) { +function projectRecords(projects, today, stats, prefix, { homes, untracked, grown }) { const bySlug = new Map(projects.map(p => [p.slug, p])); const groups = new Map(); const standing = new Map(); @@ -141,6 +146,9 @@ function projectRecords(projects, today, stats, prefix) { : null, ongoing: p ? p.ongoing : false, parents: p ? p.parents : [], + initiative: homes.get(slug)?.initiative.slug ?? null, + tracked: !untracked.has(slug), + grew: grown.has(slug), lead: p ? p.lead : null, target: p ? p.target : null, fields: p ? p.fields : null, @@ -148,6 +156,8 @@ function projectRecords(projects, today, stats, prefix) { // 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)) : [], + // its open drafts: listed, counted nowhere (shared isDraft) + drafts: g ? g.drafts.map(d => key(d.data)) : [], merged_recently: g ? g.merged.map(key) : [], people: g ? g.people : [], idle_days: g ? g.idleDays : null, @@ -182,13 +192,16 @@ export default { * `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. + * `untracked` lists the Not tracked slugs, `grew` the ones of those Decide + * asks about again, with `grew_counts` (PRs `since` it was set not + * tracked, against `baseline`) by slug. */ getBoardData: function (req, res) { const loaded = load(req, res); if (!loaded) return; respondOrError( res, - loaded.then(({ settings, projects, linked, stats }) => { + loaded.then(({ settings, projects, linked, tracked, 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 { @@ -198,6 +211,9 @@ export default { teams_from: settings.teamsFrom, decide_rotation: settings.decideRotation, ongoing: settings.ongoing, + untracked: [...tracked.untracked].sort(), + grew: [...tracked.grown.keys()].sort(), + grew_counts: Object.fromEntries(tracked.grown), projects, pull_links: Object.fromEntries( Object.entries(linked).filter(([pull]) => onBoard.has(pull)) @@ -223,7 +239,7 @@ export default { if (!loaded) return; respondOrError( res, - loaded.then(({ settings, projects, linked, stats }) => { + loaded.then(({ settings, projects, linked, tracked, stats }) => { const now = Date.now() / 1000; const pulls = pullManager.getPulls(); const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); @@ -232,13 +248,15 @@ export default { start: stats.start, end: stats.end, label_prefix: settings.prefix, - projects: projectRecords(projects, today, stats, settings.prefix), + projects: projectRecords(projects, today, stats, settings.prefix, tracked), 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)), + // open drafts no listed project holds: counted nowhere + drafts: today.drafts.map(d => key(d.data)), totals: stats.totals, days: stats.days, }; @@ -479,8 +497,9 @@ export default { /** * 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. + * project, its roadmap item if it has one, its initiative, and why it + * needs a call. A roadmap write clears it: see `decide` in GET /api/v1. + * Not-tracked projects are left out, but for the ones that grew. */ getDecide: function (req, res) { const settings = projectSettings(); @@ -488,21 +507,33 @@ export default { 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]) + const load = Promise.all([ + loadProjects(settings), + listItems(), + loadPullLinks(settings), + trackedNow(), + ]).then(([projects, plans, linked, tracked]) => + loadWork(settings, { plans, projects }).then(work => [projects, plans, work, linked, tracked]) ); respondOrError( res, - load.then(([projects, items, work, linked]) => { + load.then(([projects, allItems, work, linked, tracked]) => { const now = Date.now() / 1000; const pulls = pullManager.getPulls(); - const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); + const fullToday = todayFromBoard(pulls, projects, settings.prefix, now, linked); + const { today, items } = trackedOnly(tracked.untracked, { + today: fullToday, + items: allItems, + }); const bySlug = new Map(projects.map(p => [p.slug, p])); - const open = new Map(today.live.map(g => [g.slug, g.open.length])); + // from the full set: a grown project's row says its open PRs too + const open = new Map(fullToday.live.map(g => [g.slug, g.open.length])); + const byId = new Map(tracked.initiatives.map(i => [i.id, i])); const rows = decideQueue({ live: decideProjects(today), items, + initiatives: tracked.initiatives, + grown: [...tracked.grown].map(([slug, g]) => ({ slug, ...g })), closed: closedIssues(projects), planCounts: new Map( work.plans.map(p => [ @@ -529,10 +560,20 @@ export default { runs_next_week: decideTurn(settings.decideRotation, addWeeks(day, 1)), decisions: rows.map(({ slug, item, reasons }) => { const p = slug ? bySlug.get(slug) : undefined; + // the initiative a goal_reached row asks about, or the project's home + const asked = reasons.find(r => r.kind === 'goal_reached'); + const home = asked + ? byId.get(asked.initiative) + : slug + ? tracked.homes.get(slug)?.initiative + : undefined; return { project: slug, - name: item?.name ?? p?.name ?? slug, - lead: item?.lead ?? p?.lead ?? null, + name: item?.name ?? p?.name ?? slug ?? home?.name ?? null, + lead: item?.lead ?? p?.lead ?? (asked ? home?.owner ?? null : null), + initiative: home + ? { id: home.id, slug: home.slug, name: home.name, owner: home.owner } + : null, open: slug ? open.get(slug) ?? 0 : 0, item: item && { id: item.id, @@ -593,10 +634,15 @@ export default { : Promise.resolve({}); respondOrError( res, - Promise.all([loadProjects(settings), listItems(), history, links]).then( - ([projects, items, past, linked]) => { + Promise.all([loadProjects(settings), listItems(), history, links, trackedNow()]).then( + ([projects, allItems, allPast, linked, tracked]) => { const pulls = pullManager.getPulls(); - const today = todayFromBoard(pulls, projects, settings.prefix, now, linked); + // not-tracked projects leave the load, past and ahead + const { today, items, bySlug: past } = trackedOnly(tracked.untracked, { + today: todayFromBoard(pulls, projects, settings.prefix, now, linked), + items: allItems, + bySlug: allPast, + }); const spans = spansFrom(past, liveStarts(today), window.start); // the projects with no plan that owe a decision count ahead const closed = closedIssues(projects); diff --git a/controllers/roadmap.js b/controllers/roadmap.js index de427ccd..5cf869b1 100644 --- a/controllers/roadmap.js +++ b/controllers/roadmap.js @@ -15,6 +15,7 @@ import { restoreItem, updateItem, } from '../lib/roadmap.js'; +import { serially, tracking, trackedOnly } from '../lib/initiatives.js'; import { sharedValue } from '../lib/ttl-cache.js'; import { loadPullLinks, loadWork } from '../lib/work.js'; import { @@ -62,6 +63,22 @@ const latelyNow = sharedValue(LATELY_MS, async () => { return latelyBySlug(today, work?.pace ?? {}, ongoing, now); }); +/** Every initiative, each project's home, and which projects are Not + * tracked and which of those grew (lib/initiatives.js tracking): one + * computation every controller shares, kept as long as what they did lately + * and forgotten with it on every write. */ +const trackingNow = sharedValue(LATELY_MS, async () => { + const settings = projectSettings(); + return settings + ? tracking(settings) + : { initiatives: [], homes: new Map(), untracked: new Set(), grown: new Map() }; +}); + +/** What trackingNow keeps. */ +export function trackedNow() { + return trackingNow.get(); +} + /** 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() { @@ -73,20 +90,37 @@ async function lately() { } } +/** The Not tracked slugs (trackedNow); none when the read fails, so the + * roadmap still loads, every plan read as tracked. The counting endpoints + * read trackedNow directly and fail loud instead. */ +async function untrackedNow() { + try { + return (await trackedNow()).untracked; + } catch (err) { + console.error('roadmap: reading the initiatives failed:', err); + return new Set(); + } +} + /** * 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. + * team's day. `untracked` says its project is Not tracked, set before + * the standing is read, so a not-tracked plan owes nothing. */ async function sent(items) { - const bySlug = await lately(); + const [bySlug, untracked] = await Promise.all([lately(), untrackedNow()]); const now = Date.now() / 1000; const today = teamDay(now); return items.map(item => { - const read = { ...item, lately: (item.project && bySlug.get(item.project)) || null }; + const read = { + ...item, + untracked: Boolean(item.project && untracked.has(item.project)), + lately: (item.project && bySlug.get(item.project)) || null, + }; return { ...read, in_progress: inProgress(read), @@ -132,6 +166,7 @@ const standingNow = sharedValue(STANDING_MS, async () => { * issues and their links, changed it (app.js). */ export function forgetProjectCaches() { latelyNow.forget(); + trackingNow.forget(); standingNow.forget(); } @@ -178,7 +213,7 @@ function idOf(req) { * 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)) { +export 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; @@ -186,6 +221,13 @@ function undoTimes(undo, now = Math.floor(Date.now() / 1000)) { return { updated_at: undo.updated_at, status_at: undo.status_at ?? null }; } +/** Whether a plan edit can change if its project is going in an initiative: + * its status, or which project it tracks. Those run one at a time with + * initiative closes, so a close can't pass its check while one lands. */ +export function decidesGoing(fields) { + return 'status' in fields || 'project' in fields; +} + export default { /** * GET /roadmap (session) and GET /api/v1/roadmap (Bearer) -- every @@ -228,20 +270,24 @@ export default { 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, + Promise.all([listItems().then(sent), trackedNow()]).then(([all, { untracked }]) => { + // not-tracked plans owe nothing + const { items } = trackedOnly(untracked, { items: all }); + return { + 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' ); }, @@ -259,7 +305,8 @@ export default { waitsOnError(null, checked.fields) .then(async error => { if (error) return res.status(400).json({ error }); - const item = await createItem(checked.fields, req.roadmapLogin); + // a new plan can be under way: one at a time with initiative closes + const item = await serially(() => createItem(checked.fields, req.roadmapLogin)); res.status(201).json({ item: await sentOne(item) }); }) .catch(err => { @@ -321,7 +368,7 @@ export default { 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) + return serially(() => restoreItem(id)) .then(sentOne) .then(item => item @@ -338,10 +385,14 @@ export default { 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), - }); + const save = () => + updateItem(id, checked.fields, req.roadmapLogin, { + restate: req.body?.restate === true, + undo: undoTimes(req.body?.undo), + }); + // a status or a project decides whether a plan is going in an + // initiative: one at a time with initiative closes + const item = await (decidesGoing(checked.fields) ? serially(save) : save()); if (item) res.json({ item: await sentOne(item) }); else res.status(404).json({ error: 'no such roadmap item' }); }) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 4869b95f..970b1b3e 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -12,7 +12,19 @@ import { import { ago, closedEpoch, n, pullKey, shortRepo } from '../../shared/format'; import type { ActionStateKey } from './model/actions'; import { actionState, askedOf } from './model/actions'; -import { DEFAULT_RANGE, DEFAULT_SORT, LENS_LABELS, ORIGIN_KEYS, ZOOM_KEY, type Lens } from './lens'; +import { + DEFAULT_GROUP, + DEFAULT_RANGE, + DEFAULT_SORT, + LENS_LABELS, + groupParam, + ORIGIN_KEYS, + viewChanged, + weekParam, + windowParam, + ZOOM_KEY, + type Lens, +} from './lens'; import type { DerivedPull } from '../../shared/model/status'; import { matchesWeightFilter } from '../../shared/model/status'; import { buildParentLookup } from './model/stack'; @@ -147,9 +159,11 @@ function readHash(): HashState { (['decide', 'roadmap', 'people', 'retro'] as const).find(v => v === p.get('view')) ?? 'overview', project: p.get('project') || null, + initiative: p.get('initiative') || null, + within: p.get('within') || null, range: p.get('range') || DEFAULT_RANGE, status: p.get('status') || 'live', - group: p.get('group') || 'none', + group: groupParam(p.get('group')), sort: p.get('sort') || DEFAULT_SORT, find: p.get('find') ?? '', scale: p.get('scale') === 'month' ? 'month' : p.get('scale') === 'now' ? 'now' : 'quarter', @@ -161,11 +175,15 @@ function readHash(): HashState { : p.get('unplanned') === 'only' ? 'unplanned' : 'all', - week: /^\d{4}-\d{2}-\d{2}$/.test(p.get('week') ?? '') ? p.get('week') : null, + week: weekParam(p.get('week')), zoom: ZOOM_KEY.test(p.get('zoom') ?? '') ? p.get('zoom') : null, + // a span past five years is cut to it, and the hash says so + window: windowParam(p.get('window')), 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', + by: + (['initiative', '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, @@ -188,9 +206,11 @@ function buildHash(s: HashState): string { 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.initiative) p.set('initiative', s.projects.initiative); + if (s.projects.within) p.set('within', s.projects.within); 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.group !== DEFAULT_GROUP) 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); @@ -198,6 +218,7 @@ function buildHash(s: HashState): string { 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.window) p.set('window', s.projects.window); 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); @@ -440,9 +461,13 @@ export function App() { const view = { lens, project: projectsNav.project, + initiative: projectsNav.initiative, + within: projectsNav.within, projectsView: projectsNav.view, // zooming the roadmap in or out is a view change too, so Back undoes it zoom: projectsNav.zoom, + // and so is moving its window + window: projectsNav.window, only: projectsNav.only, team: projectsNav.team, week: projectsNav.week, @@ -463,22 +488,14 @@ export function App() { pushRequested.current = false; const next = buildHash(hashState); if (next === location.hash.slice(1)) return; - 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) { + if (viewChanged(prev, view) || asked) { + // a project's or an initiative'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.initiative !== view.initiative || + prev.projectsView !== view.projectsView + ) { window.scrollTo(0, 0); } if (next) { @@ -872,14 +889,14 @@ export function App() { setQuery(''); setLens(id); // the Projects tab, clicked from a project page, goes back to the list - if (id === 'projects') setProjectsNav(cur => ({ ...cur, project: null })); + if (id === 'projects') setProjectsNav(cur => ({ ...cur, project: null, initiative: 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 })); + setProjectsNav(cur => ({ ...cur, project: slug, initiative: null })); }, []); // and the Team lens shows the result const onPerson = useCallback( diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 4a53b539..b9a3dc66 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -123,6 +123,11 @@ const DUMMY_PROJECT_PULLS: Record = { // 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], + // Not tracked (backend/dummyProjects.ts DUMMY_INITIATIVES): ops-runbooks + // has a plan and grew since it was set not tracked, so Decide asks + // "Still Not tracked?"; dotfiles is personal workflow that asks nothing + 'ops-runbooks': [28, 33, 39, 46], + dotfiles: [49, 51], misc: [3, 18, 20, 29, 43, 47, 53], }; const SECOND_PROJECT: Record = { 50: 'webdriver-deflake' }; @@ -131,6 +136,11 @@ const SECOND_PROJECT: Record = { 50: 'webdriver-deflake' }; // stall have one to show const QUIET_FOR_DAYS: Record = { 26: 25 }; const ALL_WAITING_ON_REVIEW = new Set(DUMMY_PROJECT_PULLS['store-picker']); +// drafts, which the Projects tab lists but never counts: both of Newsletter +// promo's, a project with a plan and only drafts; one of MySQL 8's, a mix +// of drafts and real PRs, and a draft of the viewer's for People; and a +// one-off's, which lists under the Overview's PRs outside projects +const DRAFTS = new Set([23, 44, 45, 47]); 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]); @@ -138,7 +148,8 @@ 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 quiet = QUIET_FOR_DAYS[i] == null ? given : quietly(given, QUIET_FOR_DAYS[i]); + const pull = DRAFTS.has(i) ? { ...quiet, draft: true } : quiet; const out: PullData = ALL_WAITING_ON_REVIEW.has(i) ? { ...pull, diff --git a/frontend-v2/src/backend/dummyProjects.ts b/frontend-v2/src/backend/dummyProjects.ts index 4e67b114..91333fe2 100644 --- a/frontend-v2/src/backend/dummyProjects.ts +++ b/frontend-v2/src/backend/dummyProjects.ts @@ -6,6 +6,7 @@ import { type RoadmapUpdate, } from '../../../shared/model/roadmap'; import type { AttachedIssue, IssueHit, IssueRef } from '../../../shared/model/work'; +import type { Initiative } from '../../../shared/model/initiatives'; import { SCALE_TEAM_ADDS, scaleCount, scaleProjects } from './dummyScale'; // The dummy board's projects, teams and roadmap: what /projects-data and @@ -378,6 +379,42 @@ export const { DUMMY_ROADMAP, DUMMY_ROADMAP_UPDATES } = (() => { origin: 'fire', updated_at: now - 6 * 86400, }), + // Not tracked (DUMMY_INITIATIVES): ongoing upkeep nobody reports on, + // so its row owes no update and it counts nowhere + item(14, 'Ops runbooks', -9, 26, { + project: 'ops-runbooks', + lead: 'djmetzle', + status: 'active', + end_kind: 'ongoing', + }), + // a year of finished plans, as prod's backfill left them: done ones + // before the window rest in the "Done before" fold, and the picker's + // Last 12 months and the ‹ arrow bring them back into the rows + ...( + [ + [26, 'Status page move', -78, 5, 'FixBot', 'done'], + [27, 'Device page redirects', -70, 6, 'Community', 'done'], + [28, 'Shipping rate cache', -63, 7, 'Store', 'dropped'], + [15, 'Old billing export', -52, 6, 'Store', 'done'], + [16, 'Guide search reindex', -49, 8, 'FixBot', 'done'], + [17, 'Legacy cart removal', -46, 10, 'Store', 'done'], + [18, 'Wiki image proxy', -44, 4, 'Community', 'dropped'], + [19, 'PHP 8.2 upgrade', -41, 9, 'Community', 'done'], + [20, 'Answers spam filter', -37, 5, 'Community', 'done'], + [21, 'Parts compatibility table', -33, 7, 'Store', 'done'], + [22, 'Teardown video embeds', -30, 4, 'FixBot', 'dropped'], + [23, 'Review queue SLAs', -27, 6, 'FixBot', 'done'], + [24, 'Gift card checkout', -24, 8, 'Store', 'done'], + [25, 'Translation memory import', -20, 5, 'Community', 'done'], + ] as const + ).map(([id, name, start, weeks, team, status]) => + item(id, name, start, weeks, { + team, + status, + updated_at: now - (-start - weeks) * 7 * 86400, + status_at: now - (-start - weeks) * 7 * 86400, + }) + ), ]; // about one in eight scaled projects is planned: active since its first // PR, in the lane of its lead's team @@ -580,3 +617,138 @@ export const DUMMY_ISSUES: IssueHit[] = (() => { ), ]; })(); + +/** + * The initiatives above the projects, as GET /initiatives sends them, in + * rank order: one per standing a lane or a Decide row can take. Release + * safety and Store sync hold most of the work; Keep the lights on is the + * upkeep; Onboarding is finished in all but name (every project quiet, no + * plan under way), so Decide asks "Did it reach its goal?"; Catalog + * migration was closed as done while akeneo-4 still has a PR open, so Decide + * asks again; Not tracked holds ops-runbooks, which grew since it was set + * so ("Still Not tracked?"), and dotfiles. grafana-dashboards, core-primitives + * and training-periods are in none, so Unsorted shows and Decide asks where + * they go: the first two from their "Needs a plan" rows, the last on its own. + * A board scaled with ?projects= files its projects round the initiatives, + * every tenth left in Unsorted. + */ +export const DUMMY_INITIATIVES: Initiative[] = (() => { + if (FRESH) return []; + const now = Math.floor(Date.now() / 1000); + const filed = (project: string, daysAgo = 30, baseline_prs = 0) => ({ + project, + set_by: 'jarstelfox', + set_at: now - daysAgo * 86400, + baseline_prs, + }); + const initiative = ( + id: number, + slug: string, + name: string, + owner: string | null, + goal: string, + projects: string[], + over: Partial = {} + ): Initiative => ({ + id, + slug, + name, + kind: 'initiative', + goal, + owner, + priority: id - 1, + status: 'open', + status_at: null, + created_by: 'jarstelfox', + created_at: now - 40 * 86400, + updated_by: 'jarstelfox', + updated_at: now - 40 * 86400, + projects: projects.map(slug => filed(slug)), + ...over, + }); + const list = [ + initiative( + 1, + 'release-safety', + 'Release safety', + 'rjmccluskey', + 'Every release ships with security’s sign-off, by both approval paths', + ['release-gate-sso', 'sso-second-path'] + ), + initiative( + 2, + 'store-sync', + 'Store sync and checkout', + 'zdmitchell', + 'Products, orders and prices stay in step between the store and Shopify', + [ + 'shopify-sync', + 'newsletter-promo', + 'store-picker', + 'type-refresh', + 'shipping-shelf-weight', + ] + ), + initiative( + 3, + 'klo', + 'Keep the lights on', + 'djmetzle', + 'The upkeep that keeps the site running and current', + ['mysql-8', 'translations'], + { kind: 'upkeep' } + ), + initiative( + 4, + 'test-reliability', + 'Test reliability', + 'mlahargou', + 'A red build means a real failure', + ['webdriver-deflake'] + ), + initiative( + 5, + 'onboarding', + 'Onboarding', + 'sivadnor', + 'New customers get a welcome series that teaches the guides', + ['onboarding-emails', 'old-checkout'] + ), + initiative( + 6, + 'catalog', + 'Catalog migration', + 'zdmitchell', + 'The catalog runs on Akeneo 4', + ['akeneo-4'], + { status: 'done', status_at: now - 20 * 86400 } + ), + initiative( + 7, + 'not-tracked', + 'Not tracked', + null, + 'Personal and team workflow nobody reports on', + [], + { + kind: 'untracked', + priority: 99, + // set not tracked two months ago with 2 PRs; 31 more opened since + projects: [filed('ops-runbooks', 60, 2), filed('dotfiles', 60, 2)], + } + ), + ]; + // a scaled board's projects, round the open initiatives + const open = list.filter(i => i.status === 'open' && i.kind === 'initiative'); + SCALED.forEach((p, k) => { + if (k % 10 === 9) return; + open[k % open.length].projects.push(filed(p.slug)); + }); + return list; +})(); + +/** The not-tracked projects that grew, as /projects-data's grew_counts + * sends them: PRs since each was set not tracked, against its count then. */ +export const DUMMY_GROWN: Record = FRESH + ? {} + : { 'ops-runbooks': { since: 31, baseline: 2 } }; diff --git a/frontend-v2/src/components/Lane.test.tsx b/frontend-v2/src/components/Lane.test.tsx new file mode 100644 index 00000000..2c7b267e --- /dev/null +++ b/frontend-v2/src/components/Lane.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it } from 'vitest'; +import { Fold } from './Lane'; + +// React's act() warns unless the environment says it's a test +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function band(long: boolean) { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + owned by dana} long={long}> +

rows

+
+ ) + ); + const summary = host.querySelector('summary') as HTMLElement; + const name = [...summary.querySelectorAll('span')].find( + s => s.textContent === 'A very long initiative name' + ) as HTMLElement; + const detail = [...summary.querySelectorAll('span')].find( + s => s.textContent === 'owned by dana' && s.parentElement === summary + ) as HTMLElement; + act(() => root.unmount()); + host.remove(); + return { summary, name, detail }; +} + +describe('a band whose name runs long', () => { + it('ends its name in an ellipsis, keeps its count, and puts its detail on its own line on a phone', () => { + const { summary, name, detail } = band(true); + expect(name.className).toContain('truncate'); + expect(name.nextElementSibling?.textContent).toContain('· 5'); + expect(summary.className).toContain('flex-wrap'); + expect(detail.className).toContain('basis-full'); + expect(detail.className).toContain('sm:basis-auto'); + }); + + it('is laid out as before when it isn’t long', () => { + const { name, detail } = band(false); + expect(name.className).not.toContain('truncate'); + expect(detail.className).toContain('ml-auto'); + expect(detail.className).not.toContain('basis-full'); + }); +}); diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index eed26c64..1d1a9edb 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -370,6 +370,9 @@ export function Fold({ detail, id, defaultOpen = false, + gutter, + keepEmpty = false, + long = !!gutter, children, }: { count: number; @@ -391,12 +394,22 @@ export function Fold({ /** initial state when nothing is stored yet — e.g. auto-open the one fold * that's the only content on an otherwise-quiet board */ defaultOpen?: boolean; + /** a control of its own in the gutter left of the band, outside its click + * target (the roadmap's grip that reorders initiatives) */ + gutter?: ReactNode; + /** drawn with nothing in it, its count unsaid: a band that stands for + * something on its own (an initiative in the roadmap's table of contents) */ + keepEmpty?: boolean; + /** its name can run long (an initiative's): laid out as a gutter's band, + * its name ending in an ellipsis, its count kept, and on a phone its + * detail on its own line. A band with a gutter is long */ + long?: boolean; children: ReactNode; }) { const detailsRef = useRef(null); const glossId = useId(); const stored = foldOpenStore.useValue(); - if (!count) return null; + if (!count && !keepEmpty) return null; const explicit = id ? stored[id] : undefined; const open = explicit ?? defaultOpen; // native
owns its own open/closed state on click; we only need to @@ -413,19 +426,35 @@ export function Fold({ 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 + // beside them split "FIXBOT · 3" across two. A long band (an + // initiative's, on the Roadmap and the Overview) wraps its detail to the + // line below instead, so the label stays on the chevron's line, and a + // name wider than the band ends in an ellipsis, its count kept const labelInner = ( - - {label} - {showCount && · {count}} + + + {label} + + {showCount && count > 0 && ( + // a flex item drops a leading space, so a long band's takes a fixed one + + {long ? '\u00a0' : ' '}· {count} + + )} ); - return ( + const fold = (
@@ -433,9 +462,11 @@ export function Fold({ // the band is the one focus stop; a screen reader hears the gloss // with it, the way a mouse gets it on hover aria-describedby={gloss ? glossId : undefined} - className={`flex cursor-pointer list-none items-center gap-2 bg-muted/40 px-3.5 py-[6px] transition-[background-color] duration-150 ease-out hover:bg-muted motion-reduce:transition-none [&::-webkit-details-marker]:hidden ${ - caps ? eyebrowText : 'text-[11px] font-semibold' - }`} + className={`flex cursor-pointer list-none items-center gap-x-2 bg-muted/40 py-[6px] pr-3.5 ${ + long ? 'flex-wrap' : '' + } transition-[background-color] duration-150 ease-out hover:bg-muted motion-reduce:transition-none [&::-webkit-details-marker]:hidden ${ + gutter ? 'pl-8' : 'pl-3.5' + } ${caps ? eyebrowText : 'text-[11px] font-semibold'}`} > ( @@ -467,7 +504,7 @@ export function Fold({ if (el) el.open = !el.open; }} // dotted, as every explanation on hover is - className={`hit rounded border-0 bg-transparent p-0 text-left underline decoration-dotted underline-offset-2 ${ + className={`hit min-w-0 rounded border-0 bg-transparent p-0 text-left underline decoration-dotted underline-offset-2 ${ caps ? eyebrowText : 'text-[11px] font-semibold' }`} > @@ -483,7 +520,14 @@ export function Fold({ labelInner )} {detail && ( - + {detail} )} @@ -496,6 +540,15 @@ export function Fold({
{children}
); + if (!gutter) return fold; + // the gutter sits over the band's left padding, a sibling of the band, so + // a click there never folds it + return ( +
+ {gutter} + {fold} +
+ ); } export function RestGroup({ diff --git a/frontend-v2/src/components/settle.test.ts b/frontend-v2/src/components/settle.test.ts new file mode 100644 index 00000000..41a1b03d --- /dev/null +++ b/frontend-v2/src/components/settle.test.ts @@ -0,0 +1,679 @@ +// @vitest-environment jsdom +import { act, createElement, type MutableRefObject } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearHolds, + DOUBLE_MS, + GUARD_PX, + guardAt, + HOLD_MS, + onLeave, + pressedBy, + reveal, + settle, + useFocusWhenDrawn, +} from './settle'; + +// jsdom makes no trusted events: every press here is a person's +pressedBy.person = () => true; + +const box = (top: number, height: number, left = 0, width = 400) => + ({ + top, + bottom: top + height, + left, + right: left + width, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect); + +/** Put an element at a place on screen: what its rect says. */ +const place = (el: HTMLElement, left: number, top: number, width = 40, height = 20) => { + el.getBoundingClientRect = () => box(top, height, left, width); +}; + +/** A region under the rule, with two buttons that count their clicks: + * `first` around (100, 100), `other` far away until a test moves it. */ +function region() { + const root = document.createElement('div'); + root.setAttribute('data-press-guard', ''); + const first = document.createElement('button'); + const other = document.createElement('button'); + root.append(first, other); + document.body.append(root); + place(first, 80, 90); + place(other, 400, 400); + const hits = { first: 0, other: 0 }; + first.addEventListener('click', () => hits.first++); + other.addEventListener('click', () => hits.other++); + return { root, first, other, hits }; +} + +/** What a mouse sends for one press at a point, onto `el` (what's there); + * `detail` is the browser's click count (0 for a key). */ +function pressAt(el: Element, x: number, y: number, detail = 1) { + const at = { bubbles: true, cancelable: true, clientX: x, clientY: y }; + const down = new MouseEvent('pointerdown', at); + el.dispatchEvent(down); + const mouse = new MouseEvent('mousedown', { ...at, detail: Math.max(detail, 1) }); + el.dispatchEvent(mouse); + el.dispatchEvent(new MouseEvent('mouseup', at)); + el.dispatchEvent(new MouseEvent('click', { ...at, detail })); + if (detail === 2) el.dispatchEvent(new MouseEvent('dblclick', { ...at, detail })); + return { down, mouse }; +} + +const moveTo = (x: number, y: number) => + document.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y })); + +let now = 1000; +let flashed: Element[] = []; +beforeEach(() => { + now = 1000; + vi.spyOn(performance, 'now').mockImplementation(() => now); + flashed = []; + Element.prototype.animate = function (this: Element) { + flashed.push(this); + return {} as Animation; + }; + // what a press at a point lands on: the last placed element there + document.elementFromPoint = (x: number, y: number) => + [...document.body.querySelectorAll('*')].reverse().find(el => { + const r = el.getBoundingClientRect(); + return r.width > 0 && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; + }) ?? null; + // the page's own box, so a press at a point isn't on its scrollbar + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800); +}); +afterEach(() => { + clearHolds(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + delete (Element.prototype as Partial).animate; + delete (document as Partial).elementFromPoint; +}); + +describe('the press guard: a stray press', () => { + it('reaches no control that arrived under the point since the click', () => { + const { root, first, hits } = region(); + pressAt(first, 100, 100); + expect(hits.first).toBe(1); + // the click put a new control where the pointer is: a double-click's + // second press finds it, and reaches nothing + const came = document.createElement('button'); + place(came, 85, 92); + root.append(came); + let cameHits = 0; + came.addEventListener('click', () => cameHits++); + const { down, mouse } = pressAt(came, 101, 100); + expect(cameHits).toBe(0); + // nothing takes the focus or starts a selection either, and the + // control says it was kept from the press + expect(down.defaultPrevented).toBe(true); + expect(mouse.defaultPrevented).toBe(true); + expect(flashed).toEqual([came]); + }); + + it('reaches no control that slid under the point', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100); + place(other, 90, 95); + pressAt(other, 100 + GUARD_PX - 4, 100); + expect(hits.other).toBe(0); + }); + + it('reaches no control that moved, though it was near', () => { + const { first, other, hits } = region(); + place(other, 125, 90); + pressAt(first, 100, 100); + // a line above it grew: it slid down 18 px, still near the point + place(other, 125, 108); + pressAt(other, 118, 112); + expect(hits.other).toBe(0); + }); + + it('takes a double-click on one control as one click', () => { + const { first, hits } = region(); + pressAt(first, 100, 100); + now += DOUBLE_MS - 100; + pressAt(first, 100, 100); + expect(hits.first).toBe(1); + }); + + it('eats the click of a press it ate, however long the press is held', () => { + const { root, first } = region(); + pressAt(first, 100, 100); + const came = document.createElement('button'); + place(came, 85, 92); + root.append(came); + let cameHits = 0; + came.addEventListener('click', () => cameHits++); + const at = { bubbles: true, cancelable: true, clientX: 100, clientY: 100 }; + now += HOLD_MS - 200; + came.dispatchEvent(new MouseEvent('pointerdown', at)); + // held down past the hold's end, then let go + now += 600; + came.dispatchEvent(new MouseEvent('mouseup', at)); + came.dispatchEvent(new MouseEvent('click', { ...at, detail: 1 })); + expect(cameHits).toBe(0); + }); + + it('reaches nothing new where no control is, so nothing that came is selected', () => { + const { root, first } = region(); + pressAt(first, 100, 100); + first.remove(); + const words = document.createElement('span'); + root.append(words); + const { down } = pressAt(words, 100, 100); + expect(down.defaultPrevented).toBe(true); + }); + + it('holds at a drop’s point', () => { + const { root, hits, other } = region(); + guardAt(50, 60); + place(other, 40, 50); + pressAt(other, 52, 61); + expect(hits.other).toBe(0); + const came = document.createElement('button'); + root.append(came); + place(came, 45, 55); + pressAt(came, 52, 61); + expect(flashed).toEqual([other, came]); + }); + + it('judges a press by when it was made, not when a busy page got to it', () => { + const { first, hits } = region(); + pressAt(first, 100, 100); + // a long render: the page reads the second press of a double-click + // two seconds on, though it was made a moment after the first + now += 2000; + const at = { bubbles: true, cancelable: true, clientX: 100, clientY: 100 }; + const down = new MouseEvent('pointerdown', at); + Object.defineProperty(down, 'timeStamp', { value: 1080 }); + first.dispatchEvent(down); + first.dispatchEvent(new MouseEvent('click', { ...at, detail: 2 })); + expect(hits.first).toBe(1); + }); +}); + +describe('the press guard: a sloppy or doubled press', () => { + it('takes a double-click that drifts past a wobble as one click', () => { + const { root, first } = region(); + pressAt(first, 100, 100); + const end = document.createElement('button'); + place(end, 80, 105); + root.append(end); + let endHits = 0; + end.addEventListener('click', () => endHits++); + // 9 px down within 90 ms, never resting + now += 45; + moveTo(100, 105); + now += 45; + moveTo(100, 109); + pressAt(end, 100, 109); + expect(endHits).toBe(0); + // a double-click's span later, it's aimed + now += DOUBLE_MS; + moveTo(100, 110); + pressAt(end, 100, 110); + expect(endHits).toBe(1); + }); + + it('takes the browser’s double and triple click on a toggle as one click', () => { + const { first, hits } = region(); + first.setAttribute('aria-expanded', 'false'); + pressAt(first, 100, 100); + now += 120; + const { mouse } = pressAt(first, 100, 100, 2); + now += 120; + pressAt(first, 100, 100, 3); + expect(hits.first).toBe(1); + // nothing selected, and the toggle says it kept the press + expect(mouse.defaultPrevented).toBe(true); + expect(flashed).toEqual([first, first]); + }); + + it('eats a middle-click’s auxclick on a link that arrived', () => { + const { root, first } = region(); + pressAt(first, 100, 100); + const link = document.createElement('a'); + link.href = '#x'; + place(link, 85, 92); + root.append(link); + const at = { bubbles: true, cancelable: true, clientX: 100, clientY: 100, button: 1 }; + link.dispatchEvent(new MouseEvent('pointerdown', at)); + const aux = new MouseEvent('auxclick', at); + link.dispatchEvent(aux); + expect(aux.defaultPrevented).toBe(true); + }); + + it('keeps the hold through a modifier key alone', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100); + place(other, 90, 95); + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Control' })); + pressAt(other, 100, 100); + expect(hits.other).toBe(0); + }); + + it('eats a press on the control just pressed when it says something else', () => { + const { first, hits } = region(); + first.textContent = 'Mark Not tracked'; + first.addEventListener('click', () => { + first.textContent = 'Undo: Marked Not tracked'; + }); + pressAt(first, 100, 100); + now += 700; + pressAt(first, 100, 100); + expect(hits.first).toBe(1); + }); +}); + +describe('the press guard: a press a person means', () => { + it('reaches the control just pressed, whose box its own press scaled', () => { + const { first, hits } = region(); + // noted at its click mid-way back from .pressable:active's 0.97 + place(first, 81, 90.3, 38.8, 19.4); + pressAt(first, 100, 100); + place(first, 80, 90); + now += DOUBLE_MS + 100; + pressAt(first, 100, 100); + expect(hits.first).toBe(2); + }); + + it('reaches a neighbour whose box only scaled about its centre', () => { + const { first, other, hits } = region(); + place(other, 120.6, 90.3, 38.8, 19.4); + pressAt(first, 100, 100); + place(other, 120, 90); + now += 200; + pressAt(other, 122, 100); + expect(hits.other).toBe(1); + expect(flashed).toEqual([]); + }); + + it.each([ + ['wider', [70, 90, 60, 20]], + ['narrower, its words swapped', [100, 90, 20, 20]], + ['shifted 2 px', [82, 92, 40, 20]], + ] as const)('reaches a toggle whose box changed (%s), at 0.4 s', (_how, [l, t, w, h]) => { + const { first, hits } = region(); + first.setAttribute('aria-expanded', 'false'); + first.addEventListener('click', () => { + first.setAttribute('aria-expanded', 'true'); + place(first, l, t, w, h); + }); + pressAt(first, 110, 100); + now += 400; + pressAt(first, 110, 100); + expect(hits.first).toBe(2); + expect(flashed).toEqual([]); + }); + + it('reaches the control just pressed, its box changed, after a double-click’s span', () => { + const { first, hits } = region(); + first.addEventListener('click', () => place(first, 76, 88, 50, 24)); + pressAt(first, 100, 100); + now += DOUBLE_MS + 1; + pressAt(first, 100, 100); + expect(hits.first).toBe(2); + }); + + it('reaches a neighbour that stayed in place, at once', () => { + const { first, other, hits } = region(); + place(other, 122, 90, 12, 20); + pressAt(first, 100, 100); + now += 200; + // within GUARD_PX of the click, so the hold judges it + pressAt(other, 123, 100); + expect(hits.other).toBe(1); + expect(flashed).toEqual([]); + }); + + it('reaches the same control again after a double-click’s span', () => { + const { first, hits } = region(); + pressAt(first, 100, 100); + now += DOUBLE_MS + 1; + pressAt(first, 100, 100); + expect(hits.first).toBe(2); + }); + + it('closes a toggle it just opened, at 0.4 s or at once', () => { + const { first, hits } = region(); + first.setAttribute('aria-expanded', 'false'); + first.addEventListener('click', () => + first.setAttribute('aria-expanded', String(first.getAttribute('aria-expanded') !== 'true')) + ); + pressAt(first, 100, 100); + now += 400; + pressAt(first, 100, 100); + expect(first.getAttribute('aria-expanded')).toBe('false'); + pressAt(first, 100, 100); + expect(hits.first).toBe(3); + // a fold's band: a summary, or what's inside one + const fold = document.createElement('details'); + const band = document.createElement('summary'); + const label = document.createElement('button'); + band.append(label); + fold.append(band); + first.closest('[data-press-guard]')?.append(fold); + place(label, 300, 300); + let labelHits = 0; + label.addEventListener('click', () => labelHits++); + pressAt(label, 310, 310); + pressAt(label, 310, 310); + expect(labelHits).toBe(2); + }); + + it('never cancels a drag from a grip that stayed put', () => { + const { first } = region(); + first.setAttribute('draggable', 'true'); + pressAt(first, 100, 100); + now += 200; + const { down, mouse } = pressAt(first, 100, 100); + expect(down.defaultPrevented).toBe(false); + expect(mouse.defaultPrevented).toBe(false); + }); + + it('reaches a control the pointer moved onto, past a wobble', () => { + const { root, first } = region(); + pressAt(first, 100, 100); + // Other answers opened its ends 12 px below the pointer + const end = document.createElement('button'); + place(end, 80, 108); + root.append(end); + let endHits = 0; + end.addEventListener('click', () => endHits++); + moveTo(102, 104); + pressAt(end, 102, 104); + expect(endHits).toBe(0); + moveTo(100, 112); + // it rests on what it moved to + now += 150; + pressAt(end, 100, 112); + expect(endHits).toBe(1); + }); + + it('reaches what the pointer moved away to', () => { + const { first, other, hits } = region(); + place(other, 140, 90); + pressAt(first, 100, 100); + pressAt(other, 100 + GUARD_PX + 26, 100); + expect(hits.other).toBe(1); + }); + + it('reaches a key’s press always, and holds nothing outside its regions', () => { + const { root, first } = region(); + pressAt(first, 100, 100, 0); + const came = document.createElement('button'); + place(came, 85, 92); + root.append(came); + let cameHits = 0; + came.addEventListener('click', () => cameHits++); + pressAt(came, 100, 100); + expect(cameHits).toBe(1); + clearHolds(); + const outside = document.createElement('button'); + document.body.append(outside); + place(outside, 280, 290); + let outsideHits = 0; + outside.addEventListener('click', () => outsideHits++); + pressAt(outside, 300, 300); + outside.remove(); + const next = document.createElement('button'); + document.body.append(next); + place(next, 280, 290); + next.addEventListener('click', () => outsideHits++); + pressAt(next, 300, 300); + expect(outsideHits).toBe(2); + }); + + it('lets code’s own click (opening a "+ N more") start nothing and end nothing', () => { + const person = pressedBy.person; + pressedBy.person = e => e.isTrusted; + try { + const { first, other } = region(); + guardAt(100, 100); + const gone = vi.fn(); + onLeave(gone); + first.click(); + expect(gone).not.toHaveBeenCalled(); + // the hold is still the drop's: what came is still kept from a press + pressedBy.person = person; + place(other, 90, 95); + const { down } = pressAt(other, 100, 100); + expect(down.defaultPrevented).toBe(true); + } finally { + pressedBy.person = person; + } + }); +}); + +describe('the press guard lets go', () => { + /** A click on `first`, then a control that slid under the point. */ + const slid = () => { + const r = region(); + pressAt(r.first, 100, 100); + place(r.other, 90, 95); + return r; + }; + + it('once the pointer moves away, and not for a wobble', () => { + const { other, hits } = slid(); + moveTo(103, 98); + pressAt(other, 100, 100); + expect(hits.other).toBe(0); + moveTo(100, 100 + GUARD_PX + 1); + pressAt(other, 100, 100); + expect(hits.other).toBe(1); + }); + + it('after its time with the pointer still, and no sooner', () => { + const { other, hits } = slid(); + now += HOLD_MS - 1; + pressAt(other, 100, 100); + expect(hits.other).toBe(0); + now += 2; + pressAt(other, 100, 100); + expect(hits.other).toBe(1); + }); + + it('holds at least a second and a half', () => { + expect(HOLD_MS).toBeGreaterThanOrEqual(1500); + }); + + it.each([ + [ + 'a wheel', + () => document.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 40 })), + ], + [ + 'a key', + () => + document.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'PageDown' }) + ), + ], + [ + 'a touch pan', + () => document.dispatchEvent(new MouseEvent('pointercancel', { bubbles: true })), + ], + [ + 'the scrollbar', + () => + document.documentElement.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, clientX: 1005, clientY: 100 }) + ), + ], + ])('once %s scrolls the page', (_how, scroll) => { + const { other, hits } = slid(); + scroll(); + pressAt(other, 100, 100); + expect(hits.other).toBe(1); + }); +}); + +describe('words held after a click', () => { + it('go when the pointer moves away, not on the clock', () => { + const { first } = region(); + pressAt(first, 100, 100); + const gone = vi.fn(); + onLeave(gone); + now += HOLD_MS * 3; + moveTo(101, 101); + expect(gone).not.toHaveBeenCalled(); + moveTo(200, 200); + expect(gone).toHaveBeenCalledTimes(1); + }); + + it('go when the next action starts, by key or by a press elsewhere', () => { + const { first, other } = region(); + pressAt(first, 100, 100); + const gone = vi.fn(); + onLeave(gone); + pressAt(other, 100, 100, 0); + expect(gone).toHaveBeenCalledTimes(1); + // after a key's Undo, a press anywhere is the next action + const again = vi.fn(); + onLeave(again); + moveTo(500, 500); + expect(again).not.toHaveBeenCalled(); + pressAt(document.body, 500, 500); + expect(again).toHaveBeenCalledTimes(1); + }); +}); + +describe('reveal', () => { + let scrolled: number[]; + // how far the page has scrolled: what isn't stuck moves up by it + let offset = 0; + beforeEach(() => { + scrolled = []; + offset = 0; + vi.spyOn(window, 'scrollBy').mockImplementation(((_x: number, y: number) => { + scrolled.push(y); + offset += y; + }) as typeof window.scrollBy); + }); + + const header = (top: number, height: number) => { + const h = document.createElement('div'); + h.className = 'sticky'; + h.style.position = 'sticky'; + h.style.top = `${top}px`; + h.getBoundingClientRect = () => box(top, height); + document.body.append(h); + return h; + }; + const at = (top: number, height = 30) => { + const el = document.createElement('div'); + el.getBoundingClientRect = () => box(top - offset, height); + document.body.append(el); + return el; + }; + + it('brings what hides under the stuck headers out below them', () => { + header(0, 48); + header(48, 40); + reveal(at(60)); + expect(scrolled).toEqual([60 - 88 - 8]); + }); + + it('leaves a header that isn’t stuck out of the sum', () => { + // sticky at 0, but sitting lower in the flow: not over anything + const lower = document.createElement('div'); + lower.className = 'sticky'; + lower.style.position = 'sticky'; + lower.style.top = '0px'; + lower.getBoundingClientRect = () => box(300, 40); + document.body.append(lower); + reveal(at(100)); + expect(scrolled).toEqual([]); + }); + + it('brings what’s past the window’s bottom up', () => { + reveal(at(innerHeight - 10)); + expect(scrolled).toEqual([30 - 10 + 8]); + }); + + it('does nothing to what’s in view', () => { + header(0, 48); + reveal(at(200)); + expect(scrolled).toEqual([]); + }); +}); + +describe('settle', () => { + it('keeps the element where it was, every time, leaving no style behind', () => { + const scrolled: number[] = []; + vi.spyOn(window, 'scrollBy').mockImplementation(((_x: number, y: number) => + scrolled.push(y)) as unknown as typeof window.scrollBy); + const el = document.createElement('div'); + document.body.append(el); + let top = 300; + el.getBoundingClientRect = () => box(top, 30); + const held: (number | undefined)[] = []; + // an Undo that sends its row up 60 px, twice on one element + for (let k = 0; k < 2; k++) { + settle( + () => el, + h => { + held.push(h); + top -= 60; + } + ); + top += 60; // the scroll put it back + } + expect(held).toEqual([30, 30]); + expect(scrolled).toEqual([-60, -60]); + expect(el.getAttribute('style')).toBeNull(); + expect(document.body.getAttribute('style')).toBeNull(); + }); +}); + +describe('a focus that waits for its target', () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + /** A view that draws a button with the id `drawn` names, and asks for it. */ + function mount() { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + let want: MutableRefObject = { current: null }; + const found: HTMLElement[] = []; + function View({ drawn }: { drawn: string | null }) { + want = useFocusWhenDrawn(el => found.push(el)); + return drawn ? createElement('button', { id: drawn }) : null; + } + const draw = (drawn: string | null) => act(() => root.render(createElement(View, { drawn }))); + draw(null); + return { want: () => want, found, draw, done: () => act(() => root.unmount()) }; + } + + it('gives the focus to it once a render draws it', () => { + const v = mount(); + v.want().current = ['later']; + v.draw(null); + expect(v.found).toEqual([]); + v.draw('later'); + expect(v.found.map(e => e.id)).toEqual(['later']); + v.done(); + }); + + it('drops it at the next press or key, so it never comes late', () => { + const v = mount(); + v.want().current = ['later']; + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'j' })); + v.draw('later'); + v.want().current = ['again']; + document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + v.draw('again'); + expect(v.found).toEqual([]); + v.done(); + }); +}); diff --git a/frontend-v2/src/components/settle.ts b/frontend-v2/src/components/settle.ts new file mode 100644 index 00000000..0f8264d3 --- /dev/null +++ b/frontend-v2/src/components/settle.ts @@ -0,0 +1,479 @@ +import { type MutableRefObject, useEffect, useRef } from 'react'; +import { flushSync } from 'react-dom'; + +/** + * One rule for what follows a click in the initiatives views (Decide, the + * Roadmap's lanes and a plan's details, a project's and an initiative's + * page, the Overview): a press near a click's point can't reach a control + * that arrived there, or slid there, since the click. A click often + * changes what sits under the pointer (a receipt replaces the answers, a + * row joins a section above, Other answers opens its ends), and a + * double-click's second press, or a "to be sure" press a second later, + * would land on whatever came. + * + * A pointer click on a control inside a `PRESS_GUARD` region starts a hold + * at its point, and notes what was under it and every control within + * GUARD_PX and where it sat. A later press near the point is judged once, + * at its `pointerdown`, and the verdict holds for all of its events (its + * click too, however long it's held). It reaches nothing (no focus, no + * selection, no click, and the control dims a moment) when: + * + * - it's the control just pressed, again within a double-click + * (DOUBLE_MS), or saying new words, unless it's a toggle (it opens or + * closes something, or is pressed) or a grip that drags: its own change + * of size or label didn't slide under the pointer; + * - it's another control that wasn't there, or has moved since (its + * centre or its layout size, by more than 1 px, so a press's own + * `scale` doesn't count); + * - it lands on no control, and on something other than what was there. + * + * So a control that stayed put takes every press: a toggle closed again, + * the next week on the load chart, a grip's drag. A press the browser + * itself counts as a double or triple click on the control just pressed is + * one click, toggles too. The hold ends when the pointer moves away, after + * HOLD_MS, or once the page is scrolled by a person (a wheel, any key but + * a modifier alone, the scrollbar, a touch pan). A pointer that moved + * further than a wobble is aimed, once a double-click's span has passed or + * it has rested on what it moved to: what it lands on, it means. A touch + * has no move between taps, so a tap on what came under a still finger + * waits out the hold. + * + * What the click left (a receipt, Undo's words) is put where it can be + * seen with `settle` or `reveal`, below the sticky headers, and the caller + * gives it the focus. Words that stand in for a control a while (Decide's + * "Put X back") go with `onLeave`: when the pointer moves away, or the + * next action starts, never on the clock. + */ + +/** The attribute that puts a region under the rule. */ +export const PRESS_GUARD = { 'data-press-guard': '' } as const; + +/** How far the pointer can drift from the press and still be still: a + * hand's wobble through a double or triple click. */ +export const GUARD_PX = 24; + +/** How long a pointer that never moves is held: past a triple click and a + * second press "to be sure". */ +export const HOLD_MS = 1600; + +/** A double-click's span: the same control pressed again sooner is one + * click, not two. */ +export const DOUBLE_MS = 500; + +/** How far a pointer moves before it's aiming, not wobbling. */ +const WOBBLE_PX = 6; + +/** How long a moved pointer rests before it's aiming: a sloppy + * double-click's drift never rests between its presses. */ +const REST_MS = 120; + +const CONTROL = + 'button, a[href], summary, [role="button"], [role="tab"], [role="radio"], [role="switch"], [role="menuitem"]'; + +/** What a press can land on: a control, a field or a grip. */ +const TARGET = `${CONTROL}, input, select, textarea, label, [draggable="true"], [tabindex]:not([tabindex="-1"])`; + +/** A control whose second press undoes its first: a double-click on it + * does what it would anywhere. */ +const TOGGLE = '[aria-expanded], [aria-pressed], [role="switch"], summary, summary *'; + +/** Who made a press: a person, not code's own `.click()` (opening a + * "+ N more" on the way to a row), which starts and ends nothing. Tests + * swap it, since jsdom makes no trusted events. */ +export const pressedBy = { person: (e: Event) => e.isTrusted }; + +/** Where a control sits by its layout: its centre on screen, which a + * `scale` about the centre keeps, and its size before transforms. */ +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +function boxOf(el: Element): Box { + const r = el.getBoundingClientRect(); + const { offsetWidth, offsetHeight } = el as HTMLElement; + return { + x: r.left + r.width / 2, + y: r.top + r.height / 2, + w: offsetWidth ?? r.width, + h: offsetHeight ?? r.height, + }; +} + +interface Hold { + /** the press's point; null for a key's click, which holds no point */ + x: number | null; + y: number; + /** when the press was made */ + at: number; + /** presses near the point are judged until then */ + until: number; + /** the control pressed, its words then, and what was under the point */ + first: Element | null; + said: string | null; + under: Element | null; + /** every control near the point then, and where it was */ + stayed: Map; + /** the furthest the pointer has gone from the point, and when it last moved */ + far: number; + movedAt: number; + /** what goes once the pointer moves away or the next action starts */ + leave: (() => void)[]; +} + +let hold: Hold | null = null; + +const away = (h: Hold, e: MouseEvent) => + h.x == null ? Infinity : Math.hypot(e.clientX - h.x, e.clientY - h.y); +const near = (h: Hold, e: MouseEvent) => away(h, e) <= GUARD_PX; + +/** When a press was made, not when the page got to it: a double-click + * made while a long render held the page is handled after it, and must + * still count as made inside the hold. (jsdom stamps events with + * Date.now(), another clock, so there it's now.) */ +const madeAt = (e: Event) => + e.timeStamp > 0 && e.timeStamp <= performance.now() + 1 ? e.timeStamp : performance.now(); + +/** The pointer left, or a new action began: the hold and its words go. */ +function leave(): void { + const h = hold; + hold = null; + h?.leave.forEach(fn => fn()); +} + +/** Where to look around a point: every 8 px within GUARD_PX. */ +const GRID: [number, number][] = []; +for (let dx = -GUARD_PX; dx <= GUARD_PX; dx += 8) + for (let dy = -GUARD_PX; dy <= GUARD_PX; dy += 8) + if (Math.hypot(dx, dy) <= GUARD_PX) GRID.push([dx, dy]); + +/** The controls a press within GUARD_PX of a point would land on, and + * where they are: a hit test per grid point, not a walk of the page. + * ponytail: a control smaller than the grid's 8 px can fall between its + * points and read as arrived; a finer grid if one ever matters. */ +function controlsNear(x: number, y: number): Map { + const found = new Map(); + for (const [dx, dy] of GRID) { + const el = document.elementFromPoint?.(x + dx, y + dy)?.closest(TARGET); + if (el && !found.has(el)) found.set(el, boxOf(el)); + } + return found; +} + +/** Start a hold at a point (a click, a drop), or with none (a key's + * click): the hold before it ends, as the next action. Call it before + * the press changes anything, so it notes what was there. */ +export function guardAt( + x: number | null, + y = 0, + at = performance.now(), + first: Element | null = null +): void { + leave(); + hold = { + x, + y, + at, + until: x == null ? 0 : at + HOLD_MS, + first, + said: first?.textContent ?? null, + under: x == null ? null : document.elementFromPoint?.(x, y) ?? first, + stayed: x == null ? new Map() : controlsNear(x, y), + far: 0, + movedAt: at, + leave: [], + }; +} + +/** Run `fn` when the current hold ends by the pointer moving away or the + * next action, not by the clock. */ +export function onLeave(fn: () => void): void { + hold ??= { + x: null, + y: 0, + at: 0, + until: 0, + first: null, + said: null, + under: null, + stayed: new Map(), + far: 0, + movedAt: 0, + leave: [], + }; + hold.leave.push(fn); +} + +/** End the hold without running its words' leave (tests start clean). */ +export function clearHolds(): void { + hold = null; + eating = false; +} + +const moved = (a: Box, b: Box) => + Math.abs(a.x - b.x) > 1 || + Math.abs(a.y - b.y) > 1 || + Math.abs(a.w - b.w) > 1 || + Math.abs(a.h - b.h) > 1; + +/** The hold is over by the clock: what it noted can go. */ +function expire(h: Hold): void { + h.until = 0; + h.first = h.under = null; + h.stayed = new Map(); +} + +/** Whether a press at this pointerdown lands on something that came under + * the point since the click: the rule above. */ +function stray(e: MouseEvent): Element | boolean { + const h = hold; + if (!h || !near(h, e)) return false; + const made = madeAt(e); + if (made >= h.until) { + expire(h); + return false; + } + const target = e.target instanceof Element ? e.target : null; + const control = target?.closest(TARGET); + if (!control) { + // no control: only what was there all along takes it + const was = h.under; + return !(target && was?.isConnected && (target.contains(was) || was.contains(target))); + } + // moved past a wobble, and then rested or took its time: aimed + const aimed = h.far > WOBBLE_PX && (made - h.at >= DOUBLE_MS || made - h.movedAt >= REST_MS); + if (control === h.first) { + if (control.matches(TOGGLE) || control.getAttribute('draggable') === 'true') return false; + if (made - h.at < DOUBLE_MS) return control; + // the same element saying something else is a new control + return control.textContent !== h.said && !aimed ? control : false; + } + const was = h.stayed.get(control); + if (was && !moved(was, boxOf(control))) return false; + return aimed ? false : control; +} + +/** A press the browser counts as a double or triple click, on the control + * just pressed: one click, whatever that control is. */ +const again = (e: MouseEvent) => + e.detail >= 2 && + !!hold?.first && + near(hold, e) && + (e.target as Element | null)?.closest?.(TARGET) === hold.first; + +/** Dim a control a press didn't reach, a moment, with a nudge, so it reads + * as kept from a stray press, not as picked or dead. Under reduced motion + * it dims and is outlined in its own colour instead of moving. */ +function flash(el: Element): void { + const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + const kept = still + ? { opacity: 0.5, outline: '2px dashed currentColor', outlineOffset: '2px' } + : { opacity: 0.45 }; + el.animate?.( + still + ? [kept, kept] + : [ + { opacity: 1, transform: 'none' }, + { ...kept, transform: 'translateX(-3px)' }, + { ...kept, transform: 'translateX(3px)' }, + { opacity: 1, transform: 'none' }, + ], + { duration: still ? 600 : 360, easing: 'ease-out' } + ); +} + +/** The verdict on the press under way, from its pointerdown. */ +let eating = false; + +if (typeof window !== 'undefined') { + const listeners: [string, EventListener, AddEventListenerOptions][] = []; + const on = ( + type: K, + fn: (e: WindowEventMap[K]) => void, + opts: AddEventListenerOptions = { capture: true } + ) => { + addEventListener(type, fn, opts); + listeners.push([type, fn as EventListener, opts]); + }; + const eat = (e: Event) => { + e.preventDefault(); + e.stopImmediatePropagation(); + }; + const scrolled = () => { + if (hold) expire(hold); + }; + on('pointerdown', e => { + if (!pressedBy.person(e)) return; + // the scrollbar, outside the page's own box: a scroll on purpose + const page = document.documentElement; + if (e.target === page && (e.clientX >= page.clientWidth || e.clientY >= page.clientHeight)) { + eating = false; + return scrolled(); + } + const verdict = stray(e); + eating = !!verdict; + if (verdict instanceof Element) flash(verdict); + if (eating) return eat(e); + // a press away from the point: the pointer moved there + if (hold && !near(hold, e)) leave(); + }); + for (const type of ['mousedown', 'pointerup', 'mouseup', 'auxclick', 'dblclick'] as const) + on(type, e => { + if (!pressedBy.person(e)) return; + if (eating) return eat(e); + // a double-click's second press on what was just pressed (a toggle + // passed pointerdown): no selection, no second click + if ((type === 'mousedown' || type === 'dblclick') && again(e)) eat(e); + }); + on('click', e => { + if (!pressedBy.person(e)) return; + // a key's click (detail 0) is never a stray press + if (e.detail > 0 && eating) return eat(e); + if (again(e)) { + flash(hold?.first as Element); + return eat(e); + } + const control = (e.target as Element | null)?.closest?.(CONTROL); + if (!control?.closest('[data-press-guard]')) return; + guardAt(e.detail > 0 ? e.clientX : null, e.clientY, madeAt(e), control); + }); + on( + 'pointermove', + e => { + if (hold?.x == null) return; + const d = away(hold, e); + if (d > GUARD_PX) return leave(); + if (d > hold.far) hold.far = d; + hold.movedAt = madeAt(e); + }, + { capture: true, passive: true } + ); + // a person scrolls the page on purpose: what's under the pointer is seen + on('wheel', scrolled, { capture: true, passive: true }); + // a modifier alone scrolls nothing: a ctrl-click is still guarded + on( + 'keydown', + e => { + if (!['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) scrolled(); + }, + { capture: true, passive: true } + ); + on('pointercancel', scrolled, { capture: true, passive: true }); + import.meta.hot?.dispose(() => { + for (const [type, fn, opts] of listeners) removeEventListener(type, fn, opts); + }); +} + +/** Space kept between what's revealed and an edge or a sticky header. */ +const MARGIN = 8; + +/** How far down the window the stuck headers reach over `left..right`, + * leaving out any that hold the elements themselves. */ +function coveredTo(els: HTMLElement[], left: number, right: number): number { + let under = 0; + for (const s of document.querySelectorAll('[class*="sticky"], .fixed')) { + if (els.some(e => s.contains(e))) continue; + const style = getComputedStyle(s); + if (style.position !== 'sticky' && style.position !== 'fixed') continue; + const r = s.getBoundingClientRect(); + if (!r.height || r.height > innerHeight / 2 || r.right <= left || r.left >= right) continue; + // stuck at its top, not sitting in the flow lower down + const at = parseFloat(style.top); + if (Number.isNaN(at) || Math.abs(r.top - at) > 1) continue; + under = Math.max(under, r.bottom); + } + return under; +} + +/** + * Scroll the least that shows every element whole, below the sticky + * headers and above the window's bottom (the top wins when they can't + * both fit). A header that sticks once it scrolls (a lane's) counts on the + * next pass. + */ +export function reveal(...els: (HTMLElement | null | undefined)[]): void { + for (let pass = 0; pass < 3; pass++) { + const shown = els.filter((e): e is HTMLElement => !!e?.isConnected); + const rects = shown.map(e => e.getBoundingClientRect()).filter(r => r.width || r.height); + if (!rects.length) return; + const top = Math.min(...rects.map(r => r.top)); + const bottom = Math.max(...rects.map(r => r.bottom)); + const under = coveredTo( + shown, + Math.min(...rects.map(r => r.left)), + Math.max(...rects.map(r => r.right)) + ); + const dy = + top < under + MARGIN + ? top - under - MARGIN + : bottom > innerHeight - MARGIN + ? Math.min(bottom - innerHeight + MARGIN, top - under - MARGIN) + : 0; + if (Math.abs(dy) < 1) return; + window.scrollBy(0, dy); + } +} + +/** Focus what a click left, without the browser's own scroll (which knows + * nothing of sticky headers), and show it, with `also` beside it. */ +export function focusResult( + el: HTMLElement | null | undefined, + ...also: (HTMLElement | null | undefined)[] +): void { + el?.focus({ preventScroll: true }); + reveal(el, ...also); +} + +/** + * Write a change that moves what `find` returns (an Undo that sends its + * row up or down, or changes a line above it), drawn at once, then scroll + * once so it stays where it was on screen, and show it whole. `write` gets + * the height it had, for the words that replace it to keep. `leaving` runs + * when the pointer moves away or the next action starts. + */ +export function settle( + find: () => HTMLElement | null, + write: (held?: number) => void, + leaving?: () => void +): void { + const before = find()?.getBoundingClientRect(); + flushSync(() => write(before?.height)); + if (leaving) onLeave(leaving); + const after = find(); + if (!after) return; + const moved = before ? after.getBoundingClientRect().top - before.top : 0; + if (Math.abs(moved) >= 1) window.scrollBy(0, moved); + reveal(after); +} + +/** + * Ids to focus once one is drawn: set `.current`, and the first render + * that draws one of them gives it to `found` (a receipt a write is on its + * way to, a moved row's control). The next press or key drops it, so a + * target that never comes (a row a find hides) can't take the focus later. + */ +export function useFocusWhenDrawn( + found: (el: HTMLElement) => void +): MutableRefObject { + const want = useRef(null); + useEffect(() => { + const drop = () => { + want.current = null; + }; + addEventListener('pointerdown', drop, true); + addEventListener('keydown', drop, true); + return () => { + removeEventListener('pointerdown', drop, true); + removeEventListener('keydown', drop, true); + }; + }, []); + useEffect(() => { + const el = want.current?.map(id => document.getElementById(id)).find(Boolean); + if (!el) return; + want.current = null; + found(el); + }); + return want; +} diff --git a/frontend-v2/src/components/useRowKeys.test.tsx b/frontend-v2/src/components/useRowKeys.test.tsx new file mode 100644 index 00000000..1b038de0 --- /dev/null +++ b/frontend-v2/src/components/useRowKeys.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { landingIn, useRowKeys } from './useRowKeys'; + +// React's act() warns unless the environment says it's a test +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom draws nothing: every element counts as drawn here +const real = Element.prototype.getClientRects; +beforeAll(() => { + Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList; +}); +afterAll(() => { + Element.prototype.getClientRects = real; +}); + +/** Decide's kinds of row, as far as j and k can tell them apart. */ +function Rows() { + useRowKeys('[data-row]', '[data-lead]'); + return ( + <> + + {/* a question: its answer */} +
+ + +
+ {/* Unsorted, nothing picked: File waits on the chooser */} +
+ + + + +
+ {/* No project yet: Link waits on its project */} +
+ + ' + + 'Store picker'; + expect(landingIn(row, '[data-lead]')?.id).toBe('name'); + }); +}); diff --git a/frontend-v2/src/components/useRowKeys.ts b/frontend-v2/src/components/useRowKeys.ts index 60220fb9..d61a3a83 100644 --- a/frontend-v2/src/components/useRowKeys.ts +++ b/frontend-v2/src/components/useRowKeys.ts @@ -9,6 +9,12 @@ import { modalOpen } from '../hooks'; * row and not only the ones drawn. Rows hidden in a closed fold are * skipped. Typing in a field never moves. * + * One rule for where it lands: the row's first `target` that can take the + * focus. A target that's off (an answer waiting on its chooser) is passed + * over for the row's first control that isn't a field (its name), since j + * typed in a select would pick an option. A row with nowhere to land is + * stepped past, so j and k reach every row from every row. + * * 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. @@ -16,7 +22,7 @@ import { modalOpen } from '../hooks'; export function useRowKeys(row: string, target: string) { useEffect(() => { const focusRow = (el: HTMLElement) => { - el.querySelector(target)?.focus({ preventScroll: true }); + landingIn(el, 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 @@ -53,8 +59,7 @@ export function useRowKeys(row: string, target: string) { more.click(); requestAnimationFrame(() => { const now = drawn(); - const from = now.indexOf(rows[at]); - const to = now[from + 1]; + const to = seek(now, now.indexOf(rows[at]), 1); if (to) focusRow(to); }); return; @@ -68,14 +73,22 @@ export function useRowKeys(row: string, target: string) { !!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); + // the first row after the focus for j, the last one before it for k + const before = rows.filter(r => !after(r)).length; + const first = step > 0 ? before : before - 1; + const next = seek(rows, first - step, step) ?? seek(rows, step > 0 ? -1 : rows.length, step); + if (next) focusRow(next); return; } - focusRow(rows[Math.min(Math.max(at + step, 0), rows.length - 1)]); + // past the last row (or the first), it stays + const next = seek(rows, at, step); + if (next) focusRow(next); + }; + /** the next row from `at` going `step` that has somewhere to land */ + const seek = (rows: HTMLElement[], at: number, step: number) => { + for (let i = at + step; i >= 0 && i < rows.length; i += step) + if (landingIn(rows[i], target)) return rows[i]; + return undefined; }; // capture: ahead of the board's hotkeys, which stand down for a key // claimed here (hooks.ts checks defaultPrevented) @@ -83,3 +96,21 @@ export function useRowKeys(row: string, target: string) { return () => document.removeEventListener('keydown', onKey, true); }, [row, target]); } + +/** A control that isn't off (by `disabled` or `aria-disabled`) and is drawn. */ +const pressable = (el: HTMLElement) => + !el.matches(':disabled, [aria-disabled="true"]') && el.getClientRects().length > 0; + +/** The controls a row can land on when its target is off: never a field, + * even one given a tabindex. */ +const CONTROL = + 'button, a[href], [role="button"], [tabindex]:not([tabindex="-1"], input, select, textarea)'; + +/** Where j or k lands in a row: its first target that can take the focus, + * else its first control that isn't a field, else nowhere. */ +export function landingIn(row: HTMLElement, target: string): HTMLElement | undefined { + return ( + [...row.querySelectorAll(target)].find(pressable) ?? + [...row.querySelectorAll(CONTROL)].find(pressable) + ); +} diff --git a/frontend-v2/src/lens.test.ts b/frontend-v2/src/lens.test.ts new file mode 100644 index 00000000..dc47a457 --- /dev/null +++ b/frontend-v2/src/lens.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_GROUP, + GROUP_KEYS, + groupParam, + viewChanged, + weekParam, + windowParam, + ZOOM_KEY, +} from './lens'; +import { GROUPINGS } from './model/portfolio'; + +describe('viewChanged', () => { + const none = null as string | null; + const view = { lens: 'projects', project: none, initiative: none, within: none, week: none }; + + it('counts opening an initiative’s page, or Decide narrowed to one, as a new view', () => { + expect(viewChanged(view, { ...view, initiative: 'release-safety' })).toBe(true); + expect(viewChanged(view, { ...view, within: 'release-safety' })).toBe(true); + }); + + it('counts nothing changed as the same view', () => { + expect(viewChanged(view, { ...view })).toBe(false); + }); +}); + +describe('the URL’s keys', () => { + it('take a grouping the switch offers, else the default', () => { + expect(groupParam('team')).toBe('team'); + expect(groupParam('parent')).toBe(DEFAULT_GROUP); + expect(groupParam(null)).toBe(DEFAULT_GROUP); + expect(GROUPINGS.map(([k]) => k)).toEqual(GROUP_KEYS); + }); + + it('take a week only on a real Monday', () => { + expect(weekParam('2026-10-12')).toBe('2026-10-12'); + expect(weekParam('2026-10-14')).toBeNull(); // a Wednesday + expect(weekParam('2026-02-30')).toBeNull(); + expect(weekParam('2026-13-45')).toBeNull(); + }); + + it('take years 1970 to 2999 in a window and a zoom', () => { + expect(windowParam('0050-01..0050-06')).toBeNull(); + expect(windowParam('9999-07..9999-12')).toBeNull(); + expect(windowParam('2026-01..2026-06')).toBe('2026-01..2026-06'); + expect(windowParam('2026-Q1..2026-Q2')).toBe('2026-Q1..2026-Q2'); + expect(ZOOM_KEY.test('0050-Q1')).toBe(false); + expect(ZOOM_KEY.test('2026-Q1')).toBe(true); + }); +}); diff --git a/frontend-v2/src/lens.ts b/frontend-v2/src/lens.ts index 82a58a36..29c76e07 100644 --- a/frontend-v2/src/lens.ts +++ b/frontend-v2/src/lens.ts @@ -11,9 +11,72 @@ export const DEFAULT_RANGE = '30d'; * reads it); Last activity's own key is `last`. */ export const DEFAULT_SORT = 'idle'; +/** What the URL holds when nobody picked how the Overview's list and the + * Roadmap split their rows: by initiative, so both open on the short list + * of ranked initiatives. Kept here for the same reason. */ +export const DEFAULT_GROUP = 'initiative'; + +/** The groupings the Group by switch offers (model/portfolio.ts GROUPINGS; + * a test holds the two lists together). */ +export const GROUP_KEYS = ['none', 'initiative', 'lead', 'team']; + +/** A grouping from the URL: one the switch offers, else the default, so an + * old link's `group=parent` shows a grouping the switch has picked. */ +export const groupParam = (key: string | null | undefined): string => + key && GROUP_KEYS.includes(key) ? key : DEFAULT_GROUP; + +/** A year the board draws, 1970 to 2999, so a hand-edited key can't reach + * the years `new Date` reads as 19xx, or a horizon in the year 10000. */ +const YEAR = '(19[7-9]\\d|2\\d{3})'; + +/** A picked week from the URL: a real day that is a Monday, as the load + * chart's weeks start, else null. */ +export function weekParam(key: string | null | undefined): string | null { + const m = new RegExp(`^${YEAR}-(\\d{2})-(\\d{2})$`).exec(key ?? ''); + if (!m) return null; + const day = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + return day.toISOString().slice(0, 10) === key && day.getUTCDay() === 1 ? key : null; +} + /** 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]))$/; +export const ZOOM_KEY = new RegExp(`^${YEAR}-(?:Q([1-4])|(0[1-9]|1[0-2]))$`); + +/** The longest window the roadmap draws: five years. A longer link is cut + * to it, so a typo can't draw a century of columns. */ +export const MAX_WINDOW_MONTHS = 60; + +const WINDOW_PRESET_KEYS = ['quarter', '2q', '6m', 'ytd', '12m', 'year']; +const MONTHS_KEY = new RegExp(`^${YEAR}-(0[1-9]|1[0-2])\\.\\.${YEAR}-(0[1-9]|1[0-2])$`); +const QUARTERS_KEY = new RegExp(`^${YEAR}-Q([1-4])\\.\\.${YEAR}-Q([1-4])$`); + +/** + * The roadmap's window from the URL, checked here for the same reason + * (model/roadmapTime.ts reads it): a preset's key, or a span drawn in months, + * "2025-10..2026-09", or in quarters, "2025-Q4..2026-Q3", the last one + * counted. A span past MAX_WINDOW_MONTHS is cut to it; anything else is + * null. + */ +export function windowParam(key: string | null | undefined): string | null { + if (!key) return null; + if (WINDOW_PRESET_KEYS.includes(key)) return key; + const months = MONTHS_KEY.exec(key); + const quarters = QUARTERS_KEY.exec(key); + const m = months ?? quarters; + if (!m) return null; + // in the key's own periods: months, or quarters + const per = months ? 12 : 4; + const max = months ? MAX_WINDOW_MONTHS : MAX_WINDOW_MONTHS / 3; + const a = Number(m[1]) * per + Number(m[2]) - 1; + const b = Number(m[3]) * per + Number(m[4]) - 1; + if (b < a) return null; + if (b - a < max) return key; + const last = a + max - 1; + const index = (last % per) + 1; + return `${m[1]}-${quarters ? 'Q' : ''}${m[2]}..${Math.floor(last / per)}-${ + quarters ? `Q${index}` : String(index).padStart(2, '0') + }`; +} /** 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 @@ -35,3 +98,10 @@ export const LENS_LABELS: Record = { ci: 'CI', stats: 'Stats', }; + +/** Whether any key of a view (app.tsx's `view`) differs from the last + * one's: each is a view change that earns a history entry, so none can be + * left out of the comparison by accident. */ +export function viewChanged(prev: V, view: V): boolean { + return (Object.keys(view) as (keyof V)[]).some(k => prev[k] !== view[k]); +} diff --git a/frontend-v2/src/model/decide.test.ts b/frontend-v2/src/model/decide.test.ts index af85eb36..e8f1443a 100644 --- a/frontend-v2/src/model/decide.test.ts +++ b/frontend-v2/src/model/decide.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { dayStart, type Today } from '../../../shared/model/projects'; import { closedIssues, + compareRows, decideQueue, type DecideProject, type DecideReason, @@ -13,21 +14,23 @@ 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 { + answerOf, + bulkWords, + callWords, + endsFor, + reasonWords, + writeFor, + type Call, +} from './decideCalls'; import { teamLoad } from './teamLoad'; const today = '2026-09-30'; @@ -453,7 +456,10 @@ describe('Decide’s calls', () => { quiet, today ) - ).toBe('Promised to finish by the end of Q1 2027. Decide asks again if it runs past that.'); + ).toBe( + // its answer said Restart: the receipt says it's under way again + 'Restarted, 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', () => { @@ -514,7 +520,7 @@ describe('Decide’s calls', () => { expect( callWords( answerOf(row([fresh]), target('2026-10-21'), today) as Call, - reopened, + row([fresh]), { open: 0, ongoing: false }, today ) @@ -599,16 +605,16 @@ describe('Decide’s calls', () => { 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, 52), 'do')).toBe('Promise all 52 by the end of Oct'); + expect(bulkWords(times(oct, 52), 'did')).toBe('Promised 52 by the end of Oct'); expect(bulkWords([...times(oct, 40), own(21), own(23)], 'do')).toBe( - 'Promise 40 by Oct 31 and 2 by their target dates' + 'Promise 40 by the end of Oct 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' + 'Promised 1 by the end of Oct 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' + 'Promise 2 by the end of Oct 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'); @@ -760,6 +766,12 @@ describe('reasonWords', () => { expect(said('Waiting on the vendor')).toMatch(/vendor\. Move the end date\?$/); }); + it('says how a Not tracked project grew in the words its home uses', () => { + expect(reasonWords({ kind: 'untracked_grew', since: 31, baseline: 2 }, null)).toMatch( + /^33 PRs, 31 since it was marked Not tracked\./ + ); + }); + 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?' @@ -850,3 +862,250 @@ describe('filledIn', () => { }); const dataOfNumber = (p: unknown) => ((p as { data?: PullData }).data ?? (p as PullData)).number; + +describe('decideQueue: where projects belong', () => { + const initiative = ( + id: number, + projects: string[], + over: Partial<{ kind: 'initiative' | 'upkeep' | 'untracked'; status: 'open' | 'done' | 'dropped'; status_at: number | null }> = {} + ) => ({ + id, + kind: 'initiative' as const, + status: 'open' as const, + status_at: null, + projects: projects.map(project => ({ project, set_by: null, set_at: ago(60), baseline_prs: 0 })), + ...over, + }); + + it('asks nothing about homes while no initiative exists', () => { + const rows = decideQueue({ + live: [project('big', { prs: 5 })], + items: [item(1, { project: 'planned', start: '2026-10-05', end_kind: 'soft' })], + today, + now: NOW, + // a stall keeps `new` away, so only unfiled could ask + ongoing: new Set(['big']), + }); + expect(kinds(rows)).toEqual([]); + }); + + it('asks a tracked project with no home where it goes: 3+ PRs, or a plan under way', () => { + const rows = decideQueue({ + live: [ + project('fresh'), + project('upkeep', { prs: 5 }), + project('small', { prs: 2 }), + project('filed', { prs: 9 }), + ], + items: [ + item(1, { project: 'future', start: '2026-11-02', end_kind: 'soft' }), + item(2, { project: 'finished', status: 'done', updated_at: NOW }), + ], + ongoing: new Set(['upkeep']), + initiatives: [initiative(7, ['filed'])], + today, + now: NOW, + }); + // `new` files from its own row, so it isn't asked twice; a filed + // project is asked only for its plan + expect(kinds(rows)).toEqual([ + ['future', 'unfiled'], + ['upkeep', 'unfiled'], + ['filed', 'new'], + ['fresh', 'new'], + ]); + expect(rows.find(r => r.slug === 'future')?.item?.id).toBe(1); + }); + + it('puts a project’s unfiled call on the row it already has', () => { + const rows = decideQueue({ + live: [project('quiet', { lastActivity: ago(30), prs: 4 })], + items: [], + initiatives: [initiative(7, [])], + today, + now: NOW, + }); + expect(kinds(rows)).toEqual([['quiet', 'stalled', 'unfiled']]); + }); + + it('asks about a grown not-tracked project, with its counts', () => { + const rows = decideQueue({ + live: [], + items: [], + grown: [{ slug: 'ops-ci', since: 32, baseline: 32 }], + today, + now: NOW, + }); + expect(rows).toEqual([ + { slug: 'ops-ci', item: null, reasons: [{ kind: 'untracked_grew', since: 32, baseline: 32 }] }, + ]); + }); + + it('asks whether an initiative reached its goal once every project settled two weeks ago', () => { + const done = item(1, { project: 'a', status: 'done', status_at: ago(20), updated_at: ago(20) }); + const parked = item(2, { project: 'b', status: 'parked', status_at: ago(15), updated_at: ago(15) }); + const ask = (over: Parameters[0]) => kinds(decideQueue(over)); + const base = { + items: [done, parked], + // c has no plan and nothing in flight + initiatives: [initiative(7, ['a', 'b', 'c'])], + today, + now: NOW, + }; + expect(ask({ ...base, live: [] })).toEqual([['#undefined', 'goal_reached']]); + const [row] = decideQueue({ ...base, live: [] }); + expect(row.reasons[0]).toEqual({ kind: 'goal_reached', initiative: 7 }); + // a project with no plan still in flight, or a plan settled lately, keeps it open + expect(ask({ ...base, live: [project('c', { prs: 1 })] })).toEqual([]); + expect( + ask({ ...base, live: [], items: [done, { ...parked, status_at: ago(3), updated_at: ago(3) }] }) + ).toEqual([]); + // Keep open quiets it until something in it changes after, and that + // change has settled for two weeks too + const kept = [initiative(7, ['a', 'b', 'c'], { status_at: ago(15) })]; + expect(ask({ ...base, live: [], initiatives: kept })).toEqual([]); + const refiled = (at: number) => [ + { + ...kept[0], + projects: [...kept[0].projects, { project: 'd', set_by: null, set_at: at, baseline_prs: 0 }], + }, + ]; + expect(ask({ ...base, live: [], initiatives: refiled(ago(1)) })).toEqual([]); + expect(ask({ ...base, live: [], initiatives: refiled(ago(14.5)) })).toEqual([ + ['#undefined', 'goal_reached'], + ]); + // never Keep the lights on, Not tracked, or an empty initiative + expect(ask({ ...base, live: [], initiatives: [initiative(8, ['a'], { kind: 'upkeep' })] })).toEqual([]); + expect(ask({ ...base, live: [], initiatives: [initiative(9, [])] })).toEqual([]); + }); + + it('waits two weeks after a long-finished project is filed before asking about the goal', () => { + // the load files a project whose plan was done 40 days ago + const ask = (filedAt: number) => + kinds( + decideQueue({ + items: [item(1, { project: 'a', status: 'done', status_at: ago(40), updated_at: ago(40) })], + live: [], + initiatives: [ + { + ...initiative(7, []), + projects: [{ project: 'a', set_by: null, set_at: filedAt, baseline_prs: 0 }], + }, + ], + today, + now: NOW, + }) + ); + expect(ask(NOW - 60_000)).toEqual([]); + expect(ask(ago(15))).toEqual([['#undefined', 'goal_reached']]); + }); + + it('asks about a closed initiative’s project that is worked on again, worst first', () => { + const rows = decideQueue({ + live: [project('back', { open: 2, prs: 2 }), project('fresh', { prs: 4 })], + items: [item(1, { project: 'plan', end_kind: 'soft' })], + initiatives: [ + initiative(7, ['back', 'plan', 'gone'], { status: 'done', status_at: ago(30) }), + initiative(8, ['fresh']), + ], + today, + now: NOW, + }); + expect(kinds(rows).sort()).toEqual([ + ['back', 'reopened_in_closed'], + ['fresh', 'new'], + ['plan', 'reopened_in_closed'], + ]); + expect(kinds(rows).at(-1)).toEqual(['fresh', 'new']); + expect(rows.find(r => r.slug === 'back')?.reasons).toEqual([ + { kind: 'reopened_in_closed', initiative: 7, as: 'done' }, + ]); + }); + + it('never asks to close an initiative the server would refuse: a finished plan with PRs open is still going', () => { + // a's plan was done 20 days ago, but 3 PRs are open and moved yesterday + const base = { + items: [item(1, { project: 'a', status: 'done', status_at: ago(20), updated_at: ago(20) })], + live: [project('a', { open: 3, prs: 3, lastActivity: ago(1) })], + today, + now: NOW, + }; + const open = decideQueue({ ...base, initiatives: [initiative(7, ['a'])] }); + expect(open.some(r => r.reasons.some(x => x.kind === 'goal_reached'))).toBe(false); + // and the same rule says it's going once the initiative is closed + const closed = decideQueue({ + ...base, + initiatives: [initiative(7, ['a'], { status: 'done', status_at: ago(10) })], + }); + expect(closed.find(r => r.slug === 'a')?.reasons.map(x => x.kind)).toContain( + 'reopened_in_closed' + ); + }); + + it('keeps an initiative kept open quiet while its finished projects merge more', () => { + const ask = (live: Parameters[0]['live']) => + kinds( + decideQueue({ + items: [item(1, { project: 'a', status: 'done', status_at: ago(20), updated_at: ago(20) })], + live, + initiatives: [initiative(7, ['a'], { status_at: ago(5) })], + today, + now: NOW, + }) + ).filter(([, kind]) => kind === 'goal_reached'); + expect(ask([])).toEqual([]); + // a merge yesterday, nothing open: no plan or filing changed + expect(ask([project('a', { open: 0, prs: 1, lastActivity: ago(1) })])).toEqual([]); + }); + + it('asks about a closed initiative on the row the project already has, once', () => { + // done 20 days ago, PRs still open: `reopened`, in a closed initiative + const rows = decideQueue({ + items: [item(1, { project: 'a', status: 'done', status_at: ago(20), updated_at: ago(20) })], + live: [project('a', { open: 3, prs: 3, firstOpened: '2026-09-01', lastActivity: ago(1) })], + initiatives: [initiative(7, ['a'], { status: 'done', status_at: ago(10) })], + today, + now: NOW, + }); + const mine = rows.filter(r => r.slug === 'a'); + expect(mine).toHaveLength(1); + expect(mine[0].item?.id).toBe(1); + expect(mine[0].reasons.map(r => r.kind).sort()).toEqual(['reopened', 'reopened_in_closed']); + }); + + it('asks about a project’s home on its worst row, whichever plan came first', () => { + // two plans of a, asked about for different reasons, in both orders + const plans = [ + item(1, { project: 'a', status: 'active', start: '2026-08-24', weeks: 2, end_kind: 'hard' }), + item(2, { project: 'a', status: 'active', start: '2026-06-01', weeks: 4, end_kind: 'hard' }), + ]; + for (const items of [plans, [...plans].reverse()]) { + const rows = decideQueue({ + items, + live: [project('a', { open: 3, prs: 3, firstOpened: '2026-05-01', lastActivity: ago(1) })], + initiatives: [initiative(7, ['a'], { status: 'done', status_at: ago(10) })], + today, + now: NOW, + }); + const mine = rows.filter(r => r.slug === 'a'); + expect(mine).toHaveLength(2); + const without = (r: DecideRow) => ({ + ...r, + reasons: r.reasons.filter(x => x.kind !== 'reopened_in_closed'), + }); + const worst = mine.map(without).sort(compareRows)[0]; + const asked = mine.find(r => r.reasons.some(x => x.kind === 'reopened_in_closed')); + expect(asked?.item?.id).toBe(worst.item?.id); + } + }); + + it('asks which project an active plan with no project is, once initiatives exist', () => { + const items = [ + item(1, { project: null, status: 'active', end_kind: 'soft' }), + item(2, { project: null, status: 'planned', start: '2026-11-02', end_kind: 'soft' }), + ]; + expect(kinds(decideQueue({ live: [], items, today, now: NOW }))).toEqual([]); + const rows = decideQueue({ live: [], items, initiatives: [initiative(7, [])], today, now: NOW }); + expect(rows.map(r => [r.item?.id, r.reasons.map(x => x.kind)])).toEqual([[1, ['no_project']]]); + }); +}); diff --git a/frontend-v2/src/model/decideCalls.ts b/frontend-v2/src/model/decideCalls.ts new file mode 100644 index 00000000..eb98a6f7 --- /dev/null +++ b/frontend-v2/src/model/decideCalls.ts @@ -0,0 +1,795 @@ +/** + * Decide's question model, with no components: the sections a row sits in, + * what each reason asks and its answers, the words for a call, and the call + * types. Decide.tsx, the home answers (HomeCalls.tsx) and every view that + * shows a Decide row read it, so none of them imports another's components + * to get at it. + */ +import { createContext, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { n } from '../../../shared/format'; +import { + DECIDE_MIN_PRS, + STALL_DAYS, + type DecideReason, + type DecideRow, +} from '../../../shared/model/decide'; +import { firstOpenDay } from '../../../shared/model/projects'; +import { + addWeeks, + HEALTH_WORD, + isUnderWay, + mondayOf, + planEnd, + weeksThrough, + type RoadmapFields, + type RoadmapItem, +} from '../../../shared/model/roadmap'; +import { slugName, type PortfolioItem } from './portfolio'; +import { dayWords } from './projectData'; +import { commitEnds, type CommitEnd } from './roadmapTime'; +import { readInitiatives } from './initiativesData'; +import { homesOf } from '../../../shared/model/initiatives'; +import { + ALL_ISSUES_CLOSED, + andList, + LAST_14_DAYS, + missedTarget, + NEEDS_A_PLAN, + noPrActivity, + ONGOING, + pastEnd, + targetOn, + NO_PROJECT_YET, + MARKED_NOT_TRACKED, + UNSORTED_WORD, +} from './words'; +import { PLAN_STATUS_WORD, when } from '../views/projects/roadmapHealth'; + +export 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. */ +export 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. */ +export const SECTIONS: { + kinds: DecideReason['kind'][]; + title: string; + more: string[]; +}[] = [ + { + kinds: ['reopened_in_closed'], + title: 'Its initiative is closed, still worked on', + more: [ + 'Its initiative was closed as done or dropped, and the project has open PRs or a plan under way again. Reopen the initiative, or file the project in another. A plan of its own marked done but still worked on is asked about once this is answered.', + ], + }, + { + 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: ['goal_reached'], + title: 'Goal maybe reached', + more: [ + 'Every project in the initiative is done, dropped, parked, or quiet with no plan, and has been for two weeks. Close it, or keep it open; kept open, it’s asked again only once something in it changes.', + ], + }, + { + 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, + ], + }, + { + kinds: ['unfiled'], + title: UNSORTED_WORD, + more: [ + `Filed nowhere yet, with ${DECIDE_MIN_PRS} or more PRs open or merged in the ${LAST_14_DAYS} or a plan under way. File it in an initiative, in Keep the lights on, or in Not tracked.`, + ], + }, + { + kinds: ['no_project'], + title: NO_PROJECT_YET, + more: [ + 'An active plan with no project label, so it can’t be filed in an initiative. Link it to its project, and it goes where that project is filed.', + ], + }, + { + kinds: ['untracked_grew'], + title: 'Not tracked, but growing', + more: [ + 'Marked Not tracked, and since then it has opened as many PRs as it had before, and 30 or more. Keep it Not tracked and the count starts again, or track it in an initiative.', + ], + }, +]; + +export 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. + * Every view that asks a row's question asks this one. */ +export 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. */ +export 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; +} + +/** The initiative a row asks about itself (whether it reached its goal, or + * reopening it), by id; null for a project's own questions. */ +export const askedInitiative = (row: DecideRow): number | null => { + for (const r of row.reasons) { + if (r.kind === 'goal_reached' || r.kind === 'reopened_in_closed') return r.initiative; + } + return null; +}; +export const rowKey = (row: DecideRow) => + !row.slug && !row.item && askedInitiative(row) != null + ? `initiative:${askedInitiative(row)}` + : `${row.slug ?? ''}:${row.item?.id ?? ''}`; +export const kindsOf = (row: DecideRow) => row.reasons.map(r => r.kind).join(','); +export const nameOf = ( + row: DecideRow, + project?: Pick & Partial> +) => + // a row asking where a project goes is named by the project, never its + // plan: its issue's name, or its label's words (a project with no issue + // otherwise takes its plan's name) + (row.slug && primaryOf(row)?.kind === 'unfiled' + ? project?.project?.name ?? slugName(row.slug) + : null) ?? + row.item?.name ?? + project?.name ?? + row.slug ?? + initiativeById(askedInitiative(row))?.name ?? + 'A plan'; +/** An initiative by id, as the board has them now. */ +export const initiativeById = (id: number | null) => + id == null ? undefined : readInitiatives().initiatives?.find(i => i.id === id); +/** The reasons that ask where a project belongs, or about an initiative, + * and are answered by a filing or the initiative's status, not a plan. */ +export const HOME_KINDS: readonly DecideReason['kind'][] = [ + 'unfiled', + 'no_project', + 'untracked_grew', + 'goal_reached', + 'reopened_in_closed', +]; +export const isHomeReason = (r: DecideReason) => HOME_KINDS.includes(r.kind); + +/** Every project, Not tracked ones too, for a row's one-slug lookups (a grown + * project's name and team) and the chooser's guess. Projects.tsx gives it. */ +export const AllItemsContext = createContext([]); + +export const closedAs = (as: 'done' | 'dropped') => (as === 'done' ? 'completed' : 'not planned'); +export 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". */ +export 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' } + /** file the project in the initiative with slug `to`, named `name`; + * `untracked` when that's Not tracked */ + | { kind: 'file'; to: string; name: string; untracked?: boolean } + /** keep it Not tracked (the initiative with slug `to`), starting its + * count again */ + | { kind: 'keep_untracked'; to: string } + /** give a plan with no project its project, by slug */ + | { kind: 'link'; project: string; name: string } + /** close the initiative asked about */ + | { kind: 'close'; as: 'done' | 'dropped' } + /** keep it open, asked again only once something in it changes */ + | { kind: 'keep_open' } + /** reopen the closed initiative a project is in */ + | { kind: 'reopen' }; + +/** The calls that write a plan. */ +export type PlanCallKind = Extract; +/** The calls that write a filing or an initiative's status. */ +export type HomeCall = Extract< + Call, + { kind: 'file' | 'keep_untracked' | 'link' | 'close' | 'keep_open' | 'reopen' } +>; +export const isHomeCall = (call: Call): call is HomeCall => + ['file', 'keep_untracked', 'link', 'close', 'keep_open', 'reopen'].includes(call.kind); + +/** A filing that rides along with a plan call: the home picked in the row's + * Initiative chooser, when it differs from the one it has. */ +export interface Filed { + to: string; + name: string; + /** the home it had, which `expect` checks and Undo puts back */ + from: string | null; + /** it's Not tracked */ + untracked?: boolean; +} + +/** Where a filing puts a project, mid-sentence: "Not tracked", "in X". */ +export const filedWhere = (filed: Pick) => + filed.untracked ? 'Not tracked' : `in ${filed.name}`; + +/** The question that asks a plan to end later: its answers are only ends + * after the one it has. */ +export 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' }; + // where it belongs: answered with a filing or the initiative's status + case 'unfiled': + return { question: 'Which initiative is it in?', call: 'file' }; + case 'no_project': + return { question: 'Which project is it?', call: 'link' }; + case 'untracked_grew': + return { question: 'Still Not tracked?', call: 'keep_untracked' }; + case 'goal_reached': + return { question: 'Did it reach its goal?', call: 'close' }; + case 'reopened_in_closed': + return { question: 'Reopen its initiative?', call: 'reopen' }; + } +} + +/** 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; + // a filing or an initiative's status: its own strip suggests it (HomeStrip) + if (isHomeReason(primaryOf(row))) return null; + const asked = restarted(row) + ? 'commit' + : (askOf(primaryOf(row)).call as Exclude); + 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; +} + +/** A finished plan still worked on whose initiative was reopened after it + * finished: the work started again, so its answer restarts it rather than + * keeping it done. */ +export function restarted(row: DecideRow): boolean { + const plan = row.item; + if (!plan || !row.slug || primaryOf(row).kind !== 'reopened') return false; + const home = homesOf(readInitiatives().initiatives ?? []).get(row.slug)?.initiative; + return ( + home?.kind === 'initiative' && + home.status === 'open' && + (home.status_at ?? 0) > (plan.status_at ?? plan.updated_at ?? 0) + ); +} + +export const VERBS: Record = { + commit: ['Promise', 'Promised'], + park: ['Park', 'Parked'], + done: ['Mark', 'Marked'], + drop: ['Drop', 'Dropped'], + ongoing: ['Mark', 'Marked'], + file: ['File', 'Filed'], + keep_untracked: ['Keep', 'Kept'], + link: ['Link', 'Linked'], + close: ['Close', 'Closed'], + keep_open: ['Keep', 'Kept'], + reopen: ['Reopen', 'Reopened'], +}; + +/** + * What one click on a section does to its rows, or did, in the words each + * row's own button says: "Promise all 52 by the end of Oct", "Promised 52 + * by the end of Oct". Rows whose answers differ say each one and how many + * ("Promise 5 by the end of Oct and mark 3 done"), and rows going through + * their own target dates count together. + */ +export function bulkWords( + made: readonly Call[], + tense: 'do' | 'did', + /** false when the section leaves some row for its own answer: no "all" */ + every = true +): 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 = every && 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 && 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': { + // the receipt's words and the end buttons' ("End of Oct"): one + // date said one way + const day = call.through; + 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'; + case 'file': + return call.untracked ? 'Mark Not tracked' : `File in ${call.name}`; + case 'keep_untracked': + return 'Keep Not tracked'; + case 'link': + return `Link to ${call.name}`; + case 'close': + return call.as === 'done' ? 'Close as done' : 'Close as dropped'; + case 'keep_open': + return 'Keep open'; + case 'reopen': + return 'Reopen it'; + } +} + +/** + * The facts behind a reason, in words. `bare` leaves out what the reason's + * section title already says, for a row under that title. + */ +export 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}`; + } + case 'unfiled': + return bare ? 'Filed nowhere yet' : `It’s ${UNSORTED_WORD}, filed nowhere yet`; + case 'no_project': + return bare + ? 'Under way with no project label' + : 'It has no project, so it can’t be filed in an initiative'; + case 'untracked_grew': + return `${n(reason.baseline + reason.since, 'PR')}, ${ + reason.since + } since it was marked Not tracked`; + case 'goal_reached': + return 'Every project in it is done, dropped, parked or quiet, for two weeks or more'; + case 'reopened_in_closed': { + const name = initiativeById(reason.initiative)?.name ?? 'Its initiative'; + return `${name} was closed as ${reason.as}, and this is being worked on again`; + } + } +} + +/** 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?"). */ +export 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. */ +export 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. */ +export 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 + ); +} + +export 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: PlanCallKind, + 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. */ +export 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, + filed?: Pick +): string { + // a plan call that files the project too says where, once + const home = filed && filedWhere(filed); + if (home && call.kind !== 'commit' && !isHomeCall(call)) { + return `${callWords(call, row, project, today)} It’s ${home} now.`; + } + const initiative = initiativeById(askedInitiative(row)); + switch (call.kind) { + case 'commit': { + // a stopped plan's answer was Restart: the receipt says it's under way again + const was = row.item?.status; + const restarted = was === 'parked' || was === 'done' || was === 'dropped'; + return `${restarted ? 'Restarted, promised' : 'Promised'} to finish by ${call.through}${ + home ? `, ${home}` : '' + }. Decide asks again if it runs past that.`; + } + case 'file': + return call.untracked + ? `${MARKED_NOT_TRACKED}.` + : `Filed in ${call.name}.`; + case 'link': { + // what the link did, by where that project is filed now + const home = homesOf(readInitiatives().initiatives ?? []).get(call.project)?.initiative; + return !home + ? `Linked to ${call.name}. It’s ${UNSORTED_WORD}, so Decide asks where it goes.` + : home.kind === 'untracked' + ? `Linked to ${call.name}, so it’s Not tracked.` + : `Linked to ${call.name}, so it’s in ${home.name}.`; + } + case 'keep_untracked': + return 'Kept Not tracked. Its count starts again, so Decide asks only if it doubles again.'; + case 'close': + return `Closed ${initiative?.name ?? 'it'} as ${call.as}.`; + case 'keep_open': + return 'Kept open. Decide asks again once something in it changes.'; + case 'reopen': + return `Reopened ${initiative?.name ?? 'its initiative'}.`; + 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}.`; + } + } +} + +/** 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. */ +export 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(); +} diff --git a/frontend-v2/src/model/initiativeBoard.test.ts b/frontend-v2/src/model/initiativeBoard.test.ts new file mode 100644 index 00000000..21e05c30 --- /dev/null +++ b/frontend-v2/src/model/initiativeBoard.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from 'vitest'; +import type { Initiative } from '../../../shared/model/initiatives'; +import type { Project, ProjectGroup, Today } from '../../../shared/model/projects'; +import { weeksPast, type RoadmapItem } from '../../../shared/model/roadmap'; +import { + bandGloss, + grownProjects, + guessHome, + homeChoices, + homeWords, + initiativeSplitKey, + initiativeSplitLabel, + initiativeSplitRank, + spanWords, + SPLIT_NO_PROJECT, + SPLIT_NOT_TRACKED, + SPLIT_ONE_OFFS, + SPLIT_UNSORTED, + trackedToday, +} from './initiativeBoard'; +import { goingProjects, homesOf } from '../../../shared/model/initiatives'; +import { groupItems, portfolioItems, type PortfolioItem } from './portfolio'; +import { trackedData, notTrackedSlugs, type ProjectsData } from './projectData'; +import { mergeOrder, trackedPlans } from './roadmapData'; + +const NOW = Date.UTC(2026, 9, 11, 12); + +const initiative = ( + id: number, + slug: string, + projects: string[], + over: Partial = {} +): Initiative => ({ + id, + slug, + name: slug.replace(/-/g, ' '), + kind: 'initiative', + goal: '', + owner: null, + priority: id, + status: 'open', + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: null, + projects: projects.map(project => ({ project, set_by: null, set_at: 0, baseline_prs: 0 })), + ...over, +}); + +const group = (slug: string, people: string[] = []): ProjectGroup => ({ + slug, + project: null, + open: [], + drafts: [], + merged: [], + closed: [], + people, + idleDays: null, + lastActivity: null, + flags: [], +}); + +const plan = (id: number, project: string, over: Partial = {}): RoadmapItem => ({ + id, + name: `Plan ${id}`, + project, + team: null, + lead: null, + status: 'active', + 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, +}); + +const person = ( + slug: string, + developers: string[] +): Pick => ({ + slug, + developers, + nonDevelopers: [], +}); + +describe('trackedToday', () => { + it('leaves the Not tracked projects out, and the PRs in no project in', () => { + const today: Today = { + live: [group('kept'), group('ops')], + quiet: [group('quiet-ops')], + misc: [], + unsorted: [], + doubleLabeled: [], + drafts: [], + }; + const tracked = trackedToday(today, new Set(['ops', 'quiet-ops'])); + expect(tracked.live.map(g => g.slug)).toEqual(['kept']); + expect(tracked.quiet).toEqual([]); + expect(trackedToday(today, new Set())).toBe(today); + }); +}); + +describe('trackedToday’s drafts', () => { + const draft = (number: number, label: string | null) => + ({ + data: { repo: 'o/r', number, labels: label ? [{ title: label }] : [] }, + } as unknown as Today['drafts'][number]); + it('leaves out a Not tracked project’s drafts that no group lists', () => { + const today: Today = { + live: [], + quiet: [], + misc: [], + unsorted: [], + doubleLabeled: [], + drafts: [draft(1, 'project:ops'), draft(2, 'project:store'), draft(3, null)], + }; + const tracked = trackedToday(today, new Set(['ops']), { prefix: 'project:' }); + expect(tracked.drafts.map(p => p.data.number)).toEqual([2, 3]); + }); +}); + +describe('grownProjects', () => { + it('asks about the grown ones still Not tracked, with their counts', () => { + const data = { + grew: ['ops', 'moved'], + grew_counts: { ops: { since: 31, baseline: 2 }, moved: { since: 40, baseline: 9 } }, + }; + // moved was filed in an initiative on this board since + const filed = [initiative(1, 'site', ['moved'])]; + expect(grownProjects(data, filed)).toEqual([{ slug: 'ops', since: 31, baseline: 2 }]); + expect(grownProjects(undefined, null)).toEqual([]); + }); +}); + +describe('guessHome', () => { + const initiatives = [ + initiative(1, 'site-speed', ['cache', 'cdn']), + initiative(2, 'fixbot', ['answers']), + initiative(3, 'not-tracked', ['dotfiles'], { kind: 'untracked' }), + initiative(4, 'old', ['legacy'], { status: 'done' }), + ]; + const items = [ + person('new-thing', ['dana', 'erin']), + person('cache', ['dana']), + person('cdn', ['dana', 'finn']), + person('answers', ['erin']), + person('dotfiles', ['dana', 'erin']), + person('legacy', ['dana', 'erin']), + ]; + + it('picks the initiative its people work in most', () => { + expect(guessHome('new-thing', items, initiatives)?.slug).toBe('site-speed'); + }); + + it('never guesses Not tracked or a closed initiative', () => { + const only = items.filter(i => ['new-thing', 'dotfiles', 'legacy'].includes(i.slug)); + expect(guessHome('new-thing', only, initiatives)).toBeNull(); + }); + + it('breaks a tie by rank', () => { + const tie = [ + person('new-thing', ['erin']), + person('cache', ['erin']), + person('answers', ['erin']), + ]; + expect(guessHome('new-thing', tie, initiatives)?.slug).toBe('site-speed'); + }); +}); + +describe('the words', () => { + it('lists the homes to choose: open initiatives in rank, Not tracked last', () => { + const list = [ + initiative(5, 'not-tracked', [], { kind: 'untracked', priority: 0 }), + initiative(2, 'b', []), + initiative(1, 'a', []), + initiative(3, 'closed', [], { status: 'dropped' }), + ]; + expect(homeChoices(list).map(i => i.slug)).toEqual(['a', 'b', 'not-tracked']); + }); + + it('says where a project lives', () => { + expect(homeWords(initiative(1, 'site-speed', []))).toBe('in site speed'); + expect(homeWords(initiative(2, 'nt', [], { kind: 'untracked' }))).toBe('Not tracked'); + expect(homeWords(null)).toBe('Unsorted'); + }); + + it('says a band’s dates and counts from its projects’ plans', () => { + const plans = [ + plan(1, 'a', { start: '2026-07-13', weeks: 16 }), + plan(2, 'a', { start: '2026-08-03', weeks: 4, end_kind: 'ongoing' }), + plan(3, 'b', { start: '2026-06-01', status: 'done', weeks: 2 }), + plan(4, 'c', { start: '2026-01-05', status: 'parked' }), + ]; + expect(spanWords(['a', 'b', 'c'], plans, NOW)).toBe( + 'Jun 1 to Nov 1 · 1 plan with an end date · 1 ongoing' + ); + // open with every plan stopped: not "Ended", which reads as closed + expect(spanWords(['b'], plans, NOW)).toBe('Nothing under way since Jun 14'); + // closed: said the one way, with its day + expect( + spanWords(['a'], plans, NOW, { status: 'done', status_at: Date.UTC(2026, 8, 20) / 1000 }) + ).toBe('Closed as done Sep 20'); + expect(spanWords(['nothing'], plans, NOW)).toBe(''); + }); + + it('glosses a band with its goal, what it holds and its owner', () => { + const site = initiative(1, 'site', [], { goal: 'Pages load fast', owner: 'dana' }); + expect(bandGloss(site, 10, 7)).toBe( + 'Pages load fast. 10 projects, 7 being worked on. Owned by dana.' + ); + expect(bandGloss({ ...site, status: 'done' }, 2, 0)).toBe( + 'Pages load fast. 2 projects, 0 being worked on. Owned by dana. Closed as done.' + ); + }); + + it('says when an initiative’s latest end has gone by, so it never reads as finished', () => { + // Oct 11: its plan was due Sep 27 + const plans = [plan(1, 'a', { start: '2026-08-17', weeks: 6 })]; + expect(spanWords(['a'], plans, NOW)).toBe('Aug 17 to Sep 27, 2 weeks past · 1 plan with an end date'); + // a day later it's 15 days: 3 weeks, as the plan's own row rounds + expect(spanWords(['a'], plans, NOW + 86400 * 1000)).toBe( + `Aug 17 to Sep 27, ${weeksPast('2026-09-27', '2026-10-12')} weeks past · 1 plan with an end date` + ); + expect(weeksPast('2026-09-27', '2026-10-12')).toBe(3); + }); +}); + +describe('the one rule for an initiative’s work still going', () => { + it('counts a plan under way, or PRs open on a finished one', () => { + const plans = [ + plan(1, 'going'), + plan(2, 'done-quiet', { status: 'done' }), + plan(3, 'done-open', { status: 'done' }), + plan(4, 'parked', { status: 'parked' }), + ]; + const open = (slug: string) => (slug === 'done-open' || slug === 'unplanned-open' ? 2 : 0); + expect( + goingProjects( + ['going', 'done-quiet', 'done-open', 'parked', 'unplanned-open', 'idle'], + plans, + open + ) + ).toEqual(['going', 'done-open', 'unplanned-open']); + }); +}); + +describe('Look back’s split by initiative', () => { + const list = [ + initiative(1, 'site', ['fast']), + initiative(2, 'klo', ['db'], { kind: 'upkeep', priority: 2 }), + initiative(3, 'shelf', ['dotfiles'], { kind: 'untracked', priority: 1 }), + ]; + const homes = homesOf(list); + const key = (project: string | null) => + initiativeSplitKey(project, homes, new Set(['ops-repo-default'])); + + it('keys each PR by its project’s home, never a hard-coded slug', () => { + expect(key('fast')).toBe('site'); + expect(key('db')).toBe('klo'); + // Not tracked is one group, filed there or by its repo, whatever its slug + expect(key('dotfiles')).toBe(SPLIT_NOT_TRACKED); + expect(key('ops-repo-default')).toBe(SPLIT_NOT_TRACKED); + // a project filed nowhere apart from small work and no project at all + expect(key('loose')).toBe(SPLIT_UNSORTED); + expect(key('misc')).toBe(SPLIT_ONE_OFFS); + expect(key(null)).toBe(SPLIT_NO_PROJECT); + }); + + it('names the groups as the Projects table does, in rank then the rest', () => { + expect( + [SPLIT_NO_PROJECT, SPLIT_ONE_OFFS, SPLIT_NOT_TRACKED, SPLIT_UNSORTED, 'klo', 'site'].map( + k => initiativeSplitLabel(k, list) + ) + ).toEqual([ + 'Not in a project', + 'Small work, no project', + 'Not tracked', + 'Unsorted', + 'klo', + 'site', + ]); + const rank = initiativeSplitRank(list); + const keys = [SPLIT_NO_PROJECT, SPLIT_ONE_OFFS, SPLIT_NOT_TRACKED, SPLIT_UNSORTED, 'klo', 'site']; + expect([...keys].sort((a, b) => rank(a) - rank(b))).toEqual([ + 'site', + 'klo', + SPLIT_UNSORTED, + SPLIT_NOT_TRACKED, + SPLIT_ONE_OFFS, + SPLIT_NO_PROJECT, + ]); + }); +}); + +/** + * The tracked sources (R4): a Not tracked project is absent from every list + * a view reads by default, and present in the three places that name the + * full set: its own project page, the Roadmap's Not tracked lane and the + * Overview's Not tracked group. + */ +describe('a Not tracked project', () => { + const project = (slug: string): Project => ({ + slug, + name: slug, + 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, + }); + const data = { + projects: [project('store'), project('ops')], + untracked: ['ops'], + window: { + projects: { store: {}, ops: {} }, + totals: { opened: 9 }, + }, + } as unknown as ProjectsData; + const fullToday: Today = { + live: [group('store', ['dana']), group('ops', ['dana'])], + quiet: [], + misc: [], + unsorted: [], + doubleLabeled: [], + drafts: [], + }; + // the server set the flag on its plan + const allPlans = [plan(1, 'store'), plan(2, 'ops', { untracked: true }), plan(3, 'store')]; + const initiatives = [initiative(1, 'store-work', ['store'])]; + const untracked = notTrackedSlugs(data, initiatives); + const today = trackedToday(fullToday, untracked); + const plans = trackedPlans(allPlans, initiatives); + const all = portfolioItems(data.projects, fullToday, {}, () => 'Store', NOW, allPlans); + const items = all.filter(i => !untracked.has(i.slug)); + + it('is in none of the default lists', () => { + expect(untracked).toEqual(new Set(['ops'])); + expect(today.live.map(g => g.slug)).toEqual(['store']); + expect(plans.map(p => p.id)).toEqual([1, 3]); + expect(items.map(i => i.slug)).toEqual(['store']); + const tracked = trackedData(data, initiatives); + expect(tracked.projects.map(p => p.slug)).toEqual(['store']); + expect(Object.keys(tracked.window.projects)).toEqual(['store']); + // the server's aggregates count everything + expect(tracked.window.totals).toBe(data.window.totals); + // grouped by initiative it's in no group, Unsorted included + expect( + groupItems(items, 'initiative', undefined, [], initiatives).flatMap(g => g.items) + ).toEqual(items); + }); + + it('is on its own page, in the Roadmap’s Not tracked lane and the Overview’s group', () => { + // its page reads one slug from the full set + expect(all.find(i => i.slug === 'ops')?.plan?.id).toBe(2); + // the Roadmap's lane: every plan less the tracked ones + const tracked = new Set(plans.map(p => p.id)); + expect(allPlans.filter(p => !tracked.has(p.id)).map(p => p.id)).toEqual([2]); + // the Overview's group + expect(all.filter(i => untracked.has(i.slug)).map(i => i.slug)).toEqual(['ops']); + // and a reorder of the tracked plans keeps its place in the whole order + expect(mergeOrder(allPlans, [3, 1])).toEqual([3, 2, 1]); + }); + + it('is tracked again the moment a filing on this board says so', () => { + const filed = [...initiatives, initiative(2, 'ops-work', ['ops'])]; + expect(notTrackedSlugs(data, filed)).toEqual(new Set()); + expect(trackedPlans(allPlans, filed).map(p => p.id)).toEqual([1, 2, 3]); + }); +}); + +describe('Group by Initiative’s bands', () => { + it('have keys of their own when two share a name, or one is called Unsorted', () => { + const initiatives = [ + initiative(1, 'a', ['p'], { name: 'Same' }), + initiative(2, 'b', ['q'], { name: 'Same' }), + initiative(3, 'c', ['r'], { name: 'Unsorted' }), + ]; + const items = ['p', 'q', 'r', 'loose'].map(slug => ({ slug } as PortfolioItem)); + const keys = groupItems(items, 'initiative', undefined, [], initiatives).map(g => g.key); + expect(keys).toHaveLength(4); + expect(new Set(keys).size).toBe(4); + }); +}); diff --git a/frontend-v2/src/model/initiativeBoard.ts b/frontend-v2/src/model/initiativeBoard.ts new file mode 100644 index 00000000..475f4123 --- /dev/null +++ b/frontend-v2/src/model/initiativeBoard.ts @@ -0,0 +1,234 @@ +import { n } from '../../../shared/format'; +import type { GrownProject } from '../../../shared/model/decide'; +import { homesOf, initiativeSpan, type Initiative } from '../../../shared/model/initiatives'; +import { MISC_SLUG, type PullLinks, type Today } from '../../../shared/model/projects'; +import { projectOf } from '../../../shared/model/projectLabel'; +import { issueKey } from '../../../shared/model/issueRef'; +import { weeksPast, type RoadmapItem } from '../../../shared/model/roadmap'; +import type { PortfolioItem } from './portfolio'; +import { dayWords, type ProjectsData } from './projectData'; +import { byRank, isNotTrackedNow } from './initiativesData'; +import { NOT_IN_A_PROJECT, ONE_OFFS, UNSORTED_WORD } from './words'; + +/** + * The board's side of initiatives (shared/model/initiatives.ts): the tracked + * Today, the grown projects Decide asks about, a guess at an Unsorted + * project's home, and the words an initiative's band and page say. + */ + +/** The key Unsorted stands for (a project with no filing) in a lane, the + * Initiative switch and Look back's split: never a slug, so no initiative + * can share it (PROJECT_SLUG takes no parentheses). */ +export const UNSORTED = '(unsorted)'; +/** The key for plans with no project, which no initiative can hold. */ +export const NO_PROJECT = '(no-project)'; + +/** Today without the Not tracked projects: what every count and question + * reads. PRs outside any project stay, since they're in no project. With + * the label prefix and the PRs' links, a Not tracked project's drafts that + * no group lists (Today's `drafts`) are left out too. */ +export function trackedToday( + today: Today, + untracked: ReadonlySet, + labels?: { prefix: string | null; links?: PullLinks } +): Today { + if (!untracked.size) return today; + const ours = (p: Today['drafts'][number]) => { + if (!labels?.prefix) return true; + const slug = projectOf(p.data.labels, labels.prefix, labels.links?.[issueKey(p.data)] ?? []); + return slug == null || !untracked.has(slug); + }; + return { + ...today, + live: today.live.filter(g => !untracked.has(g.slug)), + quiet: today.quiet.filter(g => !untracked.has(g.slug)), + drafts: today.drafts.filter(ours), + }; +} + +/** The Not tracked projects that grew, for decideQueue's `grown`: the + * server's list, less any tracked by a filing made on this board since. A + * Keep made here leaves its row until the refetch that follows every write, + * and the row's receipt stands meanwhile. */ +export function grownProjects( + data: Pick | null | undefined, + initiatives: readonly Initiative[] | null +): GrownProject[] { + return (data?.grew ?? []).flatMap(slug => { + const counts = data?.grew_counts?.[slug]; + return counts && isNotTrackedNow(slug, true, initiatives) ? [{ slug, ...counts }] : []; + }); +} + +/** + * Where an Unsorted project probably belongs: the initiative (or Keep the + * lights on) its people work in most, counting each person once per other + * project of theirs filed there. Null with nothing to go on. Never asked of + * a filed project: its home is the default, so taking it blind moves + * nothing. + */ +export function guessHome( + slug: string, + items: readonly Pick[], + initiatives: readonly Initiative[] +): Initiative | null { + const homes = homesOf(initiatives); + const mine = items.find(i => i.slug === slug); + if (!mine) return null; + const people = new Set([...mine.developers, ...mine.nonDevelopers]); + const tally = new Map(); + for (const other of items) { + if (other.slug === slug) continue; + const home = homes.get(other.slug)?.initiative; + if (!home || home.kind === 'untracked' || home.status !== 'open') continue; + const shared = [...other.developers, ...other.nonDevelopers].filter(p => people.has(p)); + if (!shared.length) continue; + const t = tally.get(home.id) ?? { initiative: home, count: 0 }; + t.count += shared.length; + tally.set(home.id, t); + } + const best = [...tally.values()].sort( + (a, b) => + b.count - a.count || + a.initiative.priority - b.initiative.priority || + a.initiative.id - b.initiative.id + )[0]; + return best?.initiative ?? null; +} + +/** The homes a project can be filed in, as a chooser lists them: the open + * initiatives in rank order (Keep the lights on among them), then Not + * tracked. */ +export function homeChoices(initiatives: readonly Initiative[]): Initiative[] { + const open = byRank(initiatives).filter(i => i.status === 'open'); + return [ + ...open.filter(i => i.kind !== 'untracked'), + ...open.filter(i => i.kind === 'untracked'), + ]; +} + +/** "in Release safety", "in Keep the lights on", "Not tracked", + * "Unsorted": where a project lives, mid-sentence. */ +export function homeWords(home: Pick | null | undefined): string { + if (!home) return UNSORTED_WORD; + return home.kind === 'untracked' ? 'Not tracked' : `in ${home.name}`; +} + +/** An initiative's span and counts as its band says them, in ink: "Jul 13 + * to Nov 1 · 1 plan with an end date · 3 ongoing", or "Ended Sep 30", or nothing for + * one with no plans. Dates come from its projects' plans (initiativeSpan). */ +export function spanWords( + projects: readonly string[], + plans: readonly Pick[], + now: number = Date.now(), + initiative?: Pick +): string { + // a closed one says so, as the Overview and its page do + if (initiative && initiative.status !== 'open') return closedWords(initiative, now); + const span = initiativeSpan(projects, plans); + if (!span.start) return ''; + const day = (d: string) => dayWords(d, { now }); + // every plan stopped, but it's open: not "Ended", which reads as closed + if (span.end_kind === 'ended') { + return span.ended ? `Nothing under way since ${day(span.ended)}` : ''; + } + // a latest end already gone by reads as finished unless it says so + const today = new Date(now).toISOString().slice(0, 10); + const past = span.end && span.end < today ? weeksPast(span.end, today) : 0; + const parts = [ + span.end + ? `${day(span.start)} to ${day(span.end)}${past ? `, ${n(past, 'week')} past` : ''}` + : `Since ${day(span.start)}`, + ...endParts(span), + ]; + return parts.filter(Boolean).join(' · '); +} + +/** How many of its plans have an end and how many are ongoing: "2 plans + * with an end date", "1 ongoing", each only when there are any. */ +export function endParts(span: { with_end: number; ongoing: number }): string[] { + return [ + span.with_end ? `${n(span.with_end, 'plan')} with an end date` : '', + span.ongoing ? `${span.ongoing} ongoing` : '', + ].filter(Boolean); +} + +/** A closed initiative, in one phrase everywhere: "Closed as done Sep 20". */ +export function closedWords( + initiative: Pick, + now: number = Date.now() +): string { + const on = + initiative.status_at != null + ? ` ${dayWords(new Date(initiative.status_at * 1000).toISOString().slice(0, 10), { now })}` + : ''; + return `Closed as ${initiative.status}${on}`; +} + +/** What an initiative's band says on hover, on the Roadmap and the + * Overview alike: its goal, how much it holds ("10 projects, 7 being worked + * on"), its owner, and whether it's closed. */ +export function bandGloss( + initiative: Pick, + projects: number, + working: number +): string { + const held = `${n(projects, 'project')}, ${working} being worked on`; + const owner = initiative.owner ? ` Owned by ${initiative.owner}.` : ''; + const goal = initiative.goal ? `${initiative.goal.replace(/\.?$/, '.')} ` : ''; + const closed = initiative.status === 'open' ? '' : ` ${closedWords(initiative)}.`; + return `${goal}${held}.${owner}${closed}`; +} + +/** Look back's split by initiative: the group a PR's project's days go + * in. Not tracked is one group, filed there or by its repo. */ +export const SPLIT_UNSORTED = UNSORTED; +export const SPLIT_NOT_TRACKED = '(not-tracked)'; +export const SPLIT_ONE_OFFS = '(one-offs)'; +export const SPLIT_NO_PROJECT = '(none)'; + +/** A PR's project's group in Look back's split by initiative: its + * initiative's slug (Keep the lights on among them), Not tracked, Unsorted + * for a project filed nowhere, small work, or no project at all. */ +export function initiativeSplitKey( + project: string | null, + homes: ReadonlyMap }>, + untracked: ReadonlySet +): string { + if (!project) return SPLIT_NO_PROJECT; + if (project === MISC_SLUG) return SPLIT_ONE_OFFS; + const home = homes.get(project)?.initiative; + if (home?.kind === 'untracked' || (!home && untracked.has(project))) return SPLIT_NOT_TRACKED; + return home ? home.slug : SPLIT_UNSORTED; +} + +/** That split's groups in order: the initiatives in their rank, then + * Unsorted, Not tracked, small work and no project. */ +export function initiativeSplitRank(initiatives: readonly Initiative[]): (key: string) => number { + const order = byRank(initiatives) + .filter(i => i.kind !== 'untracked') + .map(i => i.slug); + const tail = [SPLIT_UNSORTED, SPLIT_NOT_TRACKED, SPLIT_ONE_OFFS, SPLIT_NO_PROJECT]; + return key => + tail.includes(key) + ? order.length + tail.indexOf(key) + : order.indexOf(key) < 0 + ? order.length - 0.5 + : order.indexOf(key); +} + +/** A group of that split by name, in the words the Projects table uses. */ +export function initiativeSplitLabel(key: string, initiatives: readonly Initiative[]): string { + switch (key) { + case SPLIT_UNSORTED: + return UNSORTED_WORD; + case SPLIT_NOT_TRACKED: + return 'Not tracked'; + case SPLIT_ONE_OFFS: + return ONE_OFFS; + case SPLIT_NO_PROJECT: + return NOT_IN_A_PROJECT; + default: + return initiatives.find(i => i.slug === key)?.name ?? key; + } +} diff --git a/frontend-v2/src/model/initiatives.test.ts b/frontend-v2/src/model/initiatives.test.ts new file mode 100644 index 00000000..8360466a --- /dev/null +++ b/frontend-v2/src/model/initiatives.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + checkInitiativeFields, + grew, + homesOf, + initiativeSpan, +} from '../../../shared/model/initiatives'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; + +const plan = (project: string, over: Partial = {}) => ({ + project, + status: 'active' as RoadmapItem['status'], + start: '2026-09-07', + weeks: 4, + end_kind: 'soft' as RoadmapItem['end_kind'], + ...over, +}); + +describe('initiativeSpan', () => { + it('runs from the earliest start, done included, to the latest end under way', () => { + const span = initiativeSpan( + ['firmware', 'hub', 'bench'], + [ + plan('firmware', { start: '2026-05-04', weeks: 37 }), + plan('hub', { status: 'done', start: '2026-03-02', weeks: 2 }), + plan('bench', { end_kind: 'ongoing' }), + // parked and dropped plans, and other initiatives' plans, add nothing + plan('hub', { status: 'parked', start: '2026-01-05', weeks: 90 }), + plan('firmware', { status: 'dropped', start: '2026-01-05' }), + plan('elsewhere', { start: '2025-01-06', weeks: 100 }), + ] + ); + expect(span).toEqual({ + start: '2026-03-02', + end: '2027-01-17', + with_end: 1, + ongoing: 1, + end_kind: 'soft', + ended: null, + }); + }); + + it('says ongoing with no end in sight, ended with only done plans, nothing with no plans', () => { + expect(initiativeSpan(['a'], [plan('a', { end_kind: 'ongoing' })]).end_kind).toBe('ongoing'); + const ended = initiativeSpan(['a'], [plan('a', { status: 'done', weeks: 2 })]); + expect([ended.end_kind, ended.ended, ended.end]).toEqual(['ended', '2026-09-20', null]); + expect(initiativeSpan(['a'], []).end_kind).toBeNull(); + }); +}); + +describe('grew', () => { + it('asks once it opened as many PRs as it had, and at least 30', () => { + expect(grew(29, 0)).toBe(false); + expect(grew(30, 0)).toBe(true); + expect(grew(31, 32)).toBe(false); + expect(grew(32, 32)).toBe(true); + }); +}); + +describe('homesOf', () => { + it('finds each project’s initiative and filing', () => { + const filing = { project: 'a', set_by: null, set_at: 5, baseline_prs: 0 }; + const homes = homesOf([{ id: 1, projects: [filing] }, { id: 2, projects: [] }]); + expect(homes.get('a')?.initiative.id).toBe(1); + expect(homes.get('a')?.filing).toBe(filing); + expect(homes.has('b')).toBe(false); + }); +}); + +describe('checkInitiativeFields', () => { + it('needs a slug and a name to make one, and cleans the goal to one line', () => { + expect( + checkInitiativeFields( + { slug: 'site-speed', name: ' Site speed ', goal: 'Fast\n pages', owner: 'dana' }, + { partial: false } + ) + ).toEqual({ + fields: { slug: 'site-speed', name: 'Site speed', goal: 'Fast pages', owner: 'dana' }, + }); + expect(checkInitiativeFields({ name: 'X' }, { partial: false })).toHaveProperty('error'); + expect(checkInitiativeFields({ slug: 'x' }, { partial: false })).toHaveProperty('error'); + expect( + checkInitiativeFields({ slug: 'x', name: 'X', kind: 'big' }, { partial: false }) + ).toHaveProperty('error'); + expect( + checkInitiativeFields({ slug: 'x', name: 'X', status: 'done' }, { partial: false }) + ).toHaveProperty('error'); + }); + + it('edits keep the slug and kind, and clear the owner with null', () => { + expect(checkInitiativeFields({ owner: null, status: 'done' }, { partial: true })).toEqual({ + fields: { owner: null, status: 'done' }, + }); + expect(checkInitiativeFields({ slug: 'y' }, { partial: true })).toHaveProperty('error'); + expect(checkInitiativeFields({ goal: 'x'.repeat(256) }, { partial: true })).toHaveProperty( + 'error' + ); + expect(checkInitiativeFields({ owner: 'not a login' }, { partial: true })).toHaveProperty( + 'error' + ); + }); +}); diff --git a/frontend-v2/src/model/initiativesData.test.ts b/frontend-v2/src/model/initiativesData.test.ts new file mode 100644 index 00000000..35ca9566 --- /dev/null +++ b/frontend-v2/src/model/initiativesData.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Initiative } from '../../../shared/model/initiatives'; + +const initiative = ( + id: number, + slug: string, + projects: string[], + over: Partial = {} +) => ({ + id, + slug, + name: slug, + kind: 'initiative' as const, + goal: '', + owner: null, + priority: id, + status: 'open' as const, + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: 100, + projects: projects.map(project => ({ project, set_by: null, set_at: 50, baseline_prs: 0 })), + ...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: (json: unknown, status?: number) => pending.shift()?.(json, status) }; +} + +const sent = (call: number) => { + const [path, init] = vi.mocked(fetch).mock.calls[call] as unknown as [string, RequestInit]; + return { path, method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null }; +}; + +// a fresh module per test, so each starts with nothing loaded +const load = async () => { + vi.resetModules(); + return import('./initiativesData'); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('the initiatives store', () => { + it('reads a server with no /initiatives as none, so nothing changes', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({}, 404); + await first; + expect(data.readInitiatives().initiatives).toEqual([]); + }); + + it('files a project at once, checking the home the person saw, and hands back the old one', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ initiatives: [initiative(1, 'a', ['p']), initiative(2, 'b', [])] }); + await first; + const filing = data.fileProject('p', 'b', { expect: 'a' }); + // on screen before the server answers + expect(data.readInitiatives().initiatives?.map(i => i.projects.map(f => f.project))).toEqual([ + [], + ['p'], + ]); + expect(sent(1)).toEqual({ + path: '/initiatives/file', + method: 'PUT', + body: { project: 'p', initiative: 'b', expect: 'a' }, + }); + const before = { initiative: 'a', set_at: 50, baseline_prs: 0 }; + answer({ project: 'p', before, home: { initiative: 'b' } }); + expect(await filing).toEqual({ before }); + }); + + it('puts the home back with its time and count on an Undo', async () => { + scriptedFetch(); + const data = await load(); + void data.fileProject('p', 'not-tracked', { set_at: 40, baseline_prs: 12 }); + expect(sent(0).body).toEqual({ + project: 'p', + initiative: 'not-tracked', + set_at: 40, + baseline_prs: 12, + }); + }); + + it('takes a refused filing back off the screen and says why', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ initiatives: [initiative(1, 'a', ['p']), initiative(2, 'b', [])] }); + await first; + const filing = data.fileProject('p', 'b', { expect: 'a' }); + answer({ error: 'its home changed; here it is now' }, 409); + expect(await filing).toEqual({ + error: 'Couldn’t file the project: its home changed; here it is now.', + }); + expect(data.readInitiatives().initiatives?.[0].projects.map(f => f.project)).toEqual(['p']); + }); + + it('moves an initiative above another at once, and sends one move', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ + initiatives: [initiative(1, 'a', []), initiative(2, 'b', []), initiative(3, 'c', [])], + }); + await first; + const move = data.moveInitiative(3, 1); + expect(data.byRank(data.readInitiatives().initiatives ?? []).map(i => i.slug)).toEqual([ + 'c', + 'a', + 'b', + ]); + expect(sent(1)).toEqual({ path: '/initiatives/3/move', method: 'POST', body: { before: 1 } }); + answer({ error: 'nope' }, 500); + expect(await move).toEqual({ error: 'Couldn’t save the new order: nope.' }); + // a failed move reads the order again, rather than restoring a + // snapshot another write may have changed since + expect(sent(2).path).toBe('/initiatives'); + answer({ + initiatives: [initiative(1, 'a', []), initiative(2, 'b', []), initiative(3, 'c', [])], + }); + await new Promise(r => setTimeout(r, 0)); + expect(data.byRank(data.readInitiatives().initiatives ?? []).map(i => i.slug)).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + + it('closes an initiative, keeps one open with its status said again, and names what’s live', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ initiatives: [initiative(1, 'a', ['p'])] }); + await first; + const close = data.updateInitiative(1, { status: 'done' }); + answer({ error: 'move or finish the projects still going in it first', live: ['p'] }, 409); + expect(await close).toMatchObject({ live: ['p'] }); + const keep = data.updateInitiative(1, { status: 'open' }, { restate: true }); + expect(sent(2).body).toEqual({ status: 'open', restate: true }); + answer({ initiative: initiative(1, 'a', ['p'], { status_at: 999 }) }); + expect(await keep).toMatchObject({ was: { status_at: null } }); + }); +}); + +describe('isNotTrackedNow', () => { + it('takes a filing’s word over the server’s, and the server’s with none', async () => { + const { isNotTrackedNow } = await load(); + const list = [ + initiative(1, 'a', ['tracked']), + initiative(2, 'nt', ['shelved'], { kind: 'untracked' }), + ] as Initiative[]; + expect(isNotTrackedNow('tracked', true, list)).toBe(false); + expect(isNotTrackedNow('shelved', false, list)).toBe(true); + expect(isNotTrackedNow('repo-default', true, list)).toBe(true); + expect(isNotTrackedNow('shelved', false, null)).toBe(false); + }); +}); + +describe('two writes in flight', () => { + const where = (data: Awaited>) => + data.readInitiatives().initiatives?.map(i => [i.slug, i.projects.map(f => f.project)]); + + it('a refused filing takes back only its own change, so two refused leave neither filed', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ initiatives: [initiative(1, 'a', []), initiative(2, 'b', [])] }); + await first; + const p = data.fileProject('p', 'a'); + const q = data.fileProject('q', 'b'); + answer({ error: 'boom' }, 500); // p + await p; + answer({ error: 'boom' }, 500); // q + await q; + expect(where(data)).toEqual([ + ['a', []], + ['b', []], + ]); + }); + + it('a refused filing leaves another still in flight on screen', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ initiatives: [initiative(1, 'a', []), initiative(2, 'b', [])] }); + await first; + const p = data.fileProject('p', 'a'); + void data.fileProject('q', 'b'); + answer({ error: 'boom' }, 500); // p + await p; + expect(where(data)).toEqual([ + ['a', []], + ['b', ['q']], + ]); + }); + + it('a refused edit puts back only the fields it sent', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ initiatives: [initiative(1, 'a', [])] }); + await first; + const naming = data.updateInitiative(1, { name: 'Renamed' }); + const owning = data.updateInitiative(1, { owner: 'erin' }); + answer({ error: 'boom' }, 500); // the name + await naming; + const [a] = data.readInitiatives().initiatives ?? []; + expect([a.name, a.owner]).toEqual(['a', 'erin']); + void owning; + }); +}); + +describe('a filing that saves', () => { + it('reads the roadmap again, so a plan’s Not tracked flag from the server is fresh', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadInitiatives(); + answer({ initiatives: [initiative(1, 'a', [])] }); + await first; + const filing = data.fileProject('p', null); + answer({ + project: 'p', + before: { initiative: null, set_at: null, baseline_prs: 0 }, + home: null, + }); + await filing; + const asked = vi.mocked(fetch).mock.calls.map(([path]) => String(path)); + expect(asked).toContain('/roadmap'); + }); +}); diff --git a/frontend-v2/src/model/initiativesData.ts b/frontend-v2/src/model/initiativesData.ts new file mode 100644 index 00000000..518bd2b7 --- /dev/null +++ b/frontend-v2/src/model/initiativesData.ts @@ -0,0 +1,445 @@ +import { useEffect } from 'react'; +import { isDummy } from '../backend/dummy'; +import { DUMMY_GROWN, DUMMY_INITIATIVES, DUMMY_ROADMAP } from '../backend/dummyProjects'; +import { createMemoryStore } from '../storage'; +import { UNSORTED_WORD } from './words'; +import { loadRoadmap } from './roadmapData'; +import { + goingProjects, + homesOf, + type Filing, + type Initiative, + type InitiativeFields, +} from '../../../shared/model/initiatives'; + +/** + * The initiatives on the board (shared/model/initiatives.ts): the layer above + * projects, each holding projects by slug, in their own rank order. Loaded + * on first use, like the roadmap, and again whenever the server says the + * projects changed (Projects.tsx). Changes show at once and are rolled back + * if the server says no. Null until the first load; an older server with no + * /initiatives reads as none, so the board shows what it showed before. + */ +interface InitiativesState { + initiatives: Initiative[] | null; + loadFailed: boolean; +} + +const store = createMemoryStore({ initiatives: null, loadFailed: false }); + +type Reply = { status: number; json: Record }; + +/** A project's home as a filing reply says it: the initiative's slug, or + * null for Unsorted, with when it was set and its growth baseline. */ +export interface Home { + initiative: string | null; + set_at: number | null; + baseline_prs: number; +} + +interface FileBody { + project: string; + initiative: string | null; + expect?: string | null; + baseline_prs?: number; + set_at?: number; + undo?: true; +} + +interface Api { + list: () => Promise; + file: (body: FileBody) => Promise; + move: (id: number, before: number | null) => Promise; + update: ( + id: number, + fields: Partial, + extra?: { restate?: boolean; undo?: { updated_at: number; status_at: number | null } } + ) => Promise; +} + +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), + }); + const json = (await res.json().catch(() => ({}))) as Record; + return { status: res.redirected ? 401 : res.status, json }; + }; + return { + list: () => send('GET', '/initiatives'), + file: body => send('PUT', '/initiatives/file', body), + move: (id, before) => send('POST', `/initiatives/${id}/move`, { before }), + update: (id, fields, extra) => send('PATCH', `/initiatives/${id}`, { ...fields, ...extra }), + }; +} + +// the dummy board's table: what the server would hold, gone on reload +const dummy = { + initiatives: DUMMY_INITIATIVES.map(i => ({ ...i, projects: i.projects.map(f => ({ ...f })) })), + grown: new Map(Object.entries(DUMMY_GROWN)), + // the growth a filing ended, which its Undo (a filing with set_at) puts back + ended: new Map(), +}; + +/** The dummy board's not-tracked slugs and the ones that grew, as + * /projects-data sends them (`untracked`, `grew_counts`). */ +export function dummyTracking(): { + untracked: string[]; + grew: Record; +} { + const untracked = dummy.initiatives + .filter(i => i.kind === 'untracked') + .flatMap(i => i.projects.map(f => f.project)); + return { untracked, grew: Object.fromEntries(dummy.grown) }; +} + +function dummyApi(): Api { + const ok = (json: Record) => Promise.resolve({ status: 200, json }); + const bad = (error: string, status = 400) => Promise.resolve({ status, json: { error } }); + const copy = () => + [...dummy.initiatives] + .sort((a, b) => a.priority - b.priority || a.id - b.id) + .map(i => ({ ...i, projects: i.projects.map(f => ({ ...f })) })); + const now = () => Math.floor(Date.now() / 1000); + return { + list: () => ok({ initiatives: copy() }), + file: ({ project, initiative, expect, baseline_prs, set_at, undo }) => { + const homes = homesOf(dummy.initiatives); + const current = homes.get(project); + const was: Home = current + ? { + initiative: current.initiative.slug, + set_at: current.filing.set_at, + baseline_prs: current.filing.baseline_prs, + } + : { initiative: null, set_at: null, baseline_prs: 0 }; + if (expect !== undefined && was.initiative !== expect) { + return Promise.resolve({ status: 409, json: { error: 'its home changed', home: was } }); + } + const target = + initiative == null ? null : dummy.initiatives.find(i => i.slug === initiative); + if (initiative != null && !target) return bad('no initiative has that slug', 404); + // as the server: nothing into a closed one, but an Undo puts it back + if (target && target.status !== 'open' && set_at == null && !undo) { + return bad('that initiative is closed; reopen it before filing a project in it', 409); + } + if (current) { + current.initiative.projects = current.initiative.projects.filter( + f => f.project !== project + ); + } + // a filing restarts the growth check, as the server's does, and + // an Undo's (with set_at) puts back the growth it ended + const grown = dummy.grown.get(project); + dummy.grown.delete(project); + if (grown) dummy.ended.set(project, grown); + const back = set_at != null ? dummy.ended.get(project) : undefined; + if (back && target?.kind === 'untracked') dummy.grown.set(project, back); + if (!target) return ok({ project, before: was, home: null }); + const filing: Filing = { + project, + set_by: 'danielbeardsley', + set_at: set_at ?? now(), + baseline_prs: + baseline_prs ?? + (target.kind === 'untracked' ? (grown ? grown.baseline + grown.since : 0) : 0), + }; + target.projects.push(filing); + return ok({ + project, + before: was, + home: { + initiative: target.slug, + set_at: filing.set_at, + baseline_prs: filing.baseline_prs, + }, + }); + }, + move: (id, before) => { + const order = copy().map(i => i.id); + if (!order.includes(id)) return bad('no such initiative', 404); + const rest = order.filter(x => x !== id); + const at = before == null ? rest.length : rest.indexOf(before); + rest.splice(at < 0 ? rest.length : at, 0, id); + rest.forEach((x, i) => { + const row = dummy.initiatives.find(r => r.id === x); + if (row) row.priority = i; + }); + return ok({ initiatives: copy() }); + }, + update: (id, fields, extra) => { + const row = dummy.initiatives.find(i => i.id === id); + if (!row) return bad('no such initiative', 404); + // as the server: a close waits on the projects still going in it, + // but an Undo puts back the status it replaced. ponytail: the + // dummy's starting plans, not ones edited since + const closing = fields.status != null && fields.status !== 'open'; + if (closing && row.status === 'open' && !extra?.undo) { + const projects = row.projects.map(f => f.project); + const live = goingProjects(projects, DUMMY_ROADMAP, () => 0); + if (live.length) { + const error = 'move or finish the projects still going in it first'; + return Promise.resolve({ status: 409, json: { error, live } }); + } + } + const stamp = now(); + const restated = + fields.status != null && (fields.status !== row.status || !!extra?.restate); + Object.assign(row, fields, { updated_by: 'danielbeardsley', updated_at: stamp }); + if (extra?.undo) Object.assign(row, extra.undo); + else if (restated) row.status_at = stamp; + return ok({ initiative: { ...row, projects: row.projects.map(f => ({ ...f })) } }); + }, + }; +} + +/** The dummy board says projectsChanged itself after a write, as the server + * does, so every view's data comes back with the new filing in it. */ +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, file: said(inner.file), move: said(inner.move), update: said(inner.update) }; +} + +const api = isDummy() ? announcing(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.`; +} + +// a load that went out before a write and answers after it mustn't put the +// old filings back on screen +let writes = 0; + +export async function loadInitiatives(): Promise { + const seen = writes; + try { + const reply = await api.list(); + // a server from before initiatives: none, so nothing changes + if (reply.status === 404) { + if (seen === writes) store.set({ initiatives: [], loadFailed: false }); + return; + } + if (reply.status !== 200) throw new Error(String(reply.status)); + if (seen !== writes) return; + store.set({ initiatives: reply.json.initiatives as Initiative[], loadFailed: false }); + } catch { + if (seen === writes) store.set({ ...store.get(), loadFailed: true }); + } +} + +/** Change the initiatives on screen now. A write that fails takes back + * only its own change, on the list as it is then, since another write may + * have changed it since, and reads them again. */ +function optimistic(change: (list: Initiative[]) => Initiative[]): void { + writes++; + store.set({ ...store.get(), initiatives: change(store.get().initiatives ?? []) }); +} + +/** Change the list on screen now, if there is one. */ +function onScreen(change: (list: Initiative[]) => Initiative[]): void { + const list = store.get().initiatives; + if (list) store.set({ ...store.get(), initiatives: change(list) }); +} + +/** The list with one project moved to `to` (a slug, or null for none). */ +export function withFiling( + list: readonly Initiative[], + project: string, + to: string | null, + filing: Omit = { + set_by: null, + set_at: Math.floor(Date.now() / 1000), + baseline_prs: 0, + } +): Initiative[] { + return list.map(i => { + const rest = i.projects.filter(f => f.project !== project); + return { + ...i, + projects: i.slug === to ? [...rest, { project, ...filing }] : rest, + }; + }); +} + +/** + * File a project in the initiative with slug `to`, or in none. `expect` is + * the home the person saw; `set_at` and `baseline_prs` put back a home as it + * was (an Undo). Resolves to the home it replaced, or the problem in words. + */ +export async function fileProject( + project: string, + to: string | null, + { + expect, + set_at, + baseline_prs, + undo, + }: { + expect?: string | null; + set_at?: number | null; + baseline_prs?: number; + undo?: boolean; + } = {} +): Promise<{ before: Home } | { error: string }> { + const prev = homesOf(store.get().initiatives ?? []).get(project); + const takeBack = () => { + onScreen(list => + prev + ? withFiling(list, project, prev.initiative.slug, prev.filing) + : withFiling(list, project, null) + ); + void loadInitiatives(); + }; + optimistic(list => + withFiling(list, project, to, { + set_by: null, + set_at: set_at ?? Math.floor(Date.now() / 1000), + baseline_prs: baseline_prs ?? 0, + }) + ); + const reply = await api + .file({ + project, + initiative: to, + ...(expect !== undefined ? { expect } : {}), + ...(set_at != null ? { set_at } : {}), + ...(baseline_prs != null && set_at != null ? { baseline_prs } : {}), + ...(undo ? { undo: true as const } : {}), + }) + .catch((): Reply => ({ status: 0, json: {} })); + if (reply.status === 409 && reply.json.home !== undefined) { + // someone filed it since: say where it is now, and show it there + takeBack(); + const now = (reply.json.home as Home | null)?.initiative ?? null; + const name = now + ? store.get().initiatives?.find(i => i.slug === now)?.name ?? now + : UNSORTED_WORD; + return { + error: `Couldn’t file the project: someone filed it in ${name} since, so nothing changed.`, + }; + } + if (reply.status !== 200) { + // read again too, so the choosers stop offering one closed since + takeBack(); + return { error: problemOf(reply, 'file the project') }; + } + void loadInitiatives(); + // the plans too: a plan Not tracked by the server's flag alone (a repo + // default) reads that flag until the roadmap is read again + void loadRoadmap(); + return { before: reply.json.before as Home }; +} + +/** Put the initiatives in this order, top first, by moving `id` just above + * `before` (null: the bottom). Resolves to whether it saved. */ +export async function moveInitiative( + id: number, + before: number | null +): Promise { + optimistic(list => { + const sorted = byRank(list); + const moving = sorted.find(i => i.id === id); + if (!moving) return list; + const rest = sorted.filter(i => i.id !== id); + const at = before == null ? rest.length : rest.findIndex(i => i.id === before); + rest.splice(at < 0 ? rest.length : at, 0, moving); + return rest.map((i, k) => ({ ...i, priority: k })); + }); + const reply = await api.move(id, before).catch((): Reply => ({ status: 0, json: {} })); + if (reply.status !== 200) { + // every rank may have moved since: the server's order, not a snapshot + void loadInitiatives(); + return { error: problemOf(reply, 'save the new order') }; + } + store.set({ ...store.get(), initiatives: reply.json.initiatives as Initiative[] }); + return true; +} + +/** Change an initiative's name, goal, owner or status. `restate` stamps a + * status kept as it was (Keep open); `undo` puts back the times a change + * replaced. Resolves to the initiative as it was, or the problem. */ +export async function updateInitiative( + id: number, + fields: Partial, + extra: { restate?: boolean; undo?: { updated_at: number; status_at: number | null } } = {} +): Promise<{ was: Initiative } | { error: string; live?: string[] }> { + const was = store.get().initiatives?.find(i => i.id === id); + optimistic(list => list.map(i => (i.id === id ? { ...i, ...fields } : i))); + const reply = await api.update(id, fields, extra).catch((): Reply => ({ status: 0, json: {} })); + if (reply.status !== 200 || !was) { + // only the fields this write sent go back to what they were + const back = Object.fromEntries( + Object.keys(fields).map(k => [k, (was as unknown as Record)?.[k]]) + ); + if (was) onScreen(list => list.map(i => (i.id === id ? { ...i, ...back } : i))); + const live = Array.isArray(reply.json.live) ? (reply.json.live as string[]) : undefined; + return { error: problemOf(reply, 'save the initiative'), live }; + } + const saved = reply.json.initiative as Initiative; + store.set({ + ...store.get(), + initiatives: (store.get().initiatives ?? []).map(i => (i.id === id ? saved : i)), + }); + return { was }; +} + +/** Initiatives in their rank order, the older first on a tie. */ +export const byRank = >(list: readonly I[]): I[] => + [...list].sort((a, b) => a.priority - b.priority || a.id - b.id); + +/** The initiatives as the board has them now, outside React. */ +export function readInitiatives(): InitiativesState { + return store.get(); +} + +/** The initiatives as loaded so far, without asking for a load: for a part + * drawn under a view that loads them (Projects.tsx). */ +export function useInitiativesValue(): InitiativesState { + return store.useValue(); +} + +/** The initiatives, loaded on first use. */ +export function useInitiatives(): InitiativesState { + useEffect(() => { + if (store.get().initiatives == null) void loadInitiatives(); + }, []); + return store.useValue(); +} + +/** + * Whether a project is Not tracked now: its home says so when it has one, + * which a filing made on this board changes at once; with none, what the + * server last said (`serverSays`: a repo default, or the flag it set on a + * plan). With the initiatives not loaded yet, the server's word stands. + */ +export function isNotTrackedNow( + slug: string | null, + serverSays: boolean, + initiatives: readonly Initiative[] | null = store.get().initiatives +): boolean { + if (!slug || !initiatives) return serverSays; + const kind = homeKinds(initiatives).get(slug); + return kind ? kind === 'untracked' : serverSays; +} + +// each list's homes' kinds by slug, read once per list: the roadmap asks +// about every plan in one pass +const kindsByList = new WeakMap>(); +function homeKinds(initiatives: readonly Initiative[]): Map { + let kinds = kindsByList.get(initiatives); + if (!kinds) { + kinds = new Map([...homesOf(initiatives)].map(([slug, h]) => [slug, h.initiative.kind])); + kindsByList.set(initiatives, kinds); + } + return kinds; +} diff --git a/frontend-v2/src/model/portfolio.test.ts b/frontend-v2/src/model/portfolio.test.ts index de8e2411..7b0b03a4 100644 --- a/frontend-v2/src/model/portfolio.test.ts +++ b/frontend-v2/src/model/portfolio.test.ts @@ -9,6 +9,7 @@ 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 { Initiative } from '../../../shared/model/initiatives'; import type { PullData } from '../../../shared/types'; import { behindWords, @@ -16,6 +17,7 @@ import { groupItems, IDLE_BUCKETS, AGE_BUCKETS, + type Ask, isYours, mainTeam, matchesFind, @@ -101,6 +103,7 @@ function group( slug, project: p, open, + drafts: [], merged, closed: [], people, @@ -182,6 +185,7 @@ const today: Today = { misc: [], unsorted: [], doubleLabeled: [], + drafts: [], }; const projects = [ alpha, @@ -298,6 +302,12 @@ describe('planCell', () => { const cell = (over: Partial[0]>) => planCell({ plan: null, project: null, ...over }, day, now); + it('asks Unsorted’s question, since its band already says the name', () => { + expect(cell({ asks: [{ reason: { kind: 'unfiled' }, item: null }] }).text).toBe( + 'Which initiative is it in?' + ); + }); + 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({ @@ -318,6 +328,22 @@ describe('planCell', () => { ).toBe('Update due'); }); + it('names the closed initiative first on a row that also asks about its plan', () => { + const done = plan(1, 'a', { status: 'done' }); + // the order Decide's sections ask them in, whichever came first + const asks: Ask[] = [ + { + reason: { kind: 'reopened', open: 1, late: 0, as: 'done', by: 'roadmap' }, + item: done, + }, + { reason: { kind: 'reopened_in_closed', initiative: 7, as: 'done' }, item: done }, + ]; + expect(cell({ plan: done, asks }).text).toBe('Its initiative is closed, still worked on'); + expect(cell({ plan: done, asks: [...asks].reverse() }).text).toBe( + 'Its initiative is closed, still worked on' + ); + }); + 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', @@ -712,6 +738,83 @@ describe('groupItems', () => { }); }); +const initiative = ( + id: number, + slug: string, + projects: string[], + over: Partial = {} +): Initiative => ({ + id, + slug, + name: slug.toUpperCase(), + kind: 'initiative', + goal: '', + owner: null, + priority: id, + status: 'open', + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: null, + projects: projects.map(project => ({ project, set_by: null, set_at: 0, baseline_prs: 0 })), + ...over, +}); + +describe('groupItems by initiative', () => { + const initiatives = [ + initiative(3, 'later', ['beta']), + initiative(1, 'first', ['paused', 'alpha']), + initiative(2, 'closed', ['gone'], { status: 'done' }), + initiative(4, 'not-tracked', ['shipped'], { kind: 'untracked' }), + ]; + + it('puts the initiatives in their rank, closed ones after, with Unsorted last', () => { + const groups = groupItems(sortItems(items, 'name'), 'initiative', undefined, [], initiatives); + expect(groups.map(g => [g.title, g.items.map(i => i.slug)])).toEqual([ + ['FIRST', ['alpha', 'paused']], + ['LATER', ['beta']], + ['CLOSED', ['gone']], + // a Not tracked filing isn't a group here: the Overview draws its own + ['Unsorted', ['label-only', 'merges-only', 'shipped']], + ]); + expect(groups[0].initiative?.slug).toBe('first'); + expect(groups[3].initiative).toBeUndefined(); + }); + + it('shows Unsorted only when it holds a project, and an initiative only with one', () => { + const all = [ + initiative( + 1, + 'everything', + items.map(i => i.slug) + ), + initiative(2, 'empty', []), + ]; + expect(groupItems(items, 'initiative', undefined, [], all).map(g => g.title)).toEqual([ + 'EVERYTHING', + ]); + // but one Decide asks about keeps its band, empty, where its question is + expect( + groupItems(items, 'initiative', undefined, [], all, new Set([2])).map(g => [ + g.title, + g.items.length, + ]) + ).toEqual([ + ['EVERYTHING', items.length], + ['EMPTY', 0], + ]); + }); + + it('finds by the start of an initiative’s name or slug with “initiative:”', () => { + const homes = new Map(initiatives.flatMap(i => i.projects.map(f => [f.project, i]))); + const of = (slug: string | null) => (slug ? homes.get(slug) : undefined); + const found = items.filter(i => matchesFind(i, 'initiative: fir', of)).map(i => i.slug); + expect(found.sort()).toEqual(['alpha', 'paused']); + expect(items.filter(i => matchesFind(i, 'initiative:nothing', of))).toEqual([]); + }); +}); + 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' }]); diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts index 375ae342..9d1750e7 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -24,11 +24,13 @@ import { healthStanding, isUnderWay, planFor, + weeksPast, 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 { homesOf, type Initiative } from '../../../shared/model/initiatives'; import { dayOf } from './days'; import { dayWords } from './projectData'; import type { ProjectWorker } from './retro'; @@ -39,7 +41,9 @@ import { NEEDS_A_PLAN, NO_PLAN, NO_UPDATE_YET, + NO_PROJECT_YET, pastEnd, + UNSORTED_WORD, UPDATE_DUE, } from './words'; @@ -234,10 +238,6 @@ function stageOf( 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) { @@ -264,6 +264,19 @@ function callWords(reason: DecideReason): Pick { return { kind: 'stalled', text: 'Stalled' }; case 'new': return { kind: 'no_plan', text: NEEDS_A_PLAN }; + // the initiative calls (shared/model/decide.ts), in Decide's section + // words; Unsorted's in its question's, since the row sits under an + // Unsorted band that says the name already + case 'unfiled': + return { kind: 'no_plan', text: 'Which initiative is it in?' }; + case 'no_project': + return { kind: 'no_plan', text: NO_PROJECT_YET }; + case 'untracked_grew': + return { kind: 'no_plan', text: 'Not tracked, but growing' }; + case 'reopened_in_closed': + return { kind: 'reopened', text: 'Its initiative is closed, still worked on' }; + case 'goal_reached': + return { kind: 'issues_done', text: 'Goal maybe reached' }; } } @@ -644,14 +657,25 @@ export interface FindFields { * 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. + * click on a parent puts in the box; "initiative:" matches the start of the + * name or slug of the initiative a project is in (`initiativeOf`). */ -export function findFilter(find: string): ((row: FindFields) => boolean) | null { +export function findFilter( + find: string, + initiativeOf: (slug: string | null) => Pick | undefined = () => + undefined +): ((row: FindFields) => boolean) | null { const q = find.trim().toLowerCase(); if (!q) return null; - const exact = /^(lead|team|parent):\s*(.+)$/.exec(q); + const exact = /^(lead|team|parent|initiative):\s*(.+)$/.exec(q); if (exact) { const [, field, name] = exact; + if (field === 'initiative') { + return row => { + const home = initiativeOf(row.slug); + return !!home && [home.name, home.slug].some(s => s.toLowerCase().startsWith(name)); + }; + } if (field === 'parent') return row => row.parents.some(p => p.toLowerCase() === name); return row => (row[field as 'lead' | 'team'] ?? '').toLowerCase().startsWith(name); } @@ -662,8 +686,12 @@ export function findFilter(find: string): ((row: FindFields) => boolean) | null } /** Whether a project's row matches the find box (findFilter). */ -export function matchesFind(item: PortfolioItem, find: string): boolean { - const filter = findFilter(find); +export function matchesFind( + item: PortfolioItem, + find: string, + initiativeOf?: (slug: string | null) => Pick | undefined +): boolean { + const filter = findFilter(find, initiativeOf); return !filter || filter(item); } @@ -760,11 +788,14 @@ export function sortItems(items: readonly PortfolioItem[], sort: string): Portfo export const GROUPINGS: [string, string][] = [ ['none', 'No grouping'], - ['parent', 'Parent'], + ['initiative', 'Initiative'], ['lead', 'Lead'], ['team', 'Team'], ]; +/** The group for projects in no initiative. */ +export const UNSORTED_TITLE = UNSORTED_WORD; + /** * 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 @@ -798,8 +829,41 @@ export function groupItems( items: readonly PortfolioItem[], by: string, nameOf: (slug: string) => string = slug => slug, - teams: readonly string[] = [] -): { title: string; items: PortfolioItem[]; repeats: ReadonlySet }[] { + teams: readonly string[] = [], + initiatives: readonly Initiative[] = [], + /** by initiative: the ones drawn even with no project in the list (one + * Decide asks about, which keeps its band where its question is) */ + keep: ReadonlySet = new Set() +): { + title: string; + /** unique among the groups, as a React key: names aren't, and an + * initiative may be called Unsorted */ + key: string; + items: PortfolioItem[]; + repeats: ReadonlySet; + /** by initiative: the one the group is, absent for Unsorted */ + initiative?: Initiative; +}[] { + return groupsOf(items, by, nameOf, teams, initiatives, keep).map(g => ({ + ...g, + key: g.initiative ? `i:${g.initiative.id}` : g.title, + })); +} + +function groupsOf( + items: readonly PortfolioItem[], + by: string, + nameOf: (slug: string) => string, + teams: readonly string[], + initiatives: readonly Initiative[], + keep: ReadonlySet +): { + title: string; + items: PortfolioItem[]; + repeats: ReadonlySet; + initiative?: Initiative; +}[] { + if (by === 'initiative') return byInitiative(items, initiatives, keep); 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]; @@ -833,6 +897,59 @@ export function groupItems( }); } +/** + * Projects by initiative, each group in its initiative's rank (closed ones + * after the open), the projects in the list's own order inside, and + * Unsorted last, only when it holds one. Not tracked projects aren't in + * `items`: the Overview draws them in their own group. + */ +function byInitiative( + items: readonly PortfolioItem[], + initiatives: readonly Initiative[], + keep: ReadonlySet +): { + title: string; + items: PortfolioItem[]; + repeats: ReadonlySet; + initiative?: Initiative; +}[] { + const homes = homesOf(initiatives); + const groups = new Map(); + for (const item of items) { + const home = homes.get(item.slug)?.initiative; + const key = home && home.kind !== 'untracked' ? home.id : null; + const group = groups.get(key); + if (group) group.push(item); + else groups.set(key, [item]); + } + const ranked = [...initiatives].sort( + (a, b) => + Number(a.status !== 'open') - Number(b.status !== 'open') || + a.priority - b.priority || + a.id - b.id + ); + const out: { + title: string; + items: PortfolioItem[]; + repeats: ReadonlySet; + initiative?: Initiative; + }[] = ranked.flatMap(i => + groups.has(i.id) || keep.has(i.id) + ? [ + { + title: i.name, + items: groups.get(i.id) ?? [], + repeats: new Set(), + initiative: i, + }, + ] + : [] + ); + const loose = groups.get(null); + if (loose?.length) out.push({ title: UNSORTED_TITLE, items: loose, repeats: new Set() }); + return out; +} + const STAGE_WORD: Record = { progress: upperFirst(BEING_WORKED_ON), parked: 'Parked', diff --git a/frontend-v2/src/model/projectData.ts b/frontend-v2/src/model/projectData.ts index 8de77cc1..3beb7be2 100644 --- a/frontend-v2/src/model/projectData.ts +++ b/frontend-v2/src/model/projectData.ts @@ -1,4 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import type { Initiative } from '../../../shared/model/initiatives'; +import { dummyTracking, isNotTrackedNow, useInitiatives } from './initiativesData'; import { createMemoryStore } from '../storage'; import { dateOf, dayOf } from './days'; import { isDummy, loadDummy } from '../backend/dummy'; @@ -46,9 +48,58 @@ export interface ProjectsData { * puts a PR with no project label where the server does. Older servers * omit it. */ pull_links?: PullLinks; + /** the Not tracked projects (filed so, or every PR in a repo config.js + * leaves untracked), and of those the ones that grew since, with the PRs + * `since` it was set not tracked against its count then (`baseline`). + * Older servers omit them. */ + untracked?: string[]; + grew?: string[]; + grew_counts?: Record; window: WindowStats; } +/** + * The Not tracked slugs now: the server's list, with any filing made on this + * board since (initiativesData.ts isNotTrackedNow), and every slug a filing + * puts in Not tracked. + */ +export function notTrackedSlugs( + data: Pick | null | undefined, + initiatives: readonly Initiative[] | null +): Set { + const out = new Set( + (data?.untracked ?? []).filter(slug => isNotTrackedNow(slug, true, initiatives)) + ); + for (const i of initiatives ?? []) { + if (i.kind === 'untracked') for (const f of i.projects) out.add(f.project); + } + return out; +} + +/** + * One window's data with the Not tracked projects left out of its issues and + * its per-project numbers, as every list and count reads it. The server's + * aggregates (totals, weeks, people) always count everything. + */ +export function trackedData( + data: D, + initiatives: readonly Initiative[] | null +): D { + if (!data) return data; + const out = notTrackedSlugs(data, initiatives); + if (!out.size) return data; + return { + ...data, + projects: data.projects.filter(p => !out.has(p.slug)), + window: { + ...data.window, + projects: Object.fromEntries( + Object.entries(data.window.projects).filter(([slug]) => !out.has(slug)) + ), + }, + }; +} + /** 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 { @@ -89,13 +140,25 @@ export interface Range { * 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, { + const thisYear = d.getUTCFullYear() === new Date(now).getFullYear(); + return (thisYear ? DAY_WORDS.thisYear : DAY_WORDS.otherYear).format(d); +} + +// made once: toLocaleDateString with options builds a formatter per call, +// and the roadmap says hundreds of days a render +const DAY_WORDS = { + thisYear: new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', - year: d.getUTCFullYear() === new Date(now).getFullYear() ? undefined : 'numeric', timeZone: 'UTC', - }); -} + }), + otherYear: new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }), +}; /** How many days a range covers, both ends counted. */ export function rangeDays({ start, end }: Range): number { @@ -198,7 +261,8 @@ async function dummyData({ start, end }: Range, project: string | null): Promise !(bots ?? []).includes(p.user.login) && (project == null || projectOfPull(p) === project) ); - const spans: PullSpan[] = people.map(p => ({ + // drafts left out, as the server's spans leave them (lib/projects.js) + const spans: PullSpan[] = people.filter(p => !p.draft).map(p => ({ author: p.user.login, project: projectOfPull(p), opened: epoch(p.created_at), @@ -221,6 +285,10 @@ async function dummyData({ start, end }: Range, project: string | null): Promise decide_rotation: dummyRotation, ongoing: dummyOngoing, pull_links: linked, + ...(() => { + const { untracked, grew } = dummyTracking(); + return { untracked, grew: Object.keys(grew), grew_counts: grew }; + })(), window: windowStats(spans, start, end, { teamOf: teamLookup(dummyTeams ?? DUMMY_TEAMS), reviews, @@ -282,9 +350,27 @@ export function ongoingSlugs(data: ProjectsData | null | undefined): ReadonlySet ]); } +/** + * One window's projects data, tracked (trackedData): Not tracked projects + * left out, unless `all` asks for every project, or the data is one named + * project's, which is read whole. + */ export function useProjectsData( range: Range | null, - project: string | null = null + project: string | null = null, + { all = false }: { all?: boolean } = {} +): ProjectsData | null | undefined { + const got = useAllProjectsData(range, project); + const { initiatives } = useInitiatives(); + return useMemo( + () => (all || project != null ? got : trackedData(got, initiatives)), + [got, initiatives, all, project] + ); +} + +function useAllProjectsData( + range: Range | null, + project: string | null ): ProjectsData | null | undefined { const { n } = version.useValue(); const base = range ? `${range.start}..${range.end}:${project ?? ''}` : ''; diff --git a/frontend-v2/src/model/projects.test.ts b/frontend-v2/src/model/projects.test.ts index 85ef5f2e..65d5bf4e 100644 --- a/frontend-v2/src/model/projects.test.ts +++ b/frontend-v2/src/model/projects.test.ts @@ -29,6 +29,7 @@ function open(over: { workedDays?: number; /** a reviewer who asked for changes the author hasn't answered */ changesBy?: string; + draft?: boolean; }): DerivedPull { return { status: over.status ?? 'needs_cr', @@ -37,6 +38,7 @@ function open(over: { number: ++n, user: { login: over.author ?? 'alice' }, labels: labels(...(over.labels ?? [])), + draft: over.draft ?? false, created_at: iso(NOW - (over.ageDays ?? 1) * DAY), updated_at: iso(NOW - (over.idleDays ?? 0) * DAY), status: { @@ -278,6 +280,39 @@ describe('buildToday', () => { expect(today.live[0].flags).toEqual([]); }); + it('never counts a draft, and lists it under its project or outside', () => { + const draft = (labels: string[], over: Parameters[0] = {}) => + open({ labels, status: 'draft', draft: true, ...over }); + const real = open({ labels: ['project:mixed'], author: 'a', idleDays: 2 }); + const mixedDraft = draft(['project:mixed'], { author: 'b', idleDays: 90 }); + const planned = draft(['project:planned'], { author: 'c' }); + const noIssue = draft(['project:nothing-yet']); + const oneOff = draft(['project:misc']); + const loose = draft([]); + const two = draft(['project:mixed', 'project:planned']); + const today = buildToday( + [project({ slug: 'mixed' }), project({ slug: 'planned' })], + [real, mixedDraft, planned, noIssue, oneOff, loose, two], + [], + P, + NOW + ); + // live and quiet go by real PRs: a project with only drafts is quiet + expect(today.live.map(g => g.slug)).toEqual(['mixed']); + expect(today.quiet.map(g => g.slug)).toEqual(['planned']); + const [mixed] = today.live; + expect(mixed.open).toEqual([real]); + expect(mixed.drafts).toEqual([mixedDraft, two]); + // its people, idle days and flags read the real PR only + expect([mixed.people, mixed.idleDays]).toEqual([['a'], 2]); + expect(today.quiet[0].open).toEqual([]); + expect(today.quiet[0].drafts).toEqual([planned]); + expect(today.quiet[0].people).toEqual([]); + // the rest list outside, never as one-offs, unsorted or two projects + expect(new Set(today.drafts)).toEqual(new Set([noIssue, oneOff, loose])); + expect([today.misc, today.unsorted, today.doubleLabeled]).toEqual([[], [], []]); + }); + it('reports idle days from the stalest open PR and sorts the busiest first', () => { const today = buildToday( [], diff --git a/frontend-v2/src/model/retroData.ts b/frontend-v2/src/model/retroData.ts index 67096529..0ef12186 100644 --- a/frontend-v2/src/model/retroData.ts +++ b/frontend-v2/src/model/retroData.ts @@ -52,7 +52,8 @@ async function dummyRetro({ start, end }: Range): Promise { const prefix = projectLabelPrefix ?? 'project:'; const from = dayStart(start) ?? 0; const to = (dayStart(end) ?? from) + 86400; - const touches: Touch[] = pulls.flatMap(p => { + // days on a draft aren't days on a project yet, as the server counts them + const touches: Touch[] = pulls.filter(p => !p.draft).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 }, diff --git a/frontend-v2/src/model/roadmapData.test.ts b/frontend-v2/src/model/roadmapData.test.ts index 49b78934..e05dfdf3 100644 --- a/frontend-v2/src/model/roadmapData.test.ts +++ b/frontend-v2/src/model/roadmapData.test.ts @@ -313,3 +313,59 @@ describe('what fills itself in', () => { expect(facts.get('b')?.issues).toBeNull(); }); }); + +describe('Not tracked plans', () => { + it('fills the places tracked plans held, in their new order, and keeps every other plan’s', async () => { + const { mergeOrder } = await load(); + // 2 and 4 are Not tracked: a view shows 1, 3, 5 and reorders them 5, 1, 3 + const full = [1, 2, 3, 4, 5].map(id => item(id)); + const merged = mergeOrder(full, [5, 1, 3]); + // (1) the tracked ids fill exactly the slots tracked plans held + expect([0, 2, 4].map(i => merged[i])).toEqual([5, 1, 3]); + // (2) every Not tracked plan keeps its index + expect(merged[1]).toBe(2); + expect(merged[3]).toBe(4); + expect(merged).toEqual([5, 2, 1, 4, 3]); + }); + + it('sends the whole order when a view reorders only the tracked plans', async () => { + const { answer } = scriptedFetch(); + const data = await load(); + const first = data.loadRoadmap(); + answer({ items: [item(1), item(2, { untracked: true }), item(3)] }); + await first; + expect(data.readRoadmap().items?.map(i => i.id)).toEqual([1, 3]); + const save = data.reorderRoadmap([3, 1]); + const sent = JSON.parse(String(vi.mocked(fetch).mock.calls[1][1]?.body)); + expect(sent).toEqual({ ids: [3, 2, 1] }); + // on screen at once, the Not tracked one still in its place + expect(data.readRoadmap({ all: true }).items?.map(i => i.id)).toEqual([3, 2, 1]); + answer({ items: [item(3), item(2, { untracked: true }), item(1)] }); + expect(await save).toBe(true); + }); + + it('leaves a Not tracked plan out of every list but the full set', async () => { + const { trackedPlans } = await load(); + const plans = [item(1, { project: 'a' }), item(2, { project: 'b', untracked: true })]; + expect(trackedPlans(plans, null).map(i => i.id)).toEqual([1]); + // a filing made on this board since wins over the server's flag + const filed = (slug: string, kind: 'initiative' | 'untracked') => ({ + id: kind === 'untracked' ? 9 : 1, + slug: kind, + name: kind, + kind, + goal: '', + owner: null, + priority: 0, + status: 'open' as const, + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: null, + projects: [{ project: slug, set_by: null, set_at: 0, baseline_prs: 0 }], + }); + expect(trackedPlans(plans, [filed('b', 'initiative')]).map(i => i.id)).toEqual([1, 2]); + expect(trackedPlans(plans, [filed('a', 'untracked')]).map(i => i.id)).toEqual([]); + }); +}); diff --git a/frontend-v2/src/model/roadmapData.ts b/frontend-v2/src/model/roadmapData.ts index 0f976d10..4255f29d 100644 --- a/frontend-v2/src/model/roadmapData.ts +++ b/frontend-v2/src/model/roadmapData.ts @@ -1,5 +1,7 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { isDummy, loadDummy } from '../backend/dummy'; +import type { Initiative } from '../../../shared/model/initiatives'; +import { dummyTracking, isNotTrackedNow, useInitiatives, readInitiatives } from './initiativesData'; import { DUMMY_PROJECTS, DUMMY_ROADMAP, DUMMY_ROADMAP_UPDATES } from '../backend/dummyProjects'; import { createMemoryStore } from '../storage'; import { @@ -225,10 +227,13 @@ function dummyApi(): Api { // each with what its project did lately, as the server sends it const sent = async (items: readonly RoadmapItem[]) => { const bySlug = await dummyLately(live()); + // as the server sets it, from the filings (lib/initiatives.js) + const untracked = new Set(dummyTracking().untracked); return items.map(r => ({ ...r, waits_on: r.waits_on.filter(other => !removed.has(other)), lately: (r.project && bySlug.get(r.project)) || null, + untracked: !!r.project && untracked.has(r.project), })); }; return { @@ -529,10 +534,28 @@ export async function restoreRoadmapItem(id: number): Promise { 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 { +/** + * The whole order PUT /roadmap/order needs, from an order of only some plans + * (the tracked ones a view shows): those fill, in their new order, exactly + * the places they held in `full`, and every other plan keeps its own. An id + * `full` doesn't have goes last. + */ +export function mergeOrder( + full: readonly Pick[], + ids: readonly number[] +): number[] { + const given = new Set(ids); + const queue = [...ids]; + const merged = full.map(i => (given.has(i.id) ? (queue.shift() as number) : i.id)); + return [...merged, ...queue]; +} + +/** Put the items in this order, top first; resolves to whether it saved. A + * list of only the tracked plans keeps every not-tracked one where it was + * (mergeOrder). If someone else changed the roadmap meanwhile, the server's + * list wins and the person hears why. */ +export async function reorderRoadmap(shownIds: number[]): Promise { + const ids = mergeOrder(byPriority(store.get().items ?? []), shownIds); const undo = optimistic(items => byPriority(items.map(i => ({ ...i, priority: ids.indexOf(i.id) }))) ); @@ -592,9 +615,26 @@ export async function takeBackRoadmapUpdate( return { update }; } -/** The roadmap as the board has it now, outside React. */ -export function readRoadmap(): RoadmapState { - return store.get(); +/** + * The tracked plans: every plan but a Not tracked project's (its own flag, + * set by the server before anything else read it, or a filing made on this + * board since: initiativesData.ts isNotTrackedNow). Every list, count and + * question reads these; only a reader that names the full set (`all`) sees + * the rest. + */ +export function trackedPlans>( + items: readonly T[], + initiatives: readonly Initiative[] | null +): T[] { + return items.filter(i => !isNotTrackedNow(i.project, !!i.untracked, initiatives)); +} + +/** The roadmap as the board has it now, outside React: the tracked plans, + * or every plan with `all`. */ +export function readRoadmap({ all = false }: { all?: boolean } = {}): RoadmapState { + const state = store.get(); + if (all || !state.items) return state; + return { ...state, items: trackedPlans(state.items, readInitiatives().initiatives) }; } export function dismissRoadmapProblem(): void { @@ -620,8 +660,15 @@ function watch(): () => void { }; } -/** The roadmap, loaded now and kept fresh while any view shows it. */ -export function useRoadmap(): RoadmapState { +/** The roadmap, loaded now and kept fresh while any view shows it: the + * tracked plans, or every plan with `all` (trackedPlans). */ +export function useRoadmap({ all = false }: { all?: boolean } = {}): RoadmapState { useEffect(watch, []); - return store.useValue(); + const state = store.useValue(); + const { initiatives } = useInitiatives(); + return useMemo( + () => + all || !state.items ? state : { ...state, items: trackedPlans(state.items, initiatives) }, + [state, initiatives, all] + ); } diff --git a/frontend-v2/src/model/roadmapTime.test.ts b/frontend-v2/src/model/roadmapTime.test.ts index 1b857622..ac29260b 100644 --- a/frontend-v2/src/model/roadmapTime.test.ts +++ b/frontend-v2/src/model/roadmapTime.test.ts @@ -1,13 +1,30 @@ import { describe, expect, it } from 'vitest'; +import { MAX_WINDOW_MONTHS, windowParam } from '../lens'; import { + columnNames, columnsFor, commitEnds, + defaultWindow, + doneBefore, + drawnWindow, + inUnit, + isPreset, parseZoom, + resolveWindow, + shiftWindow, + windowHolding, + windowKey, + windowOfDays, + windowWords, + WINDOW_PRESETS, + type Picked, quarterOf, shiftZoom, zoomAround, zoomKey, type Zoom, + dayAcross, + historyRange, } from './roadmapTime'; const z = (key: string) => parseZoom(key) as Zoom; @@ -114,3 +131,284 @@ describe('commitEnds', () => { expect(commitEnds('2026-12-01', '2027-01-15')[0].label).toBe('Its target date, Jan 15 2027'); }); }); + +describe('the roadmap window', () => { + // Oct 13, 2026 + const now = new Date(2026, 9, 13); + const picked = (k: string) => resolveWindow(k, now) as Picked; + const key = (k: string) => windowKey(picked(k), picked(k).unit); + + it('resolves every preset as of today, each in its own unit', () => { + expect(WINDOW_PRESETS.map(([k]) => [k, key(k)])).toEqual([ + ['quarter', '2026-10..2026-12'], + ['2q', '2026-Q4..2027-Q1'], + ['6m', '2026-10..2027-03'], + ['ytd', '2026-01..2026-10'], + ['12m', '2025-11..2026-10'], + ['year', '2026-Q1..2026-Q4'], + ]); + // in February, year to date is two months + expect(windowKey(resolveWindow('ytd', new Date(2027, 1, 3)) as Picked, 'month')).toBe( + '2027-01..2027-02' + ); + }); + + it('keeps a preset’s unit whatever the month, so a bookmark keeps its layout', () => { + for (const month of [0, 1, 2, 9, 11]) { + const at = new Date(2026, month, 13); + expect(resolveWindow('6m', at)?.unit).toBe('month'); + expect(resolveWindow('12m', at)?.unit).toBe('month'); + expect(resolveWindow('2q', at)?.unit).toBe('quarter'); + expect(resolveWindow('year', at)?.unit).toBe('quarter'); + } + expect(isPreset('ytd')).toBe(true); + expect(isPreset('2026-10..2026-12')).toBe(false); + expect(isPreset('toString')).toBe(false); + }); + + it('reads its own keys back in either unit, future ones too, and refuses anything else', () => { + for (const k of [ + '2025-10..2026-09', + '2027-01..2027-01', + '2025-Q4..2026-Q3', + '2027-Q2..2027-Q2', + ]) { + expect(key(k)).toBe(k); + } + expect(picked('2025-Q4..2026-Q3')).toMatchObject({ unit: 'quarter' }); + expect(picked('2025-10..2026-09')).toMatchObject({ unit: 'month' }); + for (const bad of [ + '2026-10..2026-09', + '2026-Q4..2026-Q1', + '2026-13..2027-01', + '2026-10', + '', + ]) { + expect(resolveWindow(bad, now)).toBeNull(); + } + expect(resolveWindow(null, now)).toBeNull(); + // what the URL takes (lens.ts) is what the picker writes + for (const [k] of WINDOW_PRESETS) expect(windowParam(k)).toBe(k); + expect(windowParam(key('ytd'))).toBe(key('ytd')); + }); + + it('cuts a span past five years to five, in its own unit', () => { + expect(windowParam('1970-01..2170-12')).toBe('1970-01..1974-12'); + expect(windowParam('2024-Q2..2040-Q1')).toBe('2024-Q2..2029-Q1'); + expect(windowParam('2026-01..2030-12')).toBe('2026-01..2030-12'); + const long = picked('1970-01..2170-12'); + expect(long.to - long.from).toBe(MAX_WINDOW_MONTHS); + expect(columnsFor('quarter', null, now, long)).toHaveLength(60); + }); + + it('leaves the default exactly as it was with no window picked', () => { + for (const scale of ['quarter', 'month'] as const) { + expect(columnsFor(scale, null, now, null)).toEqual(columnsFor(scale, null, now)); + expect(drawnWindow(scale, null, now)).toEqual({ + months: defaultWindow(scale, now), + unit: scale, + }); + } + expect(windowKey(defaultWindow('quarter', now), 'quarter')).toBe('2026-Q3..2027-Q2'); + expect(windowKey(defaultWindow('month', now), 'month')).toBe('2026-09..2027-03'); + }); + + it('draws a calendar pick in months up to four, or a year not on quarters; quarters past that', () => { + const unit = (a: string, b: string) => windowOfDays(a, b).unit; + expect(unit('2025-11-03', '2026-02-20')).toBe('month'); + expect(unit('2026-01-01', '2026-10-31')).toBe('month'); + expect(unit('2026-01-01', '2026-06-30')).toBe('quarter'); + // longer than a year, off the quarters: out to whole quarters + const long = windowOfDays('2025-11-17', '2027-01-04'); + expect(windowKey(long, long.unit)).toBe('2025-Q4..2027-Q1'); + // days in the same month still make a whole month + expect(windowKey(windowOfDays('2027-02-14', '2027-05-02'), 'month')).toBe('2027-02..2027-05'); + // and never more than five years + const huge = windowOfDays('2020-01-01', '2035-12-31'); + expect(huge.to - huge.from).toBe(MAX_WINDOW_MONTHS); + }); + + it('converts a window between units for the Months and Quarters switch', () => { + const six = picked('6m'); + expect(windowKey(inUnit(six, 'quarter'), 'quarter')).toBe('2026-Q4..2027-Q1'); + const twelve = picked('12m'); + expect(windowKey(inUnit(twelve, 'quarter'), 'quarter')).toBe('2025-Q4..2026-Q4'); + const year = picked('year'); + expect(windowKey(inUnit(year, 'month'), 'month')).toBe('2026-01..2026-12'); + }); + + it('draws a picked window as its columns', () => { + const ytd = columnsFor('quarter', null, now, picked('ytd')); + expect(ytd.map(c => c.zoom)).toEqual([ + '2026-01', + '2026-02', + '2026-03', + '2026-04', + '2026-05', + '2026-06', + '2026-07', + '2026-08', + '2026-09', + '2026-10', + ]); + expect(ytd.at(-1)?.end).toBe('2026-11-01'); + const back = columnsFor('month', null, now, picked('2025-Q4..2026-Q3')); + expect(back.map(c => c.label)).toEqual(['Q4 2025', 'Q1 2026', 'Q2 2026', 'Q3 2026']); + // a picked window still yields to a zoom + expect(columnsFor('quarter', parseZoom('2026-Q4'), now, picked('ytd'))).toEqual( + columnsFor('quarter', parseZoom('2026-Q4'), now) + ); + }); + + it('steps by its own length', () => { + expect(windowKey(shiftWindow(picked('ytd'), -1), 'month')).toBe('2025-03..2025-12'); + expect(windowKey(shiftWindow(defaultWindow('quarter', now), -1), 'quarter')).toBe( + '2025-Q3..2026-Q2' + ); + expect(windowKey(shiftWindow(defaultWindow('month', now), 1), 'month')).toBe( + '2027-04..2027-10' + ); + }); + + it('moves to hold a day: the edge chip and zooming out', () => { + const w = defaultWindow('quarter', now); + expect(windowHolding(w, '2026-08-03', 'quarter')).toBe(w); + expect(windowKey(windowHolding(w, '2025-11-17', 'quarter'), 'quarter')).toBe( + '2025-Q4..2026-Q3' + ); + expect(windowKey(windowHolding(w, '2027-09-06', 'month'), 'month')).toBe('2027-09..2028-08'); + }); + + it('says what it covers in its columns’ unit', () => { + const words = (k: string) => { + const d = drawnWindow('quarter', picked(k), now); + return windowWords(d.months, d.unit); + }; + expect(words('ytd')).toBe('Jan to Oct 2026'); + expect(words('2025-Q4..2026-Q3')).toBe('Q4 2025 to Q3 2026'); + expect(words('2026-12..2027-01')).toBe('Dec 2026 to Jan 2027'); + expect(words('2027-03..2027-03')).toBe('Mar 2027'); + expect(windowWords(defaultWindow('quarter', now), 'quarter')).toBe('Q3 2026 to Q2 2027'); + }); +}); + +describe('doneBefore', () => { + const plan = (status: 'done' | 'active', start: string, weeks: number, ongoing = false) => ({ + status, + start, + weeks, + end_kind: ongoing ? ('ongoing' as const) : ('soft' as const), + }); + + it('folds only done plans whose last day is before the window', () => { + const old = plan('done', '2025-10-06', 4); + const into = plan('done', '2026-06-01', 6); + expect( + doneBefore( + [old, into, plan('active', '2025-10-06', 4), plan('done', '2025-10-06', 4, true)], + '2026-07-01' + ) + ).toEqual([old]); + }); +}); + +describe('columnNames', () => { + const now = new Date(2026, 9, 13); + const twelve = columnsFor('quarter', null, now, resolveWindow('12m', now)); + const quarters = columnsFor('quarter', null, now); + const each = (cols: unknown[], px: number) => cols.map(() => px); + + it('says every full name while they fit, and before the width is known', () => { + expect(columnNames(twelve, each(twelve, 0))).toEqual(twelve.map(c => c.label)); + expect(columnNames(twelve, each(twelve, 80))).toEqual(twelve.map(c => c.label)); + expect(columnNames(quarters, each(quarters, 200))).toEqual([ + 'Q3 2026', + 'Q4 2026', + 'Q1 2027', + 'Q2 2027', + ]); + }); + + it('shortens on a phone, keeping a year where one starts, and never drops February', () => { + const short = columnNames(twelve, each(twelve, 40)); + expect(short).toHaveLength(12); + // the first column and January say their year, the rest their month + expect(short.filter(t => t?.length !== 3)).toEqual(['2025', '2026']); + expect(columnNames(twelve, each(twelve, 20))).toEqual([ + 'N', + 'D', + 'J', + 'F', + 'M', + 'A', + 'M', + 'J', + 'J', + 'A', + 'S', + 'O', + ]); + // the year where it starts while those fit, else the quarters alone + expect(columnNames(quarters, [70, 30, 70, 30])).toEqual(['Q3 2026', 'Q4', 'Q1 2027', 'Q2']); + expect(columnNames(quarters, each(quarters, 45))).toEqual(['Q3', 'Q4', '2027', 'Q2']); + expect(columnNames(quarters, each(quarters, 30))).toEqual(['Q3', null, '2027', null]); + }); + + it('keeps the year on the first month and on each January when months shorten', () => { + const two = columnsFor('quarter', null, now, resolveWindow('2023-01..2024-12', now)); + const yearly = (names: (string | null)[]) => names.filter((_, i) => i % 12 === 0); + expect(yearly(columnNames(two, each(two, 60)))).toEqual(['Jan ’23', 'Jan ’24']); + expect(yearly(columnNames(two, each(two, 45)))).toEqual(['2023', '2024']); + expect(columnNames(two, each(two, 45))[1]).toBe('Feb'); + // a window starting mid-year says its year on its first column + expect(columnNames(twelve, each(twelve, 60))[0]).toBe('Nov ’25'); + }); + + it('thins five years of months to their years, every column still a column', () => { + const long = columnsFor('quarter', null, now, resolveWindow('2026-01..2030-12', now)); + const names = columnNames(long, each(long, 5)); + expect(names).toHaveLength(60); + expect(names.filter(Boolean)).toEqual(['2026', '2027', '2028', '2029', '2030']); + }); +}); + +describe('historyRange', () => { + it('ends a past window’s history on its last day, not the day after', () => { + expect(historyRange('2026-07-01', '2026-10-01', '2026-10-13', 400)).toEqual({ + start: '2026-07-01', + end: '2026-09-30', + }); + }); + + it('ends at today in a window that holds it, and keeps the server’s last 400 days', () => { + expect(historyRange('2022-01-01', '2027-01-01', '2026-10-13', 400)).toEqual({ + start: '2025-09-09', + end: '2026-10-13', + }); + expect(historyRange('2027-01-01', '2027-04-01', '2026-10-13', 400)).toBeNull(); + }); +}); + +describe('dayAcross', () => { + it('puts the window’s last day at the track’s right edge, never the day after', () => { + const from = Date.UTC(2027, 0, 1) / 1000; + const to = Date.UTC(2027, 2, 1) / 1000; // Mar 1, 2027, a Monday + expect(dayAcross(from, to, 1)).toBe('2027-02-28'); + expect(dayAcross(from, to, 0)).toBe('2027-01-01'); + }); +}); + +describe('a step past the years the URL takes', () => { + it('stays put at 1970 and at 2999, in a window and a zoom', () => { + const first = { from: 1970 * 12, to: 1970 * 12 + 12 }; + expect(shiftWindow(first, -1)).toEqual(first); + expect(shiftWindow(first, 1)).toEqual({ from: 1971 * 12, to: 1971 * 12 + 12 }); + const last = { from: 2999 * 12, to: 3000 * 12 }; + expect(shiftWindow(last, 1)).toEqual(last); + const jan = { kind: 'month' as const, year: 1970, index: 0 }; + expect(shiftZoom(jan, -1)).toEqual(jan); + const q4 = { kind: 'quarter' as const, year: 2999, index: 3 }; + expect(shiftZoom(q4, 1)).toEqual(q4); + expect(shiftZoom(q4, -1)).toEqual({ kind: 'quarter', year: 2999, index: 2 }); + }); +}); diff --git a/frontend-v2/src/model/roadmapTime.ts b/frontend-v2/src/model/roadmapTime.ts index 1c03b6e0..abbc013a 100644 --- a/frontend-v2/src/model/roadmapTime.ts +++ b/frontend-v2/src/model/roadmapTime.ts @@ -1,5 +1,7 @@ -import { ZOOM_KEY } from '../lens'; +import { endOf, type RoadmapItem } from '../../../shared/model/roadmap'; +import { MAX_WINDOW_MONTHS, windowParam, ZOOM_KEY } from '../lens'; import { dateOf, dayOf } from './days'; +import { dayStart, utcDay } from '../../../shared/model/projects'; /** * The roadmap timeline's columns: four quarters or seven months across, or, @@ -41,7 +43,10 @@ export function zoomKey(z: Zoom): string { 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 }; + const year = Math.floor(at / per); + // past the years the URL takes (lens.ts), stay put + if (year < 1970 || year > 2999) return z; + return { kind: z.kind, year, index: ((at % per) + per) % per }; } /** The quarter or month a day falls in. */ @@ -71,7 +76,12 @@ export function zoomWords(z: Zoom): string { 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[] { +export function columnsFor( + scale: Scale, + zoom: Zoom | null, + now: Date, + picked: Picked | null = null +): Column[] { if (zoom?.kind === 'quarter') { const first = firstDay(zoom); return [0, 1, 2].map(i => { @@ -104,15 +114,13 @@ export function columnsFor(scale: 'month' | 'quarter', zoom: Zoom | null, now: D } 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); + const { months: w, unit } = drawnWindow(scale, picked, now); + if (unit === 'month') { + return Array.from({ length: w.to - w.from }, (_, i) => { + const a = monthDate(w.from + i); return { start: dayOf(a), - end: dayOf(new Date(y, m + i, 1)), + end: dayOf(monthDate(w.from + i + 1)), label: a.toLocaleDateString(undefined, { month: 'short', year: i === 0 || a.getMonth() === 0 ? 'numeric' : undefined, @@ -121,20 +129,262 @@ export function columnsFor(scale: 'month' | 'quarter', zoom: Zoom | null, now: D }; }); } - // 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); + return Array.from({ length: (w.to - w.from) / 3 }, (_, i) => { + const a = monthDate(w.from + i * 3); const quarter = Math.floor(a.getMonth() / 3) + 1; return { start: dayOf(a), - end: dayOf(new Date(y, q + i * 3, 1)), + end: dayOf(monthDate(w.from + i * 3 + 3)), label: `Q${quarter} ${a.getFullYear()}`, zoom: `${a.getFullYear()}-Q${quarter}`, }; }); } +export type Scale = 'month' | 'quarter'; + +/** + * The roadmap's window, in whole months counted from year 0 (year × 12 + + * month), `to` the first month after it. None picked is the default: last + * quarter and the next three, or at Months, last month and the next six. + */ +export interface Months { + from: number; + to: number; +} + +/** A window picked, and the columns it's drawn in. In the URL as a preset's + * key ("ytd"), or as its span in its unit, "2025-10..2026-09" in months or + * "2025-Q4..2026-Q3" in quarters, the last one counted. */ +export interface Picked extends Months { + unit: Scale; +} + +const monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth(); +const monthDate = (i: number) => new Date(Math.floor(i / 12), i % 12, 1); +const monthText = (i: number) => `${Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, '0')}`; +const quarterText = (i: number) => `${Math.floor(i / 12)}-Q${Math.floor((i % 12) / 3) + 1}`; + +/** The picker's presets, in its order; Custom is the calendar beside them. */ +export const WINDOW_PRESETS: [string, string][] = [ + ['quarter', 'This quarter'], + ['2q', 'Next 2 quarters'], + ['6m', 'Next 6 months'], + ['ytd', 'Year to date'], + ['12m', 'Last 12 months'], + ['year', 'This year'], +]; + +// each preset is drawn in the unit its name says, whatever the month, so a +// bookmarked one keeps its layout; This quarter and Year to date in months +const PRESET_UNIT: Record = { + quarter: 'month', + '2q': 'quarter', + '6m': 'month', + ytd: 'month', + '12m': 'month', + year: 'quarter', +}; + +export const isPreset = (key: string | null | undefined): boolean => + !!key && Object.hasOwn(PRESET_UNIT, key); + +/** A window key's months and unit as of `now`, cut to five years; null for + * none or a key that isn't one. */ +export function resolveWindow(key: string | null | undefined, now: Date): Picked | null { + const clean = windowParam(key); + if (!clean) return null; + const m = monthIndex(now); + const q = m - (m % 3); + const jan = m - now.getMonth(); + const spans: Record = { + quarter: { from: q, to: q + 3 }, + '2q': { from: q, to: q + 6 }, + '6m': { from: m, to: m + 6 }, + ytd: { from: jan, to: m + 1 }, + '12m': { from: m - 11, to: m + 1 }, + year: { from: jan, to: jan + 12 }, + }; + if (isPreset(clean)) return { ...spans[clean], unit: PRESET_UNIT[clean] }; + const [a, b] = clean.split('..').map(t => { + const y = Number(t.slice(0, 4)) * 12; + return t[5] === 'Q' ? y + (Number(t[6]) - 1) * 3 : y + Number(t.slice(5)) - 1; + }); + return clean.includes('Q') + ? { from: a, to: b + 3, unit: 'quarter' } + : { from: a, to: b + 1, unit: 'month' }; +} + +/** A window's key in its unit: "2025-10..2026-09", or "2025-Q4..2026-Q3" + * for one on whole quarters. */ +export function windowKey(w: Months, unit: Scale): string { + return unit === 'quarter' + ? `${quarterText(w.from)}..${quarterText(w.to - 3)}` + : `${monthText(w.from)}..${monthText(w.to - 1)}`; +} + +/** Out to whole quarters. */ +const toQuarters = (w: Months): Months => ({ + from: w.from - (w.from % 3), + to: w.to + ((3 - (w.to % 3)) % 3), +}); + +/** The same window in a unit: as it is in months, out to whole quarters in + * quarters, at most five years. What the Months and Quarters switch does to + * a window picked. */ +export function inUnit(w: Months, unit: Scale): Picked { + const out = unit === 'quarter' ? toQuarters(w) : w; + return { from: out.from, to: Math.min(out.to, out.from + MAX_WINDOW_MONTHS), unit }; +} + +/** The window a calendar pick makes: the months of its first through last + * day, in the unit its length gives (windowUnit), at most five years. */ +export function windowOfDays(start: string, end: string): Picked { + const w = { from: monthIndex(dateOf(start)), to: monthIndex(dateOf(end)) + 1 }; + return inUnit(w, windowUnit(w)); +} + +/** The window people land on, as before there was a picker. */ +export function defaultWindow(scale: Scale, now: Date): Months { + const m = monthIndex(now); + const q = m - (m % 3); + return scale === 'month' ? { from: m - 1, to: m + 6 } : { from: q - 3, to: q + 9 }; +} + +/** + * The columns a window picked on the calendar is drawn in, from its length: + * months up to four months, and up to a year that doesn't fall on whole + * quarters, so it's drawn as picked; quarters past that, or when it's whole + * quarters anyway. A preset has its own unit (PRESET_UNIT). + */ +export function windowUnit(w: Months): Scale { + const length = w.to - w.from; + const quarters = w.from % 3 === 0 && length % 3 === 0; + return length <= 4 || (length <= 12 && !quarters) ? 'month' : 'quarter'; +} + +/** The window drawn: the one picked, in its unit, or the default at `scale`. */ +export function drawnWindow( + scale: Scale, + picked: Picked | null, + now: Date +): { months: Months; unit: Scale } { + if (!picked) return { months: defaultWindow(scale, now), unit: scale }; + return { months: { from: picked.from, to: picked.to }, unit: picked.unit }; +} + +/** The window `by` of its own lengths later (earlier when negative). */ +export function shiftWindow(w: Months, by: number): Months { + const length = w.to - w.from; + const moved = { from: w.from + by * length, to: w.to + by * length }; + // the URL takes years 1970 to 2999 (lens.ts): past them, stay put + return moved.from < FIRST_MONTH || moved.to > LAST_MONTH ? w : moved; +} + +/** The months the board draws, as month indexes (year * 12 + month): Jan + * 1970, and the month after Dec 2999. */ +const FIRST_MONTH = 1970 * 12; +const LAST_MONTH = 3000 * 12; + +/** + * A window of the same length holding a day: the same one when it already + * does, else one starting at the day's month (its quarter, drawn in + * quarters). Where the edge chip and zooming out land. + */ +export function windowHolding(w: Months, day: string, unit: Scale): Months { + const m = monthIndex(dateOf(day)); + if (m >= w.from && m < w.to) return w; + const from = unit === 'quarter' ? m - (m % 3) : m; + return { from, to: from + w.to - w.from }; +} + +/** "Jan to Oct 2026", "Q4 2025 to Q3 2026", "Oct 2026": what the window + * covers, in its columns' unit. */ +export function windowWords(w: Months, unit: Scale): string { + const year = (i: number) => Math.floor(i / 12); + const word = (i: number, withYear: boolean) => { + const y = withYear ? ` ${year(i)}` : ''; + return unit === 'quarter' + ? `Q${Math.floor((i % 12) / 3) + 1}${y}` + : `${monthDate(i).toLocaleDateString(undefined, { month: 'short' })}${y}`; + }; + const last = unit === 'quarter' ? w.to - 3 : w.to - 1; + if (last <= w.from) return word(w.from, true); + return `${word(w.from, year(w.from) !== year(last))} to ${word(last, true)}`; +} + +/** About how wide a header name draws: 11px small caps, its left pad and + * line, measured at about 7px a letter. A little wide, so names never + * touch. */ +const nameWidth = (text: string) => text.length * 7 + 10; + +/** + * What each month or quarter column's name says, given each column's width + * in px: every full name when they all fit ("Nov 2025", "Q4 2026"), else + * short ones ("Nov", "Q4" with the year kept where it starts), else + * letters ("N"); past that, names thin out to every second, third, sixth or + * twelfth month, or to quarters with a year in Q1's place ("2027 Q2 Q3"), + * then every second or fourth, a year where one starts. + * Null is a column with no words, still its own zoom button. With no width + * yet (0), the full names. Week columns aren't asked: their slivers have + * their own rule. + */ +export function columnNames(columns: readonly Column[], px: readonly number[]): (string | null)[] { + if (!px.some(w => w > 0)) return columns.map(c => c.label); + const quarters = /^Q\d \d{4}$/.test(columns[0]?.label ?? ''); + const first = columns.map(c => monthIndex(dateOf(c.start))); + const month = (i: number) => + monthDate(first[i]).toLocaleDateString(undefined, { month: 'short' }); + const year = (i: number) => String(Math.floor(first[i] / 12)); + const startsYear = (i: number) => first[i] % 12 === 0; + const yearAt = (i: number) => i === 0 || startsYear(i); + const levels: ((c: Column, i: number) => string)[] = quarters + ? [c => c.label, (c, i) => (i === 0 || startsYear(i) ? c.label : c.label.slice(0, 2))] + : [ + c => c.label, + // shorter, the year still on the first column and each January: + // "Jan ’23", then the year in the month's place, as thin() does + (_, i) => (yearAt(i) ? `${month(i)} ’${year(i).slice(2)}` : month(i)), + (_, i) => (yearAt(i) ? year(i) : month(i)), + (_, i) => month(i).slice(0, 1), + ]; + for (const level of levels) { + const names = columns.map(level); + if (names.every((t, i) => nameWidth(t) <= px[i])) return names; + } + // quarters thin from one in one, so a year still says where it starts + const steps = quarters ? [1, 2, 4] : [2, 3, 6, 12]; + const each = Math.min(...px); + const period = (i: number) => (quarters ? first[i] / 3 : first[i]); + const thin = (k: number) => + columns.map((_, i) => + period(i) % k === 0 + ? startsYear(i) + ? year(i) + : quarters + ? `Q${(first[i] % 12) / 3 + 1}` + : month(i) + : null + ); + for (const k of steps) { + const names = thin(k); + if (names.every(t => !t || nameWidth(t) <= each * k)) return names; + } + return thin(steps[steps.length - 1]); +} + +/** The done plans whose last week is before a window's first day: they + * rest in one fold under the rows instead of each taking a row. */ +export function doneBefore>( + plans: readonly T[], + start: string +): T[] { + return plans.filter(i => { + const end = i.status === 'done' && endOf(i); + return !!end && end < start; + }); +} + /** An end a plan can commit to. */ export interface CommitEnd { /** its words on a button: "End of Oct", "Its target date, Oct 21" */ @@ -201,3 +451,31 @@ export function commitEnds(today: string, target?: string | null): CommitEnd[] { ...sorted.filter(c => c.end !== target), ]; } + +const DAY_SECS = 86400; + +/** + * The PR history a window from `start` to `end` (the day after its last) + * asks for, both days counted: to today or the window's last day, whichever + * is first, never the day after the window; capped at the server's + * `maxDays`, the most recent ones. Null for a window wholly ahead. + */ +export function historyRange( + start: string, + end: string, + today: string, + maxDays: number +): { start: string; end: string } | null { + if (start > today) return null; + const lastDay = utcDay((dayStart(end) as number) - DAY_SECS); + const last = today < lastDay ? today : lastDay; + const earliest = utcDay((dayStart(last) as number) - (maxDays - 1) * DAY_SECS); + return { start: start < earliest ? earliest : start, end: last }; +} + +/** The day at a fraction (0 to 1) across a track from `from` to `to` + * (epoch secs, `to` the day after the last), the last day at its right + * edge, never the day after the window. */ +export function dayAcross(from: number, to: number, at: number): string { + return utcDay(from + Math.min(1, Math.max(0, at)) * (to - from - DAY_SECS)); +} diff --git a/frontend-v2/src/model/stage.test.ts b/frontend-v2/src/model/stage.test.ts index 2e85b792..09dd73b9 100644 --- a/frontend-v2/src/model/stage.test.ts +++ b/frontend-v2/src/model/stage.test.ts @@ -45,6 +45,8 @@ function dp( repo: 'iFixit/ifixit', number, user: { login: o.author ?? 'dana' }, + // GitHub's draft flag is what makes the status 'draft' + draft: status === 'draft', // 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 }] : [], @@ -131,7 +133,7 @@ describe('issueStanding', () => { 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]); + expect([mixed.stage, mixed.pull?.data.number]).toEqual(['review', 2]); // 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', @@ -145,6 +147,15 @@ describe('issueStanding', () => { expect(issueStanding({ state: 'open', prs: [] }, live).stage).toBe('none'); }); + it('never takes its stage from a draft, on the board or as sent', () => { + expect(issueStanding({ state: 'open', prs: [pr(1), pr(3)] }, live).stage).toBe('ready'); + expect(issueStanding({ state: 'open', prs: [pr(3)] }, live).stage).toBe('none'); + const sent = { ...pr(9), draft: true as const }; + expect(issueStanding({ state: 'open', prs: [sent, pr(8, 'merged')] }, live).stage).toBe( + 'merged' + ); + }); + 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'); @@ -257,6 +268,12 @@ const plan = (o: Partial): RoadmapItem => const at = (day: string) => Date.parse(`${day}T12:00:00Z`) / 1000; describe('lateWords', () => { + it('says a former draft was marked ready late, not opened late', () => { + expect(lateWords(at('2026-09-02'), [plan({})], null, 'marked ready')).toBe( + 'marked ready after the end date' + ); + }); + it('says a PR opened after its plan ended', () => { const plans = [plan({})]; // the plan's last day is Aug 30 @@ -419,4 +436,18 @@ describe('withBoardStates', () => { expect(fresh.issues[0].prs.map(p => p.state)).toEqual(['merged', 'closed']); expect(fresh.unlinked.map(p => p.state)).toEqual(['open', null]); }); + + it('reads a draft marked ready, or made one, off the board', () => { + const sent = { ...page, unlinked: [{ ...ref(5, 'open'), draft: true }, ref(6, 'open')] }; + const now = new Map([ + [5, dp(5, 'needs_cr')], + [6, dp(6, 'draft')], + ]); + const fresh = withBoardStates( + sent as ProjectWork, + r => now.get(r.number), + () => undefined + ); + expect(fresh.unlinked.map(p => p.draft)).toEqual([undefined, true]); + }); }); diff --git a/frontend-v2/src/model/stage.ts b/frontend-v2/src/model/stage.ts index 4911bf39..583e2f25 100644 --- a/frontend-v2/src/model/stage.ts +++ b/frontend-v2/src/model/stage.ts @@ -1,6 +1,6 @@ import { ago, n } from '../../../shared/format'; import type { ClosedIssue } from '../../../shared/model/decide'; -import { dayStart } from '../../../shared/model/projects'; +import { dayStart, isDraft } 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'; @@ -60,6 +60,16 @@ export const STAGE_ORDER: readonly IssueStage[] = [ 'dropped', ]; +/** An open draft, by its live row when the board has one (newer), else by + * what the page was sent: listed, never counted (projects.ts isDraft). */ +export function isDraftPull( + pr: IssuePull, + live: (ref: IssueRef) => DerivedPull | undefined +): boolean { + const p = live(pr); + return p ? isDraft(p) : pr.state === 'open' && !!pr.draft; +} + // an issue is as far along as its least finished open PR const LEAST_FINISHED: readonly PrStage[] = ['work', 'review', 'hold', 'ready']; @@ -78,7 +88,8 @@ export function issueStanding( let least: IssueStanding | null = null; for (const pr of issue.prs) { const p = live(pr); - if (!p && pr.state !== 'open') continue; + // a draft isn't work yet: it sets no stage + if ((!p && pr.state !== 'open') || isDraftPull(pr, live)) 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); @@ -188,7 +199,10 @@ function reviewWaits(p: DerivedPull, own: (words: string) => string, onRow: bool export function lateWords( createdAt: number | null, plans: readonly RoadmapItem[], - closed: ClosedIssue | null = null + closed: ClosedIssue | null = null, + /** what happened at `createdAt`: a former draft's is the day it was + * marked ready, which is when it started as work */ + did: 'opened' | 'marked ready' = 'opened' ): string | null { if (createdAt == null) return null; const plan = planOfWork(plans, createdAt); @@ -198,9 +212,9 @@ export function lateWords( ? { 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 mark.as === 'done' ? `${did} after it was marked done` : `${did} after it was dropped`; } - return plan && createdAt >= afterEndOf(plan) ? 'opened after the end date' : null; + return plan && createdAt >= afterEndOf(plan) ? `${did} after the end date` : null; } /** When a plan took effect: its first day, or when it was put on the @@ -296,9 +310,13 @@ export function withBoardStates( 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 }; + const p = live(pr); + const gone = p ? undefined : known(pr); + const state = p ? 'open' : gone ? (gone.merged_at ? 'merged' : 'closed') : pr.state; + // marked ready (or back to a draft) since the page loaded + const draft = p ? isDraft(p) : state === 'open' && !!pr.draft; + if (state === pr.state && draft === !!pr.draft) return pr; + return { ...pr, state, draft: draft || undefined }; }; return { ...page, diff --git a/frontend-v2/src/model/standing.ts b/frontend-v2/src/model/standing.ts index 0e860bb3..4b01e928 100644 --- a/frontend-v2/src/model/standing.ts +++ b/frontend-v2/src/model/standing.ts @@ -42,7 +42,8 @@ async function dummyStanding(): Promise { import('../../../shared/model/roadmap'), ]); await loadRoadmap(); - const items = readRoadmap().items ?? []; + // every plan, not-tracked ones too: the review board ranks every PR + const items = readRoadmap({ all: true }).items ?? []; const plans: ProjectStanding['plans'] = {}; for (const slug of new Set(items.flatMap(i => (i.project ? [i.project] : [])))) { const plan = planFor(slug, items); diff --git a/frontend-v2/src/model/words.ts b/frontend-v2/src/model/words.ts index d861f845..84b73d8a 100644 --- a/frontend-v2/src/model/words.ts +++ b/frontend-v2/src/model/words.ts @@ -31,6 +31,9 @@ export const pastEndMark = (weeks: number, kind: EndKind = 'hard') => export const LAST_14_DAYS = 'last 14 days'; /** PRs with no project label */ export const NOT_IN_A_PROJECT = 'Not in a project'; +/** open drafts, wherever the tab lists them: a draft isn't work yet, so + * they sit in a closed fold and no count takes them (projects.ts isDraft) */ +export const DRAFTS_NOT_COUNTED = 'Drafts, not counted'; /** 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" */ @@ -45,6 +48,20 @@ export const BEING_WORKED_ON = 'being worked on'; 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'; +/** a project filed nowhere: in no initiative, Keep the lights on or Not + * tracked. Its one name on every band, chooser, hover and split */ +export const UNSORTED_WORD = 'Unsorted'; +/** a project filed into Not tracked, by any view: Decide, a plan's + * details, a drop or its page. Its receipt, without a full stop */ +export const MARKED_NOT_TRACKED = + 'Marked Not tracked, so it counts nowhere. Decide asks again if it grows'; +/** a plan with no project label, which can't be filed until it has one */ +export const NO_PROJECT_YET = 'No project yet'; +/** an initiative's goal, as its own line beside a question about it */ +export const GOAL = 'Goal'; +/** what Not tracked means, the same wherever it's explained */ +export const NOT_TRACKED_GLOSS = + 'Projects marked Not tracked: personal and team workflow nobody reports on. They count in no load, tile, tab or question and owe no update, but Decide asks again if one doubles in size.'; /** 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 */ @@ -86,3 +103,10 @@ 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)); + +/** An Undo's accessible name: what it takes back, without the receipt's + * full stop; a failed one names the receipt it tries again. */ +export function undoName(r: { words: string; failed?: boolean; undoes?: string }): string { + if (!r.failed) return `Undo: ${r.words.replace(/\.$/, '')}`; + return r.undoes ? `Try the Undo again: ${r.undoes.replace(/\.$/, '')}` : 'Try the Undo again'; +} diff --git a/frontend-v2/src/model/work.test.ts b/frontend-v2/src/model/work.test.ts index 0c148855..0f7fff40 100644 --- a/frontend-v2/src/model/work.test.ts +++ b/frontend-v2/src/model/work.test.ts @@ -211,6 +211,18 @@ describe('issueCounts', () => { }); describe('planWork', () => { + it('places a former draft by the day it was marked ready', () => { + // opened Aug 10 as a draft, in the launch; marked ready after its end + const launch = plan(1, { start: '2026-05-18', weeks: 15 }); + const [w] = planWork({ + plans: [launch], + attached: new Map(), + pulls: new Map([['workbench', [pull(30, '2026-08-10', { readyAt: at('2026-09-15') })]]]), + links: new Map(), + }); + expect(w.afterEnd.map(p => p.number)).toEqual([30]); + }); + // 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 }); @@ -286,6 +298,44 @@ describe('planWork', () => { expect([first.openPulls, second.openPulls]).toEqual([1, 0]); }); + it('never counts a draft: not open, not after the end, not after done', () => { + const [late] = planWork({ + plans: [tool], + attached: new Map(), + pulls: new Map([['tool', [pull(11, '2026-09-10', { draft: true }), pull(12, '2026-09-10')]]]), + links: new Map(), + }); + expect([late.openPulls, ids(late.afterEnd), ids(late.afterDone)]).toEqual([1, [12], [12]]); + }); + + it('says which of a page’s PRs are open drafts', () => { + const page = projectWork( + { + plans: [], + attached: new Map(), + pulls: new Map([ + [ + 'tool', + [ + pull(20, '2026-09-10', { draft: true }), + pull(21, '2026-09-10'), + // GitHub can't merge a draft; one closed as a draft is just closed + pull(22, '2026-09-10', { draft: true, state: 'closed', closedAt: at('2026-09-11') }), + ], + ], + ]), + links: new Map(), + }, + 'tool', + at('2026-09-12') + ); + expect(Object.fromEntries(page.unlinked.map(p => [p.number, p.draft]))).toEqual({ + 20: true, + 21: undefined, + 22: undefined, + }); + }); + 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') }; diff --git a/frontend-v2/src/model/workData.ts b/frontend-v2/src/model/workData.ts index 3483ed65..2fc18bb5 100644 --- a/frontend-v2/src/model/workData.ts +++ b/frontend-v2/src/model/workData.ts @@ -162,6 +162,7 @@ export async function dummyWorkInputs(plans: readonly RoadmapItem[]): Promise { if (!data || !plans) return null; const now = Date.now(); + const untracked = notTrackedSlugs(data, initiatives); const today = buildToday( data.projects, allPulls, @@ -180,8 +187,10 @@ function useYourProjects( // 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, + live: decideProjects(trackedToday(today, untracked)), + items: trackedPlans(plans, initiatives), + initiatives: initiatives ?? [], + grown: grownProjects(data, initiatives), closed: closedIssues(data.projects), planCounts: new Map( [...(work?.plans ?? [])].map(([id, w]) => [ @@ -227,7 +236,7 @@ function useYourProjects( a.item.name.localeCompare(b.item.name) ) ); - }, [data, plans, work, allPulls, allClosed, prefix, me]); + }, [data, plans, work, allPulls, allClosed, prefix, me, initiatives]); } /** diff --git a/frontend-v2/src/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx index aa5f1077..fdad01b2 100644 --- a/frontend-v2/src/views/Projects.tsx +++ b/frontend-v2/src/views/Projects.tsx @@ -15,18 +15,24 @@ import { refreshProjectsData, resolveRange, teamLookup, + trackedData, + notTrackedSlugs, useProjectsData, type Range, } from '../model/projectData'; import { useWorkData, workVersion } from '../model/workData'; import { portfolioItems } from '../model/portfolio'; -import { loadRoadmap, useRoadmap } from '../model/roadmapData'; +import { loadRoadmap, trackedPlans, useRoadmap } from '../model/roadmapData'; +import { loadInitiatives, useInitiatives } from '../model/initiativesData'; +import { grownProjects, trackedToday } from '../model/initiativeBoard'; 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 { AllItemsContext } from '../model/decideCalls'; +import { InitiativePage } from './projects/Initiatives'; import { Overview } from './projects/Overview'; import { People } from './projects/People'; import { ProjectPage } from './projects/ProjectPage'; @@ -36,7 +42,7 @@ import { switchView, type Navigate, type ProjectsNav } from './projects/parts'; export type { ProjectsNav } from './projects/parts'; -const NO_TODAY: Today = { live: [], quiet: [], misc: [], unsorted: [], doubleLabeled: [] }; +const NO_TODAY: Today = { live: [], quiet: [], misc: [], unsorted: [], doubleLabeled: [], drafts: [] }; /** The way back from a project's page, named for where it goes. */ const BACK_WORDS: Record = { @@ -94,6 +100,7 @@ export function Projects({ refreshProjectsData(); workVersion.set({ n: workVersion.get().n + 1 }); void loadRoadmap(); + void loadInitiatives(); }, CHANGE_SETTLE_MS); }); return () => { @@ -108,52 +115,76 @@ export function Projects({ ); const rangeKey = resolveRange(nav.range) ? nav.range : DEFAULT_RANGE; const range = resolveRange(rangeKey) as Range; - const data = useProjectsData(prefix ? range : null); + // the full set, Not tracked projects too: only the readers that name it + // (a person's own list, one project, history) see it; every list, count + // and question reads the tracked set below + const fullData = useProjectsData(prefix ? range : null, null, { all: true }); + const { initiatives, loadFailed: initiativesFailed } = useInitiatives(); + const untracked = useMemo(() => notTrackedSlugs(fullData, initiatives), [fullData, initiatives]); + const data = useMemo(() => trackedData(fullData, initiatives), [fullData, initiatives]); // the same days just before the range, for every "compared with" line - const prev = useProjectsData(prefix ? previousRange(range) : null); + const prev = useProjectsData(prefix ? previousRange(range) : null, null, { all: true }); // 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( + const fullToday = useMemo( () => prefix ? buildToday( - data?.projects ?? [], + fullData?.projects ?? [], allPulls, allClosed, prefix, Date.now() / 1000, - data?.pull_links + fullData?.pull_links ) : NO_TODAY, - [data, allPulls, allClosed, prefix] + [fullData, allPulls, allClosed, prefix] + ); + const today = useMemo( + () => trackedToday(fullToday, untracked, { prefix, links: fullData?.pull_links }), + [fullToday, untracked, prefix, fullData] + ); + const teamOf = useMemo(() => teamLookup(fullData?.teams ?? {}), [fullData]); + const { items: allPlans, loadFailed: plansFailed } = useRoadmap({ all: true }); + const plans = useMemo( + () => allPlans && trackedPlans(allPlans, initiatives), + [allPlans, initiatives] ); - 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( + // projects run with no end; every plan's, so the not-tracked rows get + // their work too + const work = useWorkData(allPlans); + const ongoing = useMemo(() => ongoingSlugs(fullData), [fullData]); + const allItems = useMemo( () => portfolioItems( - data?.projects ?? [], - today, - data?.window.projects ?? {}, + fullData?.projects ?? [], + fullToday, + fullData?.window.projects ?? {}, teamOf, Date.now(), - plans ?? [], + allPlans ?? [], work?.projects ?? null, ongoing ), - [data, today, teamOf, plans, work, ongoing] + [fullData, fullToday, teamOf, allPlans, work, ongoing] ); + const items = useMemo(() => allItems.filter(i => !untracked.has(i.slug)), [allItems, untracked]); const closedProjects = useMemo(() => closedIssues(data?.projects ?? []), [data]); + // the Not tracked projects that grew, which Decide asks about again + const grown = useMemo(() => grownProjects(fullData, initiatives), [fullData, initiatives]); // 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] + () => + plans + ? decideRows(today, plans, closedProjects, work, ongoing, { + initiatives: initiatives ?? [], + grown, + }) + : null, + [today, plans, closedProjects, work, ongoing, initiatives, grown] ); // 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 @@ -166,11 +197,12 @@ export function Projects({ ['people', 'People'], ['retro', 'Look back'], ]; + // one slug: read from the full set, so a not-tracked project has its name const nameOf = useMemo(() => { - const names = new Map(items.map(i => [i.slug, i.name])); + const names = new Map(allItems.map(i => [i.slug, i.name])); names.set(MISC_SLUG, ONE_OFFS); return (slug: string) => names.get(slug) ?? slug; - }, [items]); + }, [allItems]); if (!prefix) { return ( @@ -192,11 +224,11 @@ export function Projects({ // range picker wherever the page shows numbers for a range const toolbar = (
- {nav.project ? ( + {nav.project || nav.initiative ? ( // named for where it goes: the view the page was opened from
); return ( - <> + {toolbar} - {nav.project ? ( + {nav.initiative && !nav.project ? ( + + ) : 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 ?? []} + item={allItems.find(i => i.slug === nav.project)} + plans={allPlans} + initiatives={initiatives} + untracked={untracked.has(nav.project)} + ongoingSaved={fullData?.ongoing ?? []} opts={tabOpts} onPerson={onPersonHere} asks={kept.owed.filter(row => row.slug === nav.project)} @@ -249,21 +300,25 @@ export function Projects({ /> ) : nav.view === 'people' ? ( ) : nav.view === 'decide' ? ( )} - + ); } diff --git a/frontend-v2/src/views/projects/BacklogSection.tsx b/frontend-v2/src/views/projects/BacklogSection.tsx index 3ecdd826..4afc6579 100644 --- a/frontend-v2/src/views/projects/BacklogSection.tsx +++ b/frontend-v2/src/views/projects/BacklogSection.tsx @@ -92,7 +92,8 @@ export function BacklogSection({ 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)); + // one slug, or every PR: the full set either way + const prev = useProjectsData(previousRange(range), null, { all: true }); const before = prev ? slug ? prev.window.projects[slug] ?? NONE diff --git a/frontend-v2/src/views/projects/DateRangePicker.tsx b/frontend-v2/src/views/projects/DateRangePicker.tsx index fe326828..3abbb578 100644 --- a/frontend-v2/src/views/projects/DateRangePicker.tsx +++ b/frontend-v2/src/views/projects/DateRangePicker.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useId, useState, type KeyboardEvent } from ' 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'; +import { RANGE_PRESETS, rangeWords, type Range } from '../../model/projectData'; // the calendar and its stylesheet load the first time the panel opens const RangeCalendar = lazy(() => import('./RangeCalendar')); @@ -18,10 +18,36 @@ export function DateRangePicker({ rangeKey, range, onChange, + presets = RANGE_PRESETS, + label = 'Pick a date range', + words = rangeWords, + future = false, + keyOf = r => `${r.start}..${r.end}`, + pickedWords, + wordsClass = 'hidden sm:inline', + name, + hint, }: { + /** the button's name before what it says: "Roadmap window: Year to date" */ + name?: string; + /** the calendar's words before a first click (RangeCalendar) */ + hint?: string; + /** where a preset's range shows beside its name */ + wordsClass?: string; rangeKey: string; range: Range; onChange: (key: string) => void; + /** [key, name] in the panel's order; Look back's by default */ + presets?: [string, string][]; + label?: string; + /** the range on the button */ + words?: (range: Range) => string; + /** the calendar takes days after today (RangeCalendar) */ + future?: boolean; + /** the key for a range picked on the calendar */ + keyOf?: (picked: Range) => string; + /** what a range picked on the calendar says beside Apply */ + pickedWords?: (picked: Range) => string; }) { // remounting the Popover is how a pick closes it (LensMenu does the same): // its children get no close callback @@ -48,11 +74,11 @@ export function DateRangePicker({ e.preventDefault(); setPicks(p => p + 1); }; - const custom = !RANGE_PRESETS.some(([key]) => key === rangeKey); + const preset = presets.find(([key]) => key === rangeKey)?.[1]; return ( ( @@ -60,18 +86,19 @@ export function DateRangePicker({ {...t} id={id} type="button" + aria-label={name && `${name}: ${preset ?? words(range)}`} className="pressable inline-flex h-8 items-center gap-2 rounded-lg border border-line bg-surface px-2.5 text-[13px] text-ink hover:border-ring" > - {custom ? ( - {rangeWords(range)} + {!preset ? ( + {words(range)} ) : ( <> - {rangeName(rangeKey)} - + {preset} + {/* the comma is for the ear: "Last 30 days, Sep 2 to Oct 1" */} , - {rangeWords(range)} + {words(range)} )} @@ -81,7 +108,7 @@ export function DateRangePicker({ >
    - {RANGE_PRESETS.map(([key, label]) => ( + {presets.map(([key, name]) => (
  • @@ -101,7 +128,13 @@ export function DateRangePicker({
}> - pick(`${r.start}..${r.end}`)} /> + pick(keyOf(r))} + />
diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 366b8d2f..ecf51c84 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -5,31 +5,26 @@ import { useMemo, useRef, useState, - type KeyboardEvent as ReactKeyboardEvent, } from 'react'; +import { flushSync } from 'react-dom'; 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, + type GrownProject, } from '../../../../shared/model/decide'; -import { firstOpenDay, projectSlugs, type Today } from '../../../../shared/model/projects'; +import type { Initiative } from '../../../../shared/model/initiatives'; +import { 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, @@ -52,6 +47,7 @@ import { Segmented, textInputClass, } from '../../components/bits'; +import { focusResult, PRESS_GUARD, settle } from '../../components/settle'; import { Icon } from '../../components/Icon'; import { ClosedRow } from '../../components/ClosedRow'; import { Fold, GroupHeader, Rows, SubDoor, Truncated } from '../../components/Lane'; @@ -61,8 +57,15 @@ 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 { + fileProject, + readInitiatives, + updateInitiative, + type Home, +} from '../../model/initiativesData'; +import { NO_PROJECT, UNSORTED } from '../../model/initiativeBoard'; +import { homesOf } from '../../../../shared/model/initiatives'; import { ALL_ISSUES_CLOSED, andList, @@ -70,15 +73,13 @@ import { COMMIT_THROUGH, COPY_AS_TEXT, DONE_WHEN, + GOAL, IN_PROGRESS, LAST_14_DAYS, - missedTarget, - NEEDS_A_PLAN, - noPrActivity, ONGOING, - pastEnd, PLAN_IT, targetOn, + UNSORTED_WORD, } from '../../model/words'; import type { WorkData } from '../../model/workData'; import { @@ -101,18 +102,65 @@ import { type Navigate, type ProjectsNav, } from './parts'; -import { PLAN_STATUS_WORD, vouchWords, when } from './roadmapHealth'; +import { vouchWords } from './roadmapHealth'; +import { + answerOf, + answerWords, + arrowsAmong, + askAll, + askedInitiative, + askOf, + bulkWords, + callWords, + endsFor, + filedWhere, + isHomeCall, + isHomeReason, + kindsOf, + midLine, + nameOf, + ongoingPlan, + primaryOf, + reasonFacts, + rowKey, + rowWords, + sectionOf, + SECTIONS, + sharedAsk, + stop, + upperFirst, + writeFor, + type Call, + type Filed, + type HomeCall, +} from '../../model/decideCalls'; +import { + forgetHomePick, + HomeChooser, + HomeStrip, + InitiativeSwitch, + useChosenHomes, + useHomeChooser, +} from './HomeCalls'; /** The decisions owed now, worst first, for this view, the tab's label and - * the Overview's tile. */ + * the Overview's tile. `today` and `items` are the tracked ones; the + * initiatives raise the questions about where projects belong, and `grown` + * names the Not tracked projects that grew, which `today` leaves out. */ export function decideRows( today: Today, items: readonly RoadmapItem[], closed: ReadonlyMap, work: WorkData | null | undefined = null, - ongoing: ReadonlySet = new Set() + ongoing: ReadonlySet = new Set(), + { + initiatives = [], + grown = [], + }: { initiatives?: readonly Initiative[]; grown?: readonly GrownProject[] } = {} ): DecideRow[] { return decideQueue({ + initiatives, + grown, live: decideProjects(today), items, closed, @@ -133,521 +181,6 @@ export function decideRows( }); } -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 { @@ -667,6 +200,10 @@ export interface Made { /** 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 filing that rode along with a plan call (the row's chooser) */ + filed?: Filed; + /** that filing saved though the plan didn't: Try again leaves it be */ + filedBefore?: Home; } // the calls made since the page loaded, so a trip to a project's page and @@ -675,6 +212,23 @@ const calls = createMemoryStore<{ made: ReadonlyMap }>({ made: new Map(), }); +/** Where the focus goes on a row once its answer is back: the answer that + * can be pressed, else the row's first control (its name), as j and k + * land; never an answer that's off, which would drop it on the page. */ +function leadOf(root: HTMLElement | null): HTMLElement | null { + const row = root?.closest('[data-decide-row]') ?? root; + return ( + root?.querySelector('[data-decide-focus]:not(:disabled)') ?? + row?.querySelector('button:not(:disabled)') ?? + null + ); +} + +/** Forget every call made (tests start clean). */ +export function forgetCalls(): void { + calls.set({ made: new Map() }); +} + function setMade(key: string, made: Made | null): void { const next = new Map(calls.get().made); if (made) next.set(key, made); @@ -698,23 +252,58 @@ function patchMade(key: string, token: number, state: Made['state'], patch: Part export function keepCalls( queue: readonly DecideRow[], made: ReadonlyMap -): { owed: DecideRow[]; all: DecideRow[]; settled: ReadonlyMap } { +): { + owed: DecideRow[]; + all: DecideRow[]; + settled: ReadonlyMap; + open: DecideRow[]; +} { const byKey = new Map(queue.map(row => [rowKey(row), row])); const settled = new Map(); + // answered about its home, still asked about its plan: owed, so every + // other view keeps the plan in sight, while this row keeps its receipt + const pending = new Set(); const back: DecideRow[] = []; + // a home call taken back while its Undo saves: the row still reads as + // answered, so it's shown as it was asked, in the same place, where its + // Undo's words hold the receipt's slot + const asked = new Map(); 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 && m.state === 'undone' && answeredHome(m, again)) asked.set(key, m.row); } else if (!again || kindsOf(again) === kindsOf(m.row)) { settled.set(key, m); + } else if (answeredHome(m, again)) { + settled.set(key, m); + pending.add(key); } } - 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 }; + const owed = [ + ...queue.filter(row => !settled.has(rowKey(row)) || pending.has(rowKey(row))), + ...back, + ].sort(compareRows); + const all = [ + ...owed.filter(row => !settled.has(rowKey(row))).map(row => asked.get(rowKey(row)) ?? row), + ...[...settled.values()].map(m => m.row), + ].sort(compareRows); + // what every count says is left: a row answered about its home this + // visit is still owed for its plan (so the Roadmap and the Overview keep + // it in sight), but Decide asks that on the next visit, so no count + // takes it now and the badge, the switches and the page agree with + // Decide's heading + const open = owed.filter(row => !settled.has(rowKey(row))); + return { owed, all, settled, open }; } +/** A filing or status call on a row that also asked about its plan: the + * row comes back with only the plan's reasons, which it asks on the next + * visit, so this one keeps the receipt and its Undo. */ +const answeredHome = (m: Made, again: DecideRow) => + isHomeCall(m.call) && + kindsOf(again) === kindsOf({ ...m.row, reasons: m.row.reasons.filter(r => !isHomeReason(r)) }); + /** keepCalls on this visit's calls, for a view that lists Decide's rows. */ export function useKeptCalls(queue: readonly DecideRow[]) { const { made } = calls.useValue(); @@ -810,9 +399,62 @@ function afterRow( return next; } -// the calls whose save landed, by token, with the plan they wrote: what -// Undo takes back -const landed = new Map(); +// the calls whose save landed, by token, with what they wrote: the plan, +// the home a filing replaced, the initiative as a status change found it. +// What Undo takes back +interface Landed { + id: number | null; + home?: { project: string; to: string | null; before: Home }; + /** the plan a link gave a project */ + linked?: number; + initiative?: { + id: number; + was: { status: Initiative['status']; updated_at: number | null; status_at: number | null }; + }; +} +const landed = new Map(); + +/** A project's home slug now, or null for none. */ +const homeSlugOf = (slug: string | null) => + (slug && homesOf(readInitiatives().initiatives ?? []).get(slug)?.initiative.slug) ?? null; + +/** Write a filing or an initiative's status: what a call on a row that asks + * where a project belongs saves. Resolves to what Undo needs, or why not. */ +async function homeWrite( + row: DecideRow, + call: HomeCall +): Promise | { why: string }> { + const slug = row.slug; + if (call.kind === 'link') { + if (!row.item) return { why: '' }; + return (await updateRoadmapItem(row.item.id, { project: call.project })) + ? { linked: row.item.id } + : { why: takeProblem() }; + } + if (call.kind === 'file' || call.kind === 'keep_untracked') { + if (!slug) return { why: '' }; + const r = await fileProject(slug, call.to, { expect: homeSlugOf(slug) }); + return 'error' in r + ? { why: whyNot(r.error) } + : { home: { project: slug, to: call.to, before: r.before } }; + } + const id = askedInitiative(row); + if (id == null) return { why: '' }; + const r = + call.kind === 'close' + ? await updateInitiative(id, { status: call.as }) + : await updateInitiative(id, { status: 'open' }, { restate: call.kind === 'keep_open' }); + if ('error' in r) { + const live = r.live?.length ? ` Still going in it: ${andList(r.live)}.` : ''; + return { why: `${whyNot(r.error)}${live}` }; + } + return { + initiative: { + id, + was: { status: r.was.status, updated_at: r.was.updated_at, status_at: r.was.status_at }, + }, + }; +} let tokens = 0; /** Why a save didn't land, after "Didn’t save.": the server's reason, or @@ -836,7 +478,9 @@ function makeCall( row: DecideRow, call: Call, project: PortfolioItem | undefined, - batch?: { after: Promise } + batch?: { after: Promise }, + filed?: Filed, + filedBefore?: Home ): Promise { const key = rowKey(row); const token = ++tokens; @@ -845,19 +489,39 @@ function makeCall( setMade(key, { row, call, - words: callWords(call, row, project, today), + words: callWords(call, row, project, today, filed), state: 'made', origin: row.item?.origin ?? null, token, batch: !!batch, + filed, }); + // the pick is in the call now: no later answer carries it + if (row.slug) forgetHomePick(row.slug); return afterRow( key, async () => { - let id: number | null = null; + const done: Landed = { id: 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; + // the filing goes first: if the plan then fails, the project stays + // filed, which is harmless, and Try again doesn't file it twice + if (filed && row.slug) { + if (filedBefore) done.home = { project: row.slug, to: filed.to, before: filedBefore }; + else { + const r = await fileProject(row.slug, filed.to, { expect: filed.from }); + if ('error' in r) why = whyNot(r.error); + else done.home = { project: row.slug, to: filed.to, before: r.before }; + } + } + if (why != null) { + // nothing saved + } else if (isHomeCall(call)) { + const r = await homeWrite(row, call); + if ('why' in r) why = r.why; + else Object.assign(done, r); + } else if (call.kind === 'ongoing' && row.item && ongoingPlan(row)) { + if (await updateRoadmapItem(row.item.id, { end_kind: 'ongoing' })) + done.id = row.item.id; else why = takeProblem(); } else if (call.kind === 'ongoing') { const saved = await setOngoing(row.slug as string, true); @@ -868,22 +532,24 @@ function makeCall( const ok = await updateRoadmapItem(write.id, write.fields, { restate: write.restate, }); - if (ok) id = write.id; + if (ok) done.id = write.id; else why = takeProblem(); } else { - id = (await createRoadmapItem(write.fields))?.id ?? null; - if (id == null) why = takeProblem(); + done.id = (await createRoadmapItem(write.fields))?.id ?? null; + if (done.id == null) why = takeProblem(); } } - if (why == null) landed.set(token, { id }); - else patchMade(key, token, 'made', { state: 'failed', why }); + if (why == null) landed.set(token, done); + else + patchMade(key, token, 'made', { state: 'failed', why, filedBefore: done.home?.before }); }, 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. */ + * gone, or the project no longer ongoing; then the home a filing replaced, + * or the initiative's status as it was. */ function undoCall(key: string, batch?: { after: Promise }): Promise { const made = calls.get().made.get(key); if (!made) return Promise.resolve(); @@ -897,7 +563,17 @@ function undoCall(key: string, batch?: { after: Promise }): Promise }): Promise): 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 @@ -1007,7 +693,21 @@ export function DecideCall({ const shown = mine?.state === 'made' || mine?.state === 'failed' ? mine : undefined; const name = nameOf(row, project); const rootRef = useRef(null); + const receiptRef = useRef(null); const refocus = useRef(false); + // the receipt's height when its Undo was clicked here: the words "Put X + // back as it was." hold that slot, and the answers come back below it, + // so a second click on Undo lands on words, never on an answer. They + // leave once the keyboard moves on (a Tab), never under a pointer + const [undid, setUndid] = useState<{ height: number; held: boolean } | null>(null); + const putBackRef = useRef(null); + const tabbing = useRef(false); + // the held words go; the focus they had moves to the row's answer + const letGo = () => { + const had = !!putBackRef.current?.contains(document.activeElement); + flushSync(() => setUndid(u => (u?.held ? { height: 0, held: false } : u))); + if (had) leadOf(rootRef.current)?.focus({ preventScroll: true }); + }; // 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 @@ -1015,6 +715,8 @@ export function DecideCall({ // 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'); + // where a new or unfiled project goes, filed with its plan call + const chooser = useHomeChooser(row); 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 @@ -1026,7 +728,9 @@ export function DecideCall({ const lost = document.activeElement === document.body && view !== 'ask'; if (!refocus.current && !lost) return; refocus.current = false; - rootRef.current?.querySelector('[data-decide-focus]')?.focus(); + // after an Undo, its held words; j and k still land on the answer. + // Shown below the sticky headers, wherever the row went + focusResult(putBackRef.current ?? leadOf(rootRef.current)); }, [view]); const act = (fn: () => void) => () => { refocus.current = true; @@ -1044,7 +748,10 @@ export function DecideCall({ ? '' : mine.state === 'failed' ? `Didn’t save the decision on ${name}.${mine.why ?? ''}` - : `Undid the decision on ${name}.`; + : // undone here, the focus lands on the same words in the row + undid != null + ? '' + : `Put ${name} back as it was.`; const live = ( {said} @@ -1065,15 +772,51 @@ export function DecideCall({
); } + // taken back here: its words where the receipt was, the answers after + const putBack = undid?.held && mine?.state === 'undone' && !mine.batch && ( +

{ + tabbing.current = e.key === 'Tab'; + }} + onBlur={() => { + if (tabbing.current) setUndid({ height: 0, held: false }); + tabbing.current = false; + }} + // a double-click's second press lands here: nothing to select + className="m-0 flex select-none items-center rounded text-[13px] text-ink-2" + style={{ minHeight: undid.height }} + > + Put {name} back as it was. +

+ ); return (
{live} + {putBack} {shown?.state === 'failed' ? (

- Didn’t save.{shown.why} + + {shown.filed && shown.filedBefore + ? `${ + shown.filed.untracked ? 'Marked Not tracked' : `Filed in ${shown.filed.name}` + }; the plan didn’t save.` + : 'Didn’t save.'} + {shown.why} + makeCall(shown.row, shown.call, project))} + onClick={act(() => + makeCall( + shown.row, + shown.call, + project, + undefined, + shown.filed, + shown.filedBefore + ) + )} aria-label={`Try the decision on ${name} again`} > Try again @@ -1086,7 +829,10 @@ export function DecideCall({

) : shown ? ( -
+
{/* one run of text, so Undo shares the words' last line and the receipt is no taller than the calls it replaced */} @@ -1099,13 +845,29 @@ export function DecideCall({ {shown.why && ` ${shown.why}`} {' '} undoCall(rowKey(row)))} - aria-label={`Undo the decision on ${name}`} + onClick={act(() => + // its words where Undo was, even when a count above + // the row changes with it (a section's, on a phone); + // they go once the pointer moves away or the next + // action starts, the focus back on the row's answer + settle( + () => rootRef.current, + () => { + setUndid({ + height: receiptRef.current?.offsetHeight ?? 0, + held: true, + }); + undoCall(rowKey(row)); + }, + letGo + ) + )} + aria-label={`Undo: ${shown.words.replace(/\.$/, '')}`} > Undo - {shown.call.kind !== 'ongoing' && ( + {shown.call.kind !== 'ongoing' && !isHomeCall(shown.call) && ( // optional, after the call: saying it alone never clears a row )}
- ) : ( - act(() => makeCall(row, call, project))()} - onCancel={row.reasons.length || opened ? undefined : act(() => setOpen(false))} - full={opened} quiet={compact} /> + ) : ( + <> + {/* where it goes, then the answer that files it there */} + {chooser.shown && } + + act(() => makeCall(row, call, project, undefined, chooser.filed))() + } + onCancel={row.reasons.length || opened ? undefined : act(() => setOpen(false))} + full={opened} + quiet={compact} + /> + )}
); @@ -1177,11 +955,19 @@ function CallStrip({ onCancel, full = false, quiet = false, + waiting = false, + alsoFiles, }: { row: DecideRow; name: string; /** its project, for its target */ project: PortfolioItem | undefined; + /** the File it in chooser above has nothing picked: the answer waits + * for it, outlined and unmarked, as the chooser's own File button does */ + waiting?: boolean; + /** where the call files the project too ("in Release safety"), said to + * a screen reader with each answer */ + alsoFiles?: string; describedBy?: string; onCall: (call: Call) => void; /** close it again, for calls opened from Change */ @@ -1194,6 +980,7 @@ function CallStrip({ }) { const [more, setMore] = useState(false); const stripRef = useRef(null); + const moreRef = 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 @@ -1210,19 +997,15 @@ function CallStrip({ 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}` }, + { call: { kind: 'park' }, label: 'Park', ariaLabel: `Park: ${name}` }, + { call: { kind: 'done' }, label: 'Mark done', ariaLabel: `Mark done: ${name}` }, + { call: { kind: 'drop' }, label: 'Drop', ariaLabel: `Drop: ${name}` }, ...(ongoing ? [ { call: { kind: 'ongoing' } as Call, label: ONGOING, - ariaLabel: `Mark ${name} ongoing`, + ariaLabel: `${ONGOING}: ${name}`, }, ] : []), @@ -1234,13 +1017,20 @@ function CallStrip({ (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]; + // the strip's one Tab stop, and where j and k land: the first answer + // that can be pressed, so one waiting on an empty chooser never hides + // the rest from the keyboard + const off = (o: Option) => waiting && o === first; + const lead = [first, ...dates, ...others].find(o => o && !off(o)); + // the answer is off, so Other answers takes the stop + const moreLeads = !!first && off(first); // 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 marked = (!quiet || more || full) && !waiting; + // its name holds the words it shows (a voice user says what they see), + // then the row's name and where the call files it const button = (o: Option, words = o.label) => ( ); - // opened, the focus moves to the first of the answers it showed + // opened, the focus moves to the first row's label, not an answer: a + // second Enter (the keyboard's double-click) answers nothing, and Tab + // or an arrow reaches the answers const showMore = () => { setMore(true); requestAnimationFrame(() => - stripRef.current?.querySelector('button:not([data-decide-focus])')?.focus() + stripRef.current?.querySelector('[data-strip-label]')?.focus() ); }; const group = (label: string, options: Option[]) => options.length > 0 && (
- {label} + + {label} + {options.map(o => button(o))}
); @@ -1286,12 +1088,14 @@ function CallStrip({ > {button(first, answerWords(first.call, row.item))} @@ -1304,9 +1108,14 @@ function CallStrip({ role="toolbar" aria-label={`Decisions for ${name}`} onKeyDown={e => { - if (e.key !== 'Escape' || !onCancel) return arrowsAmong(e); + if (e.key !== 'Escape' || (!onCancel && !more)) return arrowsAmong(e); + // the innermost thing closes: the answers Other answers opened, + // back to it, else the calls Change opened e.preventDefault(); - onCancel(); + e.stopPropagation(); + if (!more) return onCancel?.(); + flushSync(() => setMore(false)); + moreRef.current?.focus(); }} className="flex flex-col gap-2" > @@ -1387,6 +1196,10 @@ function factsTitle( team, people.length ? people.join(', ') : null, project?.open ? n(project.open, 'open PR') : null, + // so a project quiet but for drafts isn't read as having nothing + project?.group?.drafts.length + ? `${n(project.group.drafts.length, 'draft')}, not counted` + : null, project?.merged ? `${project.merged} merged in the ${LAST_14_DAYS}` : null, target && targetOn(targetWords(target)), ] @@ -1419,9 +1232,10 @@ function useBackToRow(ready: boolean) { }, [ready]); } -function DecideRowView({ +export function DecideRowView({ row, decided, + owner, project, team, nav, @@ -1434,6 +1248,9 @@ function DecideRowView({ row: DecideRow; /** a call was made on it here */ decided: boolean; + /** the initiative the row is about, when it asks about one: it names + * the owner, who takes the call */ + owner?: Initiative; /** its section asks the question once, so the row is one line: name, * lead, the one fact and its answer */ asked?: boolean; @@ -1456,8 +1273,14 @@ function DecideRowView({ writeSessionStorage(BACK_TO, key); navigate(patch); }; + // a row about an initiative itself opens the initiative's page + const asksInitiative = askedInitiative(row) != null && !row.slug; const openProject = () => - row.slug ? go({ project: row.slug }) : item && go(openPlan(nav, item.id)); + asksInitiative && owner + ? go({ initiative: owner.slug, project: null }) + : 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)); @@ -1482,10 +1305,27 @@ function DecideRowView({ className={`hit pressable rounded border-0 bg-transparent p-0 text-left text-[13px] font-medium underline decoration-line underline-offset-2 hover:text-brand ${ decided ? 'text-ink-3' : 'text-ink' }`} - title={row.slug ? 'Open the project and its PRs' : 'Open its plan on the roadmap'} + title={ + asksInitiative + ? 'Open the initiative and its projects' + : row.slug + ? 'Open the project and its PRs' + : 'Open its plan on the roadmap' + } > {nameOf(row, project)} + {/* a question about an initiative is its owner's to take */} + {owner?.owner && asksInitiative && ( + onPerson?.(owner.owner as string)} + title={`${owner.owner} owns ${owner.name}. See them on People`} + > + {owner.owner} + + )} {DONE_WHEN}: {item.done_when} )} + {/* "Did it reach its goal?" beside the goal it asks about */} + {primary.kind === 'goal_reached' && owner?.goal && ( + + {GOAL}: {owner.goal} + + )}

{row.reasons .filter(r => r !== primary) @@ -1563,7 +1409,7 @@ function DecideRowView({ * 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({ +export function SectionCalls({ title, rows, owed, @@ -1592,10 +1438,18 @@ function SectionCalls({ }); const standing = batch('made'); const failed = batch('failed').length; - // a row with no answer to suggest waits for a person + // a row with no answer to suggest waits for a person, as does one whose + // File it in chooser is empty (its own answer is off until a pick); the + // rest file where their chooser says, guess included, as their own + // answer would + const homeOf = useChosenHomes(); + let waiting = 0; const answers = owed.flatMap(row => { const call = answerOf(row, projectOf(row), day); - return call ? [{ row, call }] : []; + if (!call) return []; + const home = homeOf(row); + if (home.waiting) waiting++; + return home.waiting ? [] : [{ row, call, filed: home.filed }]; }); const view = standing.length ? 'did' : 'ask'; const before = useRef(view); @@ -1615,16 +1469,21 @@ function SectionCalls({ }, [view]); const words = bulkWords( answers.map(a => a.call), - 'do' + 'do', + !waiting ); const all = () => { refocus.current = true; setSaid(''); - inTurn(answers.map(a => after => makeCall(a.row, a.call, projectOf(a.row), { after }))); + inTurn( + answers.map( + a => after => makeCall(a.row, a.call, projectOf(a.row), { after }, a.filed) + ) + ); }; const undoAll = () => { refocus.current = true; - setSaid(`Undid ${n(standing.length, 'decision')} under ${title}.`); + setSaid(`Put ${n(standing.length, 'decision')} under ${title} back as they were.`); inTurn(standing.map(m => after => undoCall(rowKey(m.row), { after }))); }; // the rows' failures, said once for all of them @@ -1661,7 +1520,7 @@ function SectionCalls({ data-decide-focus onClick={all} aria-label={`${words}, under ${title}`} - className="hit pressable inline-flex h-8 items-center rounded-lg border border-brand bg-brand px-3 text-[13px] font-medium text-surface hover:opacity-90" + className="hit pressable inline-flex min-h-8 max-w-full items-center py-1 text-left rounded-lg border border-brand bg-brand px-3 text-[13px] font-medium text-surface hover:opacity-90" > {words} @@ -1807,7 +1666,7 @@ function ParkLine({ {state === 'failed' ? `Didn’t park ${name}.${parked?.why ?? ''}` : parked?.state === 'undone' - ? `Undid parking ${name}.` + ? `Put ${name} back as it was.` : parked?.why ?? ''} @@ -2369,6 +2228,9 @@ export function teamOrder(configured: readonly string[], named: readonly (string export function Decide({ today, items, + allItems = items, + initiatives = null, + grown = [], closed, teamOf, teamMembers, @@ -2386,6 +2248,13 @@ export function Decide({ today: Today; /** the portfolio from the same, for names, leads, sizes and teams */ items: PortfolioItem[]; + /** every project, Not tracked ones too: a row's one-slug lookups */ + allItems?: PortfolioItem[]; + /** the initiatives, which raise where-it-belongs questions; null while + * they load */ + initiatives?: Initiative[] | null; + /** the Not tracked projects that grew (initiativeBoard.ts grownProjects) */ + grown?: readonly GrownProject[]; closed: ReadonlyMap; teamOf: (login: string) => string | null; teamMembers: Record; @@ -2420,26 +2289,60 @@ export function Decide({ ); } const day = dayOf(new Date()); - const bySlug = new Map(items.map(i => [i.slug, i])); + // one slug each: from every project, so a grown Not tracked project has + // its name and team + const bySlug = new Map(allItems.map(i => [i.slug, i])); const projectOf = (row: DecideRow) => (row.slug ? bySlug.get(row.slug) : undefined); + // the initiative each row is about: the one it asks about, or its + // project's home; for the owner it names and the Initiative switch + const homes = homesOf(initiatives ?? []); + const initiativeOfRow = (row: DecideRow) => + (initiatives ?? []).find(i => i.id === askedInitiative(row)) ?? + (row.slug ? homes.get(row.slug)?.initiative : 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 kept = keepCalls( + decideRows(today, plans, closed, work, ongoing, { initiatives: initiatives ?? [], grown }), + made + ); + // the Initiative switch's key for a row: its initiative, Unsorted, or + // for a plan with no project, the No project yet entry, as the Roadmap + // lanes split them + const withinOf = (row: DecideRow) => + initiativeOfRow(row)?.slug ?? (row.slug ? UNSORTED : NO_PROJECT); + const inTeam = (row: DecideRow) => + (!nav.team || (teamOfRow(row) ?? '(none)') === nav.team) && + (!nav.within || withinOf(row) === nav.within); 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 + const withinName = nav.within + ? nav.within === UNSORTED + ? `${UNSORTED_WORD} projects` + : nav.within === NO_PROJECT + ? 'plans with no project yet' + : (initiatives ?? []).find(i => i.slug === nav.within)?.name ?? nav.within + : null; + // both when both narrow it: "for Store in Site speed" + const teamWords = !nav.team ? '' : nav.team === '(none)' ? ' for work with no team' : ` for ${nav.team}`; - const heading = owed.length - ? `${n(owed.length, 'decision')} to make` + const whose = withinName + ? teamWords + ? `${teamWords} in ${withinName}` + : ` for ${withinName}` + : teamWords; + // as the sections count: a row answered about its home here keeps its + // receipt, and its plan question waits for the next visit + const toMake = kept.open.filter(inTeam).length; + const heading = toMake + ? `${n(toMake, 'decision')} to make` : decidedHere.length ? 'Nothing left to decide' : 'Nothing to decide'; @@ -2491,9 +2394,9 @@ export function Decide({ // 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 teams = teamOrder(Object.keys(teamMembers), kept.open.map(teamOfRow)); const owedBy = (team: string) => - kept.owed.filter(row => (teamOfRow(row) ?? '(none)') === team).length; + kept.open.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 @@ -2505,7 +2408,7 @@ export function Decide({ reasons: [], }; return ( -
+
+ {(initiatives ?? []).length > 0 && ( + kept.open.filter(row => withinOf(row) === slug).length} + value={nav.within} + onChange={within => navigate({ within })} + /> + )} {teams.length > 0 && (
owedBy(t) > 0 || nav.team === t) @@ -2600,7 +2511,7 @@ export function Decide({ : `Nothing left to decide for ${nav.team}` } sub={ - nav.team && kept.owed.length + nav.team && kept.open.length ? 'Other teams have decisions waiting.' : decidedHere.length ? `${n( @@ -2610,7 +2521,7 @@ export function Decide({ : 'Rows show up here when work needs a plan, stalls, is overdue or misses its target date, or is marked done or parked but still worked on.' } /> - {nav.team && kept.owed.length > 0 && ( + {nav.team && kept.open.length > 0 && (

navigate({ team: null })}> Show all teams @@ -2674,6 +2585,7 @@ export function Decide({ key={rowKey(row)} row={row} decided={decided(row)} + owner={initiativeOfRow(row)} project={projectOf(row)} team={teamOfRow(row)} nav={nav} diff --git a/frontend-v2/src/views/projects/DecideCall.test.tsx b/frontend-v2/src/views/projects/DecideCall.test.tsx index 1b0b36bb..5ab55aa8 100644 --- a/frontend-v2/src/views/projects/DecideCall.test.tsx +++ b/frontend-v2/src/views/projects/DecideCall.test.tsx @@ -5,7 +5,8 @@ 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'; +import { DecideCall } from './Decide'; +import { askAll, bulkWords, type Call, sharedAsk } from '../../model/decideCalls'; // React's act() warns unless the environment says it's a test (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -84,6 +85,16 @@ describe('the calls on a row Decide asks about', () => { expect(buttons(el)).toEqual(expect.arrayContaining(['Park', 'Drop', 'Mark doneSuggested'])); }); + it('put the focus on the first row’s label once opened, so a second Enter answers nothing', async () => { + const el = render(false, done); + const other = [...el.querySelectorAll('button')].find(b => b.textContent === 'Other answers'); + // Enter on a button is a click with no pointer (detail 0) + act(() => other?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))); + await act(() => new Promise(requestAnimationFrame)); + expect(document.activeElement?.textContent).toBe('Promise to finish by'); + expect(document.activeElement?.tagName).toBe('SPAN'); + }); + it('stand open where someone opened the plan to change it', () => { const el = render(true, done); expect(el.textContent).toContain('Or instead'); @@ -129,7 +140,7 @@ describe('a section whose rows all ask the same thing', () => { end: '2026-10-31', through: 'the end of Oct', }; - expect(bulkWords(Array(7).fill(oct), 'do')).toBe('Promise all 7 by Oct 31'); + expect(bulkWords(Array(7).fill(oct), 'do')).toBe('Promise all 7 by the end of Oct'); }); it('gives each row an outlined answer, unmarked, and every other answer a click away', () => { @@ -137,7 +148,7 @@ describe('a section whose rows all ask the same thing', () => { 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'); + expect(buttons()[0].getAttribute('aria-label')).toBe('Mark done: Visual regression checks'); 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/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx new file mode 100644 index 00000000..3dc47bcf --- /dev/null +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -0,0 +1,1594 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { DecideRow } from '../../../../shared/model/decide'; +import type { Initiative } from '../../../../shared/model/initiatives'; +import type { RoadmapItem } from '../../../../shared/model/roadmap'; +import { fileProject, loadInitiatives } from '../../model/initiativesData'; +import type { PortfolioItem } from '../../model/portfolio'; +import { HomeLine, InitiativePage } from './Initiatives'; +import { clearHomePicks, InitiativeSwitch } from './HomeCalls'; +import { clearHolds, pressedBy } from '../../components/settle'; +import { NO_PROJECT, UNSORTED } from '../../model/initiativeBoard'; +import { PROJECT_SLUG } from '../../../../shared/model/roadmap'; +import type { ProjectsNav } from './parts'; +import { + DecideCall, + DecideRowView, + forgetCalls, + keepCalls, + SectionCalls, + useKeptCalls, + type Made, +} from './Decide'; +import { + AllItemsContext, + answerWords, + callWords, + rowKey, + sectionOf, +} from '../../model/decideCalls'; + +// jsdom makes no trusted events: every press here is a person's +pressedBy.person = () => true; + +// 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 initiative = ( + id: number, + slug: string, + name: string, + projects: string[], + over: Partial = {} +): Initiative => ({ + id, + slug, + name, + kind: 'initiative', + goal: '', + owner: 'dana', + priority: id, + status: 'open', + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: 100, + projects: projects.map(project => ({ project, set_by: null, set_at: 50, baseline_prs: 0 })), + ...over, +}); + +const INITIATIVES = [ + initiative(1, 'site-speed', 'Site speed', ['cache', 'filed']), + initiative(2, 'fixbot', 'FixBot chat', ['answers']), + initiative(3, 'catalog', 'Catalog', ['akeneo'], { status: 'done' }), + initiative(4, 'onboarding', 'Onboarding', ['emails'], { + goal: 'New customers get a welcome series', + }), + initiative(5, 'fragile', 'Fragile', []), + // Not tracked under a slug of its own: the board finds it by its kind + initiative(9, 'shelf', 'Not tracked', ['ops'], { kind: 'untracked' }), +]; + +const created: RoadmapItem = { + id: 77, + name: 'New thing', + project: 'new-thing', + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-10-05', + weeks: 4, + end_kind: 'hard', + done_when: '', + priority: 9, + notes: '', + waits_on: [], + updated_by: null, + updated_at: 1, + created_at: 1, + update: null, +}; + +/** An active plan with no project. */ +const unlinked: RoadmapItem = { ...created, id: 5, name: 'Checkout redesign', project: null }; + +/** The server, by route: each write answers as the real one would. */ +const calls: { method: string; path: string; body: unknown }[] = []; +let flakyFailed = false; +beforeAll(async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (path: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(String(init.body)) : null; + calls.push({ method, path, body }); + const reply = (json: unknown, status = 200) => + ({ status, redirected: false, json: async () => json } as Response); + if (path === '/initiatives' && method === 'GET') + return reply({ initiatives: INITIATIVES }); + if (path === '/initiatives/file') { + // as the server: nothing into a closed initiative but an undo + if (body.initiative === 'catalog' && body.set_at == null) + return reply( + { error: 'that initiative is closed; reopen it before filing a project in it' }, + 409 + ); + // someone filed it elsewhere since the board last looked + if (body.project === 'raced') + return reply( + { + error: 'its home changed; here it is now', + home: { initiative: 'fixbot', set_at: 60, baseline_prs: 0 }, + }, + 409 + ); + const before = + body.project === 'ops' + ? { initiative: 'shelf', set_at: 50, baseline_prs: 2 } + : body.project === 'filed' + ? { initiative: 'site-speed', set_at: 50, baseline_prs: 0 } + : body.project === 'akeneo' + ? { initiative: 'catalog', set_at: 50, baseline_prs: 0 } + : { initiative: null, set_at: null, baseline_prs: 0 }; + return reply({ project: body.project, before, home: null }); + } + if (path.startsWith('/initiatives/')) { + const id = Number(path.split('/')[2]); + // a database gone between a save and its Undo + if (id === 5 && body.undo) return reply({ error: 'the database is down' }, 500); + const was = INITIATIVES.find(i => i.id === id) ?? INITIATIVES[3]; + return reply({ initiative: { ...was, ...body } }); + } + if (path === '/roadmap' && method === 'POST') { + // a plan that fails once, after its filing saved + if (body.project === 'flaky' && !flakyFailed) { + flakyFailed = true; + return reply({ error: 'the database is down' }, 500); + } + return reply({ item: created }, 201); + } + if (path === '/roadmap' && method === 'GET') return reply({ items: [] }); + if (path.startsWith('/roadmap/') && method === 'DELETE') return reply({ ok: true }); + if (path.startsWith('/roadmap/') && method === 'PATCH') + return reply({ item: { ...unlinked, ...body } }); + return reply({}, 404); + }) + ); + await loadInitiatives(); +}); + +let host: HTMLDivElement | null = null; +// every tree a test mounts, unmounted after it, so none listens on to the +// stores the next test writes +const roots: Root[] = []; +afterEach(async () => { + act(() => roots.splice(0).forEach(r => r.unmount())); + clearHomePicks(); + clearHolds(); + forgetCalls(); + host?.remove(); + host = null; + // a filing a test made leaves the board's copy as the server has it + await loadInitiatives(); + calls.length = 0; +}); + +const person = (slug: string, developers: string[]) => + ({ slug, name: slug, developers, nonDevelopers: [] } as unknown as PortfolioItem); +const ITEMS = [ + person('new-thing', ['erin']), + person('cache', ['dana']), + person('answers', ['erin']), + person('filed', ['dana']), + person('flaky', ['erin']), + // nobody it shares people with: no guess + person('lonely', ['zed']), + person('bulk-a', ['erin']), + person('bulk-b', ['dana']), + person('ops', ['dana']), +]; + +function render(row: DecideRow): HTMLDivElement { + return mount(); +} + +function mount(node: React.ReactNode): HTMLDivElement { + host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + roots.push(root); + act(() => root.render({node})); + return host; +} + +const buttons = (el: HTMLElement) => [...el.querySelectorAll('button')].map(b => b.textContent); +const click = (el: HTMLElement, words: string) => + act(() => + [...el.querySelectorAll('button')].find(b => b.textContent?.startsWith(words))?.click() + ); +/** the chooser's File button, the answer or the opened chooser's */ +const fileButton = (el: HTMLElement) => + [...el.querySelectorAll('button')].find(b => /^File(Suggested)?$/.test(b.textContent ?? '')); +/** what a chooser's line holds, in order: its label, the select, its buttons */ +const parts = (el: HTMLElement) => { + const line = el.querySelector('select')?.parentElement as HTMLElement; + return [...line.children] + .map(c => + c.tagName === 'SELECT' ? 'select' : c.textContent === 'guessed' ? null : c.textContent + ) + .filter(Boolean); +}; +/** a callback the test doesn't watch */ +const nothing = () => undefined; +const settle = () => act(() => new Promise(resolve => setTimeout(resolve, 0))); +/** A mouse's press at a point onto `el`, whatever sits there now. */ +const pressAt = (el: Element, x: number, y: number) => + act(() => { + const at = { bubbles: true, cancelable: true, clientX: x, clientY: y }; + for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) + el.dispatchEvent(new MouseEvent(type, at)); + el.dispatchEvent(new MouseEvent('click', { ...at, detail: 1 })); + }); + +describe('a row that asks about an initiative', () => { + const goal: DecideRow = { + slug: null, + item: null, + reasons: [{ kind: 'goal_reached', initiative: 4 }], + }; + + it('suggests closing it as done, with dropped and Keep open a click away', () => { + const el = render(goal); + expect(buttons(el)).toEqual(['Close as doneSuggested', 'Other answers']); + click(el, 'Other answers'); + // the button clicked stays where it was; the others come after it + expect(buttons(el)).toEqual([ + 'Close as doneSuggested', + 'Other answers', + 'Close as dropped', + 'Keep open', + ]); + }); + + it('says Suggested on the outlined answer at a Roadmap row’s end too', () => { + const el = mount(); + expect(buttons(el)).toEqual(['Close as doneSuggested', 'Other answers']); + // outlined, not filled, beside rows whose questions are words + expect(el.querySelector('button')?.className).not.toContain('bg-brand'); + }); + + it('keeps it open by saying its status again, and Undo puts back the times', async () => { + const el = render(goal); + click(el, 'Other answers'); + click(el, 'Keep open'); + await settle(); + expect(el.textContent).toContain( + 'Kept open. Decide asks again once something in it changes.' + ); + expect(calls.at(-1)).toMatchObject({ + method: 'PATCH', + path: '/initiatives/4', + body: { status: 'open', restate: true }, + }); + // named as every Undo is: by what it takes back + expect( + [...el.querySelectorAll('button')] + .find(b => b.textContent === 'Undo') + ?.getAttribute('aria-label') + ).toBe('Undo: Kept open. Decide asks again once something in it changes'); + click(el, 'Undo'); + await settle(); + expect(calls.at(-1)).toMatchObject({ + method: 'PATCH', + path: '/initiatives/4', + body: { status: 'open', undo: { updated_at: 100, status_at: null } }, + }); + }); + + it('suggests reopening a closed one a project is worked on in, or moving the project', () => { + const el = render({ + slug: 'akeneo', + item: null, + reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }], + }); + expect(buttons(el)).toEqual(['Reopen CatalogSuggested', 'File elsewhere']); + click(el, 'File elsewhere'); + // the chooser leaves out the closed home, and offers Not tracked last, + // as every other chooser does + const options = [...el.querySelectorAll('option')].map(o => o.textContent); + expect(options).not.toContain('Catalog'); + expect(options.at(-1)).toBe('Not tracked'); + }); + + it('files it elsewhere through File, with Undo back into the closed one', async () => { + const el = render({ + slug: 'akeneo', + item: null, + reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }], + }); + click(el, 'File elsewhere'); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'fixbot'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + act(() => fileButton(el)?.click()); + await settle(); + expect(calls.filter(c => c.path === '/initiatives/file').at(-1)?.body).toEqual({ + project: 'akeneo', + initiative: 'fixbot', + expect: 'catalog', + }); + expect(el.textContent).toContain('Filed in FixBot chat.'); + click(el, 'Undo'); + await settle(); + // back where it was, closed or not: an Undo may file there + expect(calls.filter(c => c.path === '/initiatives/file').at(-1)?.body).toEqual({ + project: 'akeneo', + initiative: 'catalog', + // only while it's still where the call put it + expect: 'fixbot', + set_at: 50, + baseline_prs: 0, + undo: true, + }); + }); +}); + +describe('a Not tracked project that grew', () => { + const grew: DecideRow = { + slug: 'ops', + item: null, + reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }], + }; + + it('suggests keeping it not tracked, which files it there again', async () => { + const el = render(grew); + expect(buttons(el)).toEqual(['Keep Not trackedSuggested', 'Track it']); + click(el, 'Keep Not tracked'); + await settle(); + expect(calls.find(c => c.path === '/initiatives/file')?.body).toEqual({ + project: 'ops', + initiative: 'shelf', + expect: 'shelf', + }); + // Undo brings back its count as it stood, so the row comes back + click(el, 'Undo'); + await settle(); + expect(calls.filter(c => c.path === '/initiatives/file').at(-1)?.body).toEqual({ + project: 'ops', + initiative: 'shelf', + expect: 'shelf', + set_at: 50, + baseline_prs: 2, + undo: true, + }); + }); + + it('opens the chooser under Track it, with the focus and its own File button', () => { + const el = render(grew); + const [keep, track] = [...el.querySelectorAll('button')]; + click(el, 'Track it'); + // nothing that files took Track it's place: the same two buttons lead + expect([...el.querySelectorAll('button')].slice(0, 2)).toEqual([keep, track]); + expect(track.getAttribute('aria-expanded')).toBe('true'); + const select = el.querySelector('select') as HTMLSelectElement; + expect(document.activeElement).toBe(select); + // its File button sits on the chooser's line, out of the toolbar + const file = fileButton(el); + expect(file?.closest('[role="toolbar"]')).toBeNull(); + // and Cancel beside it, as everywhere a project is filed + expect(file?.nextElementSibling?.textContent).toBe('Cancel'); + // the guess's hover says where the project is now, never Unsorted + const guessed = [...el.querySelectorAll('span')].find(x => x.textContent === 'guessed'); + expect(guessed?.title).toBe( + 'It’s Not tracked now. This is the initiative its people work in most.' + ); + }); + + it('files it where Track it’s chooser says, with Undo back to Not tracked and its count', async () => { + const el = render(grew); + click(el, 'Track it'); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'fixbot'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + act(() => fileButton(el)?.click()); + await settle(); + expect(calls.filter(c => c.path === '/initiatives/file').at(-1)?.body).toEqual({ + project: 'ops', + initiative: 'fixbot', + expect: 'shelf', + }); + expect(el.textContent).toContain('Filed in FixBot chat.'); + click(el, 'Undo'); + await settle(); + expect(calls.filter(c => c.path === '/initiatives/file').at(-1)?.body).toEqual({ + project: 'ops', + initiative: 'shelf', + expect: 'fixbot', + set_at: 50, + baseline_prs: 2, + undo: true, + }); + }); +}); + +describe('the Initiative chooser on a row Decide asks to plan', () => { + const fresh = (slug: string): DecideRow => ({ + slug, + item: null, + reasons: [{ kind: 'new', since: '2026-09-10' }], + }); + const select = (el: HTMLElement) => + el.querySelector('select[aria-label^="File in"]'); + + it('isn’t shown for a filed project, so its answer stays a plan answer and moves nothing', async () => { + const el = render(fresh('filed')); + // filed in Site speed already: nothing asks to file it + expect(select(el)).toBeNull(); + expect(el.textContent).not.toContain('File in'); + click(el, 'Promise to finish by'); + await settle(); + expect(calls.some(c => c.path === '/initiatives/file')).toBe(false); + }); + + it('guesses for an Unsorted project from where its people work', () => { + const el = render(fresh('new-thing')); + expect(select(el)?.value).toBe('fixbot'); + expect(el.textContent).toContain('guessed'); + }); + + it('files first, then plans, in one receipt, and Undo takes both back', async () => { + const el = render(fresh('new-thing')); + click(el, 'Promise to finish by'); + await settle(); + const writes = calls.filter(c => c.method !== 'GET'); + expect(writes.map(c => `${c.method} ${c.path}`)).toEqual([ + 'PUT /initiatives/file', + 'POST /roadmap', + ]); + expect(writes[0].body).toEqual({ project: 'new-thing', initiative: 'fixbot', expect: null }); + expect(el.textContent).toMatch( + /Promised to finish by .+, in FixBot chat\. Decide asks again/ + ); + calls.length = 0; + click(el, 'Undo'); + await settle(); + expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual([ + 'DELETE /roadmap/77', + 'PUT /initiatives/file', + ]); + expect(calls.find(c => c.path === '/initiatives/file')?.body).toMatchObject({ + project: 'new-thing', + initiative: null, + }); + }); + it('keeps the filing when the plan fails, says so, and Try again only plans', async () => { + const el = render(fresh('flaky')); + click(el, 'Promise to finish by'); + await settle(); + expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual([ + 'PUT /initiatives/file', + 'POST /roadmap', + ]); + expect(el.textContent).toContain('Filed in FixBot chat; the plan didn’t save.'); + calls.length = 0; + click(el, 'Try again'); + await settle(); + // its expect would be stale now: the filing isn't sent twice + expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual([ + 'POST /roadmap', + ]); + expect(el.textContent).toMatch(/Promised to finish by .+, in FixBot chat\./); + }); +}); + +describe('a section’s one click on rows with a File in chooser', () => { + const fresh = (slug: string): DecideRow => ({ + slug, + item: null, + reasons: [{ kind: 'new', since: '2026-09-10' }], + }); + // its own rows: a call made here stays made for the tests after + const rows = [fresh('bulk-a'), fresh('lonely'), fresh('bulk-b')]; + + it('files each where its chooser says and skips one whose chooser is empty', async () => { + const el = mount( + undefined} main /> + ); + // lonely has no guess, so its own answer is off: the section leaves it, + // and says "2", not "all 2" + const all = [...el.querySelectorAll('button')].find(b => b.textContent?.startsWith('Promise')); + expect(all?.textContent).toMatch(/^Promise 2 /); + act(() => all?.click()); + await settle(); + await settle(); + // each guessed home rides along with its plan, as its own answer would + expect( + calls.filter(c => c.method !== 'GET').map(c => [c.path, (c.body as { project: string }).project]) + ).toEqual([ + ['/initiatives/file', 'bulk-a'], + ['/roadmap', 'bulk-a'], + ['/initiatives/file', 'bulk-b'], + ['/roadmap', 'bulk-b'], + ]); + expect(calls.find(c => c.path === '/initiatives/file')?.body).toEqual({ + project: 'bulk-a', + initiative: 'fixbot', + expect: null, + }); + // the board's homes as the server has them, for the tests after + await act(() => loadInitiatives()); + }); +}); + +describe('the words for where a project belongs', () => { + const row: DecideRow = { slug: 'x', item: null, reasons: [{ kind: 'unfiled' }] }; + it('say what each answer does', () => { + expect(answerWords({ kind: 'file', to: 'fixbot', name: 'FixBot chat' }, null)).toBe( + 'File in FixBot chat' + ); + // by the home's kind, never its slug: any Not tracked initiative + expect( + answerWords({ kind: 'file', to: 'shelf', name: 'Shelf', untracked: true }, null) + ).toBe('Mark Not tracked'); + expect(answerWords({ kind: 'link', project: 'pp', name: 'Parts pages' }, null)).toBe( + 'Link to Parts pages' + ); + expect( + callWords( + { kind: 'file', to: 'fixbot', name: 'FixBot chat' }, + row, + undefined, + '2026-10-11' + ) + ).toBe('Filed in FixBot chat.'); + expect( + callWords({ kind: 'park' }, row, undefined, '2026-10-11', { + name: 'FixBot chat', + }) + ).toBe( + 'Parked, so it stops counting in the weeks ahead. Decide asks again if its PRs move. It’s in FixBot chat now.' + ); + }); + + it('keep two initiatives’ questions apart', () => { + const ask = (initiative: number): DecideRow => ({ + slug: null, + item: null, + reasons: [{ kind: 'goal_reached', initiative }], + }); + const made = new Map([ + [ + 'initiative:1', + { + row: ask(1), + call: { kind: 'keep_open' }, + words: '', + state: 'made', + origin: null, + token: 1, + }, + ], + ]); + const kept = keepCalls([ask(1), ask(2)], made); + expect(kept.owed).toEqual([ask(2)]); + expect(kept.all).toHaveLength(2); + }); +}); + +describe('an answer’s button', () => { + it('grows with its words when they wrap on a phone, never a fixed height', () => { + const rows: DecideRow[] = [ + { slug: 'cache', item: null, reasons: [{ kind: 'stalled', days: 30 }] }, + { slug: 'new-thing', item: null, reasons: [{ kind: 'unfiled' }] }, + { slug: 'akeneo', item: null, reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }] }, + ]; + for (const row of rows) { + const el = render(row); + for (const b of el.querySelectorAll('button')) { + expect(b.className, b.textContent ?? '').not.toMatch(/(^|\s)h-8(\s|$)/); + } + } + }); +}); + +describe('the File in chooser and the answer that waits on it', () => { + const unfiled = (slug: string): DecideRow => ({ slug, item: null, reasons: [{ kind: 'unfiled' }] }); + const order = (el: HTMLElement) => + [...el.querySelectorAll('select, button')].map(n => n.tagName.toLowerCase()); + + it('sits above the buttons it brings to life, which wait while nothing is picked', () => { + // nobody it shares work with is filed anywhere: no guess + const el = render(unfiled('loner')); + expect(order(el)[0]).toBe('select'); + const file = fileButton(el); + expect(file?.disabled).toBe(true); + }); + + it('says Suggested only for the board’s own guess, never for a person’s pick', () => { + const el = render(unfiled('new-thing')); + expect(buttons(el)).toEqual(['FileSuggested']); + expect(fileButton(el)?.getAttribute('aria-label')).toBe('File New thing in FixBot chat'); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'site-speed'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(buttons(el)).toEqual(['File']); + expect(fileButton(el)?.getAttribute('aria-label')).toBe('File New thing in Site speed'); + }); + + it('holds a plan answer the same way while its chooser is empty', () => { + const el = render({ slug: 'loner', item: null, reasons: [{ kind: 'new', since: '2026-09-10' }] }); + expect(order(el)[0]).toBe('select'); + const promise = [...el.querySelectorAll('button')].find(b => + b.textContent?.startsWith('Promise to finish by') + ); + expect(promise?.disabled).toBe(true); + expect(promise?.textContent).not.toContain('Suggested'); + }); + + it('gives the Tab stop to Other answers while its answer waits on the chooser', () => { + const el = render({ slug: 'loner', item: null, reasons: [{ kind: 'new', since: '2026-09-10' }] }); + const [promise, other] = el.querySelectorAll('[role="toolbar"] button'); + expect(promise.disabled).toBe(true); + expect(promise.tabIndex).toBe(-1); + expect(other.textContent).toBe('Other answers'); + expect(other.tabIndex).toBe(0); + // and j and k land there + expect(el.querySelector('[data-decide-focus]')).toBe(other); + }); + + it('says the filing in the plan answer’s label', () => { + const el = render({ slug: 'new-thing', item: null, reasons: [{ kind: 'new', since: '2026-09-10' }] }); + const promise = [...el.querySelectorAll('button')].find(b => + b.textContent?.startsWith('Promise to finish by') + ); + expect(promise?.getAttribute('aria-label')).toMatch(/, in FixBot chat$/); + }); + + it('names where a project went when someone filed it first', async () => { + await settle(); + calls.length = 0; + const el = render(unfiled('raced')); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'site-speed'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + act(() => fileButton(el)?.click()); + await settle(); + expect(el.textContent).toContain('Someone filed it in FixBot chat since, so nothing changed.'); + // and the board reads the homes again + expect(calls.filter(c => c.path === '/initiatives' && c.method === 'GET')).toHaveLength(1); + }); +}); + +describe('a plan with no project', () => { + const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] }; + + it('asks for its project, then links it, and Undo unlinks it', async () => { + const el = render(row); + // the File in chooser's layout: "Its project [ ] Link", Link off till a pick + expect(parts(el)).toEqual(['Its project', 'select', 'Link']); + expect(el.querySelector('button')?.disabled).toBe(true); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'cache'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(el.querySelector('button')?.getAttribute('aria-label')).toBe( + 'Link Checkout redesign to cache' + ); + click(el, 'Link'); + await settle(); + expect(calls.at(-1)).toMatchObject({ + method: 'PATCH', + path: '/roadmap/5', + body: { project: 'cache' }, + }); + expect(el.textContent).toContain('Linked to cache'); + click(el, 'Undo'); + await settle(); + expect(calls.at(-1)).toMatchObject({ + method: 'PATCH', + path: '/roadmap/5', + body: { project: null, undo: { updated_at: 1, status_at: null } }, + }); + }); +}); + +describe('a double-click on Link', () => { + const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] }; + + it('links once: its second press reaches nothing, and the focus is on the receipt', async () => { + // under the press guard, as on every view that shows Decide's rows + const el = mount( +

+ +
+ ); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'cache'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + const link = [...el.querySelectorAll('button')].find(b => + b.textContent?.startsWith('Link') + ) as HTMLButtonElement; + pressAt(link, 300, 200); + await settle(); + expect(el.textContent).toContain('Linked to cache'); + expect(document.activeElement?.textContent).toContain('Linked to cache'); + const sent = calls.length; + // a row joined above, so Undo (or the plan's name) slid under the + // pointer: the second press of the double-click finds it, and does nothing + const undo = [...el.querySelectorAll('button')].find( + b => b.textContent === 'Undo' + ) as HTMLButtonElement; + pressAt(undo, 300, 200); + await settle(); + expect(calls.length).toBe(sent); + expect(document.activeElement?.textContent).toContain('Linked to cache'); + // once the pointer has moved away and back, Undo is a click again + act(() => { + document.dispatchEvent( + new MouseEvent('pointermove', { bubbles: true, clientX: 400, clientY: 300 }) + ); + }); + pressAt(undo, 300, 200); + await settle(); + expect(calls.at(-1)).toMatchObject({ method: 'PATCH', body: { project: null } }); + }); +}); + +describe('the focus once Undo’s words go', () => { + it('lands on the row’s name when its answer is off, never on the page', async () => { + const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] }; + const el = mount( +
+ +
+ ); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'cache'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + click(el, 'Link'); + await settle(); + const undo = [...el.querySelectorAll('button')].find( + b => b.textContent === 'Undo' + ) as HTMLButtonElement; + pressAt(undo, 50, 50); + await settle(); + // Link waits off again for a project; the words go as the pointer leaves + act(() => { + document.dispatchEvent( + new MouseEvent('pointermove', { bubbles: true, clientX: 300, clientY: 300 }) + ); + }); + expect(document.activeElement?.textContent).toBe('Checkout redesign'); + }); +}); + +describe('a project whose closed initiative and done plan are both asked about', () => { + const both: DecideRow = { + slug: 'akeneo', + item: { ...created, id: 8, project: 'akeneo', status: 'done', status_at: 10 }, + reasons: [ + { kind: 'reopened', open: 3, late: 0, as: 'done', by: 'roadmap' }, + { kind: 'reopened_in_closed', initiative: 3, as: 'done' }, + ], + }; + + it('asks about the initiative first, on the one row', () => { + const el = render(both); + expect(buttons(el)).toEqual(['Reopen CatalogSuggested', 'File elsewhere']); + }); + + it('keeps the reopen’s receipt here, and the plan question owed everywhere else', () => { + const made = new Map([ + [ + 'akeneo:8', + { row: both, call: { kind: 'reopen' }, words: '', state: 'made', origin: null, token: 1 }, + ], + ]); + const plan: DecideRow = { ...both, reasons: [both.reasons[0]] }; + const kept = keepCalls([plan], made); + // still owed, so the Roadmap and the Overview keep the project in + // sight; shown once, as the row with its receipt + expect(kept.owed).toEqual([plan]); + expect(kept.settled.has('akeneo:8')).toBe(true); + expect(kept.all).toEqual([both]); + // but counted nowhere until Decide asks it, on the next visit, so the + // badge, the switches and the initiative page agree with its heading + expect(kept.open).toEqual([]); + }); +}); + +describe('Decide’s Initiative switch', () => { + it('offers a closed initiative while a project in it is asked about', () => { + const owed: Record = { catalog: 1, 'site-speed': 2 }; + const el = mount( + owed[slug] ?? 0} + value={null} + onChange={nothing} + /> + ); + expect([...el.querySelectorAll('option')].map(o => o.textContent)).toEqual([ + 'All initiatives', + 'Site speed · 2 to decide', + 'Catalog · 1 to decide', + ]); + }); + + it('counts plans with no project apart from Unsorted, under keys no slug can take', () => { + const owed: Record = { [UNSORTED]: 3, [NO_PROJECT]: 1 }; + const el = mount( + owed[slug] ?? 0} + value={null} + onChange={nothing} + /> + ); + const options = [...el.querySelectorAll('option')]; + expect(options.map(o => o.textContent)).toEqual([ + 'All initiatives', + 'Unsorted · 3 to decide', + 'No project yet · 1 to decide', + ]); + // an initiative may be called "unsorted"; these keys can't be its slug + for (const o of options.slice(1)) expect(PROJECT_SLUG.test(o.value)).toBe(false); + }); +}); + +describe('an initiative’s page', () => { + const page = (slug: string, calls: DecideRow[] = [], items = ITEMS, plans: RoadmapItem[] = []) => + mount( + + ); + + it('asks a closed one’s reopen at its head, and keeps live work out of Done', () => { + const done = { ...created, id: 8, project: 'akeneo', status: 'done' as const }; + const asked: DecideRow = { + slug: 'akeneo', + item: done, + reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }], + }; + const akeneo = { ...person('akeneo', ['erin']), name: 'Akeneo 4', open: 3 } as PortfolioItem; + const el = page('catalog', [asked], [...ITEMS, akeneo], [done]); + expect(el.textContent).toContain('Akeneo 4: Catalog was closed as done'); + expect(buttons(el)).toContain('Reopen CatalogSuggested'); + // its PRs are open: it's still going, so not folded under Done or dropped + expect(el.textContent).not.toContain('Done or dropped'); + expect(el.textContent).toMatch(/Health.*1 question on Decide/); + // closed: its head says how; no estimated end beside the reopen question + expect(el.textContent).not.toContain('Ends'); + }); + + it('opens Decide on this initiative from its questions', () => { + const done = { ...created, id: 8, project: 'akeneo', status: 'done' as const }; + const asked: DecideRow = { + slug: 'akeneo', + item: done, + reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }], + }; + const go = vi.fn(); + const el = mount( + + ); + const door = [...el.querySelectorAll('dd button')].find( + b => b.textContent === '1 question on Decide' + ) as HTMLButtonElement; + act(() => door.click()); + expect(go).toHaveBeenCalledWith(expect.objectContaining({ view: 'decide', within: 'catalog' })); + }); + + it('counts only the questions still owed, not one answered on this visit', () => { + const answered: DecideRow = { + slug: 'emails', + item: null, + reasons: [{ kind: 'stalled', days: 30 }], + }; + const el = mount( + + ); + expect(el.textContent).not.toMatch(/question.? on Decide/); + }); + + it('sends only the fields Edit changed, so a save someone made since stays', async () => { + const at = (initiatives: Initiative[]) => ( + + + + ); + host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + roots.push(root); + act(() => root.render(at(INITIATIVES))); + click(host, 'Edit'); + const name = host.querySelector('input') as HTMLInputElement; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + name, + 'Welcome' + ); + name.dispatchEvent(new Event('input', { bubbles: true })); + }); + // someone else sets its owner, and the socket's reload brings that in + act(() => + root.render(at(INITIATIVES.map(i => (i.id === 4 ? { ...i, owner: 'someone-else' } : i)))) + ); + click(host, 'Save'); + await settle(); + expect(calls.filter(c => c.method === 'PATCH').at(-1)?.body).toEqual({ name: 'Welcome' }); + }); + + it('says the initiative couldn’t load, with Try again, when a load failed', () => { + const el = mount( + + ); + expect(el.textContent).toContain('Couldn’t load the initiative.'); + expect(buttons(el)).toContain('Try again'); + }); + + it('says it has no plans once, with no end or people to name', () => { + const el = page('onboarding'); + const terms = [...el.querySelectorAll('dt')].map(d => d.textContent); + expect(terms).toEqual(['Span', 'Health']); + expect(el.textContent).toContain('No plans yet'); + }); + + it('says a reopened one’s end waits on the work going on in it', () => { + const done = { ...created, id: 8, project: 'akeneo', status: 'done' as const, weeks: 2 }; + const akeneo = { ...person('akeneo', ['erin']), name: 'Akeneo 4', open: 3 } as PortfolioItem; + const reopened = INITIATIVES.map(i => + i.slug === 'catalog' ? { ...i, status: 'open' as const } : i + ); + const el = mount( + + ); + expect(el.textContent).toContain( + 'Endsnot planned: its plans are finished, but Akeneo 4 is still worked on' + ); + }); + + it('says no initiative is at a link that names none, in plain words', () => { + const el = page('gone'); + expect(el.textContent).toContain('No initiative is at this link.'); + expect(el.textContent).not.toContain('slug'); + }); + + it('asks each project’s own question on its row', () => { + const going = { ...created, id: 9, project: 'cache', status: 'active' as const }; + const asked: DecideRow = { slug: 'cache', item: going, reasons: [{ kind: 'stalled', days: 30 }] }; + const el = page('site-speed', [asked], ITEMS, [going]); + expect(el.textContent).toContain('Park it for now?'); + }); + + it('shows Not tracked as a ledger: no owner, Edit, span or end', () => { + const el = page('shelf'); + expect(el.querySelector('h2')?.textContent).toBe('Not tracked'); + expect(el.textContent).not.toContain('Not trackedNot tracked'); + expect(buttons(el)).not.toContain('Edit'); + expect(el.textContent).not.toContain('Span'); + expect(el.textContent).not.toContain('Ends'); + }); + + it('keeps an edit’s receipt, saying why, when its Undo fails', async () => { + const el = page('fragile'); + click(el, 'Edit'); + const goal = el.querySelectorAll('input')[1] as HTMLInputElement; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(goal, 'Holds'); + goal.dispatchEvent(new Event('input', { bubbles: true })); + }); + click(el, 'Save'); + await settle(); + expect(el.textContent).toContain('Saved its goal.'); + click(el, 'Undo'); + await settle(); + expect(el.textContent).toContain('the database is down'); + expect(buttons(el)).toContain('Try again'); + }); + + it('says a goal emptied was cleared, not saved', async () => { + const el = page('onboarding'); + click(el, 'Edit'); + const goal = el.querySelectorAll('input')[1] as HTMLInputElement; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(goal, ''); + goal.dispatchEvent(new Event('input', { bubbles: true })); + }); + click(el, 'Save'); + await settle(); + expect(el.textContent).toContain('Cleared its goal.'); + }); +}); + +describe('a project page’s home line', () => { + it('opens the chooser below its home, as Decide does, and File waits for another pick', () => { + const el = mount(); + expect(el.textContent).toContain('in Site speed'); + click(el, 'File elsewhere'); + // the home and its opener stay; the opener says it's open + expect(el.textContent).toContain('in Site speed'); + const opener = [...el.querySelectorAll('button')].find(b => + b.textContent?.startsWith('File elsewhere') + ); + expect(opener?.getAttribute('aria-expanded')).toBe('true'); + const move = [...el.querySelectorAll('button')].find(b => b.textContent === 'File'); + expect(move?.disabled).toBe(true); + const select = el.querySelector('select') as HTMLSelectElement; + expect(select.value).toBe('site-speed'); + act(() => { + select.value = ''; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect([...select.options].at(-1)?.textContent).toBe('Unsorted'); + expect(move?.disabled).toBe(false); + }); + + it('starts a project in a closed initiative on Choose where it goes', () => { + // akeneo is filed in Catalog, which is closed, so no option carries it + const el = mount(); + click(el, 'File elsewhere'); + const select = el.querySelector('select') as HTMLSelectElement; + expect(select.selectedOptions[0]?.textContent).toBe('Choose where it goes'); + const move = [...el.querySelectorAll('button')].find(b => b.textContent === 'File'); + expect(move?.disabled).toBe(true); + // the first offered home is a change of the select, so File comes on + act(() => { + select.value = 'site-speed'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(move?.disabled).toBe(false); + expect(move?.getAttribute('aria-label')).toBe('File akeneo in Site speed'); + }); + + it('closes the chooser from its opener, the opener kept', () => { + const el = mount(); + click(el, 'File elsewhere'); + click(el, 'File elsewhere'); + expect(el.querySelector('select')).toBeNull(); + expect(el.textContent).toContain('in Site speed'); + }); + + it('offers no File elsewhere while Decide’s question on the page files it', () => { + const el = mount( + + ); + expect(el.textContent).toBe('Unsorted'); + expect(el.querySelector('button')).toBeNull(); + }); + + it('says the move once, and its Undo in the one Undo form', async () => { + const said: string[] = []; + const el = mount( + said.push(words)} + /> + ); + click(el, 'File elsewhere'); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'fixbot'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + act(() => [...el.querySelectorAll('button')].find(b => b.textContent === 'File')?.click()); + await settle(); + // the receipt names the new home; the home word isn't said beside it + expect(el.textContent?.match(/FixBot chat/g)).toHaveLength(1); + expect(el.textContent).toContain('Filed in FixBot chat.'); + click(el, 'Undo'); + // said before it saves, so a second click finds no Undo to press + expect(el.querySelector('[role="status"]')?.textContent).toBe( + 'Put cache back in Site speed.' + ); + await settle(); + expect(said.at(-1)).toBe('Put cache back in Site speed'); + // Undo's words hold the receipt's place with nothing to click; File + // elsewhere comes back after them, on the line below + const status = el.querySelector('[role="status"]') as HTMLElement; + expect(status.textContent).toBe('Put cache back in Site speed.'); + expect(status.querySelector('button')).toBeNull(); + expect(buttons(el)).toEqual(['File elsewhere']); + expect(document.activeElement?.textContent).toBe('Put cache back in Site speed.'); + }); + + it('says filing into Not tracked in Decide’s words, its Undo named to match', async () => { + const el = mount(); + click(el, 'File elsewhere'); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'shelf'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + act(() => [...el.querySelectorAll('button')].find(b => b.textContent === 'File')?.click()); + await settle(); + expect(el.querySelector('[role="status"]')?.textContent).toContain( + 'Marked Not tracked, so it counts nowhere. Decide asks again if it grows.' + ); + expect( + el.querySelector('[aria-label="Undo: Marked Not tracked, so it counts nowhere. Decide asks again if it grows"]') + ).not.toBeNull(); + }); +}); + +describe('a row asking whether an initiative reached its goal', () => { + it('shows the goal it asks about', () => { + const el = mount( + + ); + expect(el.textContent).toContain('Goal: New customers get a welcome series'); + }); +}); + +describe('a pick left in a chooser that closed', () => { + const closedHome: DecideRow = { + slug: 'akeneo', + item: null, + reasons: [{ kind: 'reopened_in_closed', initiative: 3, as: 'done' }], + }; + const plan: DecideRow = { + slug: 'akeneo', + item: { ...created, id: 8, project: 'akeneo', status: 'done', status_at: 10 }, + reasons: [{ kind: 'reopened', open: 3, late: 0, as: 'done', by: 'roadmap' }], + }; + const pick = (el: HTMLElement, slug: string) => + act(() => { + const select = el.querySelector('select') as HTMLSelectElement; + select.value = slug; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + + it('files nothing with the next answer', async () => { + // pick FixBot chat in File elsewhere, then close it without filing + const first = render(closedHome); + click(first, 'File elsewhere'); + pick(first, 'fixbot'); + click(first, 'File elsewhere'); + // the row that asks about its plan shows no chooser: its answer files nothing + const el = render(plan); + expect(el.querySelector('select')).toBeNull(); + click(el, 'Keep it done'); + await settle(); + expect(calls.some(c => c.path === '/initiatives/file')).toBe(false); + }); + + it('closes on Escape, forgets its pick, and gives the focus back', async () => { + const el = render(closedHome); + click(el, 'File elsewhere'); + pick(el, 'fixbot'); + const opener = [...el.querySelectorAll('button')].find( + b => b.textContent === 'File elsewhere' + ) as HTMLButtonElement; + act(() => { + (el.querySelector('select') as HTMLSelectElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + }); + expect(el.querySelector('select')).toBeNull(); + expect(opener.getAttribute('aria-expanded')).toBe('false'); + expect(document.activeElement).toBe(opener); + // opened again, it starts on the guess, not the abandoned pick + click(el, 'File elsewhere'); + expect((el.querySelector('select') as HTMLSelectElement).value).not.toBe('fixbot'); + }); + + it('is forgotten once a filing saves', async () => { + const grew: DecideRow = { + slug: 'ops', + item: null, + reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }], + }; + const el = render(grew); + click(el, 'Track it'); + pick(el, 'fixbot'); + act(() => fileButton(el)?.click()); + await settle(); + click(el, 'Undo'); + await settle(); + calls.length = 0; + // the row asks again, from a fresh chooser: nothing carries the old pick + click(el, 'Track it'); + expect((el.querySelector('select') as HTMLSelectElement).value).not.toBe('fixbot'); + }); +}); + +describe('filing into an initiative someone closed since', () => { + it('says so and reads the initiatives again, so the choosers stop offering it', async () => { + // an earlier test's reads land first, whatever order the tests run in + await settle(); + calls.length = 0; + const r = await act(() => fileProject('cache', 'catalog', { expect: 'site-speed' })); + expect(r).toEqual({ + error: expect.stringContaining('that initiative is closed'), + }); + expect(calls.filter(c => c.path === '/initiatives' && c.method === 'GET')).toHaveLength(1); + }); +}); + +describe('a pick whose initiative closed since', () => { + it('is no pick: the chooser waits again, and the answer files nothing', async () => { + const el = render({ + slug: 'lonely', + item: null, + reasons: [{ kind: 'new', since: '2026-09-10' }], + }); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'fragile'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + const fragile = INITIATIVES[4]; + try { + fragile.status = 'done'; + await act(() => loadInitiatives()); + expect(select.value).toBe(''); + const promise = [...el.querySelectorAll('button')].find(b => + b.textContent?.startsWith('Promise to finish by') + ); + expect(promise?.disabled).toBe(true); + } finally { + fragile.status = 'open'; + await act(() => loadInitiatives()); + } + }); +}); + +describe('Undo on a row', () => { + const grew: DecideRow = { + slug: 'ops', + item: null, + reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }], + }; + + it('holds its words where the receipt was, so a second click there answers nothing', async () => { + const el = render(grew); + click(el, 'Keep Not tracked'); + await settle(); + const undo = [...el.querySelectorAll('button')].find( + b => b.textContent === 'Undo' + ) as HTMLButtonElement; + act(() => undo.click()); + await settle(); + // the slot says what Undo did, with nothing to click in it, and the + // answers come back after it + const slot = [...el.querySelectorAll('p')].find( + p => p.textContent === 'Put ops back as it was.' + ) as HTMLElement; + expect(slot).toBeDefined(); + expect(slot.querySelector('button')).toBeNull(); + expect(slot.compareDocumentPosition(el.querySelector('button') as Node)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(document.activeElement).toBe(slot); + // a double-click's second press lands on the old Undo: nothing is sent + const sent = calls.length; + act(() => undo.click()); + await settle(); + expect(calls.length).toBe(sent); + expect(buttons(el)).toEqual(['Keep Not trackedSuggested', 'Track it']); + // j and k land on the row's answer, never on the held words + expect(el.querySelector('[data-decide-focus]')?.textContent).toBe( + 'Keep Not trackedSuggested' + ); + // and a Tab on from the words lets them go + act(() => { + slot.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + (el.querySelector('button') as HTMLButtonElement).focus(); + }); + expect(el.textContent).not.toContain('Put ops back as it was.'); + }); + + it('lets its words go when the pointer moves away, the focus back on the answer', async () => { + const el = mount( +
+ +
+ ); + click(el, 'Keep Not tracked'); + await settle(); + const undo = [...el.querySelectorAll('button')].find( + b => b.textContent === 'Undo' + ) as HTMLButtonElement; + pressAt(undo, 120, 80); + await settle(); + expect(el.textContent).toContain('Put ops back as it was.'); + // a wobble keeps them; moving off lets them go + const move = (x: number, y: number) => + act(() => { + document.dispatchEvent( + new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y }) + ); + }); + move(122, 81); + expect(el.textContent).toContain('Put ops back as it was.'); + move(400, 300); + expect(el.textContent).not.toContain('Put ops back as it was.'); + expect(document.activeElement?.textContent).toBe('Keep Not trackedSuggested'); + }); + + it('lets its words go when the next action starts', async () => { + const el = mount( +
+ +
+ ); + click(el, 'Keep Not tracked'); + await settle(); + click(el, 'Undo'); + await settle(); + expect(el.textContent).toContain('Put ops back as it was.'); + // the next answer, by key: the words go before it lands + click(el, 'Track it'); + expect(el.textContent).not.toContain('Put ops back as it was.'); + }); + + const both: DecideRow = { + slug: 'akeneo', + item: { ...created, id: 8, project: 'akeneo', status: 'done', status_at: 10 }, + reasons: [ + { kind: 'reopened', open: 3, late: 0, as: 'done', by: 'roadmap' }, + { kind: 'reopened_in_closed', initiative: 3, as: 'done' }, + ], + }; + const planOnly: DecideRow = { ...both, reasons: [both.reasons[0]] }; + /** Decide's rows in their sections, as the queue reads now. */ + function Sections({ queue }: { queue: DecideRow[] }) { + const { all } = useKeptCalls(queue); + const sections = [...new Set(all.map(sectionOf))]; + return ( + <> + {sections.map(k => ( +
+ {all + .filter(r => sectionOf(r) === k) + .map(r => ( + + ))} +
+ ))} + + ); + } + + it('holds a reopen’s Undo words in place while the initiative is still open', async () => { + host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + roots.push(root); + const show = (queue: DecideRow[]) => + act(() => + root.render( + + + + ) + ); + const el = host; + show([both]); + click(el, 'Reopen Catalog'); + await settle(); + // reopened: the queue asks only about the plan now + show([planOnly]); + const undo = [...el.querySelectorAll('button')].find( + b => b.textContent === 'Undo' + ) as HTMLButtonElement; + const row = undo.closest('[data-decide-row]') as HTMLElement; + const held = () => + [...row.querySelectorAll('p')].find(p => p.textContent === 'Put New thing back as it was.'); + act(() => undo.click()); + // the Undo is saving, so the queue still reads it reopened: the row + // keeps its place, its words where the receipt was + show([planOnly]); + expect(row.isConnected).toBe(true); + expect(held()).toBeDefined(); + await settle(); + await settle(); + // closed again, it asks both on the same row, the words before the answers + show([both]); + expect(row.isConnected).toBe(true); + const answer = row.querySelector('[role="toolbar"] button') as HTMLButtonElement; + expect(answer.textContent).toBe('Reopen CatalogSuggested'); + expect(held()?.compareDocumentPosition(answer)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(document.activeElement).toBe(held()); + }); +}); + +describe('the one chooser, wherever a project is filed', () => { + const escape = (el: Element) => + act(() => { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + // the next frame: frames run in order, so the one the code asked for ran + const frame = () => + act(() => new Promise(resolve => requestAnimationFrame(() => resolve()))); + + it('reads "File in [ ] File" on Decide’s row and "File in [ ] File Cancel" where it opens', () => { + const unfiled = render({ slug: 'new-thing', item: null, reasons: [{ kind: 'unfiled' }] }); + expect(parts(unfiled)).toEqual(['File in', 'select', 'FileSuggested']); + const grew = render({ + slug: 'ops', + item: null, + reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }], + }); + click(grew, 'Track it'); + expect(parts(grew)).toEqual(['File in', 'select', 'File', 'Cancel']); + const line = mount(); + click(line, 'File elsewhere'); + expect(parts(line)).toEqual(['File in', 'select', 'File', 'Cancel']); + }); + + it('closes on Escape on a project’s line, the focus back on File elsewhere, and only it', async () => { + const outer = vi.fn(); + const el = mount( +
+ +
+ ); + click(el, 'File elsewhere'); + escape(el.querySelector('select') as HTMLSelectElement); + await frame(); + expect(el.querySelector('select')).toBeNull(); + expect(document.activeElement?.textContent).toBe('File elsewhere'); + // the plan details around it stay open + expect(outer).not.toHaveBeenCalled(); + }); + + it('closes what Other answers opened on Escape, the focus back on it', () => { + const el = render({ slug: 'cache', item: null, reasons: [{ kind: 'stalled', days: 30 }] }); + click(el, 'Other answers'); + expect(el.textContent).toContain('Or instead'); + escape(el.querySelector('[role="toolbar"] button:not(:disabled)') as HTMLButtonElement); + expect(el.textContent).not.toContain('Or instead'); + expect(document.activeElement?.textContent).toBe('Other answers'); + }); + + it('closes an initiative’s Edit on Escape, the focus back on Edit', async () => { + const el = mount( + + ); + click(el, 'Edit'); + escape(el.querySelector('input') as HTMLInputElement); + await frame(); + expect(el.querySelector('form')).toBeNull(); + expect(document.activeElement?.textContent).toBe('Edit'); + }); +}); + +describe('a plan linked to a project', () => { + const row: DecideRow = { slug: null, item: unlinked, reasons: [{ kind: 'no_project' }] }; + it('says where the link put it: in that project’s initiative, or Unsorted and asked', () => { + const words = (project: string) => + callWords({ kind: 'link', project, name: project }, row, undefined, '2026-10-11'); + expect(words('cache')).toBe('Linked to cache, so it’s in Site speed.'); + expect(words('ops')).toBe('Linked to ops, so it’s Not tracked.'); + expect(words('lonely')).toBe('Linked to lonely. It’s Unsorted, so Decide asks where it goes.'); + }); + + it('names the Unsorted row it becomes by its project', () => { + const el = mount( + + ); + expect(el.querySelector('button')?.textContent).toBe('Lonely'); + }); +}); diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx new file mode 100644 index 00000000..240c1bf7 --- /dev/null +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -0,0 +1,588 @@ +/** + * The home answers Decide draws on a row: the File in chooser and its + * hook, the strip that answers where a project belongs or an initiative's own + * question, and Decide's Initiative switch. It reads the question model + * (model/decideCalls.ts) and never Decide.tsx, so Decide imports it without + * a cycle. + */ +import { useContext, useId, useRef, useState } from 'react'; +import { createMemoryStore } from '../../storage'; +import type { PortfolioItem } from '../../model/portfolio'; +import type { DecideRow } from '../../../../shared/model/decide'; +import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; +import { QuietButton, textInputClass } from '../../components/bits'; +import { byRank, useInitiativesValue } from '../../model/initiativesData'; +import { guessHome, homeChoices, NO_PROJECT, UNSORTED } from '../../model/initiativeBoard'; +import { NO_PROJECT_YET, UNSORTED_WORD } from '../../model/words'; +import { + AllItemsContext, + arrowsAmong, + askedInitiative, + initiativeById, + primaryOf, + type Call, + type Filed, +} from '../../model/decideCalls'; + +/** The chooser's label, wherever a project is filed. */ +const FILE_IN = 'File in'; + +/** + * The File in chooser on a row Decide asks to plan or to file (a new or + * unfiled project): where the project goes, filed with whatever call the row + * gets. It starts on the home it has, so taking any answer blind moves + * nothing; only a project with no home starts on a guess, the initiative its + * people work in most. `filed` is the filing the next call carries, when the + * pick differs from its home; `picked` once a person chose. + */ +export function useHomeChooser(row: DecideRow, { away = false }: { away?: boolean } = {}) { + const { initiatives } = useInitiativesValue(); + const items = useContext(AllItemsContext); + const { picks } = homePicks.useValue(); + const pick = row.slug ? picks.get(row.slug) ?? null : null; + return { + ...homeChoice(row, initiatives ?? [], items, pick, away), + setPick: (slug: string) => row.slug && setHomePick(row.slug, slug), + }; +} + +/** Each project's pick in its File in chooser, by slug: kept outside the + * row, so a section's one click files every row where its own chooser says + * (chosenHomes), and a trip away and back keeps it. */ +const homePicks = createMemoryStore<{ picks: ReadonlyMap }>({ picks: new Map() }); + +function setHomePick(slug: string, to: string): void { + homePicks.set({ picks: new Map(homePicks.get().picks).set(slug, to) }); +} + +/** Forget a project's pick: its filing saved, or its chooser closed + * without one, so no later answer carries it. */ +export function forgetHomePick(slug: string): void { + if (!homePicks.get().picks.has(slug)) return; + const picks = new Map(homePicks.get().picks); + picks.delete(slug); + homePicks.set({ picks }); +} + +/** Forget every pick (tests start clean). */ +export function clearHomePicks(): void { + homePicks.set({ picks: new Map() }); +} + +/** useHomeChooser's reading, for a row and a pick: what it shows, where it + * stands, and the filing the next call carries. */ +function homeChoice( + row: DecideRow, + list: readonly Initiative[], + items: readonly PortfolioItem[], + pick: string | null, + away: boolean +) { + const asks = row.reasons.some(r => r.kind === 'new' || r.kind === 'unfiled'); + const current = row.slug ? homesOf(list).get(row.slug)?.initiative ?? null : null; + // `away`: moving it out of the home it has (Track it, File elsewhere), + // so it starts on the guess; guessed only where a chooser can show it + const guess = + (asks || away) && (!current || away) && row.slug && list.length + ? guessHome(row.slug, items, list) + : null; + const choices = homeChoices(list); + // a pick no longer offered (its initiative closed since) is no pick + const live = pick && choices.some(i => i.slug === pick) ? pick : null; + const chosen = live ?? (away ? guess?.slug : current?.slug ?? guess?.slug) ?? ''; + const to = choices.find(i => i.slug === chosen); + // only while it's filed nowhere: a filed project's plan answer stays a + // plan answer, and isn't asked to be filed where it already is + const shown = asks && !current && !!row.slug && choices.length > 0; + return { + shown, + choices, + chosen, + current, + picked: live != null, + guessed: live == null && (!current || away) && !!guess && chosen === guess.slug, + // a filing rides along only with the chooser that shows it (one + // filed nowhere), so a pick left in a closed one never moves the + // project + filed: + shown && to + ? ({ + to: to.slug, + name: to.name, + from: null, + untracked: to.kind === 'untracked', + } as Filed) + : undefined, + }; +} + +/** What a section's one click does with each row's chooser: the filing it + * carries (the row's pick, or the board's guess), or `waiting` when the + * chooser is empty and the row's own answer is off until a pick. */ +export function useChosenHomes(): (row: DecideRow) => { waiting: boolean; filed?: Filed } { + const { initiatives } = useInitiativesValue(); + const items = useContext(AllItemsContext); + const { picks } = homePicks.useValue(); + return row => { + const c = homeChoice( + row, + initiatives ?? [], + items, + row.slug ? picks.get(row.slug) ?? null : null, + false + ); + return { waiting: c.shown && !c.chosen, filed: c.filed }; + }; +} + +/** + * The one chooser for where a project goes, on every view: "File in + * [Release safety] File Cancel". A select, so the keyboard and a screen + * reader get the platform's own list. Decide's question rows show it + * alone (a plan answer below carries the pick) or with File as the row's + * answer; Track it, File elsewhere, a plan's details and a project's page + * open it with File and Cancel. Escape is Cancel: it closes the chooser, + * and the caller gives the focus back to what opened it. + */ +export function HomeChooser({ + name, + chooser, + autoFocus = false, + below = false, + unsorted = false, + file, + onCancel, +}: { + /** the project's name, for the controls' names */ + name: string; + chooser: { + chosen: string; + choices: readonly Initiative[]; + setPick: (slug: string) => void; + /** it shows the board's guess, not a person's pick */ + guessed?: boolean; + /** where it is now, for the guess's hover */ + current?: Initiative | null; + }; + /** opened by a click (Track it, File elsewhere): the focus moves here */ + autoFocus?: boolean; + /** under the answers that opened it, rather than above the ones it feeds */ + below?: boolean; + /** Unsorted is offered last ('' as its value) */ + unsorted?: boolean; + /** its own File button: the row's answer, or the opened chooser's */ + file?: { + onFile: () => void; + disabled: boolean; + /** filled: the row's answer, ready to take */ + filled?: boolean; + /** the board's guess: says Suggested */ + suggested?: boolean; + describedBy?: string; + /** where j and k land on the row */ + rowFocus?: boolean; + }; + onCancel?: () => void; +}) { + const id = useId(); + const chosen = chooser.choices.find(i => i.slug === chooser.chosen); + const where = chosen ? chosen.name : unsorted && !chooser.chosen ? UNSORTED_WORD : null; + // where it is now, so the guess's hover never says Unsorted of a project + // that's filed + const now = !chooser.current + ? `It’s ${UNSORTED_WORD}` + : chooser.current.kind === 'untracked' + ? 'It’s Not tracked now' + : `It’s in ${chooser.current.name} now`; + return ( + // a span, so it sits in a line of words as well as under a row + { + if (e.key !== 'Escape' || !onCancel) return; + // the innermost thing closes, not the details around it + e.preventDefault(); + e.stopPropagation(); + onCancel(); + }} + className={`flex min-w-0 flex-wrap items-center gap-2 text-xs text-ink-3 ${ + below ? 'mt-2' : file ? '' : 'mb-2' + }`} + > + {/* on a phone the label goes above, as every chooser's and answer + row's does, so File in and Its project line up */} + + + {chooser.guessed && ( + + guessed + + )} + {file && ( + + )} + {onCancel && ( + + Cancel + + )} + + ); +} + +/** The filled or outlined button that answers a chooser's row: File, Link. */ +const answerClass = (filled: boolean | undefined) => + `hit pressable inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-lg border px-3 py-1 text-left text-[13px] font-medium disabled:opacity-60 ${ + filled + ? 'border-brand bg-brand text-surface hover:opacity-90' + : 'border-line bg-surface text-ink-2 hover:border-brand hover:text-brand' + }`; + +/** A plan's project chooser, for a plan with no project, in the File in + * chooser's layout: "Its project [▾] Link", Link the row's answer, off + * until a project is chosen. */ +function ProjectChooser({ + name, + value, + onChange, + onLink, + describedBy, +}: { + name: string; + value: string; + onChange: (slug: string) => void; + onLink: () => void; + describedBy?: string; +}) { + const id = useId(); + const items = useContext(AllItemsContext); + const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name)); + const chosen = items.find(i => i.slug === value); + return ( + + + + + + ); +} + +/** + * The answers to a row that asks where a project belongs, or about an + * initiative, in the same shape as Decide's CallStrip: a chooser first when + * the answer needs one, then the suggested answer filled, then the others. + * Unsorted: File in the chooser's pick. No project yet: Link to the chosen + * project, the same layout with Link in File's place. Not tracked, but growing: Keep Not tracked, or Track it, which + * opens the chooser. Goal maybe reached: Close as done, or Close as dropped + * or Keep open. Its initiative closed: Reopen it, or File elsewhere + * with the chooser. An answer that waits on an empty chooser is outlined and + * off, and only an answer the board picked says Suggested. + */ +export function HomeStrip({ + row, + name, + describedBy, + onCall, + quiet = false, +}: { + row: DecideRow; + name: string; + describedBy?: string; + onCall: (call: Call) => void; + /** its answer outlined, still marked Suggested, at a row's end beside + * rows whose questions are words (the Roadmap's goal question) */ + quiet?: boolean; +}) { + const [more, setMore] = useState(false); + const [project, setProject] = useState(''); + const opener = useRef(null); + const items = useContext(AllItemsContext); + const primary = primaryOf(row); + const chooser = useHomeChooser(row, { away: primary.kind !== 'unfiled' }); + const initiative = initiativeById(askedInitiative(row)); + // every open home, as on every chooser, but Track it's: a project set + // not tracked is tracked somewhere else (the closed one it's in is + // never offered, as no closed one is) + const pickable = + primary.kind === 'untracked_grew' + ? chooser.choices.filter(i => i.kind !== 'untracked') + : chooser.choices; + const chosen = pickable.find(i => i.slug === chooser.chosen); + const filing: Call | null = chosen + ? { kind: 'file', to: chosen.slug, name: chosen.name, untracked: chosen.kind === 'untracked' } + : null; + const notTracked = chooser.choices.find(i => i.kind === 'untracked'); + const linked = items.find(i => i.slug === project); + type Option = { call: Call | null; words: string; suggested?: boolean }; + const options: Option[] = + primary.kind === 'unfiled' || primary.kind === 'no_project' + ? [] + : primary.kind === 'untracked_grew' + ? [ + { + call: notTracked ? { kind: 'keep_untracked', to: notTracked.slug } : null, + words: 'Keep Not tracked', + suggested: true, + }, + ] + : primary.kind === 'goal_reached' + ? [{ call: { kind: 'close', as: 'done' }, words: 'Close as done', suggested: true }] + : [ + { + call: { kind: 'reopen' }, + words: initiative ? `Reopen ${initiative.name}` : 'Reopen it', + suggested: true, + }, + ]; + // the other answers, after the button that shows them, so a second click + // there never lands on one + const extras: Option[] = + more && primary.kind === 'goal_reached' + ? [ + { call: { kind: 'close', as: 'dropped' }, words: 'Close as dropped' }, + { call: { kind: 'keep_open' }, words: 'Keep open' }, + ] + : []; + const moreWords = + primary.kind === 'untracked_grew' + ? 'Track it' + : primary.kind === 'reopened_in_closed' + ? 'File elsewhere' + : 'Other answers'; + // one name form for every row's more button + const moreName = + moreWords === 'Other answers' ? `Other answers for ${name}` : `${moreWords}: ${name}`; + // closed without filing: its pick goes, so no later answer carries it + const toggle = () => { + if (more && row.slug) forgetHomePick(row.slug); + setMore(m => !m); + }; + const close = () => { + toggle(); + opener.current?.focus(); + }; + // Track it and File elsewhere open the chooser under the answers, with + // its own File button and the focus: the button clicked stays where it + // was, and nothing that files sits under the pointer. It starts on a + // guess, never on the home being left + const moves = + more && (primary.kind === 'untracked_grew' || primary.kind === 'reopened_in_closed'); + const button = (o: Option, k: number) => { + const filled = k === 0 && !!o.call && !quiet; + return ( + + ); + }; + return ( +
+ {primary.kind === 'no_project' && ( + // the question is its project: Link is the row's answer + + linked && onCall({ kind: 'link', project: linked.slug, name: linked.name }) + } + /> + )} + {primary.kind === 'unfiled' && pickable.length > 0 && ( + // the question is where it goes: File is the row's answer + filing && onCall(filing), + disabled: !filing, + filled: !!filing, + suggested: chooser.guessed, + describedBy, + rowFocus: true, + }} + /> + )} + {primary.kind !== 'unfiled' && primary.kind !== 'no_project' && ( +
{ + if (e.key !== 'Escape' || !more) return arrowsAmong(e); + // what its more button opened closes, the focus back on it + e.preventDefault(); + e.stopPropagation(); + close(); + }} + className="flex flex-wrap items-center gap-2" + > + {options.map(button)} + + {extras.map((o, k) => button(o, k + 1))} +
+ )} + {moves && pickable.length > 0 && ( + filing && onCall(filing), disabled: !filing }} + /> + )} +
+ ); +} + +/** + * Decide's Initiative switch, beside the team switch: an owner runs their + * slice. A select, since there can be twenty; each says how many are left + * to decide, and only the ones with something to decide are offered (and the one picked): + * the open homes in rank order, a closed initiative while a project in it + * is asked about, then Unsorted and No project yet. + */ +export function InitiativeSwitch({ + initiatives, + owedIn, + value, + onChange, +}: { + initiatives: readonly Initiative[]; + owedIn: (slug: string) => number; + value: string | null; + onChange: (slug: string | null) => void; +}) { + const id = useId(); + // what it counts, said in the heading's words ("12 decisions to make"): + // a band elsewhere counts its rows, which aren't all asked about + // (Unsorted · 13 on the Roadmap holds 8 to decide here). "owed" is an + // update a lead owes, so it isn't the word here + const counted = (label: string, count: number) => + count ? `${label} · ${count} to decide` : label; + const closed = byRank(initiatives).filter(i => i.status !== 'open'); + const offered = [ + ...[...homeChoices(initiatives), ...closed].map(i => [i.slug, i.name] as const), + [UNSORTED, UNSORTED_WORD] as const, + // a plan with no project is in no initiative, as its Roadmap lane says + [NO_PROJECT, NO_PROJECT_YET] as const, + ].filter(([slug]) => owedIn(slug) > 0 || slug === value); + if (!offered.length) return null; + return ( +
+ + +
+ ); +} diff --git a/frontend-v2/src/views/projects/Initiatives.test.ts b/frontend-v2/src/views/projects/Initiatives.test.ts new file mode 100644 index 00000000..d246e218 --- /dev/null +++ b/frontend-v2/src/views/projects/Initiatives.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { RoadmapItem, RoadmapUpdate } from '../../../../shared/model/roadmap'; +import { DUMMY_INITIATIVES, DUMMY_ROADMAP } from '../../backend/dummyProjects'; +import { healthWords, leadWords, projectLeadWords } from './Initiatives'; + +const NOW = Date.UTC(2026, 9, 11, 12) / 1000; +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: NOW - daysAgo * 86400, +}); +const plan = (over: Partial): RoadmapItem => ({ + id: 1, + name: 'Plan', + project: 'p', + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-09-07', + weeks: 12, + end_kind: 'soft', + done_when: '', + priority: 1, + notes: '', + waits_on: [], + updated_by: null, + updated_at: NOW - 30 * 86400, + created_at: NOW - 40 * 86400, + update: null, + ...over, +}); + +describe('an initiative’s page', () => { + it('says what its plans owe, read off them, or nothing owed', () => { + expect(healthWords([plan({ update: update('on_track', 2) })], NOW)).toBe('nothing owed'); + expect( + healthWords( + [ + plan({ update: update('off_track', 2) }), + plan({ update: update('at_risk', 3) }), + // no update since it started: one is owed + plan({ update: null }), + // finished plans owe nothing + plan({ status: 'done', update: null }), + ], + NOW + ) + ).toBe('1 off track, 1 at risk, 1 update owed'); + }); + + it('names who leads its live plans, most first', () => { + expect( + leadWords([ + plan({ lead: 'brett' }), + plan({ lead: 'brett' }), + plan({ lead: 'dan' }), + plan({ lead: 'zed', status: 'done' }), + ]) + ).toBe('brett leads 2 of 3 live plans; dan 1'); + expect(leadWords([plan({ lead: null })])).toBe(''); + }); + + it('falls back to its projects’ leads, guessed ones said so, as their rows say them', () => { + expect( + projectLeadWords([ + { lead: 'danielbeardsley', leadByPrs: true }, + { lead: 'erin', leadByPrs: false }, + { lead: 'erin', leadByPrs: false }, + { lead: null, leadByPrs: false }, + ]) + ).toBe('erin leads 2; danielbeardsley (guessed) leads 1'); + }); +}); + +describe('the dummy board’s initiatives', () => { + it('file each project once, and hold every standing a lane can take', () => { + const slugs = DUMMY_INITIATIVES.flatMap(i => i.projects.map(f => f.project)); + expect(new Set(slugs).size).toBe(slugs.length); + const kinds = new Set(DUMMY_INITIATIVES.map(i => `${i.kind}:${i.status}`)); + expect(kinds).toEqual( + new Set(['initiative:open', 'upkeep:open', 'initiative:done', 'untracked:open']) + ); + // a Not tracked project with a plan, whose row owes nothing + const untracked = DUMMY_INITIATIVES.find(i => i.kind === 'untracked'); + const planned = DUMMY_ROADMAP.filter(p => + untracked?.projects.some(f => f.project === p.project) + ); + expect(planned.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx new file mode 100644 index 00000000..59feeff7 --- /dev/null +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -0,0 +1,754 @@ +import { type ReactNode, useContext, useEffect, useId, useRef, useState } from 'react'; +import { Check } from 'lucide-react'; +import { n } from '../../../../shared/format'; +import type { DecideRow } from '../../../../shared/model/decide'; +import { + checkInitiativeFields, + goingProjects, + homesOf, + initiativeSpan, + type Initiative, + type InitiativeFields, +} from '../../../../shared/model/initiatives'; +import { + healthStanding, + isUnderWay, + planFor, + type RoadmapItem, +} from '../../../../shared/model/roadmap'; +import { + EmptyState, + FactLink, + LoadFailed, + QuietButton, + textInputClass, +} from '../../components/bits'; +import { Fold, Rows } from '../../components/Lane'; +import { Icon } from '../../components/Icon'; +import { focusResult, PRESS_GUARD } from '../../components/settle'; +import { + fileProject, + loadInitiatives, + updateInitiative, + type Home, +} from '../../model/initiativesData'; +import { loadRoadmap } from '../../model/roadmapData'; +import { closedWords, homeChoices } from '../../model/initiativeBoard'; +import { dayWords } from '../../model/projectData'; +import { stageWord, type PortfolioItem } from '../../model/portfolio'; +import { andList, MARKED_NOT_TRACKED, UNSORTED_WORD, undoName } from '../../model/words'; +import { DecideCall } from './Decide'; +import { HomeChooser } from './HomeCalls'; +import { + AllItemsContext, + askedInitiative, + primaryOf, + reasonParts, +} from '../../model/decideCalls'; +import { openPlan, switchView, type Navigate, type ProjectsNav } from './parts'; +import { PLAN_STATUS_WORD, planWords } from './roadmapHealth'; + +/** "Release safety", "Keep the lights on", "Not tracked", "Unsorted": a + * home by name, standing alone. */ +export const homeName = (home: Pick | null | undefined) => + home ? home.name : UNSORTED_WORD; + +/** A receipt for a filing: what it did, with Undo, or why not. */ +interface MoveReceipt { + words: string; + failed?: boolean; + undo?: () => void; + /** a failed Undo's: the words of the receipt it takes back */ + undoes?: string; + /** what an Undo did: held in the receipt's place with nothing to click */ + settled?: boolean; +} + +/** + * Where a project lives, in its page's head: "in Release safety · File + * elsewhere", "Not tracked · …", or "Unsorted · …". The initiative's name + * opens its page. File elsewhere stays, as on Decide, and opens the chooser + * on the line below (never a popover): the homes in rank order, Not + * tracked last, and Unsorted, starting on "Choose where it goes" when its + * home is closed; File, on once the pick differs, files it there and leaves a + * receipt with Undo, which puts back the home it had with its time and + * count. Undo's own words then hold the receipt's place, and File + * elsewhere comes back on the line below, never under the pointer. + */ +export function HomeLine({ + slug, + initiatives, + navigate, + onSaid, + movable = true, + onFile, +}: { + slug: string; + initiatives: readonly Initiative[]; + navigate: Navigate; + /** files it in place of this line's own write, with the receipt kept + * by whoever outlives this line (the Roadmap's plan details move with + * the plan, so they remount) */ + onFile?: (to: string | null) => void; + /** say a change aloud, for a screen reader */ + onSaid?: (words: string) => void; + /** false while another control on the page files it (Decide's question) */ + movable?: boolean; +}) { + const home = homesOf(initiatives).get(slug)?.initiative ?? null; + const projectName = useContext(AllItemsContext).find(i => i.slug === slug)?.name ?? slug; + const [moving, setMoving] = useState(false); + const [pick, setPick] = useState(home?.slug ?? ''); + const [receipt, setReceipt] = useState(null); + const moveId = useId(); + const doneRef = useRef(null); + useEffect(() => { + if (receipt) focusResult(doneRef.current); + }, [receipt]); + const choices = homeChoices(initiatives); + const say = (r: MoveReceipt) => { + setReceipt(r); + onSaid?.(r.words); + }; + const move = async () => { + const to = pick || null; + setMoving(false); + if (to === (home?.slug ?? null)) return; + if (onFile) return onFile(to); + const into = choices.find(i => i.slug === to); + const words = into?.kind === 'untracked' ? MARKED_NOT_TRACKED : `Filed in ${homeName(into)}`; + const saved = await fileProject(slug, to, { expect: home?.slug ?? null }); + if ('error' in saved) return say({ words: saved.error.replace(/\.$/, ''), failed: true }); + const before: Home = saved.before; + const undo = async () => { + // said at once, in Undo's place, so a second click lands on words + setPick(before.initiative ?? ''); + say({ words: `Put ${projectName} back in ${homeName(home)}`, settled: true }); + // expect: only while it's still where this move put it + const back = await fileProject(slug, before.initiative, { + expect: to, + set_at: before.set_at, + baseline_prs: before.baseline_prs, + undo: true, + }); + // the server's reason, with the Undo kept to try again + if ('error' in back) + say({ words: back.error.replace(/\.$/, ''), failed: true, undo, undoes: words }); + }; + say({ words, undo }); + }; + const close = () => { + setMoving(false); + requestAnimationFrame(() => document.getElementById(moveId)?.focus()); + }; + return ( + + {/* the receipt names the new home; saying it beside would repeat it */} + {receipt && !receipt.failed ? null : home ? ( + home.kind === 'untracked' ? ( + Not tracked + ) : ( + + in{' '} + navigate({ initiative: home.slug, project: null })} + title={`Open ${home.name}: its projects, owner and goal`} + > + {home.name} + + + ) + ) : ( + {UNSORTED_WORD} + )} + {receipt && ( + + {!receipt.failed && ( + + )} + + {receipt.words}. + + {receipt.undo ? ( + + {receipt.failed ? 'Try again' : 'Undo'} + + ) : receipt.settled ? null : ( + setReceipt(null)}>Close + )} + + )} + {movable && (moving || !receipt || receipt.settled) && ( + <> + {/* after an Undo, on its own line below the words */} + {receipt && } + {/* as on Decide: it stays, and opens the chooser below */} + { + if (moving) return setMoving(false); + setReceipt(null); + setPick(home?.slug ?? ''); + setMoving(true); + }} + aria-label={`File elsewhere: ${projectName}`} + > + File elsewhere + + + )} + {moving && ( + + void move(), + // on once the pick is a home it can go to, other than its own + disabled: + pick === (home?.slug ?? '') || + !(pick === '' || choices.some(i => i.slug === pick)), + }} + onCancel={close} + /> + + )} + + ); +} + +/** + * What an initiative's plans owe, in words: "1 off track, 1 update owed", + * or "nothing owed". Read off its plans under way, as their rows say it; + * an initiative keeps no health of its own. + */ +export function healthWords( + plans: readonly Pick< + RoadmapItem, + | 'status' + | 'start' + | 'weeks' + | 'end_kind' + | 'update' + | 'created_at' + | 'lately' + | 'untracked' + | 'updated_at' + >[], + now: number = Date.now() / 1000 +): string { + let owed = 0; + const said = { off_track: 0, at_risk: 0 }; + for (const plan of plans) { + if (!isUnderWay(plan.status)) continue; + const standing = healthStanding(plan, now); + if (standing.kind === 'missing' || standing.kind === 'stale') owed++; + const u = plan.update; + // a word the plan has changed since is answered + if (u && u.health !== 'on_track' && u.at > (plan.updated_at ?? 0)) said[u.health]++; + } + const parts = [ + said.off_track ? `${said.off_track} off track` : '', + said.at_risk ? `${said.at_risk} at risk` : '', + owed ? `${n(owed, 'update')} owed` : '', + ].filter(Boolean); + return parts.length ? parts.join(', ') : 'nothing owed'; +} + +/** Who leads its live plans, most first: "brettlhartt leads 6 of 7 live + * plans; danielbeardsley 1". Empty with none. */ +export function leadWords(plans: readonly Pick[]): string { + const live = plans.filter(p => isUnderWay(p.status)); + const counts = new Map(); + for (const p of live) if (p.lead) counts.set(p.lead, (counts.get(p.lead) ?? 0) + 1); + const sorted = [...counts].sort(([a, x], [b, y]) => y - x || a.localeCompare(b)); + if (!sorted.length) return ''; + const [[first, most], ...rest] = sorted; + return [ + `${first} leads ${most} of ${n(live.length, 'live plan')}`, + ...rest.map(([login, count]) => `${login} ${count}`), + ].join('; '); +} + +/** With no plan under way naming a lead, its projects' leads as their rows + * say them: "danielbeardsley (guessed) leads 1". */ +export function projectLeadWords( + items: readonly Pick[] +): string { + const counts = new Map(); + for (const i of items) { + if (!i.lead) continue; + const who = i.leadByPrs ? `${i.lead} (guessed)` : i.lead; + counts.set(who, (counts.get(who) ?? 0) + 1); + } + return [...counts] + .sort(([a, x], [b, y]) => y - x || a.localeCompare(b)) + .map(([who, count]) => `${who} leads ${count}`) + .join('; '); +} + +/** "May 4 to Jan 17, from 7 live plans · 1 ongoing": its span in a line. */ +function spanLine(span: ReturnType, live: number): string { + if (!span.start) return 'No plans yet'; + const dates = span.end + ? `${dayWords(span.start)} to ${dayWords(span.end)}` + : span.end_kind === 'ended' && span.ended + ? `${dayWords(span.start)} to ${dayWords(span.ended)}` + : `Since ${dayWords(span.start)}`; + const from = live ? `, from ${n(live, 'live plan')}` : ''; + return `${dates}${from}${span.ongoing ? ` · ${span.ongoing} ongoing` : ''}`; +} + +/** "estimated, from its projects", "ongoing", "every plan in it is + * finished": how firm its end is, derived from its plans, never stored. + * Plans finished while a project in it is still worked on (`going`, a + * reopened one) end nowhere yet, and it says so. */ +function endWords(span: ReturnType, going: readonly string[]): string { + switch (span.end_kind) { + case 'soft': + return 'estimated, from its projects'; + case 'ongoing': + return 'ongoing, no plan in it has an end'; + case 'ended': + return going.length + ? `not planned: its plans are finished, but ${andList([...going])} ${ + going.length === 1 ? 'is' : 'are' + } still worked on` + : 'every plan in it is finished'; + default: + return 'no plans yet'; + } +} + +/** An initiative's name, goal and owner, edited in place: Save leaves a + * receipt with Undo, never a modal. */ +function EditFields({ + initiative, + onDone, +}: { + initiative: Initiative; + onDone: (receipt: MoveReceipt | null) => void; +}) { + const [name, setName] = useState(initiative.name); + const [goal, setGoal] = useState(initiative.goal); + const [owner, setOwner] = useState(initiative.owner ?? ''); + // the initiative as the form opened it: only what the person changed is + // sent, so a field someone else saved since isn't put back + const [start] = useState(initiative); + const [problem, setProblem] = useState(null); + const ids = { name: useId(), goal: useId(), owner: useId() }; + const save = async () => { + const checked = checkInitiativeFields({ name, goal, owner }, { partial: true }); + if ('error' in checked) return setProblem(checked.error); + const changed = Object.fromEntries( + Object.entries(checked.fields).filter( + ([k, v]) => v !== (start as unknown as Record)[k] + ) + ) as Partial; + if (!Object.keys(changed).length) return onDone(null); + const r = await updateInitiative(initiative.id, changed); + if ('error' in r) return setProblem(r.error); + const back = Object.fromEntries( + Object.keys(changed).map(k => [k, (r.was as unknown as Record)[k]]) + ) as Partial; + // what it did: a field emptied was cleared, not saved + const its = (keys: string[]) => andList(keys.map(k => `its ${k}`)); + const keys = Object.keys(changed) as (keyof InitiativeFields)[]; + const set = keys.filter(k => changed[k]); + const cleared = keys.filter(k => !changed[k]); + const words = [ + set.length ? `Saved ${its(set)}` : '', + cleared.length ? `${set.length ? 'cleared' : 'Cleared'} ${its(cleared)}` : '', + ] + .filter(Boolean) + .join(' and '); + const undo = () => + void updateInitiative(initiative.id, back, { + undo: { + updated_at: r.was.updated_at ?? Math.floor(Date.now() / 1000), + status_at: r.was.status_at, + }, + }).then(u => + // a failed Undo keeps its receipt, saying why, and a way to try again + onDone( + 'error' in u + ? { words: u.error.replace(/\.$/, ''), failed: true, undo, undoes: words } + : null + ) + ); + onDone({ words, undo }); + }; + const field = `h-8 w-full px-2.5 text-[13px] text-ink ${textInputClass}`; + return ( +
{ + e.preventDefault(); + void save(); + }} + onKeyDown={e => { + if (e.key === 'Escape') onDone(null); + }} + className="mt-3 grid max-w-[40rem] gap-2 text-xs text-ink-3 sm:grid-cols-[7rem_1fr] sm:items-center" + > + + setName(e.target.value)} + className={field} + /> + + setGoal(e.target.value)} + className={field} + /> + + setOwner(e.target.value)} + placeholder="A GitHub login" + className={field} + /> + + + + onDone(null)}>Cancel + {problem && {problem}} + + + ); +} + +/** + * An initiative's page, the same anatomy as a project's: its name and + * owner (Edit, in place), its goal, Decide's questions about it (whether it + * reached its goal, or, closed, whether to reopen it for a project still + * worked on), its span, how firm its end is and what its plans owe, all + * read off its projects' plans; then the projects in it in priority order, + * each with its plan in words and any question Decide asks of it, and a + * "Done or dropped" fold for the finished, dropped and parked work nobody + * is asked about and nothing is going in. Not tracked's page is its ledger: + * no owner, edit, span or end. + */ +export function InitiativePage({ + slug, + initiatives, + items, + plans, + calls, + owed: owedRows, + loadFailed = false, + nav, + navigate, + onPerson, +}: { + slug: string; + initiatives: readonly Initiative[] | null; + /** every project, Not tracked ones too */ + items: readonly PortfolioItem[]; + /** every plan; null while they load */ + plans: readonly RoadmapItem[] | null; + /** Decide's rows with this visit's calls kept */ + calls: readonly DecideRow[]; + /** the rows still owed: `calls` less the ones answered this visit */ + owed: readonly DecideRow[]; + /** the initiatives or the plans failed to load */ + loadFailed?: boolean; + nav: ProjectsNav; + navigate: Navigate; + onPerson: (login: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [receipt, setReceipt] = useState(null); + const editId = useId(); + // a save's receipt takes the focus from the form it closed + const savedRef = useRef(null); + useEffect(() => { + if (receipt) savedRef.current?.focus(); + }, [receipt]); + const initiative = initiatives?.find(i => i.slug === slug); + useEffect(() => { + if (!initiative) return; + const was = document.title; + document.title = `${initiative.name} · Pulldasher`; + return () => { + document.title = was; + }; + }, [initiative?.name]); + if (!initiatives || !plans) { + return loadFailed ? ( + { + void loadInitiatives(); + void loadRoadmap(); + }} + /> + ) : ( +

Loading the initiative…

+ ); + } + if (!initiative) { + return ( + + ); + } + const ledger = initiative.kind === 'untracked'; + const slugs = initiative.projects.map(f => f.project); + const mine = plans.filter(p => p.project && slugs.includes(p.project)); + const span = initiativeSpan(slugs, plans); + const live = mine.filter(p => isUnderWay(p.status)); + const bySlug = new Map(items.map(i => [i.slug, i])); + // Decide's questions about the initiative itself, at the page's head, + // and the rest under the project each is about + const aboutIt = calls.filter(r => askedInitiative(r) === initiative.id); + const asksOf = (s: string) => calls.filter(r => r.slug === s && !aboutIt.includes(r)); + // still going by the rule the close guard uses: a plan under way or PRs open + const going = new Set(goingProjects(slugs, plans, s => bySlug.get(s)?.open ?? 0)); + // the projects in it by their plan's priority, those with no plan after + const rows = slugs + .map(s => ({ slug: s, item: bySlug.get(s), plan: planFor(s, plans) })) + .sort( + (a, b) => + (a.plan?.priority ?? Infinity) - (b.plan?.priority ?? Infinity) || + (a.item?.name ?? a.slug).localeCompare(b.item?.name ?? b.slug) + ); + // finished: nothing going, and nothing Decide asks of it or about it + const finished = (r: typeof rows[number]) => + !going.has(r.slug) && + !calls.some(c => c.slug === r.slug && c.reasons.length) && + (r.plan ? !isUnderWay(r.plan.status) : r.item ? r.item.stage === 'closed' : false); + const shown = rows.filter(r => !finished(r)); + const done = rows.filter(finished); + const owed = owedRows.filter( + r => + r.reasons.length && + (askedInitiative(r) === initiative.id || (!!r.slug && slugs.includes(r.slug))) + ).length; + const kindWord = + initiative.kind === 'upkeep' && initiative.name !== 'Upkeep' + ? 'Upkeep' + : ledger && initiative.name !== 'Not tracked' + ? 'Not tracked' + : null; + const question = (r: DecideRow, named: string) => { + const primary = primaryOf(r); + const parts = r.reasons.length ? reasonParts(primary, r.item) : null; + const project = r.slug ? bySlug.get(r.slug) : undefined; + return ( +
+ {parts && ( +

+ {named} + {parts.facts} {parts.question} +

+ )} + +
+ ); + }; + const row = (r: typeof rows[number]) => { + const name = r.item?.name ?? r.plan?.name ?? r.slug; + const lead = r.plan?.lead ?? r.item?.lead ?? null; + return ( +
+
+ navigate({ project: r.slug, initiative: null })}> + {name} + + {lead && ( + onPerson(lead)}> + {lead} + + )} + + {r.plan ? ( + navigate(openPlan(nav, r.plan?.id as number))} + title="Open its plan on the roadmap" + > + {PLAN_STATUS_WORD[r.plan.status]}, {planWords(r.plan)} + + ) : r.item ? ( + `No plan · ${stageWord(r.item)}` + ) : ( + 'No plan' + )} + + {!!r.item?.open && ( + · {n(r.item.open, 'open PR')} + )} +
+ {/* a call is made where it's seen: the question its row asks + anywhere else, collapsed to its suggested answer */} + {asksOf(r.slug).map(a => question(a, ''))} +
+ ); + }; + const leads = leadWords(mine) || projectLeadWords(slugs.flatMap(s => bySlug.get(s) ?? [])); + const goingNames = [...going].map(s => bySlug.get(s)?.name ?? s); + const health = healthWords(live); + // its questions open Decide on this initiative alone + const asked = owed ? ( + navigate({ ...switchView('decide'), within: initiative.slug })}> + {n(owed, 'question')} on Decide + + ) : null; + const facts: [string, ReactNode][] = ledger + ? [['Health', asked ?? 'nothing to decide']] + : [ + ['Span', spanLine(span, live.length)], + // a closed one's head says how it closed; its end isn't estimated, + // and with no plans the span has said it all + ...(initiative.status === 'open' && span.start + ? [['Ends', endWords(span, goingNames)] as [string, string]] + : []), + [ + 'Health', + asked ? ( + <> + {health !== 'nothing owed' && `${health}, `} + {asked} + + ) : ( + health + ), + ], + // nobody to name with no plans: the span says there are none + ...(leads || mine.length + ? [['People', leads || 'No lead named on its plans'] as [string, string]] + : []), + ]; + return ( +
+
+
+

{initiative.name}

+ {kindWord && {kindWord}} + {initiative.status !== 'open' && ( + {closedWords(initiative)} + )} + {!ledger && ( + + {initiative.owner ? ( + + Owner{' '} + onPerson(initiative.owner as string)}> + {initiative.owner} + + + ) : ( + 'No owner' + )} + {!editing && ( + setEditing(true)} + aria-label={`Edit ${initiative.name}`} + > + Edit + + )} + + )} +
+ {initiative.goal && ( +

{initiative.goal}

+ )} + {editing && ( + { + setEditing(false); + setReceipt(r); + // closed without a save (Escape, Cancel), or an Undo: + // back to Edit + if (!r) requestAnimationFrame(() => document.getElementById(editId)?.focus()); + }} + /> + )} + {receipt && ( +

+ {!receipt.failed && ( + + )} + + {receipt.words}. + + {receipt.undo ? ( + + {receipt.failed ? 'Try again' : 'Undo'} + + ) : ( + setReceipt(null)}>Close + )} +

+ )} + {aboutIt.map(r => + question(r, r.slug ? `${bySlug.get(r.slug)?.name ?? r.slug}: ` : '') + )} +
+
+ {facts.map(([term, words]) => ( +
+
{term}
+
{words}
+
+ ))} +
+
+

+ Projects in it +

+ {rows.length ? ( + + {shown.map(row)} + + {done.map(row)} + + + ) : ( +

+ No projects in it yet. File one from a project’s page, or from Decide. +

+ )} +
+
+ ); +} diff --git a/frontend-v2/src/views/projects/LoadChart.tsx b/frontend-v2/src/views/projects/LoadChart.tsx index 6ac9a370..30f78cae 100644 --- a/frontend-v2/src/views/projects/LoadChart.tsx +++ b/frontend-v2/src/views/projects/LoadChart.tsx @@ -65,6 +65,13 @@ export interface Ahead { ends: PlanDay[]; } +/** The share of the chart's width the developer line's name takes, about, + * at either end. */ +const LABEL_ROOM = 10; +/** The least share of the chart's width right of today that holds "Next 4 + * weeks: …"; with less, it isn't said. */ +const AHEAD_ROOM = 25; + /** "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 }) { @@ -162,6 +169,7 @@ export function LoadChart({ onOrigin, onPeople, firstPlan, + historyFrom = null, }: { weeks: LoadWeek[]; /** this week's load, whatever weeks the chart shows */ @@ -192,7 +200,12 @@ export function LoadChart({ * to plan the first of them (null when none is asked); null once a plan * exists */ firstPlan?: { asked: number; onStart: (() => void) | null } | null; + /** the first day of PR history the window has, when the server's cap + * leaves its earlier weeks with plans alone; null when it has them all */ + historyFrom?: string | null; }) { + // weeks wholly before the history counted plans alone + const plansOnly = (week: string) => !!historyFrom && addWeeks(week, 1) <= historyFrom; // 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 @@ -212,6 +225,19 @@ export function LoadChart({ }; const top = Math.max(developers, ...weeks.map(total), 1) * 1.15; const pct = (v: number) => `${(v / top) * 100}%`; + // the developer line's name sits at the end of the line with the lower + // bars under it (the right, unless today is there), above any that cross it + const tallestBy = (side: 'left' | 'right') => + Math.max( + 0, + ...weeks + .filter(w => + side === 'right' ? at(w.week) >= 100 - LABEL_ROOM : at(w.week) < LABEL_ROOM + ) + .map(total) + ); + const devSide = tallestBy('right') <= tallestBy('left') ? 'right' : 'left'; + const devAt = Math.max(developers, tallestBy(devSide)); const each = developers ? inFlight / developers : null; const over = developers ? inFlight - developers : 0; return ( @@ -289,6 +315,11 @@ export function LoadChart({ weekName={weekName} />
+ {historyFrom && ( + + PR history from {dayWords(historyFrom)}; the weeks before count plans only + + )}
{/* today, in brand words over its line: the line marks it, so @@ -319,6 +350,18 @@ export function LoadChart({ } else if (e.key === 'Escape') onPick(null); }} > + {/* the weeks with no PR history, hatched: their bars are plans only */} + {historyFrom && at(historyFrom) > 0 && ( + + )} {weeks.map(w => { const left = at(w.week); const isPicked = w.week === picked; @@ -339,10 +382,11 @@ export function LoadChart({ ? 'color-mix(in oklab, var(--ink) 12%, transparent)' : 'transparent', }} - title={`${weekWords( - w, - developers - )}. Click to show only that week’s plans and projects.`} + title={`${weekWords(w, developers)}${ + plansOnly(w.week) + ? '. No PR history this far back, so only plans count' + : '' + }. Click to show only that week’s plans and projects.`} > )} {/* the weeks ahead, in words the rows can check, in the room - right of today */} - {when !== 'past' && ( + right of today, when a window ending near today leaves some */} + {when !== 'past' && (todayAt == null || todayAt <= 100 - AHEAD_ROOM) && ( void; + /** open it; 'plan' puts the focus on its Plan line */ + onOpen: (focus?: 'plan') => void; + /** open another plan: the one a clash waits on */ + onOpenPlan: (id: number) => void; onPerson: (login: string) => void; }) { - const bucket = bucketOf(item, today); + const bucket = nnlBucket(item, today, call); // the timeline's own warnings, in its words and with its one amber piece const w = planWarnings( item, @@ -70,18 +74,20 @@ function Card({ call ); const rest = restWords(w); + // the plan a clash opens; none when a call is asked instead + const waits = w.call ? null : w.waits; const lead = item.lead; return ( // the whole card opens the plan; the name is its keyboard door
  • onOpen()} className="cursor-pointer scroll-mt-[calc(var(--header-h,0px)_+_0.5rem)] border-t border-secondary px-3.5 py-2 first:border-t-0 hover:bg-muted" >
  • @@ -787,8 +821,40 @@ function download(items: readonly PortfolioItem[]) { URL.revokeObjectURL(url); } -/** A band's gloss when the list is grouped: what put its projects there. */ -function bandGloss(by: string, title: string): string { +/** Decide's question about an initiative itself, inside its band: the + * question in amber and its answers, as the Roadmap's lane shows it. */ +function InitiativeAsk({ row, goal }: { row: DecideRow; goal: string }) { + return ( +
    + {row.reasons.length > 0 && ( +

    + {goal && ( + + {GOAL}: {goal} + + )} + {askOf(row.reasons[0]).question} +

    + )} + +
    + ); +} + +/** A band's gloss when the list is grouped: what put its projects there. + * An initiative's is the Roadmap band's (initiativeBoard.ts bandGloss). */ +function bandGloss( + by: string, + title: string, + initiative?: Initiative, + items: readonly PortfolioItem[] = [] +): string { + if (by === 'initiative') { + if (!initiative) return 'Projects filed nowhere yet. Decide asks where each one goes.'; + const slugs = new Set(initiative.projects.map(f => f.project)); + const working = items.filter(i => slugs.has(i.slug) && i.status === 'live').length; + return initiativeGloss(initiative, slugs.size, working); + } if (title.startsWith('No ')) return `Projects with ${title.toLowerCase()}`; if (by === 'parent') return `${title}, and the projects that are part of it`; if (by === 'lead') return `Projects ${title} leads`; @@ -1051,9 +1117,28 @@ export function Portfolio( teams: readonly string[]; /** the projects Yours lists above, drawn in ink here */ above?: ReadonlySet; + /** the initiatives, for Group by Initiative and the find's "initiative:" */ + initiatives?: readonly Initiative[]; + /** the Not tracked projects, from the full set: their own group, closed, + * last, counted in no tab */ + untrackedItems?: readonly PortfolioItem[]; } ) { - const { items, placeOf = item => item, nameOf, teams, above, nav, navigate } = props; + const { + items, + calls, + placeOf = item => item, + nameOf, + teams, + above, + nav, + navigate, + initiatives = [], + untrackedItems = [], + } = props; + const { items: plans } = useRoadmap(); + const homes = homesOf(initiatives); + const initiativeOf = (slug: string | null) => (slug ? homes.get(slug)?.initiative : undefined); const [copied, setCopied] = useState(false); const barRef = useRef(null); const { columns, sort, onSort, row, head, hintLine } = useRows(props); @@ -1082,12 +1167,52 @@ export function Portfolio( const now = new Map(items.map(i => [i.slug, i])); const narrowed = items .map(placeOf) - .filter(i => matchesFind(i, nav.find) && matchesOnly(i, nav.only)); + .filter(i => matchesFind(i, nav.find, initiativeOf) && matchesOnly(i, nav.only)); const shown = sortItems( narrowed.filter(i => matchesStatus(i, nav.status)), nav.sort ).map(i => now.get(i.slug) ?? i); - const groups = groupItems(shown, nav.group, nameOf, teams); + // with no initiatives yet (a server before they're loaded), the default + // grouping is no grouping, not one band called Unsorted + const by = nav.group === 'initiative' && !initiatives.length ? 'none' : nav.group; + // an initiative Decide asks about keeps its band, its question inside + const initiativeAsks = new Map( + calls.flatMap(r => { + const id = askedInitiative(r); + return id != null && !r.slug ? [[id, r] as const] : []; + }) + ); + const groups = groupItems( + shown, + by, + nameOf, + teams, + initiatives, + new Set(initiativeAsks.keys()) + ); + // the Not tracked ones by the same tab and find, in a ledger of their own + const notTracked = sortItems( + untrackedItems.filter( + i => matchesFind(i, nav.find) && matchesOnly(i, nav.only) && matchesStatus(i, nav.status) + ), + nav.sort + ); + // an initiative's band, in ink: its owner and its plans' ends + const bandDetail = (initiative: Initiative | undefined) => { + if (!initiative) return null; + const span = initiativeSpan( + initiative.projects.map(f => f.project), + plans ?? [] + ); + const words = [ + initiative.owner ? `owned by ${initiative.owner}` : '', + ...endParts(span), + initiative.status === 'open' ? '' : closedWords(initiative), + ].filter(Boolean); + return words.length ? ( + {words.join(' · ')} + ) : null; + }; // each tab counts what it would show with the find and a tile's pick, so // the tab that's on always says how many rows are below it const statusOptions: [string, string][] = STATUS_FILTERS.map(([key, label]) => { @@ -1125,8 +1250,10 @@ export function Portfolio( Group by [k, k === 'none' ? 'None' : l])} + value={by} + options={GROUPINGS.filter(([k]) => k !== 'initiative' || initiatives.length).map( + ([k, l]) => [k, k === 'none' ? 'None' : l] + )} onChange={group => navigate({ group })} /> @@ -1240,21 +1367,33 @@ export function Portfolio(
    {groups.map(g => ( + {g.initiative && initiativeAsks.has(g.initiative.id) && ( + + )} row(item, g.repeats.has(item.slug) || repeated(item.slug))} /> @@ -1270,7 +1409,24 @@ export function Portfolio( row={item => row(item, repeated(item.slug))} /> )} - {!shown.length && ( + {/* the Not tracked projects close the list, a ledger at rest: + counted in no tab, since nobody reports on them */} + + row(item, repeated(item.slug))} + /> + + {!shown.length && !notTracked.length && (
    {nav.find || nav.only ? ( <> diff --git a/frontend-v2/src/views/projects/ProjectPage.tsx b/frontend-v2/src/views/projects/ProjectPage.tsx index edc23c88..ea2c6026 100644 --- a/frontend-v2/src/views/projects/ProjectPage.tsx +++ b/frontend-v2/src/views/projects/ProjectPage.tsx @@ -1,7 +1,7 @@ import { Check } from 'lucide-react'; import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { issueUrl, n } from '../../../../shared/format'; -import { closedIssues, RANK, type DecideRow } from '../../../../shared/model/decide'; +import { closedIssues, type DecideRow } from '../../../../shared/model/decide'; import { projectOf, targetOf, @@ -30,6 +30,7 @@ import { Segmented, textInputClass, } from '../../components/bits'; +import { PRESS_GUARD } from '../../components/settle'; import { Icon } from '../../components/Icon'; import { foldDomId, openFold, SubDoor } from '../../components/Lane'; import type { RowOptions } from '../../components/Row'; @@ -40,6 +41,7 @@ import { reloadProjectWork, useProjectWork } from '../../model/projectWork'; import { updateRoadmapItem } from '../../model/roadmapData'; import { addedLater, + isDraftPull, issueForecast, plannedAt, prStage, @@ -55,9 +57,19 @@ import { END_WORD, LAST_14_DAYS, NO_UPDATE_YET, + NOT_TRACKED_GLOSS, UPDATE_DUE, } from '../../model/words'; -import { answerWords, planRow, reasonParts, usePlanCalls, type Call } from './Decide'; +import { DecideCall, planRow, usePlanCalls } from './Decide'; +import { + answerWords, + isHomeReason, + primaryOf, + reasonParts, + type Call, +} from '../../model/decideCalls'; +import { HomeLine } from './Initiatives'; +import type { Initiative } from '../../../../shared/model/initiatives'; import { BacklogSection } from './BacklogSection'; import { flagText, @@ -469,6 +481,8 @@ export function ProjectPage({ navigate, item, plans, + initiatives = null, + untracked = false, ongoingSaved, opts, onPerson, @@ -491,6 +505,10 @@ export function ProjectPage({ item: PortfolioItem | undefined; /** the roadmap's plans, null while they load */ plans: readonly RoadmapItem[] | null; + /** the initiatives, for its home line; null while they load */ + initiatives?: readonly Initiative[] | null; + /** it's Not tracked: it owes nothing, and counts nowhere */ + untracked?: boolean; /** the projects marked ongoing on the board (not by label) */ ongoingSaved: string[]; /** how the board draws its PR rows */ @@ -578,11 +596,11 @@ export function ProjectPage({ // project, or none), and the last two weeks' merges const liveIndex = new Map( [ - ...today.live.flatMap(g => g.open), - ...today.quiet.flatMap(g => g.open), + ...[...today.live, ...today.quiet].flatMap(g => [...g.open, ...g.drafts]), ...today.misc, ...today.unsorted, ...today.doubleLabeled, + ...today.drafts, ].map(p => [issueKey(p.data), p]) ); const knownIndex = new Map([...merged, ...closed].map(p => [issueKey(p), p])); @@ -620,9 +638,12 @@ export function ProjectPage({ const byStage = new Map(PR_STAGES.map(s => [s, []])); const unread: string[] = []; const mergedLately: string[] = []; + // listed, never counted: a draft isn't work yet + const drafts: string[] = []; for (const [key, pr] of onPage) { const p = pulls.live(pr); - if (p) byStage.get(prStage(p))?.push(key); + if (isDraftPull(pr, pulls.live)) drafts.push(key); + else if (p) byStage.get(prStage(p))?.push(key); else if (pr.state === 'open') unread.push(key); else if (pulls.known(pr)?.merged_at) mergedLately.push(key); } @@ -652,17 +673,20 @@ export function ProjectPage({ row.item?.update && update && row.item.update.at === update.at ? { ...row.item, update: { ...row.item.update, body: '' } } : row.item; + // where it belongs is asked by its home line; the Plan fields answer the rest + const homeAsks = calls.filter(row => row.reasons.length && isHomeReason(primaryOf(row))); + const planAsks = asks.filter(row => !isHomeReason(primaryOf(row))); // each row asks one question, its worst reason's (the one its strip // outlines the answer to), after the facts of all of them - const asked = asks.map(row => { - const primary = row.reasons.reduce((a, r) => (RANK[r.kind] < RANK[a.kind] ? r : a)); + const asked = planAsks.map(row => { + const primary = primaryOf(row); const sentences = [primary, ...row.reasons.filter(r => r !== primary)].map(r => // the page's Status already says there's no plan reasonParts(r, quoteless(row), r.kind === 'new') ); return { key: `${row.slug ?? ''}:${row.item?.id ?? ''}`, - name: asks.length > 1 && row.item ? `${row.item.name}: ` : '', + name: planAsks.length > 1 && row.item ? `${row.item.name}: ` : '', facts: sentences.map(s => s.facts).join(' '), question: sentences[0].question, }; @@ -677,7 +701,7 @@ export function ProjectPage({ !asks.some(row => row.reasons.some(r => r.kind === update.health)); // Decide's rows here, asked or decided; a call made under the plan, where // Decide asked nothing, keeps its receipt there - const decideRows = calls.filter(row => row.reasons.length); + const decideRows = calls.filter(row => row.reasons.length && !isHomeReason(primaryOf(row))); // the row the Plan fields answer: Decide's, while it asks, else the plan's const fieldRow = decideRows[0] ?? planRow(slug, plan, calls); const org = project?.repo.split('/')[0] ?? 'iFixit'; @@ -753,7 +777,7 @@ export function ProjectPage({ const activityKey = lastActivity && issueKey(lastActivity.pr); return ( - <> +

    {said}

    @@ -820,6 +844,39 @@ export function ProjectPage({ )}
    + {initiatives && initiatives.length > 0 && ( +
    + {/* while Decide asks where it goes, its answers below are + the one way to move it */} + + {/* where it belongs, when Decide asks: its question and answers */} + {homeAsks.map(row => { + const parts = reasonParts(primaryOf(row), row.item); + return ( +
    r.kind).join()} + className="mt-1.5 text-[13px]" + > +

    + {parts.facts} {parts.question} +

    + +
    + ); + })} + {untracked && !homeAsks.length && ( +

    + {NOT_TRACKED_GLOSS} +

    + )} +
    + )}
    @@ -1044,6 +1101,13 @@ export function ProjectPage({ {loose.length > 0 && ( {pullCount(loose, 'not tied to an issue')} )} + {drafts.length > 0 && ( + // so "None open" never reads as nothing at all + + {pullCount(drafts, drafts.length === 1 ? 'draft' : 'drafts')}, not + counted + + )} {(mergedLately.length > 0 || lastActivity) && ( @@ -1165,6 +1229,6 @@ export function ProjectPage({ headerExtra={rangePicker} />
    - +
    ); } diff --git a/frontend-v2/src/views/projects/RangeCalendar.test.tsx b/frontend-v2/src/views/projects/RangeCalendar.test.tsx new file mode 100644 index 00000000..944df1c7 --- /dev/null +++ b/frontend-v2/src/views/projects/RangeCalendar.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import RangeCalendar from './RangeCalendar'; +import { dayOf } from '../../model/projectData'; + +// React's act() warns unless the environment says it's a test +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | null = null; +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ''; +}); + +function draw(props: Partial[0]>) { + window.matchMedia = (() => ({ matches: false })) as unknown as typeof window.matchMedia; + const el = document.createElement('div'); + document.body.append(el); + root = createRoot(el); + const t = new Date(); + const range = { start: dayOf(t), end: dayOf(t) }; + act(() => root?.render( undefined} {...props} />)); + return el; +} + +// two months ahead, which a roadmap can pick and Look back can't +const ahead = new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1); +const aheadRange = { + start: dayOf(ahead), + end: dayOf(new Date(ahead.getFullYear(), ahead.getMonth(), 20)), +}; +const caption = (el: HTMLElement) => el.querySelector('.rdp-caption_label')?.textContent ?? ''; +const monthName = ahead.toLocaleDateString(undefined, { month: 'long' }); +const days = (el: HTMLElement) => [...el.querySelectorAll('td button')]; + +describe('RangeCalendar', () => { + it('never opens on months ahead for looking back', () => { + expect(caption(draw({ range: aheadRange }))).not.toContain(monthName); + }); + + it('opens the roadmap on its first month ahead, every day open to pick', () => { + const el = draw({ + range: aheadRange, + future: true, + words: () => 'Oct 2026, in whole months', + }); + expect(caption(el)).toContain(monthName); + expect(days(el).length).toBeGreaterThan(27); + expect(days(el).every(b => !b.disabled)).toBe(true); + expect(el.textContent).toContain('Oct 2026, in whole months'); + }); +}); diff --git a/frontend-v2/src/views/projects/RangeCalendar.tsx b/frontend-v2/src/views/projects/RangeCalendar.tsx index 7f839a73..8bd898f2 100644 --- a/frontend-v2/src/views/projects/RangeCalendar.tsx +++ b/frontend-v2/src/views/projects/RangeCalendar.tsx @@ -49,16 +49,27 @@ const CALENDAR_VARS = { * Two months of calendar for picking a range by hand (one on a phone, so the * panel and its Apply fit the screen): click the first day, then the last, * then Apply. Clicking again starts a new range. Days after today can't be - * picked, and a range stops at the server's 400-day cap. Weeks start on + * picked, and a range stops at the server's 400-day cap, unless `future` + * (the roadmap's window, which draws plans ahead and has no cap), where it + * opens on the range's first month. Weeks start on * Monday, as the charts' weeks do. Loaded lazily with the picker's panel, * stylesheet included. */ export default function RangeCalendar({ range, onApply, + future = false, + words = r => `${rangeWords(r)} · ${rangeDays(r)} days`, + hint = `Click the first day, then the last (at most ${MAX_RANGE_DAYS} days)`, }: { + /** what it says before a first click */ + hint?: string; range: Range; onApply: (picked: Range) => void; + /** days after today can be picked, and a range has no cap */ + future?: boolean; + /** what a picked range says beside Apply */ + words?: (picked: Range) => string; }) { const [draft, setDraft] = useState({ from: dateOf(range.start), @@ -77,21 +88,21 @@ export default function RangeCalendar({ onSelect={setDraft} resetOnSelect weekStartsOn={1} - // the picker counts nights; a range counts both ends - max={MAX_RANGE_DAYS - 1} + {...(future + ? { defaultMonth: dateOf(range.start) } + : { + // the picker counts nights; a range counts both ends + max: MAX_RANGE_DAYS - 1, + defaultMonth: openingMonth(dateOf(range.end), today, months), + endMonth: today, + disabled: { after: today }, + })} numberOfMonths={months} - defaultMonth={openingMonth(dateOf(range.end), today, months)} - endMonth={today} - disabled={{ after: today }} style={CALENDAR_VARS} className="pd-calendar text-[13px] text-ink" />
    - - {picked - ? `${rangeWords(picked)} · ${rangeDays(picked)} days` - : `Click the first day, then the last (at most ${MAX_RANGE_DAYS} days)`} - + {picked ? words(picked) : hint} = { team: 'By the team of whoever spent them', + initiative: 'By the initiative of the PR’s project', origin: 'By where the work came from', author: 'By whose PR it was', repo: 'By repo', @@ -210,7 +218,8 @@ function measure(rows: readonly RetroRow[], plans: readonly RoadmapItem[]) { } /** A group's name in the last list, split another way. */ -function splitLabel(split: Split, key: string): string { +function splitLabel(split: Split, key: string, initiatives: readonly Initiative[] = []): string { + if (split === 'initiative') return initiativeSplitLabel(key, initiatives); if (split === 'origin') { if (key === NOT_FILED) return NOT_IN_A_PROJECT; if (key === UNPLANNED) return NO_PLAN; @@ -944,6 +953,8 @@ export function Retro({ range, plans, items, + initiatives = null, + untracked = new Set(), teams, teamOf, nameOf, @@ -956,6 +967,10 @@ export function Retro({ plans: readonly RoadmapItem[]; /** the project list for the range: each project's issue, plan and numbers */ items: readonly PortfolioItem[]; + /** the initiatives, for the split by initiative; null while they load */ + initiatives?: readonly Initiative[] | null; + /** the Not tracked slugs, which that split names as such */ + untracked?: ReadonlySet; /** developer teams: team name to logins */ teams: Record; teamOf: (login: string) => string | null; @@ -1209,8 +1224,13 @@ export function Retro({ const plan = planFor(project, plans); return !plan || plan.status === 'dropped' ? UNPLANNED : plan.origin ?? UNSAID; }; + // each PR's project's home: its initiative, Not tracked, Unsorted, small + // work or no project (initiativeBoard.ts initiativeSplitKey) + const homes = homesOf(initiatives ?? []); + const initiativeKey = (r: RetroRow) => initiativeSplitKey(r.pr.project, homes, untracked); const keyOf: Record string> = { origin: originOf, + initiative: initiativeKey, author: r => r.own ? 'own' : r.pr.bot ? 'bot' : teamOf(r.pr.owner) != null ? 'developer' : 'other', team: r => teamOf(r.login) ?? NO_TEAM, @@ -1226,6 +1246,12 @@ export function Retro({ }; splitGroups.sort((a, b) => rank(a.key) - rank(b.key) || a.key.localeCompare(b.key)); } + if (nav.by === 'initiative') { + // in the initiatives' own rank, then Unsorted, Not tracked, small work + // and no project + const rank = initiativeSplitRank(initiatives ?? []); + splitGroups.sort((a, b) => rank(a.key) - rank(b.key) || a.key.localeCompare(b.key)); + } const splitBefore = earlier ? byDays(groupRows(earlier, keyOf[nav.by])) : null; const splitBars = barsBy(keyOf[nav.by]); const splitName = SPLIT_OPTIONS.find(([s]) => s === nav.by)?.[1] ?? ''; @@ -1327,7 +1353,7 @@ export function Retro({ `${SPLIT_WORDS[nav.by]}:`, ...splitGroups.map( g => - `- ${splitLabel(nav.by, g.key)}: ${dayCount(g.days)} (${pct( + `- ${splitLabel(nav.by, g.key, initiatives ?? [])}: ${dayCount(g.days)} (${pct( g.days, total )})${versusBefore(splitBefore && (splitBefore.get(g.key) ?? 0))}` @@ -1748,7 +1774,7 @@ export function Retro({ }; const splitRow = (g: RetroGroup) => { - const label = splitLabel(nav.by, g.key); + const label = splitLabel(nav.by, g.key, initiatives ?? []); const prev = splitBefore ? splitBefore.get(g.key) ?? 0 : null; return ( { expect(Object.fromEntries(lanes)).toEqual({ docs: 'plan', plp: 'call', idx: 'call' }); }); }); + +describe('Now, next and later', () => { + const call = { kind: 'reopened' as const, text: 'Done, still worked on', question: 'Is it still done?', title: '' }; + it('shows a stopped plan Decide asks about under Now, and others in none', () => { + expect(nnlBucket({ ...plan, status: 'done' }, '2026-10-12', call)).toBe('now'); + expect(nnlBucket({ ...plan, status: 'done' }, '2026-10-12', null)).toBeNull(); + expect(nnlBucket({ ...plan, status: 'planned', start: '2027-06-07' }, '2026-10-12', null)).toBe( + 'later' + ); + }); +}); diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 1698a62c..04c85d99 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -8,10 +8,8 @@ import { type KeyboardEvent, type MutableRefObject, type PointerEvent, - type ReactNode, } from 'react'; import { - Check, ChevronLeft, ChevronRight, Flag, @@ -26,7 +24,6 @@ import { dayStart, firstOpenDay, utcDay } from '../../../../shared/model/project import { addWeeks, blockersOf, - bucketOf, checkRoadmapFields, DONE_WHEN_MAX, END_KINDS, @@ -57,10 +54,27 @@ import { Segmented, textInputClass, } from '../../components/bits'; +import { focusResult, guardAt, PRESS_GUARD, useFocusWhenDrawn } from '../../components/settle'; import { Icon } from '../../components/Icon'; -import { eyebrowText, Fold, GroupHeader, Rows, SubDoor, useFoldState } from '../../components/Lane'; +import { + eyebrowText, + GroupHeader, + openFold, + Rows, + SubDoor, +} from '../../components/Lane'; +import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; + +import { UNSORTED } from '../../model/initiativeBoard'; import { useRowKeys } from '../../components/useRowKeys'; -import { dateOf, dayOf, dayWords, useProjectsData, type Range } from '../../model/projectData'; +import { + dateOf, + dayOf, + dayWords, + MAX_RANGE_DAYS, + useProjectsData, + type Range, +} from '../../model/projectData'; import { findFilter, lowerFirst, planCell, type PortfolioItem } from '../../model/portfolio'; import { teamLoad } from '../../model/teamLoad'; import { @@ -94,14 +108,33 @@ import { import { columnsFor, commitEnds, + dayAcross, + defaultWindow, + doneBefore, + drawnWindow, + historyRange, + inUnit, + isPreset, parseZoom, quarterOf, + resolveWindow, + shiftWindow, + windowHolding, + windowKey, + windowOfDays, + windowWords, + WINDOW_PRESETS, shiftZoom, zoomAround, zoomKey, zoomWords, + columnNames, type Column, + type Months, + type Scale, } from '../../model/roadmapTime'; +import { MAX_WINDOW_MONTHS } from '../../lens'; +import { DateRangePicker } from './DateRangePicker'; import { andList, BEING_WORKED_ON, @@ -116,10 +149,27 @@ import { END_WORD, NO_UPDATE_YET, UPDATE_DUE, + NOT_TRACKED_GLOSS, + UNSORTED_WORD, + NO_PROJECT_YET, } from '../../model/words'; -import { askOf, DecideCall, reasonWords, useCallsMadeHere } from './Decide'; +import { DecideCall, useCallsMadeHere } from './Decide'; +import { askOf, isHomeReason, reasonWords } from '../../model/decideCalls'; +import { HomeLine } from './Initiatives'; +import { + initiativeLanes, + keepInPlace, + NO_PROJECT_LANE, + ReceiptLine, + TimelineLane, + LANE_RUN, + type LaneRun, + type PlanRun, + type Receipt, + type Standing, +} from './RoadmapLanes'; import { LoadChart } from './LoadChart'; -import { NowNextLater } from './NowNextLater'; +import { nnlBucket, NowNextLater } from './NowNextLater'; import { NarrowChip, openPlan, @@ -132,7 +182,6 @@ import { capacityWords, clearedBy, Dotted, - inInk, isAmber, latestOf, moveWords, @@ -148,7 +197,6 @@ import { vouchWords, when, type PlanCall, - type Said, type Tracked, } from './roadmapHealth'; @@ -214,6 +262,9 @@ type Span = { start: string; weeks: number }; /** no Decide rows yet, the same array each render */ const NO_ROWS: DecideRow[] = []; +const NO_SLUGS: ReadonlySet = new Set(); +/** A row that isn't a drag source or a drop target: a Not tracked plan. */ +const NO_DRAG = { grip: {}, row: {} }; /** what opens under a plan's row: from sm up, in line with the row's name * above, past its grip and rank (63px in), so it reads as that row's */ @@ -851,6 +902,9 @@ function PlanPanel({ onPerson, onOpenItem, onOpenProject, + initiatives = null, + navigate, + onFile, }: { item: RoadmapItem; all: RoadmapItem[]; @@ -859,9 +913,11 @@ function PlanPanel({ today: string; /** Decide's row for it, asked or answered here, for its calls */ row: DecideRow; - /** opened from a door (its bar, its words, a link): the focus goes to its - * calls; back from its fields, the roadmap puts it on Edit details */ - focusCalls: boolean; + /** opened from a door (its bar, a link): the focus goes to its calls; + * from a question or a clash ('plan'), to its Plan line, the answers a + * Tab away, so an Enter twice never answers; back from its fields, the + * roadmap puts it on Edit details */ + focusCalls: boolean | 'plan'; /** its update form is open */ updates: boolean; /** kept true while the update form holds words not posted */ @@ -874,6 +930,13 @@ function PlanPanel({ onPerson: (login: string) => void; onOpenItem: (id: number) => void; onOpenProject: (slug: string) => void; + /** the initiatives, for its project's home line and File elsewhere: the keyboard's + * way to file what a drag onto another lane files; none hides it */ + initiatives?: readonly Initiative[] | null; + navigate?: Navigate; + /** file its project there (null: Unsorted), as a drop on that lane + * does: the receipt and Undo go under the row, which the move remounts */ + onFile?: (to: string | null) => void; }) { const ref = useRef(null); const [removing, setRemoving] = useState(false); @@ -888,7 +951,10 @@ function PlanPanel({ const onCalls = !!panel.querySelector('[role="toolbar"]')?.contains(document.activeElement); if (!(focusCalls && !focused.current) && !onCalls) return; focused.current = true; - panel.querySelector('[data-decide-focus]')?.focus({ preventScroll: true }); + (focusCalls === 'plan' + ? document.getElementById(`roadmap-plan-${item.id}`) + : panel.querySelector('[data-decide-focus]') + )?.focus({ preventScroll: true }); }, [asked]); // the facts behind the row's words, in ink const w = planWarnings(item, all, today, trackedBy(linked)); @@ -926,20 +992,28 @@ function PlanPanel({ >
    Plan
    -
    +
    {STATUS_WORD[item.status]}, {planWords(item)} {facts}
    - {/* under the plan's words; the whole width on a phone */} -
    - -
    + {/* under the plan's words; the whole width on a phone. A plan + done or dropped has nothing left to promise or mark, unless + Decide asks about it; Edit details reopens one */} + {(asked || (item.status !== 'done' && item.status !== 'dropped')) && ( +
    + +
    + )} {/* what finished looks like, so "Done?" has something to check; upkeep is never done, so it isn't asked for there */} {(item.done_when || item.end_kind !== 'ongoing') && ( @@ -1000,6 +1074,35 @@ function PlanPanel({ )} )} + {navigate && !!initiatives?.length && ( + <> +
    Initiative
    +
    + {project ? ( + + isHomeReason(r) || + (r.kind === 'new' && !homesOf(initiatives).has(project)) + ) + } + /> + ) : ( + + {NO_PROJECT_YET}: it joins an initiative through its project, once Edit + details links one + + )} +
    + + )}
    Team
    {teamWords(item.team, lead)} @@ -1148,28 +1251,6 @@ function Gridlines({ axis }: { axis: Axis }) { ); } -/** The quiet line under a row that says what a click just saved, with the - * way back (Decide's receipt): where the click was, not far away. */ -interface Receipt { - /** the row it sits under: `plan:`, `project:`, or `added` */ - key: string; - words: string; - failed?: boolean; - undo?: () => void; - /** a plan it made, to open */ - open?: number; - /** the project it planned, kept in its place in the list of no plan */ - project?: PortfolioItem; - /** the plan it removed, kept in its place in the list of plans */ - gone?: RoadmapItem; - /** the lane the planned or parked project was in, where it stays */ - lane?: Standing; -} - -/** Where a project with no plan sits: Decide asks it for a plan, asks it - * another call (most often whether to park it), or asks nothing. */ -export type Standing = 'plan' | 'call' | 'none'; - /** Each project with no plan that Decide asks about, by its lane: any call * but a first plan (most often "Park it?") is 'call', even with a plan asked * too, so "No plan needed" never holds work Decide asks about. */ @@ -1182,28 +1263,6 @@ export function standingsOf(rows: readonly DecideRow[]): Map { return out; } -function ReceiptLine({ receipt, onOpen }: { receipt: Receipt; onOpen: (id: number) => void }) { - const open = receipt.open; - return ( -
    - - {!receipt.failed && } - {receipt.words.replace(/\.$/, '')}. - - {open != null && onOpen(open)}>Open its plan} - {/* a failed Undo keeps its button, to try again */} - {receipt.undo && ( - - {receipt.failed ? 'Try again' : 'Undo'} - - )} -
    - ); -} - /** The words inside a plan's bar, color by its fill. */ const BAR_TEXT: Record = { planned: 'var(--brand-700)', @@ -1247,15 +1306,18 @@ function PlanRow({ item, all, rank, + lanePlace, axis, today, linked, call, open, onOpen, + onOpenPlan, onPerson, onOpenProject, onMove, + onOtherLane, onShow, dragHandlers, dropHere, @@ -1263,7 +1325,11 @@ function PlanRow({ item: RoadmapItem; /** every item, for what this one waits on */ all: RoadmapItem[]; + /** its priority on the whole roadmap; 0 for none (Not tracked) */ rank: number; + /** its place in an initiative's lane, numbered there in place of the + * rank, so a lane reads 1, 2, 3 under its band's own rank */ + lanePlace?: number; axis: Axis; today: string; linked: PortfolioItem | undefined; @@ -1271,12 +1337,18 @@ function PlanRow({ call: PlanCall | null; /** its details are open under the row */ open: boolean; - /** open its details */ - onOpen: () => void; + /** open its details; 'plan' puts the focus on their Plan line */ + onOpen: (focus?: 'plan') => void; + /** open another plan's details, the focus on its Plan line: the one a + * clash waits on */ + onOpenPlan: (id: number) => void; onPerson: (login: string) => void; onOpenProject: (slug: string) => void; /** a drag or an arrow key moved or resized it */ - onMove: (to: Span) => void; + /** `elsewhere`: let go over another lane, which it stays out of */ + onMove: (to: Span, elsewhere?: boolean) => void; + /** its bar was let go over another lane, so nothing moved */ + onOtherLane: () => void; /** bring a day into view: the plan is outside the weeks shown */ onShow: (day: string) => void; dragHandlers: { @@ -1348,12 +1420,20 @@ function PlanRow({ window.removeEventListener('pointermove', move); setPreview(null); }; - const up = () => { + const up = (ev: globalThis.PointerEvent) => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('keydown', cancel); setPreview(null); - if (!cancelled) commit(latest); + // let go over another lane: a bar moves dates, and filing it there + // as well would make one drag two changes, so it keeps its lane and + // moves only the dates its preview showed + const lane = trackRef.current?.closest('[data-lane-id]'); + const over = document.elementFromPoint?.(ev.clientX, ev.clientY)?.closest('[data-lane-id]'); + const elsewhere = dragged.current && !!lane && !!over && over !== lane; + const changed = latest.start !== item.start || latest.weeks !== item.weeks; + if (!cancelled && changed) onMove(latest, elsewhere); + else if (!cancelled && elsewhere) onOtherLane(); // the click that comes with letting go has been heard by then setTimeout(() => { dragged.current = false; @@ -1399,24 +1479,36 @@ function PlanRow({ >
    + {/* a Not tracked plan has no place in the order: no grip, no rank */} + {rank === 0 ? ( + + ) : ( + + + + )} - - - - {rank} + {lanePlace ?? (rank || '')} @@ -1436,7 +1528,7 @@ function PlanRow({ type="button" data-roadmap-focus aria-expanded={open} - onClick={onOpen} + onClick={() => onOpen()} className={name} title="Open its plan; it tracks no project yet" > @@ -1456,19 +1548,31 @@ function PlanRow({ )} {rest && ( - // plain words, dotted since their hover explains: the - // bar and the name open the plan. What the answer in its - // details is read with, so a reader hears the reason too - - {rest.title} - + // dotted since their hover explains; a click does the + // natural thing: a question opens the details with its + // answers, a clash the plan it waits on. Out of the Tab + // order, since the bar and the name open the plan. What + // the answer in its details is read with, so a reader + // hears the reason too + <> + + - + )} @@ -1579,7 +1683,7 @@ function PlanRow({ type="button" // the bar opens the same details from the keyboard tabIndex={-1} - onClick={onOpen} + onClick={() => onOpen()} className={`hit @container absolute top-1.5 h-4 border-y p-0 text-left text-[11px] leading-[14px] font-medium whitespace-nowrap ${ today < horizon.end ? 'rounded-r-md border-r' : '' } ${isAmber(w.over) ? 'text-warn' : 'text-ink-2'}`} @@ -1708,8 +1812,7 @@ function InFlightRow({ // the Monday under the pointer const weekAt = (x: number): string => { const box = (trackRef.current as HTMLSpanElement).getBoundingClientRect(); - const at = Math.min(1, Math.max(0, (x - box.left) / box.width)); - return mondayOf(utcDay(horizon.from + at * (horizon.to - horizon.from))); + return mondayOf(dayAcross(horizon.from, horizon.to, (x - box.left) / box.width)); }; const spanOf = (a: string, b: string): Span => { const [first, last] = a <= b ? [a, b] : [b, a]; @@ -1778,6 +1881,10 @@ function InFlightRow({ )} + {/* the plan rows' rank column, empty: no plan, no place in the + order, but the names line up (the plus is 4px wider than the + grip it stands for) */} +
    @@ -3009,6 +3305,122 @@ export function Roadmap({ .filter((t): t is string => !!t && !teams.includes(t)) .sort((a, b) => a.localeCompare(b)), ]; + const { fileByDrag, laneInitiatives, rankOf, initiativeBody } = initiativeLanes({ + asksPlan, + bySlug, + callsHere, + counted, + dragging, + draggingLane, + everyPlan, + everything, + gone, + homeOf, + inFlightRows, + initiatives, + item: nav.item, + laneId, + laneKey, + narrowed, + navigate, + openFromReceipt, + ordered, + overLane, + person, + planCalls, + planRows, + projectCalls, + q, + receipt, + refocus, + run, + say, + said, + setAnnounced, + setDragging, + setDraggingLane, + setDropTarget, + setOverLane, + shownPlans, + shownUnplanned, + }); + // ----- Not tracked: closes every grouping, a ledger at rest ----- + // its plans under way and live projects with no plan, from the full set, + // the most recent PR first; no rank, and not dragged or dropped on + const trackedIds = new Set(ids); + const recent = (slug: string | null) => (slug && bySlug.get(slug)?.lastActivity?.at) || 0; + const notTrackedPlans = (everyPlan ?? []) + // the open plan and the one whose receipt shows stay, as heldPlan + // keeps them in the initiative lanes, so a save keeps its Undo + .filter( + i => + !trackedIds.has(i.id) && + (isUnderWay(i.status) || i.id === nav.item || receipt?.key === `plan:${i.id}`) && + found(i) + ) + .sort((a, b) => recent(b.project) - recent(a.project)); + const notTrackedPlanned = new Set(notTrackedPlans.flatMap(i => (i.project ? [i.project] : []))); + const notTrackedLoose = + everything && !nav.origin + ? allItems + .filter( + i => + untracked.has(i.slug) && + i.status === 'live' && + !notTrackedPlanned.has(i.slug) && + (!filter || filter(i)) + ) + .sort((a, b) => recent(b.slug) - recent(a.slug)) + : []; + const notTrackedLane = (showPlans ? notTrackedPlans.length : 0) + notTrackedLoose.length > 0 && ( +
    + planCalls.has(i.id)) || + notTrackedLoose.some(p => projectCalls.has(p.slug)) + } + // under the initiative lanes, its label starts where theirs do + gutter={byInitiative ? : undefined} + > + {showPlans && planRows(notTrackedPlans, notTrackedPlans, { ranked: false })} + {inFlightRows(notTrackedLoose)} + +
    + ); + + // ----- Done before the window: a ledger at rest, after every grouping ----- + // the initiative lanes hold only work under way, so they have no fold + // of it either + const doneFold = !byInitiative && shownBefore.length > 0 && ( +
    + + {planRows(shownBefore, shownBefore, { ranked: false })} + +
    + ); + const body = lanes ? ( [...laneTitles, null].map(team => { const inLane = (t: string | null | undefined) => (t ?? null) === team; @@ -3058,9 +3470,10 @@ export function Roadmap({ id={id} label={name} count={counted(shownPlanned) + shownLoose.length} + // the done plans folded under the rows aren't in the lane gloss={`${name}: ${of( counted(shownPlanned), - planned.length, + planned.filter(i => !before.has(i.id)).length, 'plan' )} in priority order${looseWords}.`} load={developers ? capacityWords(load.length, developers) : null} @@ -3089,6 +3502,8 @@ export function Roadmap({ ); }) + ) : byInitiative ? ( + initiativeBody ) : ( <> {shownPlans.length > 0 && ( @@ -3098,9 +3513,10 @@ export function Roadmap({ label={`On the roadmap · ${n(counted(shownPlans), 'plan')}`} count={counted(shownPlans)} showCount={false} + // the done plans folded under the rows aren't in the lane gloss={`${of( counted(shownPlans), - ordered.length, + ordered.filter(i => !before.has(i.id)).length, 'plan' )} in priority order: what matters most is on top.`} load={null} @@ -3235,6 +3651,12 @@ export function Roadmap({ question on a row is a decision Decide asks for about that plan; point at it for why.

    +

    + The dates button picks the months shown; ‹ and › step that many, and + Today puts back the months it opens on. A plan outside them shows its + dates at the edge: click them to move there. Done plans that ended before + the first month rest in a fold at the bottom. +

    The bars count the plans and projects with a PR open each week, merged that week included. The lanes count PRs in the last 14 days, so their @@ -3271,54 +3693,109 @@ export function Roadmap({

    navigate({ scale: next, zoom: null })} + onChange={switchScale} /> - {zoom && ( + {/* the window: its picker, or a zoom's name, between the steps + back and on by its own length; Today puts back the default */} + {timeline && ( - navigate({ zoom: zoomOut })} - title="Zoom out" - > - - {zoomOut - ? zoomWords(quarterOf(zoom)) - : scale === 'quarter' - ? 'All quarters' - : 'All months'} - + {zoom && ( + + navigate( + zoomOut + ? { zoom: zoomOut } + : // out to a window holding it, so you keep your place + { zoom: null, window: holding(horizon.start) } + ) + } + title="Zoom out" + > + + + {zoomOut + ? zoomWords(quarterOf(zoom)) + : unit === 'quarter' + ? 'All quarters' + : 'All months'} + + + )} - - {zoomWords(zoom)} - + {zoom ? ( + + {zoomWords(zoom)} + + ) : ( + navigate({ window: key || null })} + presets={WINDOW_PRESETS} + // a preset's months show from the widest screens; the + // column names under it say them anyway + wordsClass="hidden 2xl:inline" + label="Pick the months the roadmap shows" + name="Roadmap window" + words={() => windowWords(shownMonths, unit)} + future + hint={`Click the first day, then the last (at most ${ + MAX_WINDOW_MONTHS / 12 + } years)`} + keyOf={r => { + const w = windowOfDays(r.start, r.end); + return windowNav(w, w.unit) ?? ''; + }} + pickedWords={r => { + const w = windowOfDays(r.start, r.end); + return `${windowWords(w, w.unit)}, in whole ${w.unit}s`; + }} + /> + )} - {(today < horizon.start || today >= horizon.end) && ( + {(zoom || isPreset(nav.window) + ? today < horizon.start || today >= horizon.end + : !!nav.window) && ( navigate({ zoom: zoomKey(zoomAround(zoom.kind, now)) })} - title={`Zoom to this ${zoom.kind}`} + onClick={() => + navigate({ + window: null, + zoom: zoom ? zoomKey(zoomAround(zoom.kind, now)) : null, + }) + } + title={ + zoom + ? `Zoom to this ${zoom.kind}` + : `Back to ${windowWords(defaultWindow(scale, now), scale)}` + } > Today @@ -3331,10 +3808,13 @@ export function Roadmap({ Group by navigate({ group })} /> @@ -3350,7 +3830,7 @@ export function Roadmap({ placeholder="Find a project, lead or team" value={nav.find} onChange={e => navigate({ find: e.target.value })} - className={`min-w-48 flex-1 px-2.5 sm:w-52 sm:min-w-0 sm:flex-none ${textInputClass}`} + className={`min-w-48 flex-1 px-2.5 sm:w-52 sm:min-w-32 sm:flex-initial ${textInputClass}`} /> {/* what the load chart's counts and bars narrow the rows to, each with its own way out: the chart scrolls away, these @@ -3440,6 +3920,9 @@ export function Roadmap({ origin={nav.origin} onOrigin={origin => navigate({ origin })} onPeople={() => navigate(switchView('people'))} + historyFrom={ + pastRange && pastRange.start > horizon.start ? pastRange.start : null + } firstPlan={ plan && !plan.length ? { @@ -3462,23 +3945,31 @@ export function Roadmap({ : null } /> - {newPlan} - {receiptAdded} - {!ordered.length && !unplanned.length && !adding ? ( -
    - Nothing on the roadmap yet, and no projects {BEING_WORKED_ON}. Click Add to the - roadmap to start a plan. -
    - ) : ( -
    {body}
    - )} - {narrowed && - !shownPlans.length && - !(everything && shownUnplanned.length) && - nothingMatches(`on the roadmap or ${BEING_WORKED_ON}`)} + {/* the press guard covers what moves under a click (rows, + receipts, details), not the load chart above */} +
    + {newPlan} + {receiptAdded} + {!ordered.length && !unplanned.length && !adding ? ( +
    + Nothing on the roadmap yet, and no projects {BEING_WORKED_ON}. Click Add to + the roadmap to start a plan. +
    + ) : ( +
    + {body} + {doneFold} + {notTrackedLane} +
    + )} + {narrowed && + !shownPlans.length && + !(everything && shownUnplanned.length) && + nothingMatches(`on the roadmap or ${BEING_WORKED_ON}`)} +
    ) : ( - <> +
    {(newPlan || receiptAdded) && (
    @@ -3487,25 +3978,43 @@ export function Roadmap({
    )} - {q && !nnlPlans.some(i => bucketOf(i, today)) ? ( + {q && !nnlPlans.some(i => nnlBucket(i, today, planCalls.get(i.id))) ? ( {nothingMatches('on the roadmap')} ) : ( ({ key: t as string | null, title: t })), + { key: null, title: 'No team' }, + ] + : byInitiative + ? [ + ...laneInitiatives.map(i => ({ + key: i.slug as string | null, + title: rankOf(i) ? `${rankOf(i)} ${i.name}` : i.name, + })), + { key: UNSORTED, title: UNSORTED_WORD }, + { key: NO_PROJECT_LANE, title: NO_PROJECT_YET }, + ] + : null + } + laneOf={byInitiative && !lanes ? i => laneKey(i.project) : i => i.team} today={today} calls={planCalls} unplanned={unplanned.length} - onOpen={id => openFromReceipt(id)} + asked={notTrackedPlans.filter(i => planCalls.has(i.id))} + onOpen={(id, focus) => openFromReceipt(id, focus)} onPerson={person} onTimeline={() => navigate({ scale: 'quarter', zoom: null, show: 'unplanned' }) } /> )} - +
    )} ); diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx new file mode 100644 index 00000000..fe3dd5fe --- /dev/null +++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx @@ -0,0 +1,938 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DecideRow } from '../../../../shared/model/decide'; +import type { Initiative } from '../../../../shared/model/initiatives'; +import type { RoadmapItem } from '../../../../shared/model/roadmap'; +import { loadInitiatives, useInitiativesValue } from '../../model/initiativesData'; +import { loadRoadmap } from '../../model/roadmapData'; +import type { ProjectsNav } from './parts'; +import { Roadmap } from './Roadmap'; +import { clearHolds, pressedBy } from '../../components/settle'; + +// jsdom makes no trusted events: every press here is a person's +pressedBy.person = () => true; + +// 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 initiative = ( + id: number, + slug: string, + name: string, + projects: string[], + over: Partial = {} +): Initiative => ({ + id, + slug, + name, + kind: 'initiative', + goal: '', + owner: null, + priority: id, + status: 'open', + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: 1, + projects: projects.map(project => ({ project, set_by: null, set_at: 50, baseline_prs: 0 })), + ...over, +}); + +const plan = (id: number, name: string, project: string | null, priority = id): RoadmapItem => ({ + id, + name, + project, + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-10-05', + weeks: 8, + end_kind: 'soft', + done_when: '', + priority, + notes: '', + waits_on: [], + updated_by: null, + updated_at: 1, + created_at: 1, + update: null, +}); + +const START: Initiative[] = [ + initiative(1, 'site-speed', 'Site speed', ['cache', 'images']), + initiative(2, 'fixbot', 'FixBot chat', ['answers']), + initiative(3, 'catalog', 'Catalog', [], { status: 'done', status_at: 1 }), + initiative(9, 'shelf', 'Not tracked', ['ops'], { kind: 'untracked' }), +]; +const PLANS = [ + plan(1, 'Image CDN', 'images'), + plan(2, 'Answer search', 'answers'), + plan(3, 'Cache warmer', 'cache'), + plan(4, 'Checkout redesign', null), + plan(5, 'Runbooks', 'ops'), +]; + +let server: Initiative[] = []; +/** the next write of this kind fails, as the server says why */ +let refuse: { undo?: string; file?: string; move?: string } = {}; +const calls: { method: string; path: string; body: Record | null }[] = []; +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe = () => undefined; + disconnect = () => undefined; + } + ); + Element.prototype.scrollIntoView = () => undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async (path: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(String(init.body)) : null; + calls.push({ method, path, body }); + const reply = (json: unknown, status = 200) => + ({ status, redirected: false, json: async () => json } as Response); + if (path === '/roadmap' && method === 'GET') return reply({ items: PLANS }); + if (path === '/roadmap/order') { + const ids = body?.ids as number[]; + return reply({ + items: PLANS.map(p => ({ ...p, priority: ids.indexOf(p.id) })).sort( + (a, b) => a.priority - b.priority + ), + }); + } + if (path === '/initiatives' && method === 'GET') return reply({ initiatives: server }); + const saved = path.match(/^\/roadmap\/(\d+)$/); + if (saved && method === 'PATCH') + return reply({ item: { ...PLANS.find(p => p.id === Number(saved[1])), ...body } }); + const move = path.match(/^\/initiatives\/(\d+)\/move$/); + if (move && refuse.move) { + const error = refuse.move; + refuse = {}; + return reply({ error }, 500); + } + if (move) { + const id = Number(move[1]); + const ranked = [...server].sort((a, b) => a.priority - b.priority); + const moving = ranked.find(i => i.id === id) as Initiative; + const rest = ranked.filter(i => i.id !== id); + const at = body?.before == null ? rest.length : rest.findIndex(i => i.id === body.before); + rest.splice(at, 0, moving); + server = rest.map((i, k) => ({ ...i, priority: k })); + return reply({ initiatives: server }); + } + if (path === '/initiatives/file' && (body?.set_at == null ? refuse.file : refuse.undo)) { + const error = (body?.set_at == null ? refuse.file : refuse.undo) as string; + refuse = {}; + return reply({ error }, 409); + } + if (path === '/initiatives/file') { + // as the server: nothing into a closed initiative + const to = server.find(i => i.slug === body?.initiative); + if (to && to.status !== 'open' && body?.set_at == null) + return reply({ error: 'that initiative is closed' }, 409); + const was = server.find(i => i.projects.some(f => f.project === body?.project)); + // filed: out of the home it had, into the one named + server = server.map(i => ({ + ...i, + projects: [ + ...i.projects.filter(f => f.project !== body?.project), + ...(i.slug === body?.initiative + ? [{ project: body.project as string, set_by: null, set_at: 60, baseline_prs: 0 }] + : []), + ], + })); + return reply({ + project: body?.project, + before: { initiative: was?.slug ?? null, set_at: 50, baseline_prs: 0 }, + home: null, + }); + } + return reply({}, 404); + }) + ); +}); + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +beforeEach(async () => { + server = START.map(i => ({ ...i })); + refuse = {}; + localStorage.clear(); + await loadInitiatives(); + await loadRoadmap(); + calls.length = 0; +}); +afterEach(() => { + clearHolds(); + // an unmounted tree stops listening to the stores the next test writes + act(() => root?.unmount()); + root = null; + host?.remove(); + host = null; +}); + +const NAV = { + view: 'roadmap', + project: null, + initiative: null, + within: null, + range: '30d', + status: 'live', + group: 'initiative', + sort: 'plan', + find: '', + scale: 'quarter', + item: null, + show: 'all', + week: null, + zoom: null, + window: null, + team: null, + origin: null, + by: 'team', + kind: 'all', + who: null, + only: null, + psort: '', +} as ProjectsNav; + +const settle = () => act(() => new Promise(resolve => setTimeout(resolve, 0))); + +/** where the Roadmap asked to go */ +const went: Partial[] = []; + +/** The Roadmap with the initiatives as the board holds them, as the app + * passes them, so a move shows in the next render. */ +function Live({ + nav: start, + decisions = [], + goes = false, +}: { + nav: ProjectsNav; + decisions?: DecideRow[]; + /** it goes where it asks to (a link that opens another plan) */ + goes?: boolean; +}) { + const { initiatives } = useInitiativesValue(); + const [nav, setNav] = useState(start); + return ( + { + went.push(to); + if (goes) setNav(n => ({ ...n, ...to })); + }} + decisions={decisions} + /> + ); +} + +async function mount( + nav: Partial = {}, + live: { decisions?: DecideRow[]; goes?: boolean } = {} +) { + host = document.createElement('div'); + document.body.append(host); + const r = createRoot(host); + root = r; + await act(async () => r.render()); + await settle(); + return host; +} + +const lane = (el: HTMLElement, slug: string) => + el.querySelector(`[data-lane-id="roadmap:initiative:${slug}"]`) as HTMLElement; +/** the receipts drawn that say `words`: each has its own Undo */ +const receipts = (el: HTMLElement, words: string) => + [...el.querySelectorAll('button')].filter(b => b.getAttribute('aria-label') === `Undo: ${words}`) + .length; +const writes = () => calls.filter(c => c.method !== 'GET'); + +/** A drag from `from`, over and onto `to`, as the browser sends it, + * dropped at `at` on the screen. */ +function drag(from: Element, to: Element, at = { clientX: 0, clientY: 0 }): { refused: boolean } { + const data = { effectAllowed: '', setData: () => undefined, getData: () => '' }; + const event = (type: string) => + Object.assign(new Event(type, { bubbles: true, cancelable: true }), { + dataTransfer: data, + ...at, + }); + act(() => { + from.dispatchEvent(event('dragstart')); + }); + const over = event('dragover'); + act(() => { + to.dispatchEvent(over); + }); + act(() => { + to.dispatchEvent(event('drop')); + }); + return { refused: !over.defaultPrevented }; +} + +describe('a row’s question or clash', () => { + const focusedId = () => (document.activeElement as HTMLElement).id; + + it('opens the details with the focus on the Plan line, the answers a Tab away', async () => { + const asked: DecideRow = { slug: 'answers', item: PLANS[1], reasons: [{ kind: 'off_track' }] }; + const el = await mount({}, { decisions: [asked], goes: true }); + // named by the words it shows; the reason is its description + const words = el.querySelector('#roadmap-words-2') as HTMLButtonElement; + expect(words.getAttribute('aria-label')).toBeNull(); + expect(words.querySelector('.sr-only')).toBeNull(); + const why = document.getElementById(words.getAttribute('aria-describedby') as string); + expect(why?.hidden).toBe(true); + expect(why?.textContent).toBe(words.title); + act(() => words.click()); + await settle(); + expect(focusedId()).toBe('roadmap-plan-2'); + // an Enter there answers nothing + const sent = writes().length; + act(() => { + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + }); + await settle(); + expect(writes()).toHaveLength(sent); + }); + + it('opens the plan a clash waits on, the focus on its Plan line', async () => { + // Image CDN waits on Cache warmer, which ends after it starts + PLANS[0].waits_on = [3]; + try { + await act(() => loadRoadmap()); + const el = await mount({}, { goes: true }); + act(() => el.querySelector('#roadmap-words-1')?.click()); + await settle(); + expect(went.at(-1)).toMatchObject({ item: 3 }); + expect(focusedId()).toBe('roadmap-plan-3'); + } finally { + PLANS[0].waits_on = []; + } + }); +}); + +describe('the Not tracked lane', () => { + it('opens while Decide asks about a row in it, and says the question there', async () => { + const grew: DecideRow = { + slug: 'ops', + item: null, + reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }], + }; + const el = await mount({}, { decisions: [grew], goes: true }); + const fold = el.querySelector( + '[data-lane-id="roadmap:not-tracked"] details' + ) as HTMLDetailsElement; + expect(fold.open).toBe(true); + const words = el.querySelector('#roadmap-words-5') as HTMLButtonElement; + expect(words.textContent).toContain('Still Not tracked?'); + // its details answer it + act(() => words.click()); + await settle(); + const details = el.querySelector('[aria-label="Runbooks: its plan"]') as HTMLElement; + expect(details.textContent).toContain('Keep Not tracked'); + }); + + it('says a project’s question on one of its plans, the first in rank', async () => { + const grew: DecideRow = { + slug: 'ops', + item: null, + reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }], + }; + PLANS.push(plan(6, 'Alerting', 'ops', 0)); + try { + await act(() => loadRoadmap()); + const el = await mount({}, { decisions: [grew] }); + const asking = [...el.querySelectorAll('[id^="roadmap-words-"]')].filter(w => + w.textContent?.includes('Still Not tracked?') + ); + expect(asking.map(w => w.id)).toEqual(['roadmap-words-6']); + } finally { + PLANS.pop(); + await act(() => loadRoadmap()); + } + }); + + it('says on Now, next, later where its asked plan is, its name the way to the question', async () => { + const grew: DecideRow = { + slug: 'ops', + item: null, + reasons: [{ kind: 'untracked_grew', since: 31, baseline: 2 }], + }; + const el = await mount({ scale: 'now' }, { decisions: [grew] }); + expect(el.textContent).toContain( + 'Runbooks is Not tracked, so it’s in no column here, but Decide asks about it.' + ); + went.length = 0; + act(() => [...el.querySelectorAll('button')].find(b => b.textContent === 'Runbooks')?.click()); + expect(went.at(-1)).toMatchObject({ item: 5 }); + }); + + it('rests closed when nothing in it is asked about', async () => { + const el = await mount(); + const fold = el.querySelector( + '[data-lane-id="roadmap:not-tracked"] details' + ) as HTMLDetailsElement; + expect(fold.open).toBe(false); + }); +}); + +describe('File elsewhere in a plan’s details', () => { + it('leaves a receipt with Undo under the row in its new lane, the focus on its words', async () => { + const el = await mount({ item: 2 }); + const details = el.querySelector('[aria-label="Answer search: its plan"]') as HTMLElement; + act(() => details.querySelector('[aria-label^="File elsewhere"]')?.click()); + const select = details.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'site-speed'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + act(() => [...details.querySelectorAll('button')].find(b => b.textContent === 'File')?.click()); + await settle(); + await settle(); + // the plan and its details moved to Site speed's lane; the receipt went too + const words = document.activeElement as HTMLElement; + expect(words.textContent).toBe('Filed Answer search in Site speed.'); + expect(lane(el, 'site-speed').contains(words)).toBe(true); + expect(receipts(el, 'Filed Answer search in Site speed')).toBe(1); + // an extra Enter lands on words; Undo puts it back, its words take the focus + act(() => + el.querySelector('[aria-label="Undo: Filed Answer search in Site speed"]')?.click() + ); + await settle(); + await settle(); + expect((document.activeElement as HTMLElement).textContent).toBe( + 'Put Answer search back in FixBot chat.' + ); + expect(lane(el, 'fixbot').contains(document.activeElement)).toBe(true); + expect(writes().filter(c => c.path === '/initiatives/file')).toHaveLength(2); + }); +}); + +describe('a plan’s details while Decide asks where its project goes', () => { + it('offer filing once: the Plan line’s File, and no File elsewhere', async () => { + // Cache warmer's project is filed nowhere + server = START.map(i => + i.slug === 'site-speed' ? { ...i, projects: i.projects.filter(f => f.project !== 'cache') } : i + ); + await loadInitiatives(); + const unfiled: DecideRow = { slug: 'cache', item: null, reasons: [{ kind: 'unfiled' }] }; + const el = await mount({ item: 3 }, { decisions: [unfiled] }); + const details = el.querySelector('[aria-label="Cache warmer: its plan"]') as HTMLElement; + expect(details.querySelector('[aria-label^="File cache"]')).not.toBeNull(); + expect(details.querySelector('[aria-label^="File elsewhere"]')).toBeNull(); + }); +}); + +describe('the Roadmap by initiative', () => { + it('opens a linked plan’s details without putting the focus on an end to promise', async () => { + // nobody has clicked or pressed a key on the page yet + Object.defineProperty(navigator, 'userActivation', { + value: { hasBeenActive: false }, + configurable: true, + }); + try { + const el = await mount({ item: 2 }); + expect(el.querySelector('[role="toolbar"]')).not.toBeNull(); + expect(document.activeElement).toBe(document.body); + } finally { + delete (navigator as { userActivation?: unknown }).userActivation; + } + }); + + it('draws a lane per open initiative in its priority, then Unsorted, No project yet and Not tracked', async () => { + const el = await mount(); + const bands = [...el.querySelectorAll('summary')].map(s => s.textContent ?? ''); + const at = (words: string) => bands.findIndex(b => b.includes(words)); + expect(at('1 Site speed')).toBeGreaterThan(-1); + expect(at('2 FixBot chat')).toBeGreaterThan(at('1 Site speed')); + expect(at('No project yet')).toBeGreaterThan(at('2 FixBot chat')); + expect(at('Not tracked')).toBeGreaterThan(at('No project yet')); + // the closed one holds nothing, so it has no band + expect(at('Catalog')).toBe(-1); + // Not tracked's plan sits in its own lane, not in an initiative's + expect(lane(el, 'site-speed').textContent).not.toContain('Runbooks'); + expect(el.textContent).toContain('Runbooks'); + }); + + it('numbers a lane’s rows by their place in it, with the whole roadmap’s on the grip', async () => { + const el = await mount(); + const grips = [...lane(el, 'site-speed').querySelectorAll('[id^="roadmap-grip-"]')]; + expect(grips.map(g => g.getAttribute('aria-label')?.split('.')[0])).toEqual([ + 'Image CDN: priority 1 in its lane, 1 on the whole roadmap', + 'Cache warmer: priority 2 in its lane, 3 on the whole roadmap', + ]); + }); + + it('moves a lane with the arrow keys, says so once whether it’s open or closed, and Undo puts it back', async () => { + const el = await mount(); + for (const open of [false, true]) { + const band = lane(el, 'fixbot').querySelector('details') as HTMLDetailsElement; + act(() => { + band.open = open; + }); + const grip = el.querySelector('#roadmap-lane-grip-2') as HTMLElement; + act(() => { + grip.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + }); + await settle(); + expect(writes().at(-1)).toMatchObject({ path: '/initiatives/2/move', body: { before: 1 } }); + expect(receipts(el, 'Moved FixBot chat to priority 1')).toBe(1); + const undo = [...el.querySelectorAll('button')].find( + b => b.getAttribute('aria-label') === 'Undo: Moved FixBot chat to priority 1' + ) as HTMLButtonElement; + act(() => undo.click()); + await settle(); + // back above the one it sat above: Catalog, which sat after it + expect(writes().at(-1)).toMatchObject({ path: '/initiatives/2/move', body: { before: 3 } }); + expect(el.textContent).toContain('Put FixBot chat back at priority 2'); + } + }); + + it('undoes a run of lane moves to where the run began, and holds its words in place', async () => { + server = [ + ...START, + initiative(4, 'alpha', 'Alpha', []), + initiative(5, 'beta', 'Beta', []), + ]; + await loadInitiatives(); + const el = await mount(); + for (let k = 0; k < 3; k++) { + act(() => { + (el.querySelector('#roadmap-lane-grip-5') as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }) + ); + }); + await settle(); + } + expect(receipts(el, 'Moved Beta to priority 1')).toBe(1); + const undo = [...el.querySelectorAll('button')].find( + b => b.getAttribute('aria-label') === 'Undo: Moved Beta to priority 1' + ) as HTMLButtonElement; + act(() => undo.click()); + await settle(); + // above the one it sat above before the first press: none, the bottom + // of the list (Not tracked's own rank aside), so priority 4 again + expect(writes().at(-1)).toMatchObject({ path: '/initiatives/5/move', body: { before: 9 } }); + expect(el.textContent).toContain('Put Beta back at priority 4.'); + // the words stay where the receipt was, with nothing to click in it, + // so a second click on that spot does nothing + const said = [...el.querySelectorAll('div')].find(d => + d.textContent?.startsWith('Put Beta back at priority 4') + ) as HTMLElement; + expect(said.querySelector('button, a')).toBeNull(); + const before = writes().length; + act(() => undo.click()); + await settle(); + expect(writes().length).toBe(before); + }); + + it('keeps one Undo for a run that moves two lanes, and it puts both back', async () => { + server = [...START, initiative(4, 'alpha', 'Alpha', []), initiative(5, 'beta', 'Beta', [])]; + await loadInitiatives(); + const el = await mount(); + const up = (id: number, key = 'ArrowUp') => + act(() => { + (el.querySelector(`#roadmap-lane-grip-${id}`) as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true }) + ); + }); + // Beta up two, then FixBot chat down one + up(5); + await settle(); + up(5); + await settle(); + up(2, 'ArrowDown'); + await settle(); + const undos = [...el.querySelectorAll('button')].filter(b => b.textContent === 'Undo'); + expect(undos).toHaveLength(1); + expect(undos[0].getAttribute('aria-label')).toBe( + 'Undo: Moved Beta to priority 2 and FixBot chat to priority 4' + ); + act(() => undos[0].click()); + await settle(); + await settle(); + const order = [...server].sort((a, b) => a.priority - b.priority).map(i => i.slug); + expect(order).toEqual(['site-speed', 'fixbot', 'catalog', 'alpha', 'beta', 'shelf']); + expect(el.textContent).toContain('Put Beta and FixBot chat back at their priorities.'); + }); + + it('refuses a plan dragged onto a closed initiative’s lane', async () => { + // Catalog is closed, and holds a project still worked on, so it shows + server = START.map(i => + i.slug === 'catalog' + ? { ...i, projects: [{ project: 'images', set_by: null, set_at: 50, baseline_prs: 0 }] } + : i.slug === 'site-speed' + ? { ...i, projects: i.projects.filter(f => f.project !== 'images') } + : i + ); + await loadInitiatives(); + const el = await mount(); + const grip = el.querySelector('#roadmap-grip-2') as HTMLElement; + expect(drag(grip, lane(el, 'catalog')).refused).toBe(true); + // nor onto a row in that lane + expect(drag(grip, el.querySelector('#roadmap-item-1') as HTMLElement).refused).toBe(true); + expect(writes()).toEqual([]); + }); + + it('files a plan dropped on another lane, leaves its priority, and Undo files it back', async () => { + const el = await mount(); + const grip = el.querySelector('#roadmap-grip-1') as HTMLElement; + expect(drag(grip, lane(el, 'fixbot')).refused).toBe(false); + await settle(); + expect(writes()).toEqual([ + { + method: 'PUT', + path: '/initiatives/file', + body: { project: 'images', initiative: 'fixbot', expect: 'site-speed' }, + }, + ]); + expect(el.textContent).toContain('Filed Image CDN in FixBot chat.'); + act(() => [...el.querySelectorAll('button')].find(b => b.textContent === 'Undo')?.click()); + await settle(); + expect(writes().at(-1)?.body).toMatchObject({ project: 'images', initiative: 'site-speed' }); + expect(el.textContent).toContain('Put Image CDN back in Site speed'); + }); + + it('refuses a plan with no project on a lane, and says how to link it if one lands', async () => { + const el = await mount(); + const grip = el.querySelector('#roadmap-grip-4') as HTMLElement; + // nothing lights: the lane doesn't take the drag + expect(drag(grip, lane(el, 'fixbot')).refused).toBe(true); + expect(writes()).toEqual([]); + // a row in another lane doesn't take it either + expect(drag(grip, el.querySelector('#roadmap-item-2') as HTMLElement).refused).toBe(true); + expect(writes()).toEqual([]); + }); + + it('renumbers a lane’s row moved with the arrow keys in the words its row shows', async () => { + const el = await mount(); + const grip = el.querySelector('#roadmap-grip-3') as HTMLElement; + act(() => { + grip.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + }); + await settle(); + expect(writes().at(-1)).toMatchObject({ path: '/roadmap/order', body: { ids: [3, 1, 2, 4, 5] } }); + expect(el.textContent).toContain('Moved Cache warmer to priority 1 in Site speed.'); + }); + + it('opens the lane a linked plan sits in', async () => { + localStorage.clear(); + const el = await mount({ item: 2 }); + const band = lane(el, 'fixbot').querySelector('details') as HTMLDetailsElement; + expect(band.open).toBe(true); + }); + + it('opens the plan a clash waits on when its words are clicked', async () => { + // Cache warmer starts before Image CDN, which it waits on, is done + const was = PLANS[2]; + PLANS[2] = { ...was, waits_on: [1] }; + try { + await loadRoadmap(); + const el = await mount(); + const words = el.querySelector('#roadmap-words-3') as HTMLButtonElement; + expect(words.textContent).toContain('Starts before Image CDN is done'); + went.length = 0; + act(() => words.click()); + expect(went.at(-1)).toMatchObject({ item: 1 }); + } finally { + PLANS[2] = was; + } + }); + + it('links the initiative’s page with a real address', async () => { + const el = await mount(); + const link = [...lane(el, 'site-speed').querySelectorAll('a')].find( + a => a.textContent === 'Initiative page' + ); + expect(link?.getAttribute('href')).toContain('initiative=site-speed'); + }); +}); + +/** A mouse's press at a point onto `el`, whatever sits there now. */ +const pressAt = (el: Element, x: number, y: number) => + act(() => { + const at = { bubbles: true, cancelable: true, clientX: x, clientY: y }; + for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) + el.dispatchEvent(new MouseEvent(type, at)); + el.dispatchEvent(new MouseEvent('click', { ...at, detail: 1 })); + }); +const pointerTo = (x: number, y: number) => + act(() => { + document.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y })); + }); + +describe('a bar let go over another lane', () => { + /** Drag Image CDN's bar `dx` px and let go over FixBot chat's lane: the + * preview's left edge while it moves, as a percentage of the track. */ + async function dragOver(dx: number) { + const el = await mount(); + const bar = el.querySelector('#roadmap-bar-1') as HTMLElement; + const other = lane(el, 'fixbot'); + const rect = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockReturnValue({ left: 0, right: 700, width: 700, top: 0, bottom: 20, height: 20 } as DOMRect); + // the pointer ends over FixBot chat's lane + document.elementFromPoint = () => other; + const left = () => parseFloat(bar.style.left); + const before = left(); + let during = before; + try { + const at = (x: number) => ({ bubbles: true, cancelable: true, clientX: x, clientY: 300 }); + act(() => { + bar.dispatchEvent(new MouseEvent('pointerdown', at(100))); + }); + act(() => { + window.dispatchEvent(new MouseEvent('pointermove', at(100 + dx))); + }); + during = left(); + act(() => { + window.dispatchEvent(new MouseEvent('pointerup', at(100 + dx))); + }); + await settle(); + } finally { + rect.mockRestore(); + delete (document as Partial).elementFromPoint; + } + return { el, before, during }; + } + + it('moves only its dates, in its own lane, and says the grip files it', async () => { + const { el, before, during } = await dragOver(60); + // the preview followed the pointer: the drag's math ran + expect(during).toBeGreaterThan(before); + expect(writes().map(w => [w.method, w.path])).toEqual([['PATCH', '/roadmap/1']]); + expect(el.textContent).toContain( + ', in its own lane. To put it in another, drag its grip, left of its name' + ); + expect(lane(el, 'site-speed').querySelector('#roadmap-bar-1')).not.toBeNull(); + }); + + it('moves nothing when its dates didn’t change', async () => { + const { el, before, during } = await dragOver(6); + expect(during).toBe(before); + expect(writes()).toEqual([]); + expect(el.textContent).toContain( + 'Nothing moved: a bar moves its dates. To put it in another lane, drag its grip, left of its name.' + ); + }); +}); + +describe('after a click on the Roadmap', () => { + /** Lay the page out as a browser would, as far as these tests look: a + * sticky header 120 px tall, every receipt at `receiptTop`, its row's + * line 40 px above it, and nothing else with a box. */ + function layout(receiptTop: () => number) { + const header = document.createElement('div'); + header.className = 'sticky'; + header.style.position = 'sticky'; + header.style.top = '0px'; + document.body.prepend(header); + const box = (top: number, height: number) => + ({ top, bottom: top + height, left: 0, right: 800, width: 800, height, x: 0, y: top }) as DOMRect; + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); + rect.mockImplementation(function (this: HTMLElement) { + if (this === header) return box(0, 120); + if (this.closest('[data-receipt]')) return box(receiptTop(), 30); + if (this.parentElement?.matches('[data-roadmap-row]') && this === this.parentElement.firstElementChild) + return box(receiptTop() - 40, 40); + return { ...box(0, 0), width: 0, right: 0 } as DOMRect; + }); + const scrolled: number[] = []; + const spy = vi.spyOn(window, 'scrollBy').mockImplementation(((_x: number, y: number) => { + scrolled.push(y); + }) as typeof window.scrollBy); + return { + scrolled, + restore: () => { + // HTMLElement inherits it from Element: no own one may be left + // behind, or it shadows a later spy on Element's + rect.mockRestore(); + delete (HTMLElement.prototype as Partial).getBoundingClientRect; + spy.mockRestore(); + header.remove(); + }, + }; + } + + const fileFromDetails = async (el: HTMLElement, plan: string, to: string) => { + const details = el.querySelector(`[aria-label="${plan}: its plan"]`) as HTMLElement; + act(() => details.querySelector('[aria-label^="File elsewhere"]')?.click()); + const select = details.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = to; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + act(() => [...details.querySelectorAll('button')].find(b => b.textContent === 'File')?.click()); + await settle(); + await settle(); + }; + + it('says a details filing into Not tracked in Decide’s words, its Undo named to match', async () => { + const el = await mount({ item: 2 }); + await fileFromDetails(el, 'Answer search', 'shelf'); + expect(el.textContent).toContain( + 'Marked Not tracked, so it counts nowhere. Decide asks again if it grows.' + ); + expect(el.textContent).not.toContain('Filed Answer search in Not tracked'); + expect( + el.querySelector( + '[aria-label="Undo: Marked Not tracked, so it counts nowhere. Decide asks again if it grows"]' + ) + ).not.toBeNull(); + }); + + it('shows a details filing’s receipt below the sticky header when its lane is above', async () => { + // the receipt lands in Site speed's lane, above, scrolled under the header + const page = layout(() => 50); + try { + const el = await mount({ item: 2 }); + await fileFromDetails(el, 'Answer search', 'site-speed'); + expect((document.activeElement as HTMLElement).textContent).toBe( + 'Filed Answer search in Site speed.' + ); + // brought down to just below the header with its row's line, and + // no further + expect(page.scrolled.at(-1)).toBe(50 - 40 - 120 - 8); + } finally { + page.restore(); + } + }); + + it('shows a details filing’s receipt above the window’s bottom when its lane is below', async () => { + const page = layout(() => innerHeight + 200); + try { + const el = await mount({ item: 3 }); + await fileFromDetails(el, 'Cache warmer', 'fixbot'); + expect((document.activeElement as HTMLElement).textContent).toBe( + 'Filed Cache warmer in FixBot chat.' + ); + expect(page.scrolled.at(-1)).toBe(200 + 30 + 8); + } finally { + page.restore(); + } + }); + + it('holds a drop’s point: the Undo that slid there takes no press until the pointer moves', async () => { + const el = await mount(); + drag(el.querySelector('#roadmap-grip-1') as HTMLElement, lane(el, 'fixbot'), { + clientX: 240, + clientY: 400, + }); + await settle(); + expect(el.textContent).toContain('Filed Image CDN in FixBot chat.'); + const undo = [...el.querySelectorAll('button')].find( + b => b.textContent === 'Undo' + ) as HTMLButtonElement; + const sent = writes().length; + // a second press where the drop let go, with the pointer still + pressAt(undo, 240, 400); + await settle(); + expect(writes()).toHaveLength(sent); + pointerTo(400, 600); + pressAt(undo, 240, 400); + await settle(); + expect(writes()).toHaveLength(sent + 1); + expect(el.textContent).toContain('Put Image CDN back in Site speed'); + }); + + it('keeps a second Undo on one receipt as still as the first, and leaves no style on the page', async () => { + const el = await mount(); + const grip = () => el.querySelector('#roadmap-lane-grip-2') as HTMLElement; + for (const k of [1, 2]) { + act(() => { + grip().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + }); + await settle(); + const undo = [...el.querySelectorAll('button')].find( + b => b.getAttribute('aria-label') === 'Undo: Moved FixBot chat to priority 1' + ) as HTMLButtonElement; + // a pointer's Undo, each time somewhere new + pressAt(undo, 100 * k, 100 * k); + await settle(); + expect(el.textContent).toContain('Put FixBot chat back at priority 2'); + const receipt = el.querySelector('[data-receipt="initiative:2"]') as HTMLElement; + expect(receipt.style.marginTop).toBe(''); + expect(receipt.style.transition).toBe(''); + expect(document.body.getAttribute('style')).toBeNull(); + pointerTo(900, 900); + } + expect(writes().filter(c => c.path === '/initiatives/2/move')).toHaveLength(4); + }); +}); + +describe('a write on the Roadmap that fails', () => { + const undoOf = (el: HTMLElement) => + [...el.querySelectorAll('button')].find(b => b.textContent === 'Undo') as HTMLButtonElement; + + it('says the server’s reason when a drop’s Undo fails, and keeps a way to try again', async () => { + const el = await mount(); + drag(el.querySelector('#roadmap-grip-1') as HTMLElement, lane(el, 'fixbot')); + await settle(); + refuse = { undo: 'someone filed it in Catalog since' }; + act(() => undoOf(el).click()); + await settle(); + expect(el.textContent).toContain('someone filed it in Catalog since.'); + // named by the receipt it takes back, so two failed ones read apart + const again = el.querySelector( + '[aria-label="Try the Undo again: Filed Image CDN in FixBot chat"]' + ); + expect(again).not.toBeNull(); + act(() => again?.click()); + await settle(); + expect(writes().at(-1)?.body).toMatchObject({ project: 'images', initiative: 'site-speed' }); + expect(el.textContent).toContain('Put Image CDN back in Site speed'); + }); + + it('says the server’s reason when a lane move’s Undo fails, and keeps a way to try again', async () => { + const el = await mount(); + act(() => { + (el.querySelector('#roadmap-lane-grip-2') as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }) + ); + }); + await settle(); + refuse = { move: 'the database is down' }; + act(() => undoOf(el).click()); + await settle(); + expect(el.textContent).toContain('the database is down.'); + act(() => + el + .querySelector( + '[aria-label="Try the Undo again: Moved FixBot chat to priority 1"]' + ) + ?.click() + ); + await settle(); + expect(el.textContent).toContain('Put FixBot chat back at priority 2'); + }); + + it('puts the focus on why, when a filing from a plan’s details fails', async () => { + const el = await mount({ item: 2 }); + const details = el.querySelector('[aria-label="Answer search: its plan"]') as HTMLElement; + act(() => details.querySelector('[aria-label^="File elsewhere"]')?.click()); + const select = details.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'site-speed'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + refuse = { file: 'its home changed; here it is now' }; + act(() => [...details.querySelectorAll('button')].find(b => b.textContent === 'File')?.click()); + await settle(); + await settle(); + expect((document.activeElement as HTMLElement).textContent).toContain( + 'its home changed; here it is now.' + ); + }); +}); diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx new file mode 100644 index 00000000..d97dbf19 --- /dev/null +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -0,0 +1,661 @@ +/** + * The Roadmap's initiative lanes, out of Roadmap.tsx: the lane fold, the + * receipt line every Roadmap row uses, and the lanes by initiative with their + * drag to file and rank. + */ +import type { Dispatch, DragEvent, MutableRefObject, ReactNode, SetStateAction } from 'react'; +import { flushSync } from 'react-dom'; +import { Check, GripVertical } from 'lucide-react'; +import type { DecideRow } from '../../../../shared/model/decide'; +import type { Initiative } from '../../../../shared/model/initiatives'; +import { isUnderWay, type RoadmapItem } from '../../../../shared/model/roadmap'; +import { FactLink, QuietButton } from '../../components/bits'; +import { Icon } from '../../components/Icon'; +import { guardAt, settle } from '../../components/settle'; +import { Fold, openFold, useFoldState } from '../../components/Lane'; +import { askedInitiative, askOf } from '../../model/decideCalls'; +import { + bandGloss as initiativeGloss, + NO_PROJECT, + spanWords, + UNSORTED, +} from '../../model/initiativeBoard'; +import { byRank, fileProject, moveInitiative, readInitiatives } from '../../model/initiativesData'; +import type { PortfolioItem } from '../../model/portfolio'; +import { + andList, + MARKED_NOT_TRACKED, + NO_PROJECT_YET, + UNSORTED_WORD, + undoName, +} from '../../model/words'; +import { DecideCall } from './Decide'; +import type { Navigate } from './parts'; +import { inInk, SaidWords, type Said } from './roadmapHealth'; + +/** The board's address with `patch` applied, for a real link: the hash + * the board reads its view from (app.tsx readHash), null clearing a key. */ +function hashWith(patch: Record): string { + const p = new URLSearchParams(location.hash.slice(1)); + for (const [k, v] of Object.entries(patch)) { + if (v == null) p.delete(k); + else p.set(k, v); + } + return `#${p}`; +} + +/** A run of rank moves, on one lane or several (arrow presses, or drag + * after drag): the whole order when it began, which its one Undo puts + * back, and each lane it moved, with the priority it had then. */ +export interface LaneRun { + key: typeof LANE_RUN; + order: number[]; + moved: Map; +} + +/** The run key every lane move shares, so a move of another lane goes on + * the same run instead of dropping the first one's Undo. */ +export const LANE_RUN = 'initiatives'; + +/** Put the initiatives back in `order`, from the bottom up: each one moved + * just above the one after it, when it isn't there now. */ +async function restoreOrder(order: readonly number[]): Promise { + for (let k = order.length - 1; k >= 0; k--) { + const now = byRank(readInitiativesNow()).map(i => i.id); + const after = order[k + 1] ?? null; + if ((now[now.indexOf(order[k]) + 1] ?? null) === after) continue; + const moved = await moveInitiative(order[k], after); + if (moved !== true) return moved; + } + return true; +} + +/** A run of moves on one plan (arrow presses, or drag after drag): where + * it was (its span, or the whole order) and the plan as the run found it. */ +export interface PlanRun { + key: string; + from: { start: string; weeks: number } | number[]; + team: string | null; + was: RoadmapItem; +} + +/** Keep the receipt with this key where it is on screen through `write` + * (settle): an Undo that sends its row or lane back up or down. */ +export function keepInPlace(key: string, write: (held?: number) => void): void { + settle(() => document.querySelector(`[data-receipt="${key}"]`), write); +} + +/** The initiatives as the board has them, just after a write. */ +const readInitiativesNow = () => readInitiatives().initiatives ?? []; + +/** the lane, by initiative, of plans with no project */ +export const NO_PROJECT_LANE = NO_PROJECT; + +/** The quiet line under a row that says what a click just saved, with the + * way back (Decide's receipt): where the click was, not far away. */ +export interface Receipt { + /** the row it sits under: `plan:`, `project:`, or `added` */ + key: string; + words: string; + failed?: boolean; + undo?: () => void; + /** a failed Undo's: the words of the receipt it takes back */ + undoes?: string; + /** a plan it made, to open */ + open?: number; + /** the project it planned, kept in its place in the list of no plan */ + project?: PortfolioItem; + /** the plan it removed, kept in its place in the list of plans */ + gone?: RoadmapItem; + /** the lane the planned or parked project was in, where it stays */ + lane?: Standing; + /** the least height it keeps: an Undo's words hold the receipt's */ + held?: number; +} + +/** Where a project with no plan sits: Decide asks it for a plan, asks it + * another call (most often whether to park it), or asks nothing. */ +export type Standing = 'plan' | 'call' | 'none'; + +export function ReceiptLine({ receipt, onOpen }: { receipt: Receipt; onOpen: (id: number) => void }) { + const open = receipt.open; + return ( +
    + {/* the focus can land on its words (a filing from the plan's + details), so an extra Enter there never undoes it */} + + {!receipt.failed && } + {receipt.words.replace(/\.$/, '')}. + + {open != null && onOpen(open)}>Open its plan} + {/* a failed Undo keeps its button, to try again */} + {receipt.undo && ( + + {receipt.failed ? 'Try again' : 'Undo'} + + )} +
    + ); +} + +/** + * A lane of the timeline, the board's own fold: its name and how many rows + * it holds, and for a team, its load in words in the band. One call owed, + * one amber mark: while the lane's open and shows where its people run + * out, that line says it, so the band's words stay ink. + */ +export function TimelineLane({ + id, + label, + count, + gloss, + load, + lined, + defaultOpen = true, + showCount = true, + phrase, + gutter, + keepEmpty = false, + children, +}: { + /** drawn even with no rows (an open initiative, or one Decide asks about) */ + keepEmpty?: boolean; + /** an initiative's dates and counts, in ink (initiativeBoard.ts spanWords) */ + phrase?: string; + /** the grip that ranks an initiative, outside the band's click target */ + gutter?: ReactNode; + id: string; + label: string; + count: number; + /** false when the label says its count in words */ + showCount?: boolean; + gloss: string; + /** a team's load in words; null for none */ + load: Said | null; + /** its capacity line is drawn in it */ + lined: boolean; + /** closed until someone opens it: a ledger of what needs nothing */ + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open] = useFoldState(id, defaultOpen); + const words = load && lined && open ? inInk(load) : load; + return ( + + + + ) : ( + phrase && {phrase} + ) + } + > + {children} + + ); +} + +/** What the initiative lanes read of the Roadmap: its state, its plans and + * projects as shown, and the ways it says, fails, opens and drags. */ +export interface LaneContext { + asksPlan: ReadonlySet; + bySlug: ReadonlyMap; + callsHere: { all: readonly DecideRow[] }; + counted: (list: readonly RoadmapItem[]) => number; + dragging: number | null; + draggingLane: number | null; + everyPlan: readonly RoadmapItem[] | null; + everything: boolean; + gone: RoadmapItem | null; + homeOf: (slug: string | null) => Initiative | undefined; + inFlightRows: (list: PortfolioItem[]) => ReactNode; + initiatives: readonly Initiative[] | null; + /** the plan whose details are open (nav.item) */ + item: number | null; + laneId: (key: string) => string; + laneKey: (slug: string | null) => string; + narrowed: boolean; + navigate: Navigate; + openFromReceipt: (id: number) => void; + ordered: readonly RoadmapItem[]; + overLane: string | null; + person: (login: string) => void; + planCalls: ReadonlyMap; + planRows: ( + list: RoadmapItem[], + visible?: RoadmapItem[], + options?: { ranked?: boolean; placed?: boolean } + ) => ReactNode; + projectCalls: ReadonlyMap; + q: string; + receipt: Receipt | null; + refocus: MutableRefObject; + /** the run of moves Undo goes back to the start of: a plan's or a + * lane's, told apart by key ("plan:" or "initiative:") */ + run: MutableRefObject; + say: (next: Receipt | null, words?: string) => void; + /** an Undo's words, held in its receipt's place with nothing to click */ + said: (key: string, words: string, held?: number) => void; + setAnnounced: Dispatch>; + setDragging: Dispatch>; + setDraggingLane: Dispatch>; + setDropTarget: Dispatch>; + setOverLane: Dispatch>; + shownPlans: RoadmapItem[]; + shownUnplanned: PortfolioItem[]; +} + +/** + * The Roadmap grouped by initiative: one lane per initiative in its rank, + * then Unsorted and No project yet, each band with its grip (drag or the + * arrow keys rank it) and its drop (a plan dropped there files its project + * there). fileByDrag also serves a plan dropped on a row of another lane. + */ +export function initiativeLanes(c: LaneContext) { + const { + asksPlan, + bySlug, + callsHere, + counted, + dragging, + draggingLane, + everyPlan, + everything, + gone, + homeOf, + inFlightRows, + initiatives, + item: openItem, + laneId, + laneKey, + narrowed, + navigate, + openFromReceipt, + ordered, + overLane, + person, + planCalls, + planRows, + projectCalls, + q, + receipt, + refocus, + run, + say, + said, + setAnnounced, + setDragging, + setDraggingLane, + setDropTarget, + setOverLane, + shownPlans, + shownUnplanned, + } = c; + // a plan dropped in another initiative's lane: its project filed there, + // its priority left alone, the lane it went to opened on its receipt + const fileByDrag = (item: RoadmapItem, to: string, { fromDetails = false } = {}) => { + const key = `plan:${item.id}`; + // from the details' File elsewhere (the drop's keyboard twin), the + // focus goes to the receipt's words; a drop leaves it on the grip + const focusAfter = fromDetails ? [`receipt-words-${key}`] : [`roadmap-grip-${item.id}`]; + const slug = item.project; + // the lanes refuse its drag (laneDrop), so this is the fallback: said + // where it was dropped, with the way to the question that links it + if (!slug) { + return say({ + key, + words: `${item.name} has no project yet, so it can’t be filed. Open its plan to link its project`, + failed: true, + open: item.id, + }); + } + if (to === NO_PROJECT_LANE) return; + const fromHome = homeOf(slug); + const from = fromHome?.slug ?? null; + const target = to === UNSORTED ? null : to; + const into = target ? byRank(initiatives ?? []).find(i => i.slug === target) : undefined; + const toName = target ? into?.name ?? target : UNSORTED_WORD; + // a closed lane refuses the drag too (laneDrop); the server would 409 + if (into && into.status !== 'open') { + return say({ + key, + words: `${into.name} is closed, so nothing is filed in it. Reopen it first`, + failed: true, + }); + } + run.current = null; + void fileProject(slug, target, { expect: from }).then(r => { + // the focus goes to the receipt either way: a failure's words say + // why, where the chooser it came from was + refocus.current = focusAfter; + if ('error' in r) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); + const words = + into?.kind === 'untracked' ? MARKED_NOT_TRACKED : `Filed ${item.name} in ${toName}`; + const undo = () => { + refocus.current = focusAfter; + // said at once, as the board shows it back at once + keepInPlace(key, held => { + openFold(laneId(r.before.initiative ?? UNSORTED)); + said(key, `Put ${item.name} back in ${fromHome?.name ?? UNSORTED_WORD}`, held); + void fileProject(slug, r.before.initiative, { + expect: target, + set_at: r.before.set_at, + baseline_prs: r.before.baseline_prs, + undo: true, + }).then(back => { + if (!('error' in back)) return; + // the server's reason, with the Undo kept to try again + refocus.current = focusAfter; + say({ + key, + words: back.error.replace(/\.$/, ''), + failed: true, + undo, + undoes: words, + }); + }); + }); + }; + // one commit, so the focus finds the receipt in the lane it opened + flushSync(() => { + openFold(laneId(to)); + say({ key, words, undo }); + }); + }); + }; + // an initiative moved just above `before` (null: the bottom), with one + // Undo back to the order before the run of moves began (arrow presses, + // or drag after drag, of one lane or several), as a plan row's run does + const rankMove = (moving: Initiative, before: number | null) => { + const all = byRank(initiatives ?? []); + const above = all[all.findIndex(i => i.id === moving.id) + 1]?.id ?? null; + if (above === before) return; + const key = `initiative:${moving.id}`; + const going = run.current; + const start: LaneRun = + going?.key === LANE_RUN && 'order' in going + ? going + : { key: LANE_RUN, order: all.map(i => i.id), moved: new Map() }; + if (!start.moved.has(moving.id)) + start.moved.set(moving.id, { name: moving.name, wasAt: rankOf(moving) }); + run.current = start; + refocus.current = [`roadmap-lane-grip-${moving.id}`]; + void moveInitiative(moving.id, before).then(r => { + if (r !== true) { + run.current = null; + return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); + } + const ranked = byRank(readInitiativesNow()).filter( + i => i.status === 'open' && i.kind !== 'untracked' + ); + const lanes = [...start.moved]; + const now = lanes.map( + ([id, m]) => `${m.name} to priority ${ranked.findIndex(i => i.id === id) + 1}` + ); + const back = + lanes.length === 1 + ? `Put ${moving.name} back at priority ${lanes[0][1].wasAt}` + : `Put ${andList(lanes.map(([, m]) => m.name))} back at their priorities`; + const undo = () => { + run.current = null; + refocus.current = [`roadmap-lane-grip-${moving.id}`]; + // said at once, as the board shows it back at once + keepInPlace(key, held => { + said(key, back, held); + void restoreOrder(start.order).then(done => { + // the server's reason, with the Undo kept to try again + if (done !== true) + say({ + key, + words: done.error.replace(/\.$/, ''), + failed: true, + undo, + undoes: `Moved ${andList(now)}`, + }); + }); + }); + }; + say({ key, words: `Moved ${andList(now)}`, undo }); + }); + }; + // ----- by initiative: one lane per initiative, in its own rank ----- + // A lane holds its plans under way and its live projects with no plan; + // its finished, dropped and parked plans live on its page, unless Decide + // asks about one, which always shows (DESIGN.md, the lanes amendment). + const allRanked = byRank(initiatives ?? []); + const laneInitiatives = allRanked.filter(i => i.kind !== 'untracked'); + const rankOf = (i: Initiative) => + laneInitiatives.filter(x => x.status === 'open').findIndex(x => x.id === i.id) + 1; + // and the plan whose details are open or whose receipt is showing, so a + // plan saved Done, Parked or Dropped keeps its row, details and Undo + const heldPlan = (i: RoadmapItem) => + i === gone || + i.id === openItem || + receipt?.key === `plan:${i.id}` || + isUnderWay(i.status) || + planCalls.has(i.id); + // the call Decide asks about an initiative itself, with its strip + const initiativeRowOf = (id: number) => + callsHere.all.find(r => !r.slug && askedInitiative(r) === id); + const laneDrop = (key: string, initiative?: Initiative) => ({ + 'data-lane-id': laneId(key), + onDragOver: (e: DragEvent) => { + if (dragging == null && draggingLane == null) return; + if (draggingLane != null && !initiative) return; + // a plan files through its project, never into the no-project + // lane; one with no project isn't taken at all, so nothing lights + if (key === NO_PROJECT_LANE) return; + if (draggingLane == null && !ordered.find(i => i.id === dragging)?.project) return; + // nor into a closed initiative, which the choosers never offer + if (draggingLane == null && initiative && initiative.status !== 'open') return; + e.preventDefault(); + setOverLane(key); + }, + onDragLeave: () => setOverLane(o => (o === key ? null : o)), + onDrop: (e: DragEvent) => { + e.preventDefault(); + // a drop is a press: what it moves under the pointer can't be + // clicked until the pointer moves + guardAt(e.clientX, e.clientY); + setOverLane(null); + if (draggingLane != null) { + const moving = allRanked.find(i => i.id === draggingLane); + setDraggingLane(null); + if (moving && initiative && moving.id !== initiative.id) + rankMove(moving, initiative.id); + return; + } + const dropped = ordered.find(i => i.id === dragging); + setDragging(null); + setDropTarget(null); + if (dropped && laneKey(dropped.project) !== key) fileByDrag(dropped, key); + }, + }); + // the grip left of an initiative's band: drag it onto another band, or + // press the up and down arrows, to change its rank + const laneGrip = (i: Initiative, ranked: Initiative[]) => { + const at = ranked.findIndex(x => x.id === i.id); + return ( + { + setDraggingLane(i.id); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', `initiative:${i.id}`); + }} + onDragEnd={() => { + setDraggingLane(null); + setOverLane(null); + }} + onKeyDown={e => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + const up = e.key === 'ArrowUp'; + const next = ranked[at + (up ? -1 : 1)]; + if (!next) return setAnnounced(`${i.name} is already ${up ? 'first' : 'last'}`); + // above the one before, or above the one after the next + const before = up + ? next.id + : allRanked[allRanked.findIndex(x => x.id === next.id) + 1]?.id ?? null; + rankMove(i, before); + }} + className="hit flex-none cursor-grab rounded p-0.5 text-ink-3 hover:text-ink focus-visible:text-brand active:cursor-grabbing" + > + + + ); + }; + const initiativeBody = (() => { + const ranked = laneInitiatives.filter(i => i.status === 'open'); + const keys = [...laneInitiatives.map(i => i.slug), UNSORTED, NO_PROJECT_LANE]; + return keys.map(key => { + const initiative = laneInitiatives.find(i => i.slug === key); + const planned = shownPlans.filter(i => heldPlan(i) && laneKey(i.project) === key); + const loose = everything ? shownUnplanned.filter(p => laneKey(p.slug) === key) : []; + // its own question shows while the find names it, or nothing narrows + const matched = + !narrowed || + (!!q && + !!initiative && + [initiative.name, initiative.owner ?? ''].some(s => s.toLowerCase().includes(q))); + const asked = initiative && matched ? initiativeRowOf(initiative.id) : undefined; + // a closed initiative, or Unsorted, shows only while it holds a row + if (!planned.length && !loose.length && !(asked && asked.reasons.length)) { + if (!initiative || initiative.status !== 'open' || narrowed) return null; + } + const id = laneId(key); + const projects = initiative?.projects.map(f => f.project) ?? []; + const working = projects.filter(slug => bySlug.get(slug)?.status === 'live').length; + // it rests closed unless it holds a call Decide asks + const calls = + !!asked?.reasons.length || + planned.some(i => planCalls.has(i.id)) || + loose.some(p => projectCalls.has(p.slug) || asksPlan.has(p.slug)); + const rank = initiative && initiative.status === 'open' ? rankOf(initiative) : 0; + const label = initiative + ? `${rank ? `${rank} ` : ''}${initiative.name}` + : key === UNSORTED + ? UNSORTED_WORD + : NO_PROJECT_YET; + return ( +
    + + ) + } + // an open initiative keeps its place in the list of + // contents with nothing in it + keepEmpty={!!initiative} + defaultOpen={calls || narrowed} + > + {initiative && ( + + )} + {initiative && asked && ( + // a row like its plans': the question in their amber + // words, its answers outlined at the row's end +
    + {asked.reasons.length > 0 && ( + {askOf(asked.reasons[0]).question} + )} + +
    + )} + {planRows(planned, planned, { placed: true })} + {inFlightRows(loose)} + + {/* a rank move's receipt, once, under the lane whether it's + open or closed */} + {initiative && receipt?.key === `initiative:${initiative.id}` && ( + + )} +
    + ); + }); + })(); + return { fileByDrag, laneInitiatives, rankOf, initiativeBody }; +} diff --git a/frontend-v2/src/views/projects/RoadmapWindow.test.tsx b/frontend-v2/src/views/projects/RoadmapWindow.test.tsx new file mode 100644 index 00000000..0d988702 --- /dev/null +++ b/frontend-v2/src/views/projects/RoadmapWindow.test.tsx @@ -0,0 +1,426 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DecideRow } from '../../../../shared/model/decide'; +import type { Initiative } from '../../../../shared/model/initiatives'; +import type { RoadmapItem } from '../../../../shared/model/roadmap'; +import { loadInitiatives, useInitiativesValue } from '../../model/initiativesData'; +import { loadRoadmap } from '../../model/roadmapData'; +import type { ProjectsNav } from './parts'; +import { Roadmap } from './Roadmap'; +import { clearHolds, pressedBy } from '../../components/settle'; + +// the roadmap's window as the toolbar, the edge chip and the "Done before" +// fold move and fill it, on Oct 13, 2026 + +pressedBy.person = () => true; +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const plan = (id: number, over: Partial): RoadmapItem => ({ + id, + name: `Plan ${id}`, + project: null, + team: null, + lead: null, + status: 'active', + origin: null, + start: '2026-10-05', + weeks: 8, + end_kind: 'soft', + done_when: '', + priority: id, + notes: '', + waits_on: [], + updated_by: null, + updated_at: 1, + created_at: 1, + update: null, + ...over, +}); + +const PLANS = [ + plan(1, { name: 'Live work' }), + // done a year ago: the fold's + plan(2, { name: 'Old export', status: 'done', start: '2025-10-06', weeks: 4 }), + // done a year ago, and Decide asks to reopen it: never folded + plan(3, { name: 'Akeneo 4', project: 'akeneo', status: 'done', start: '2025-11-03', weeks: 6 }), + // planned for last November and never started: a row with an edge chip + plan(4, { name: 'Stale plan', status: 'planned', start: '2025-11-03', weeks: 3 }), + // done, its one week the window's first: Jun 29 to Jul 5 + plan(5, { name: 'Edge done', status: 'done', start: '2026-06-29', weeks: 1 }), + // under way, with no project: the No project yet lane + plan(6, { name: 'Loose work', status: 'active', start: '2026-10-05', weeks: 2 }), + // under way in a Not tracked project: the Not tracked lane + plan(7, { + name: 'Ops runbooks', + project: 'ops', + status: 'active', + start: '2026-10-05', + weeks: 2, + }), +]; +const REOPEN: DecideRow = { + slug: 'akeneo', + item: PLANS[2], + reasons: [{ kind: 'reopened', open: 1, late: 1, as: 'done', by: 'issue' }], +}; +const INITIATIVES: Initiative[] = [ + { + id: 1, + slug: 'catalog', + name: 'Catalog', + kind: 'initiative', + goal: '', + owner: null, + priority: 1, + status: 'open', + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: 1, + projects: [{ project: 'akeneo', set_by: null, set_at: 50, baseline_prs: 0 }], + }, + { + id: 2, + slug: 'not-tracked', + name: 'Not tracked', + kind: 'untracked', + goal: '', + owner: null, + priority: 2, + status: 'open', + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: 1, + projects: [{ project: 'ops', set_by: null, set_at: 50, baseline_prs: 0 }], + }, +]; + +const asked: string[] = []; +beforeAll(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 9, 13, 12)); + vi.stubGlobal( + 'ResizeObserver', + class { + observe = () => undefined; + disconnect = () => undefined; + } + ); + window.matchMedia = (() => ({ matches: true })) as unknown as typeof window.matchMedia; + Element.prototype.scrollIntoView = () => undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async (path: string, init?: RequestInit) => { + asked.push(path); + const patch = /^\/roadmap\/(\d+)$/.exec(path); + if (patch && init?.method === 'PATCH') { + const at = PLANS.findIndex(i => i.id === Number(patch[1])); + PLANS[at] = { ...PLANS[at], ...JSON.parse(String(init.body)), updated_at: 2 }; + return { + status: 200, + ok: true, + redirected: false, + json: async () => ({ item: PLANS[at] }), + } as Response; + } + const reply = (json: unknown, status = 200) => + ({ status, ok: status < 400, redirected: false, json: async () => json } as Response); + if (path === '/roadmap') return reply({ items: PLANS }); + if (path === '/initiatives') return reply({ initiatives: INITIATIVES }); + return reply({}, 404); + }) + ); +}); +afterAll(() => vi.useRealTimers()); + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +beforeEach(async () => { + localStorage.clear(); + await loadInitiatives(); + await loadRoadmap(); + asked.length = 0; + went.length = 0; +}); +afterEach(() => { + clearHolds(); + act(() => root?.unmount()); + root = null; + host?.remove(); + host = null; +}); + +const NAV = { + view: 'roadmap', + project: null, + initiative: null, + within: null, + range: '30d', + status: 'live', + group: 'none', + sort: 'plan', + find: '', + scale: 'quarter', + item: null, + show: 'all', + week: null, + zoom: null, + window: null, + team: null, + origin: null, + by: 'team', + kind: 'all', + who: null, + only: null, + psort: '', +} as ProjectsNav; + +const settle = () => act(() => new Promise(resolve => setTimeout(resolve, 0))); +const went: Partial[] = []; + +function Live({ nav: start }: { nav: ProjectsNav }) { + const { initiatives } = useInitiativesValue(); + const [nav, setNav] = useState(start); + return ( + { + went.push(to); + setNav(n => ({ ...n, ...to })); + }} + decisions={[REOPEN]} + /> + ); +} + +async function mount(nav: Partial = {}) { + host = document.createElement('div'); + document.body.append(host); + const r = createRoot(host); + root = r; + await act(async () => r.render()); + await settle(); + return host; +} + +// the picker's panel is drawn at the end of the page, so look there too +const buttons = (el: HTMLElement) => [ + ...el.querySelectorAll('button'), + ...document.body.querySelectorAll('[role="dialog"] button'), +]; +const button = (el: HTMLElement, name: string | RegExp) => + buttons(el).find(b => + [b.getAttribute('aria-label') ?? '', b.textContent ?? ''].some(t => + typeof name === 'string' ? t === name : name.test(t) + ) + ); +const press = async (b: HTMLElement | undefined) => { + expect(b).toBeTruthy(); + act(() => b?.click()); + await settle(); +}; +const text = (el: HTMLElement) => el.textContent ?? ''; +const last = () => went.at(-1); + +describe('the "Done before" fold', () => { + it('holds old done plans, closed, but never one Decide asks about', async () => { + const el = await mount(); + const fold = button(el, /^Done before Jul 2026/); + expect(fold?.textContent).toMatch(/1$/); + const details = fold?.closest('details'); + expect(details?.open).toBe(false); + expect(el.querySelector('#roadmap-item-2')?.closest('details')).toBe(details); + // asked to reopen: in the rows, with its call, not the fold + const akeneo = el.querySelector('#roadmap-item-3'); + expect(akeneo).toBeTruthy(); + expect(akeneo?.closest('details')).not.toBe(details); + }); + + it('keeps a plan Decide asks about on the initiative lanes too', async () => { + const el = await mount({ group: 'initiative' }); + expect(text(el)).toContain('Akeneo 4'); + expect(button(el, /^Done before/)).toBeUndefined(); + }); + + it('goes while a week picked on the load chart counts none of it', async () => { + const el = await mount({ week: '2026-10-12' }); + expect(button(el, /^Done before/)).toBeUndefined(); + }); +}); + +describe('the toolbar', () => { + it('names its window and picks a preset', async () => { + const el = await mount(); + const trigger = button(el, 'Roadmap window: Q3 2026 to Q2 2027'); + await press(trigger); + await press(button(el, 'Year to date')); + expect(last()).toMatchObject({ window: 'ytd' }); + expect(button(el, 'Roadmap window: Year to date')).toBeTruthy(); + }); + + it('steps by the window’s own length, and Today comes back', async () => { + const el = await mount(); + expect(button(el, 'Today')).toBeUndefined(); + await press(button(el, 'Back 12 months')); + expect(last()).toMatchObject({ window: '2025-Q3..2026-Q2' }); + await press(button(el, 'Back 12 months')); + expect(last()).toMatchObject({ window: '2024-Q3..2025-Q2' }); + await press(button(el, 'Today')); + expect(last()).toMatchObject({ window: null }); + }); + + it('shows Today on a preset only while today is outside it', async () => { + expect(button(await mount({ window: 'quarter' }), 'Today')).toBeUndefined(); + act(() => root?.unmount()); + const el = await mount({ window: 'quarter' }); + await press(button(el, 'On 3 months')); + expect(button(el, 'Today')).toBeTruthy(); + }); + + it('keeps the window on the lit switch, converts it on the other, and brings it back from now, next, later', async () => { + const el = await mount({ window: '12m' }); + const option = (name: string) => + [...el.querySelectorAll('[role="radio"]')].find( + b => b.textContent === name + ); + expect(option('Months')?.getAttribute('aria-checked')).toBe('true'); + await press(option('Months')); + expect(went.every(to => to.window !== null)).toBe(true); + await press(option('Quarters')); + expect(last()).toMatchObject({ scale: 'quarter', window: '2025-Q4..2026-Q4' }); + await press(option('Months')); + expect(last()).toMatchObject({ scale: 'month', window: '2025-10..2026-12' }); + await press(option('Now, next, later')); + expect(last()).toMatchObject({ scale: 'now' }); + expect(last()?.window).toBeUndefined(); + await press(option('Months')); + expect(last()).toMatchObject({ scale: 'month', window: '2025-10..2026-12' }); + }); + + it('asks for no history before a window wholly ahead', async () => { + await mount({ window: '2027-02..2027-05' }); + const inverted = asked + .filter(p => p.startsWith('/projects-data')) + .map(p => new URLSearchParams(p.split('?')[1])) + .filter(q => (q.get('start') ?? '') > (q.get('end') ?? '')); + expect(inverted).toEqual([]); + }); +}); + +describe('the edge chip', () => { + it('moves the window to start at its plan', async () => { + const el = await mount(); + await press(buttons(el).find(b => b.title.startsWith('Planned Nov 3, 2025 to'))); + expect(last()).toMatchObject({ window: '2025-Q4..2026-Q3' }); + }); +}); + +const visible = (el: Element | null | undefined) => !!el && !el.closest('details:not([open])'); + +describe('a plan just saved', () => { + it('moved out of the window keeps its receipt and Undo in view', async () => { + const el = await mount(); + const bar = el.querySelector('#roadmap-bar-5'); + expect(visible(bar)).toBe(true); + act(() => { + bar?.focus(); + bar?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + }); + await settle(); + await settle(); + expect(text(el)).toContain('Moved Edge done 1 week earlier'); + expect(visible(buttons(el).find(b => b.textContent === 'Undo'))).toBe(true); + }); + + it('marked done from its open details on the initiative lanes keeps its row and receipt', async () => { + const el = await mount({ group: 'initiative', item: 6 }); + expect(el.querySelector('#roadmap-item-6')).toBeTruthy(); + await press(button(el, 'Edit details')); + const done = [ + ...el.querySelectorAll( + '#roadmap-item-6 [role="radio"], #roadmap-item-6 button' + ), + ].find(b => b.textContent === 'Done'); + await press(done); + await press(button(el, /^Save/)); + await settle(); + expect(text(el)).toContain('Marked Loose work done'); + expect(el.querySelector('#roadmap-item-6')).toBeTruthy(); + }); +}); + +describe('the load chart', () => { + it('counts a picked week only while it draws it, so the headline and the chip agree', async () => { + const el = await mount({ week: '2026-09-14', window: '2027-Q3..2028-Q2' }); + // a window stepped away from the pick: no pick at all, so the + // headline counts this week and no rows are narrowed + expect(text(el)).not.toContain('Week of Sep 14'); + expect(text(el)).toMatch(/this week/); + expect(buttons(el).some(b => b.getAttribute('aria-pressed') === 'true')).toBe(false); + }); + + it('says where PR history starts on a window longer than the server keeps', async () => { + const el = await mount({ window: '2022-Q1..2026-Q4' }); + const ask = asked.find(a => a.startsWith('/projects-data')); + expect(ask).toContain('start=2025-'); + expect(text(el)).toContain('PR history from'); + // and hatches the weeks before it, as plans only + const hatched = [...el.querySelectorAll('span[aria-hidden]')].filter(e => + e.getAttribute('style')?.includes('repeating-linear-gradient') + ); + expect(hatched).toHaveLength(1); + expect(parseFloat(hatched[0].style.width)).toBeGreaterThan(50); + }); +}); + +describe('the lane glosses', () => { + it('count the rows they head, not the done plans folded under them', async () => { + const el = await mount(); + const label = text(el).match(/On the roadmap · (\d+) plans?/)?.[1]; + const gloss = text(el).match(/(\d+) plans? in priority order: what matters most/)?.[1]; + expect(gloss).toBe(label); + }); +}); + +describe('a plan held where it was drawn', () => { + it('in the Not tracked lane: marked done from its open details, it keeps its row and receipt', async () => { + const el = await mount({ item: 7 }); + expect(el.querySelector('#roadmap-item-7')).toBeTruthy(); + await press(button(el, 'Edit details')); + const done = [ + ...el.querySelectorAll( + '#roadmap-item-7 [role="radio"], #roadmap-item-7 button' + ), + ].find(b => b.textContent === 'Done'); + await press(done); + await press(button(el, /^Save/)); + await settle(); + expect(text(el)).toContain('Marked Ops runbooks done'); + expect(el.querySelector('#roadmap-item-7')).toBeTruthy(); + }); + + it('in the Done before fold: opening it keeps its row in the fold', async () => { + const el = await mount({ window: '2026-07..2026-12' }); + const row = () => el.querySelector('#roadmap-item-2'); + const inFold = () => + !!row() + ?.closest('details') + ?.querySelector('summary') + ?.textContent?.includes('Done before'); + expect(inFold()).toBe(true); + const name = [...(row()?.querySelectorAll('button') ?? [])].find( + b => b.textContent === 'Old export' + ); + await press(name); + expect(last()).toMatchObject({ item: 2 }); + expect(inFold()).toBe(true); + }); +}); diff --git a/frontend-v2/src/views/projects/WhoIsOnWhat.tsx b/frontend-v2/src/views/projects/WhoIsOnWhat.tsx index b02155e3..5f5e43ae 100644 --- a/frontend-v2/src/views/projects/WhoIsOnWhat.tsx +++ b/frontend-v2/src/views/projects/WhoIsOnWhat.tsx @@ -18,7 +18,7 @@ const WHO_DAYS = 14; export interface WhoRow extends PersonLoad { team: string | null; - /** their open PRs now, drafts included */ + /** their open PRs now, drafts left out: a draft isn't work yet */ open: number; /** their filed projects, each with the Monday of the last week they had * days on it */ diff --git a/frontend-v2/src/views/projects/Work.test.tsx b/frontend-v2/src/views/projects/Work.test.tsx new file mode 100644 index 00000000..ced3f16d --- /dev/null +++ b/frontend-v2/src/views/projects/Work.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { IssuePull, ProjectWork } from '../../../../shared/model/work'; +import type { RowOptions } from '../../components/Row'; +import { ProjectWorkSections } from './Work'; + +// 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 pr = (number: number, over: Partial = {}): IssuePull => ({ + repo: 'iFixit/ifixit', + number, + title: `PR ${number}`, + author: 'dana', + createdAt: 1_790_000_000, + state: 'open', + ...over, +}); + +// the board has read none of them, so each draws as a plain line +const page: ProjectWork = { + issues: [ + { + ref: { repo: 'iFixit/ifixit', number: 100 }, + title: 'Save drafts', + state: 'open', + closedAt: null, + author: 'dana', + createdAt: 1_789_000_000, + via: ['label'], + attachedAt: null, + addedBy: null, + prs: [pr(1), pr(2, { draft: true })], + alsoIn: [], + }, + ], + unlinked: [pr(3), pr(4, { draft: true }), pr(5, { draft: true })], + suggested: [], + counts: { total: 1, open: 1, done: 0, dropped: 0, lastClosedAt: null }, +}; + +let host: HTMLDivElement; +afterEach(() => host.remove()); + +function render(): HTMLDivElement { + host = document.createElement('div'); + document.body.append(host); + act(() => + createRoot(host).render( + undefined, known: () => undefined }} + opts={{} as RowOptions} + nameOf={s => s} + navigate={() => undefined} + /> + ) + ); + return host; +} + +describe('a project’s drafts on its page', () => { + it('sit in one closed fold at the end of its PRs, out of every stage', () => { + const el = render(); + const folds = [...el.querySelectorAll('#project-unlinked details')]; + const labels = folds.map(f => f.querySelector('summary')?.textContent ?? ''); + expect(labels).toHaveLength(2); + // the open one the board can't read is in development; the drafts come last + expect(labels[0]).toMatch(/In development.*1/); + expect(labels[1]).toMatch(/Drafts, not counted.*2/); + expect((folds[1] as HTMLDetailsElement).open).toBe(false); + }); + + it('stay under their issue, out of its PR count', () => { + const el = render(); + const door = el.querySelector('[data-issue] button[data-row-focus]'); + expect(door?.textContent).toContain('Show 1 PR, 1 draft'); + }); +}); diff --git a/frontend-v2/src/views/projects/Work.tsx b/frontend-v2/src/views/projects/Work.tsx index 81b945f6..da2ffb06 100644 --- a/frontend-v2/src/views/projects/Work.tsx +++ b/frontend-v2/src/views/projects/Work.tsx @@ -38,6 +38,7 @@ import { addedLater, dateWords, holderWords, + isDraftPull, issueStanding, lateWords, prStage, @@ -48,7 +49,7 @@ import { type PrStage, } from '../../model/stage'; import { removable } from '../../model/workData'; -import { LAST_14_DAYS } from '../../model/words'; +import { DRAFTS_NOT_COUNTED, LAST_14_DAYS } from '../../model/words'; import type { Navigate } from './parts'; /** a long list shows this many rows, then "+ N more" */ @@ -56,6 +57,8 @@ const LIST_CAP = 40; /** a PR on an issue's line that another project's label (or link) claims */ const COUNTED_ELSEWHERE = 'counted in another project'; +/** an open draft, listed but never counted (projects.ts isDraft) */ +const DRAFT_WORDS = 'a draft, not counted'; const upperFirst = (s: string) => s[0].toUpperCase() + s.slice(1); /** what each stage's band means, on hovering its name */ @@ -63,9 +66,9 @@ const STAGE_GLOSS: Record = { ready: 'Its PRs are signed off and just need merging', hold: 'A PR is paused, blocked outside the repo, or signed off and waiting on a deploy', review: 'A PR is waiting on code review or QA', - work: 'A PR still needs work: a draft, failing CI, conflicts, or changes asked for', + work: 'A PR still needs work: failing CI, conflicts, or changes asked for', merged: 'Its PRs merged but the issue is still open: close it, or more work is coming', - none: 'No PR links it yet', + none: 'No PR links it yet, or only a draft, which doesn’t count until it’s ready', done: 'Closed as completed', dropped: 'Closed as not planned or as a duplicate', }; @@ -75,9 +78,13 @@ const PR_GLOSS: Record = { ready: 'Signed off: what’s left is merging it', hold: 'Paused, blocked outside the repo, or signed off and held for a deploy', review: 'Waiting on a code review or on QA', - work: 'A draft, blocked, failing CI, signed off but in conflict, or has changes asked for', + work: 'Blocked, failing CI, signed off but in conflict, or has changes asked for', }; +/** what the fold at the end of a project's PRs that lists its drafts says */ +const DRAFTS_GLOSS = + 'Its open drafts. A draft isn’t work yet, so no count on this tab takes one until it’s marked ready.'; + /** What the board knows of a PR: its live row's data when it's open on the * board, or its data from the last two weeks' merges. */ export interface PullLookup { @@ -85,10 +92,15 @@ export interface PullLookup { known: (ref: IssueRef) => PullData | undefined; } -/** Which band of "PRs not tied to an issue" a PR sits in: an open one by - * where it stands (one the board can't read counts as in development), - * and the merged and closed ones together. */ -const unlinkedBand = (pr: IssuePull, live: PullLookup['live']): PrStage | 'closed' => { +/** Which band of "PRs not tied to an issue" a PR sits in: a draft in the + * drafts fold at the end, another open one by where it stands (one the + * board can't read counts as in development), and the merged and closed + * ones together. */ +const unlinkedBand = ( + pr: IssuePull, + live: PullLookup['live'] +): PrStage | 'closed' | 'drafts' => { + if (isDraftPull(pr, live)) return 'drafts'; const p = live(pr); return p ? prStage(p) : pr.state === 'open' ? 'work' : 'closed'; }; @@ -251,6 +263,7 @@ function PullItem({ navigate, asRow = true, whoSaid = false, + draftSaid = false, }: { pr: IssuePull; pulls: PullLookup; @@ -262,6 +275,8 @@ function PullItem({ navigate: Navigate; /** the issue's line above it says who holds it already */ whoSaid?: boolean; + /** it's in the drafts fold, whose name says it's a draft already */ + draftSaid?: boolean; /** false where it's a second sighting (it links two issues here): the * board's row, with its id and actions, is drawn once a page */ asRow?: boolean; @@ -274,6 +289,10 @@ function PullItem({ const parts: ReactNode[] = []; const who = live && !whoSaid ? holderWords(live, { line: true, onRow: true }) : ''; if (who) parts.push(who); + // under an issue it isn't in its numbers, so the line says why + if (!draftSaid && isDraftPull(pr, pulls.live)) { + parts.push(parts.length ? DRAFT_WORDS : upperFirst(DRAFT_WORDS)); + } if (late) parts.push(parts.length ? late : upperFirst(late)); // a person's PR this page's counts leave out, the way every view does if (pr.outside && (live || known)) { @@ -519,15 +538,18 @@ function IssueLine({ const added = addedWords(issue, planned, repoShown); // one with the label comes off by the label, which is GitHub's const offOnGitHub = !onRemove && issue.via.includes('label'); + // its drafts show under it, but no number of its counts them + const counted = issue.prs.filter(pr => !isDraftPull(pr, pulls.live)); + const drafts = issue.prs.length - counted.length; // its PRs that opened late, counted by why const late = new Map(); - for (const pr of issue.prs) { + for (const pr of counted) { const words = lateOf(pr); if (words) late.set(words, (late.get(words) ?? 0) + 1); } // the PR count, and the stages its band doesn't already say const others = new Map(); - for (const pr of issue.prs) { + for (const pr of counted) { const live = pulls.live(pr); const word = live ? prStage(live) === standing.stage @@ -543,9 +565,12 @@ function IssueLine({ if (word) others.set(word, (others.get(word) ?? 0) + 1); } const door = [ - n(issue.prs.length, 'PR'), + counted.length ? n(counted.length, 'PR') : '', ...[...others].map(([word, count]) => `${count} ${word}`), - ].join(', '); + drafts ? n(drafts, 'draft') : '', + ] + .filter(Boolean) + .join(', '); const who = standing.pull ? holderWords(standing.pull, { ageWarnDays: opts.ageWarnDays }) : ''; // anywhere on the line but its links opens or closes its PRs const lineClick = (e: MouseEvent) => { @@ -790,7 +815,11 @@ export function ProjectWorkSections({ }); }); const mine = (plans ?? []).filter(p => p.project === slug); - const lateOf = (pr: IssuePull) => lateWords(pr.createdAt, mine, closed); + // a former draft is late by the day it was marked ready, as Decide reads it + const lateOf = (pr: IssuePull) => + pr.readyAt != null + ? lateWords(pr.readyAt, mine, closed, 'marked ready') + : lateWords(pr.createdAt, mine, closed); /** Save `add` for a change's issue: the click's own change, or, when it * goes the other way, its Undo. An add's Undo forgets the row the add * put in, so a suggestion goes back to being one, where a Remove keeps @@ -934,7 +963,9 @@ export function ProjectWorkSections({ // they're all shown, so is every line, past a band's 40 too const withPrs = issues.filter(i => i.prs.length > 0); const allShown = withPrs.every(i => choices[prsFoldId(slug, issueKey(i.ref))] ?? false); - const isOpenPr = (pr: IssuePull) => (pulls.live(pr) ? true : pr.state === 'open'); + // open and counted: a draft is listed, never counted + const isOpenPr = (pr: IssuePull) => + !isDraftPull(pr, pulls.live) && (pulls.live(pr) ? true : pr.state === 'open'); // taken off from the click on, until an Undo has put it back const removing = (k: string) => change?.at === 'issues' && @@ -1014,7 +1045,7 @@ export function ProjectWorkSections({ // ones merged or closed lately together const unlinkedBands = [ ...(['ready', 'hold', 'review', 'work'] as PrStage[]).map(stage => ({ - id: stage as PrStage | 'closed', + id: stage as PrStage | 'closed' | 'drafts', word: STAGE_WORDS[stage], gloss: PR_GLOSS[stage], window: undefined as string | undefined, @@ -1026,10 +1057,13 @@ export function ProjectWorkSections({ // the window in plain words beside the band, not in its capitals window: `in the ${LAST_14_DAYS}`, }, + // last, closed, one click away: a draft isn't work yet + { id: 'drafts' as const, word: DRAFTS_NOT_COUNTED, gloss: DRAFTS_GLOSS, window: undefined }, ] .map(b => ({ ...b, list: unlinked.filter(pr => unlinkedBand(pr, pulls.live) === b.id) })) .filter(b => b.list.length > 0); const unlinkedOpen = unlinked.filter(isOpenPr).length; + const unlinkedDrafts = unlinked.filter(pr => isDraftPull(pr, pulls.live)).length; // an issue added from "Issues linked from its PRs" stays on its line there const keptSuggested = change?.kept && 'suggested' in change.kept ? change.kept : null; const suggested = keptSuggested @@ -1135,11 +1169,12 @@ export function ProjectWorkSections({ label="Which PRs are here" text={[ unlinkedOpen ? `${unlinkedOpen} open` : '', - unlinked.length - unlinkedOpen + unlinked.length - unlinkedOpen - unlinkedDrafts ? `${ - unlinked.length - unlinkedOpen + unlinked.length - unlinkedOpen - unlinkedDrafts } merged or closed in the ${LAST_14_DAYS}` : '', + unlinkedDrafts ? n(unlinkedDrafts, 'draft') : '', ] .filter(Boolean) .join(' · ')} @@ -1147,7 +1182,8 @@ export function ProjectWorkSections({

    The PRs with the {label} label {issues.length ? ' that link none of its issues' : ''}: the open ones, - and the ones merged or closed in the {LAST_14_DAYS}. + and the ones merged or closed in the {LAST_14_DAYS}. Its drafts come + last, folded: a draft isn’t work yet, so none of its counts take one.

    {issues.length > 0 && (

    @@ -1168,8 +1204,9 @@ export function ProjectWorkSections({ gloss={b.gloss} detail={b.window} id={`work:${slug}:unlinked:${b.id}`} - // their counts are the summary while its issues are open - defaultOpen={!openIssues} + // their counts are the summary while its issues are + // open; drafts wait folded, one click away + defaultOpen={!openIssues && b.id !== 'drafts'} > ))} diff --git a/frontend-v2/src/views/projects/parts.tsx b/frontend-v2/src/views/projects/parts.tsx index 0aa2f96b..b0c713c1 100644 --- a/frontend-v2/src/views/projects/parts.tsx +++ b/frontend-v2/src/views/projects/parts.tsx @@ -13,7 +13,7 @@ import { FactLink } from '../../components/bits'; import { Icon } from '../../components/Icon'; import { Avatar } from '../../components/identity'; import { eyebrowText, SubDoor } from '../../components/Lane'; -import { DEFAULT_SORT } from '../../lens'; +import { DEFAULT_GROUP, DEFAULT_SORT } from '../../lens'; import { dayWords } from '../../model/projectData'; import { LAST_14_DAYS, NOT_SAID, targetOn } from '../../model/words'; @@ -22,6 +22,11 @@ export interface ProjectsNav { view: 'overview' | 'decide' | 'roadmap' | 'people' | 'retro'; /** a project's slug when its page is open */ project: string | null; + /** an initiative's slug when its page is open */ + initiative: string | null; + /** Decide's initiative: only the rows about its projects show; null for + * every initiative */ + within: string | null; /** the date range's preset or custom key (model/projectData.ts) */ range: string; /** which projects the list and timeline show (model/portfolio.ts STATUS_FILTERS) */ @@ -45,6 +50,9 @@ export interface ProjectsNav { /** the quarter or month the timeline is zoomed into, "2026-Q4" or * "2026-10" (model/roadmapTime.ts); null for the whole view */ zoom: string | null; + /** the timeline's window, a preset's key or its months, "2025-10..2026-09" + * (model/roadmapTime.ts); null for the default */ + window: string | null; /** Decide's team: only its rows show; null for every team */ team: string | null; /** where the work came from, picked on the load chart: the timeline @@ -52,7 +60,7 @@ export interface ProjectsNav { origin: RoadmapOrigin | 'unsaid' | null; /** how Look back's last list splits the days, beside its people and * project tables */ - by: 'team' | 'origin' | 'author' | 'repo'; + by: 'team' | 'initiative' | 'origin' | 'author' | 'repo'; /** which of Look back's days count: all, only writing, or only reviewing */ kind: 'all' | 'writing' | 'reviewing'; /** a person picked: People opens their row; Look back counts only their days */ @@ -95,6 +103,8 @@ export function switchView(view: ProjectsNav['view']): Partial { return { view, project: null, + initiative: null, + within: null, item: null, // the list's own "nothing picked", so no empty sort= lands in the URL sort: DEFAULT_SORT, @@ -108,7 +118,7 @@ export function switchView(view: ProjectsNav['view']): Partial { kind: 'all', // the list's Group by and the roadmap's lanes share this key, and the // roadmap's Show narrows its rows: neither carries into another view - group: 'none', + group: DEFAULT_GROUP, show: 'all', }; } @@ -158,6 +168,7 @@ export const ORIGIN_OPTIONS: [RoadmapOrigin | 'unsaid', string][] = [ export function openPlan(nav: ProjectsNav, id: number): Partial { return { project: null, + initiative: null, view: 'roadmap', item: id, scale: nav.scale === 'now' ? 'quarter' : nav.scale, @@ -340,7 +351,8 @@ export function ProjectFacts({ { ...switchView('overview'), status: 'all', - group: 'parent', + // the find narrows the list to its parts already + group: 'none', find: `parent:${parent}`, }, { push: true } diff --git a/frontend-v2/src/views/projects/roadmapHealth.test.ts b/frontend-v2/src/views/projects/roadmapHealth.test.ts index f5a7ee39..45ff0cb8 100644 --- a/frontend-v2/src/views/projects/roadmapHealth.test.ts +++ b/frontend-v2/src/views/projects/roadmapHealth.test.ts @@ -161,8 +161,8 @@ describe('amber means someone owes something', () => { expect(after?.text).toBe('after Shopify sync'); expect(after?.pieces.some(p => p.owed)).toBe(false); const clash = waitsWords(item(1, { start: '2026-09-28', waits_on: [2] }), all); - // by its rank, the number its row shows; its name on hover - expect(clash?.text).toBe('Starts before #1 is done'); + // by name: a row number reads differently in every grouping + expect(clash?.text).toBe('Starts before Shopify sync is done'); expect(clash?.title).toMatch(/^Waits on Shopify sync/); expect(clash?.pieces[0].owed).toBe('clash'); expect(waitsWords(item(1, { waits_on: [3] }), all)?.text).toBe( @@ -202,6 +202,16 @@ describe('one owed call, one amber mark', () => { expect(w.target?.text).toBe('Missed its Sep 25 target date'); }); + it('nothing owed on a not-tracked plan: no amber, no update due', () => { + const quiet = { ...sso, untracked: true }; + expect(healthStanding({ ...quiet, update: null, start: '2026-01-05' }, NOW)).toEqual({ + kind: 'quiet', + }); + const w = planWarnings(quiet, [quiet], TODAY, project, NOW); + expect(amber(w)).toEqual([]); + expect([w.status, w.health, w.over, w.target]).toEqual([null, null, null, null]); + }); + it('past its end when nothing worse is owed', () => { const w = planWarnings( { ...sso, update: update(3, 'on_track') }, @@ -347,7 +357,7 @@ describe('a row at rest says one thing', () => { const first = item(2, { name: 'Shopify sync', start: '2026-09-07', weeks: 4 }); const clashing = item(1, { start: '2026-09-28', waits_on: [2] }); const rest = restWords(planWarnings(clashing, [first, clashing], TODAY, null, NOW)); - expect(rest?.text).toBe('Starts before #1 is done'); + expect(rest?.text).toBe('Starts before Shopify sync is done'); expect(rest?.pieces.some(p => p.amber)).toBe(false); // an update owed, a late start, an overrun, a health word: not on the row const due = planWarnings(item(1, { update: update(20, 'on_track') }), [], TODAY, null, NOW); diff --git a/frontend-v2/src/views/projects/roadmapHealth.tsx b/frontend-v2/src/views/projects/roadmapHealth.tsx index 507544d3..7d9da769 100644 --- a/frontend-v2/src/views/projects/roadmapHealth.tsx +++ b/frontend-v2/src/views/projects/roadmapHealth.tsx @@ -25,6 +25,7 @@ import { planFor, ROADMAP_HEALTHS, UPDATE_DUE_DAYS, + weeksPast, type EndKind, type HealthStanding, type RoadmapHealth, @@ -271,13 +272,6 @@ export function healthWords(s: HealthStanding, changedAt: number | null = null): } } -/** A plan by its place in the order, "#2", which its row shows beside its - * name; by name when it isn't in the list. */ -const rankOf = (item: RoadmapItem, all: readonly RoadmapItem[]) => { - const at = all.indexOf(item); - return at < 0 ? item.name : `#${at + 1}`; -}; - /** * What an item waits on, in words for its row: "after Search reindex", or * how the plan clashes with it (the item starts before one of them ends, or @@ -317,7 +311,8 @@ export function waitsWords( ? `Waits on ${clashes[0].name}, which was ${clashes[0].status}` : clashes[0].end_kind === 'ongoing' ? `Waits on ${clashes[0].name}, which has no end` - : `Starts before ${rankOf(clashes[0], all)} is done`; + : // by name: a number would point at whatever row shows it in this grouping + `Starts before ${clashes[0].name} is done`; return { ...said([{ text, owed: 'clash' }], title), opens }; } @@ -380,13 +375,17 @@ export function planWarnings( now?: number, call: PlanCall | null = null ): PlanWarnings { + // a not-tracked plan owes nothing, so says nothing owed (R4) + if (item.untracked) { + return { status: null, health: null, over: null, target: null, waits: null, call }; + } // ongoing work has no end to run past const end = endOf(item); const under = item.status === 'planned' || item.status === 'active'; const weeksFrom = (a: string, b: string) => Math.ceil(((dayStart(b) as number) - (dayStart(a) as number)) / (7 * DAY)); const isOver = under && !!project?.live && !!end && today > end; - const weeks = isOver ? weeksFrom(end as string, today) : 0; + const weeks = isOver ? weeksPast(end as string, today) : 0; const hard = item.end_kind === 'hard'; const after = `Still ${BEING_WORKED_ON} ${n(weeks, 'week')} after its`; const over = isOver @@ -836,7 +835,8 @@ export function PlanFacts({ /** it has work in flight, so the roadmap's list of work with no plan has it */ live?: boolean; }) { - const { items } = useRoadmap(); + // one slug: the full set, so a Not tracked project's plan line is real + const { items } = useRoadmap({ all: true }); if (!items) return null; const plan = planFor(slug, items); const link = (label: string, patch: Parameters[0]) => ( diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts index d53be321..d25d1427 100644 --- a/frontend-v2/vite.config.ts +++ b/frontend-v2/vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ '/project-work': 'http://localhost:3000', '/project-issues': 'http://localhost:3000', '/roadmap': 'http://localhost:3000', + '/initiatives': 'http://localhost:3000', '/settings': 'http://localhost:3000', }, }, diff --git a/lib/authentication.js b/lib/authentication.js index 492d235b..747e2aaa 100644 --- a/lib/authentication.js +++ b/lib/authentication.js @@ -139,6 +139,7 @@ export default { app.get('/project-standing', auth); app.get('/roadmap', auth); app.get('/roadmap/:id/updates', auth); + app.get('/initiatives', auth); app.get('/settings', auth); }, }; diff --git a/lib/db.js b/lib/db.js index dd7bd571..44ee13d6 100644 --- a/lib/db.js +++ b/lib/db.js @@ -37,10 +37,13 @@ export async function retryIfIdleClosed(run) { } } -export default { +const db = { + // the pool, for a test to see what each query is sent with + pool, query: function (query, params) { - return retryIfIdleClosed(() => pool.query(query, params)) - .then((result) => result[0]); // [rows, fields] + return retryIfIdleClosed(() => + pool.query(query, params) + ).then(result => result[0]); // [rows, fields] }, // The refresh-* bins call this to close the pool so the process can exit // cleanly once a backfill finishes. @@ -48,3 +51,5 @@ export default { return pool.end(); } }; + +export default db; diff --git a/lib/initiatives.js b/lib/initiatives.js new file mode 100644 index 00000000..53ff6e74 --- /dev/null +++ b/lib/initiatives.js @@ -0,0 +1,416 @@ +import db from './db.js'; +import { isBot } from './review-model.js'; +import { grew, homesOf, moveBefore, MISC_SLUG } from '../shared/dist/index.js'; + +/** + * Initiatives' storage: `initiatives` (name, goal, owner, rank, open or + * closed) and `initiative_projects`, one row per filed project, keyed by its + * slug, so a project has one home at most. A project with no row is + * Unsorted. The shared model (shared/model/initiatives.ts) owns the shape and + * the checks on what a person sends; this file reads and writes rows, and + * says which projects are Not tracked and which of those grew. + */ + +const COLUMNS = + '`id`, `slug`, `name`, `kind`, `goal`, `owner_login`, `priority`, `status`, `status_at`, ' + + '`created_by`, `created_at`, `updated_by`, `updated_at`'; + +const num = v => (v == null ? null : Number(v)); + +/** A filing row as the wire's Filing. */ +function filingFromRow(row) { + return { + project: row.project, + set_by: row.set_by || null, + set_at: Number(row.set_at), + baseline_prs: Number(row.baseline_prs), + }; +} + +/** An initiatives row as the wire's Initiative, with the projects filed in it. */ +export function initiativeFromRow(row, projects = []) { + return { + id: Number(row.id), + slug: row.slug, + name: row.name, + kind: row.kind, + goal: row.goal || '', + owner: row.owner_login || null, + priority: Number(row.priority), + status: row.status, + status_at: num(row.status_at), + created_by: row.created_by || null, + created_at: num(row.created_at), + updated_by: row.updated_by || null, + updated_at: num(row.updated_at), + projects, + }; +} + +/** Every initiative in rank order (lowest first, oldest on a tie), each with + * the projects filed in it, by slug. */ +export async function listInitiatives() { + const [rows, filings] = await Promise.all([ + db.query(`SELECT ${COLUMNS} FROM \`initiatives\` ORDER BY \`priority\`, \`id\``), + db.query( + 'SELECT `project`, `initiative_id`, `set_by`, `set_at`, `baseline_prs` ' + + 'FROM `initiative_projects` ORDER BY `project`' + ), + ]); + const byInitiative = new Map(); + for (const f of filings) { + const id = Number(f.initiative_id); + if (!byInitiative.has(id)) byInitiative.set(id, []); + byInitiative.get(id).push(filingFromRow(f)); + } + return rows.map(r => initiativeFromRow(r, byInitiative.get(Number(r.id)) ?? [])); +} + +/** One initiative with its projects; null when there's none by that id. + * ponytail: reads them all; a query of its own past a few hundred. */ +export async function getInitiative(id) { + return (await listInitiatives()).find(i => i.id === id) ?? null; +} + +// the wire's field names to the table's +const COLUMN_OF = { + slug: 'slug', + name: 'name', + kind: 'kind', + goal: 'goal', + owner: 'owner_login', + status: 'status', +}; + +/** + * Add an initiative, ranked last (as a new plan goes to the bottom). + * `{ taken: true }` when another already has the slug. + */ +export async function createInitiative(fields, login, now = Math.floor(Date.now() / 1000)) { + const row = { kind: 'initiative', goal: '', owner_login: null, status: 'open' }; + for (const [key, column] of Object.entries(COLUMN_OF)) { + if (fields[key] !== undefined) row[column] = fields[key]; + } + const [{ top }] = await db.query('SELECT MAX(`priority`) AS `top` FROM `initiatives`'); + row.priority = top == null ? 0 : Number(top) + 1; + row.created_by = row.updated_by = login; + row.created_at = row.updated_at = now; + try { + const { insertId } = await db.query('INSERT INTO `initiatives` SET ?', [row]); + return getInitiative(insertId); + } catch (err) { + if (err && err.code === 'ER_DUP_ENTRY') return { taken: true }; + throw err; + } +} + +/** + * Change only the fields sent, and say who did. A change of status says when + * (status_at); so does `restate`, the same status said again (Keep open, + * which quiets "Did it reach its goal?" until something in it changes). + * `undo` puts back the times a change replaced. Null when there's no such + * initiative. + */ +export async function updateInitiative( + id, + fields, + login, + { now = Math.floor(Date.now() / 1000), restate = false, undo = null } = {} +) { + const row = { updated_by: login, updated_at: undo ? undo.updated_at : now }; + for (const [key, value] of Object.entries(fields)) { + if (COLUMN_OF[key]) row[COLUMN_OF[key]] = value; + } + if (undo) row.status_at = undo.status_at; + else if (fields.status) { + const [was] = await db.query('SELECT `status` FROM `initiatives` WHERE `id` = ?', [id]); + if (was && (was.status !== fields.status || restate)) row.status_at = now; + } + const result = await db.query('UPDATE `initiatives` SET ? WHERE `id` = ?', [row, id]); + return result.affectedRows ? getInitiative(id) : null; +} + +/** + * Delete an initiative that holds no projects (the undo of a create), so + * its slug can be used again. `{ notEmpty: true }` while a project is filed + * in it, `{ missing: true }` when there's none by that id. + */ +export async function deleteInitiative(id) { + const result = await db.query( + 'DELETE FROM `initiatives` WHERE `id` = ? AND NOT EXISTS ' + + '(SELECT 1 FROM `initiative_projects` WHERE `initiative_id` = ?)', + [id, id] + ); + if (result.affectedRows) return { ok: true }; + return (await getInitiative(id)) ? { notEmpty: true } : { missing: true }; +} + +// Writes that check and then change what counts as going in an +// initiative run one at a time: filing, closing, deleting and creating +// initiatives, and the roadmap writes that make a plan live or not. So a +// close can't pass its check while a filing or a plan's status lands, nor +// a delete while one files into it. A write that hasn't finished in +// WRITE_LIMIT_MS fails its request and lets the chain move on, so one hung +// query can't stall every later write (the query itself isn't cancelled: +// a timeout on every query would also cut off migrations and backfills). +// ponytail: one process serves the board and the API; a lock in MySQL if +// Pulldasher ever runs more than one +export const WRITE_LIMIT_MS = 60 * 1000; +let writing = Promise.resolve(); +export function serially(run, limitMs = WRITE_LIMIT_MS) { + const step = () => { + let timer; + const late = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('initiative write timed out')), limitMs); + }); + return Promise.race([Promise.resolve().then(run), late]).finally(() => clearTimeout(timer)); + }; + const done = writing.then(step, step); + writing = done.catch(() => {}); + return done; +} + +/** + * Move one initiative to just before another (to the bottom when `beforeId` + * is null): only initiatives' ranks change, never a plan's priority. + * `{ missing: true }` when either isn't there. + */ +export async function moveInitiative(id, beforeId) { + const ids = (await listInitiatives()).map(i => i.id); + if (!ids.includes(id) || (beforeId != null && !ids.includes(beforeId))) return { missing: true }; + const order = moveBefore(ids, id, beforeId); + const cases = order.map(() => 'WHEN ? THEN ?').join(' '); + await db.query( + `UPDATE \`initiatives\` SET \`priority\` = CASE \`id\` ${cases} END WHERE \`id\` IN (?)`, + [...order.flatMap((one, i) => [one, i]), order] + ); + return { initiatives: await listInitiatives() }; +} + +/** A project's home now: the initiative's slug and kind, and its filing; + * null when it has none. */ +async function homeOf(project) { + const [row] = await db.query( + 'SELECT f.`project`, f.`set_by`, f.`set_at`, f.`baseline_prs`, i.`slug`, i.`kind` ' + + 'FROM `initiative_projects` f JOIN `initiatives` i ON i.`id` = f.`initiative_id` ' + + 'WHERE f.`project` = ?', + [project] + ); + return row ? { initiative: row.slug, kind: row.kind, ...filingFromRow(row) } : null; +} + +/** + * How many PRs carry a project's label, opened after `since` (epoch secs). + * pull_labels holds issues' labels too, so it counts only rows with a pull. + * Drafts and bots' PRs don't count, as in every other count (grownSlugs + * leaves out the same ones, so a baseline and its `since` compare). + */ +export async function prCount(prefix, project, since = 0) { + const rows = await db.query( + 'SELECT DISTINCT p.`repo`, p.`number`, p.`owner` FROM `pull_labels` l ' + + 'JOIN `pulls` p ON p.`repo` = l.`repo` AND p.`number` = l.`number` ' + + 'WHERE l.`title` = ? AND p.`date` > ? AND p.`draft` = 0', + [prefix + project, since] + ); + return rows.filter(r => !isBot(r.owner)).length; +} + +/** The project's slug as its label spells it (a PR's or an issue's), so a + * filing keys the way the board and every map read it; as sent when no + * label has it yet. */ +async function spellingOf(prefix, project) { + const [row] = await db.query('SELECT `title` FROM `pull_labels` WHERE `title` = ? LIMIT 1', [ + prefix + project, + ]); + return row ? row.title.slice(prefix.length) : project; +} + +/** + * File a project in the initiative with slug `to`, or take it out of every + * one (`to: null`, Unsorted). `expect`, when sent, is the home the person + * saw (a slug or null): `{ conflict: true, home }` when it's not the one it + * has now. `{ missing: true }` when `to` names no initiative. + * + * Filing into Not tracked writes the growth check's start: `baseline_prs` + * when sent (the load sends each project's count from GitHub), or else, for + * a project already not tracked (Keep), its old baseline plus the PRs since + * it was set, and otherwise its PR count now. `set_at`, sent with + * `baseline_prs` on an undo, puts the old filing back as it was, so undoing + * a Keep brings its growth row back rather than restarting the count. + * + * `{ untracked: true }` for `to: null` on a project Not tracked by its repos + * (untrackedSlugs), which no filing makes Unsorted, unless it's an `undo`. + * `project` comes back as its label spells it, and is stored that way. + * + * Resolves `{ project, before, home }`, the homes before and after, for the + * undo log. + * ponytail: two people filing one project at once can both pass `expect`; + * the last write wins, as two saves of a plan do. + */ +export async function fileProject( + sent, + to, + login, + { + prefix, + expect, + baseline_prs = null, + set_at = null, + undo = false, + untrackedRepos = [], + now = Math.floor(Date.now() / 1000), + } +) { + const project = await spellingOf(prefix, sent); + const current = await homeOf(project); + const home = h => + h ? { initiative: h.initiative, set_by: h.set_by, set_at: h.set_at, baseline_prs: h.baseline_prs } : null; + // slugs compare without case, as the columns do + const lower = v => (v == null ? null : v.toLowerCase()); + if (expect !== undefined && lower(current?.initiative) !== lower(expect)) { + return { project, conflict: true, home: home(current) }; + } + const before = home(current) ?? { initiative: null, set_by: null, set_at: null, baseline_prs: 0 }; + if (to == null) { + // a project whose PRs are all in untrackedRepos reads as Not tracked + // with no row, so taking its row away can't make it Unsorted. An undo + // may: it puts back the repo default a filing replaced + if (!undo && (await untrackedSlugs({ prefix, untrackedRepos }, [])).has(project)) { + return { project, untracked: true }; + } + await db.query('DELETE FROM `initiative_projects` WHERE `project` = ?', [project]); + return { project, before, home: null }; + } + const [target] = await db.query( + 'SELECT `id`, `kind`, `status`, `slug` FROM `initiatives` WHERE `slug` = ?', + [to] + ); + if (!target) return { missing: true }; + // nothing is filed into a closed initiative, as the board's choosers + // offer open ones only; an undo may put a filing back there. Any set_at + // counts as an undo too: writers are trusted (canWrite), and the skill + // sends it only on Undo + if (target.status !== 'open' && set_at == null && !undo) return { closed: true }; + let baseline = baseline_prs ?? 0; + if (baseline_prs == null && target.kind === 'untracked') { + baseline = + current?.kind === 'untracked' + ? current.baseline_prs + (await prCount(prefix, project, current.set_at)) + : await prCount(prefix, project); + } + const row = { + project, + initiative_id: Number(target.id), + set_by: login, + set_at: set_at ?? now, + baseline_prs: baseline, + }; + await db.query('REPLACE INTO `initiative_projects` SET ?', [row]); + return { + project, + before, + home: { initiative: target.slug, set_by: login, set_at: row.set_at, baseline_prs: baseline }, + }; +} + +/** + * The projects that are Not tracked: filed in an initiative of kind + * `untracked`, or, with no filing at all, whose every labeled PR is in a + * repo listed in config.js projects.untrackedRepos (`owner/repo`, or just + * `repo`). An explicit filing + * always wins, and one PR elsewhere ends the repo default. Read only when + * that list names a repo. + */ +export async function untrackedSlugs(settings, initiatives) { + const homes = homesOf(initiatives); + const out = new Set( + [...homes].filter(([, h]) => h.initiative.kind === 'untracked').map(([slug]) => slug) + ); + const repos = new Set((settings.untrackedRepos ?? []).map(r => r.toLowerCase())); + if (!repos.size) return out; + const rows = await db.query( + 'SELECT l.`title`, p.`repo` FROM `pull_labels` l ' + + 'JOIN `pulls` p ON p.`repo` = l.`repo` AND p.`number` = l.`number` ' + + 'WHERE l.`title` LIKE ? GROUP BY l.`title`, p.`repo`', + [settings.prefix.replace(/[\\%_]/g, '\\$&') + '%'] + ); + const elsewhere = new Set(); + const seen = new Set(); + for (const { title, repo } of rows) { + const slug = title.slice(settings.prefix.length); + seen.add(slug); + // a full name, or the repo's own name + const name = String(repo).toLowerCase(); + if (!repos.has(name) && !repos.has(name.split('/').pop())) elsewhere.add(slug); + } + for (const slug of seen) { + if (slug && slug !== MISC_SLUG && !elsewhere.has(slug) && !homes.has(slug)) out.add(slug); + } + return out; +} + +/** + * The not-tracked projects that grew (shared/model/initiatives.ts grew), by + * slug, with `since`, the PRs opened since they were set not tracked, and + * `baseline`, their count then. A repo default has no filing, so it counts + * from zero. Only PRs opened after set_at count, which Pulldasher stores as + * they arrive, so a gap in its older history can't make one fire early. + * ponytail: reads each not-tracked PR's open time and counts here; a + * grouped query if that list reaches the tens of thousands. + */ +export async function grownSlugs(settings, initiatives, untracked) { + const out = new Map(); + if (!untracked.size) return out; + const homes = homesOf(initiatives); + const rows = await db.query( + 'SELECT DISTINCT l.`title`, p.`repo`, p.`number`, p.`date`, p.`owner` FROM `pull_labels` l ' + + 'JOIN `pulls` p ON p.`repo` = l.`repo` AND p.`number` = l.`number` ' + + 'WHERE l.`title` IN (?) AND p.`draft` = 0', + [[...untracked].map(slug => settings.prefix + slug)] + ); + const filingOf = slug => { + const home = homes.get(slug); + return home?.initiative.kind === 'untracked' ? home.filing : null; + }; + const sinceOf = new Map(); + // drafts and bots' PRs don't count, the same as prCount's baseline + for (const { title, date, owner } of rows) { + if (isBot(owner)) continue; + const slug = title.slice(settings.prefix.length); + if (Number(date) > (filingOf(slug)?.set_at ?? 0)) sinceOf.set(slug, (sinceOf.get(slug) ?? 0) + 1); + } + for (const slug of untracked) { + const since = sinceOf.get(slug) ?? 0; + const baseline = filingOf(slug)?.baseline_prs ?? 0; + if (grew(since, baseline)) out.set(slug, { since, baseline }); + } + return out; +} + +/** Everything the counting views need of initiatives at once: every + * initiative, each project's home, the not-tracked slugs and the grown ones. */ +export async function tracking(settings) { + const initiatives = await listInitiatives(); + const untracked = await untrackedSlugs(settings, initiatives); + const grown = await grownSlugs(settings, initiatives, untracked); + return { initiatives, homes: homesOf(initiatives), untracked, grown }; +} + +/** + * What counts, with not-tracked projects left out: Today's live and quiet + * projects, the plans, and an object keyed by slug (a window's per-project + * numbers), whichever are sent. The one filter every counting endpoint + * runs, so a not-tracked project can't count in one and not another. + */ +export function trackedOnly(untracked, { today, items, bySlug }) { + const keep = slug => !slug || !untracked.has(slug); + return { + today: today && { + ...today, + live: today.live.filter(g => keep(g.slug)), + quiet: today.quiet.filter(g => keep(g.slug)), + }, + items: items && items.filter(i => keep(i.project)), + bySlug: bySlug && Object.fromEntries(Object.entries(bySlug).filter(([slug]) => keep(slug))), + }; +} diff --git a/lib/projects.js b/lib/projects.js index 0d47a71d..bde3aa1b 100644 --- a/lib/projects.js +++ b/lib/projects.js @@ -67,6 +67,9 @@ export function projectSettings() { // projects with no end, marked from the board (a project's issue can // also say so with the `ongoing` label) ongoing: savedSetting('ongoing_projects') ?? [], + // repos whose projects are Not tracked until someone files them + // (lib/initiatives.js untrackedSlugs) + untrackedRepos: config.projects.untrackedRepos || [], teamOf: login => byLogin.get(login.toLowerCase()) ?? null, }; } @@ -202,11 +205,14 @@ function pullProjects(labelRows, prefix, linked = {}) { } /** pulls rows + project-label rows + the projects each PR links -> PullSpans, - * bots left out. */ + * bots and drafts left out: a draft isn't work yet (shared isDraft), so it + * counts from the day it's marked ready (ready_at), and one closed as a + * draft never does. A PR marked ready before Pulldasher stored ready_at + * counts from its open day. */ export function spansFromRows(pulls, labelRows, prefix, linked = {}) { const projectOfPull = pullProjects(labelRows, prefix, linked); return pulls - .filter(p => p.date != null && !isBot(p.owner)) + .filter(p => p.date != null && !isBot(p.owner) && !Number(p.draft)) .map(p => { // a merged pull always has a close time, but take the merge time if a // row ever lacks one rather than counting it open forever @@ -214,7 +220,7 @@ export function spansFromRows(pulls, labelRows, prefix, linked = {}) { return { author: p.owner, project: projectOfPull(p.repo, p.number), - opened: Number(p.date), + opened: Number(p.ready_at ?? p.date), closed: closed == null ? null : Number(closed), merged: p.date_merged != null, }; @@ -262,7 +268,8 @@ export async function loadTimeSpent(settings, start, end, linked = {}) { const repos = config.repos.map(r => r.name); // when it closed too, so a PR closed without merging is dated (the board // only knows the last 14 days' closes) - const pr = 'p.title, p.state, p.date_merged AS merged, p.date_closed AS closed'; + const pr = + 'p.title, p.state, p.date_merged AS merged, p.date_closed AS closed, p.draft, p.ready_at'; // someone's activity on a PR, with the PR's author const onPulls = (table, at) => `SELECT t.repo, t.number, t.user AS login, t.${at} AS at, p.owner, ${pr} FROM ${table} t ` + @@ -272,8 +279,10 @@ export async function loadTimeSpent(settings, start, end, linked = {}) { `SELECT p.repo, p.number, p.owner AS login, p.${at} AS at, p.owner, ${pr} FROM pulls p ` + `WHERE p.${at} >= ? AND p.${at} < ? AND p.repo IN (?)`; const window = [from, to, repos]; - const [opened, merged, comments, stamps, reviews, labels] = await Promise.all([ + const [opened, readied, merged, comments, stamps, reviews, labels] = await Promise.all([ db.query(byAuthor('date'), window), + // a former draft opens, as work, the day it's marked ready + db.query(byAuthor('ready_at'), window), db.query(byAuthor('date_merged'), window), db.query(onPulls('comments', 'date'), window), db.query(onPulls('pull_signatures', 'date'), window), @@ -282,7 +291,11 @@ export async function loadTimeSpent(settings, start, end, linked = {}) { settings.prefix.replace(/[\\%_]/g, '\\$&') + '%', ]), ]); - const found = [opened, merged, comments, stamps, reviews].flat(); + // days on a draft aren't days on a project yet (shared isDraft), nor + // are a ready PR's days from before it was marked ready + const found = [opened, readied, merged, comments, stamps, reviews] + .flat() + .filter(r => !Number(r.draft) && !(Number(r.at) < Number(r.ready_at ?? 0))); const developers = Object.keys(settings.teams).length > 0; const counts = login => !isBot(login) && (!developers || settings.teamOf(login) != null); const rows = timeSpent( @@ -345,7 +358,7 @@ export async function loadWindow(settings, start, end, project, linked = {}) { const repos = config.repos.map(r => r.name); const [pulls, labels, reviewRows] = await Promise.all([ db.query( - 'SELECT repo, number, owner, date, date_closed, date_merged FROM pulls ' + + 'SELECT repo, number, owner, date, date_closed, date_merged, draft, ready_at FROM pulls ' + 'WHERE repo IN (?) AND date < ? AND (date_closed IS NULL OR date_closed >= ?)', [repos, to, from] ), diff --git a/lib/schema-check.js b/lib/schema-check.js index 9fda8d6b..e286e870 100644 --- a/lib/schema-check.js +++ b/lib/schema-check.js @@ -55,6 +55,11 @@ export const MIGRATIONS = [ column: 'project', length: 64, }, + // one file makes both tables, so each is its own entry, as 0028's are + { file: '0039-initiatives--add-tables.sql', table: 'initiatives' }, + { file: '0039-initiatives--add-tables.sql', table: 'initiative_projects' }, + { file: '0040-pulls--add-ready-at.sql', table: 'pulls', column: 'ready_at' }, + { file: '0041-pulls--add-ready-at-index.sql', table: 'pulls', index: 'pulls_ready_at' }, ]; /** The migration files this database hasn't had yet, oldest first. */ diff --git a/lib/work.js b/lib/work.js index 50ae88c2..00b36784 100644 --- a/lib/work.js +++ b/lib/work.js @@ -423,7 +423,8 @@ export async function forgetIssue(ref, now = Math.floor(Date.now() / 1000)) { /** * A project label was renamed on GitHub (its PRs and issues already carry * the new name). What's kept by slug moves with it: the issues added by hand - * or by a link, the roadmap item, and the ongoing mark. A project's label + * or by a link, the roadmap item with its initiative filing, and the + * ongoing mark. A project's label * lives in many repos and each one's rename arrives on its own, so this * waits until no repo has a PR or issue under the old label: the last * repo's rename moves the project. Resolves whether it moved. @@ -443,13 +444,22 @@ export async function renameProject(settings, from, to) { from, ]); // a project has one roadmap item: one tracking the new slug already stays - // the project's, and the old one is left for a person to merge + // the project's, and the old one is left for a person to merge. Its + // filing goes where the plan goes, so a plan left behind keeps its home, + // and when both slugs are filed the new one's home wins const [taken] = await db.query( 'SELECT COUNT(*) AS `n` FROM `roadmap_items` WHERE `project` = ? AND `project` <> ? AND `removed_at` IS NULL', [to, from] ); if (!Number(taken.n)) { await db.query('UPDATE `roadmap_items` SET `project` = ? WHERE `project` = ?', [to, from]); + await db.query('UPDATE IGNORE `initiative_projects` SET `project` = ? WHERE `project` = ?', [ + to, + from, + ]); + // BINARY: project ignores case, so a case-only rename would otherwise + // delete the row the UPDATE just renamed + await db.query('DELETE FROM `initiative_projects` WHERE BINARY `project` = ?', [from]); } const ongoing = savedSetting('ongoing_projects'); if (ongoing?.includes(from)) { @@ -634,17 +644,21 @@ export function workPullTouched(settings, repo, body, labels = []) { workIssueTouched(settings, ref.repo, ref.number, labels); } -/** A pulls row as the work model reads a PR; its body's links when read. */ +/** A pulls row as the work model reads a PR; its body's links when read. + * A former draft carries the day it was marked ready (readyAt), which the + * work model counts it from: a draft isn't work yet (shared isDraft). */ const workPull = row => ({ repo: row.repo, number: Number(row.number), title: row.title, author: row.owner, createdAt: Number(row.date), + ...(row.ready_at != null ? { readyAt: Number(row.ready_at) } : {}), mergedAt: num(row.date_merged), closedAt: num(row.date_closed), state: row.state, links: row.body ? bodyLinks(row.body, row.repo) : [], + ...(Number(row.draft) ? { draft: true } : {}), }); /** A PR open now, or opened or closed since the given epoch secs, as SQL @@ -682,7 +696,7 @@ async function projectPulls(settings, slugs, { since = null, bodiesSince = null } const rows = await db.query( 'SELECT p.repo, p.number, p.title, p.owner, p.date, p.date_merged, p.date_closed, p.state, ' + - `${bodiesSince == null ? '' : RECENT_BODY}l.title AS label ` + + `p.draft, p.ready_at, ${bodiesSince == null ? '' : RECENT_BODY}l.title AS label ` + 'FROM pulls p JOIN pull_labels l ON l.repo = p.repo AND l.number = p.number ' + `WHERE ${where.join(' AND ')}`, params @@ -735,7 +749,7 @@ async function workInputs(settings, loaded = {}, { slug = null, since = null } = // claims it (misc holds unsorted work, not a project) db.query( 'SELECT DISTINCT p.repo, p.number, p.title, p.owner, p.date, p.date_merged, p.date_closed, ' + - `p.state, ${ + `p.state, p.draft, p.ready_at, ${ bodies ? RECENT_BODY : '' }EXISTS (SELECT 1 FROM pull_labels l WHERE l.repo = p.repo AND ` + 'l.number = p.number AND l.title LIKE ? AND l.title <> ?) AS labeled ' + @@ -888,8 +902,8 @@ async function knownIssues(settings, inputs, slug) { pairs ), db.query( - 'SELECT `repo`, `number`, `title`, `owner`, `date`, `date_merged`, `date_closed`, `state` ' + - 'FROM `pulls` WHERE (`repo`, `number`) IN (?)', + 'SELECT `repo`, `number`, `title`, `owner`, `date`, `date_merged`, `date_closed`, `state`, ' + + '`draft`, `ready_at` FROM `pulls` WHERE (`repo`, `number`) IN (?)', pairs ), ]); diff --git a/migrations/0039-initiatives--add-tables.sql b/migrations/0039-initiatives--add-tables.sql new file mode 100644 index 00000000..7e619116 --- /dev/null +++ b/migrations/0039-initiatives--add-tables.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS `initiatives` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `name` varchar(120) COLLATE utf8mb4_general_ci NOT NULL, + `kind` enum('initiative','upkeep','untracked') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'initiative', + `goal` varchar(255) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '', + `owner_login` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `priority` int NOT NULL DEFAULT '0', + `status` enum('open','done','dropped') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'open', + `status_at` int unsigned DEFAULT NULL, + `created_by` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `created_at` int unsigned DEFAULT NULL, + `updated_by` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `updated_at` int unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `initiatives_slug` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `initiative_projects` ( + `project` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `initiative_id` int unsigned NOT NULL, + `set_by` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `set_at` int unsigned NOT NULL, + `baseline_prs` int unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`project`), + KEY `initiative_projects_initiative` (`initiative_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/migrations/0040-pulls--add-ready-at.sql b/migrations/0040-pulls--add-ready-at.sql new file mode 100644 index 00000000..392f8c0c --- /dev/null +++ b/migrations/0040-pulls--add-ready-at.sql @@ -0,0 +1,7 @@ +-- When a PR last left draft (its update time when Pulldasher saw it go +-- ready), so the Projects windows count it from that day, not the day it +-- opened as a draft. NULL for a PR never seen as a draft, which counts from +-- its open day. Every row stored before this column is NULL too: Pulldasher +-- kept no history of when a PR left draft, so a PR marked ready before this +-- migration still counts from its open day. +ALTER TABLE `pulls` ADD COLUMN `ready_at` int unsigned DEFAULT NULL AFTER `date_pushed`; diff --git a/migrations/0041-pulls--add-ready-at-index.sql b/migrations/0041-pulls--add-ready-at-index.sql new file mode 100644 index 00000000..3ce73d67 --- /dev/null +++ b/migrations/0041-pulls--add-ready-at-index.sql @@ -0,0 +1,4 @@ +-- Look back's time spent reads the PRs marked ready in its window (a former +-- draft opens as work that day); without an index each read scans pulls. +-- One index per file, as 0034-0037, so it lands whole or not at all. +ALTER TABLE `pulls` ADD KEY `pulls_ready_at` (`ready_at`), ALGORITHM=INPLACE, LOCK=NONE; diff --git a/migrations/schema.sql b/migrations/schema.sql index 4623be96..fae4ec5f 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -154,6 +154,7 @@ CREATE TABLE IF NOT EXISTS `pulls` ( `date` int unsigned DEFAULT NULL, `date_updated` int unsigned DEFAULT NULL, `date_pushed` int unsigned DEFAULT NULL, + `ready_at` int unsigned DEFAULT NULL, `date_closed` int unsigned DEFAULT NULL, `date_merged` int unsigned DEFAULT NULL, `mergeable` tinyint(1) DEFAULT NULL, @@ -170,7 +171,8 @@ CREATE TABLE IF NOT EXISTS `pulls` ( KEY `pulls_state` (`state`), KEY `pulls_repo` (`repo`), KEY `pulls_date` (`date`), - KEY `pulls_date_merged` (`date_merged`) + KEY `pulls_date_merged` (`date_merged`), + KEY `pulls_ready_at` (`ready_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -294,6 +296,42 @@ CREATE TABLE IF NOT EXISTS `project_settings` ( PRIMARY KEY (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for tables `initiatives` and `initiative_projects` (the +-- layer above projects: each initiative's name, goal, owner and rank, and +-- each project's one home in them; lib/initiatives.js keeps them) +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE IF NOT EXISTS `initiatives` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `slug` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `name` varchar(120) COLLATE utf8mb4_general_ci NOT NULL, + `kind` enum('initiative','upkeep','untracked') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'initiative', + `goal` varchar(255) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '', + `owner_login` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `priority` int NOT NULL DEFAULT '0', + `status` enum('open','done','dropped') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'open', + `status_at` int unsigned DEFAULT NULL, + `created_by` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `created_at` int unsigned DEFAULT NULL, + `updated_by` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `updated_at` int unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `initiatives_slug` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `initiative_projects` ( + `project` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `initiative_id` int unsigned NOT NULL, + `set_by` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `set_at` int unsigned NOT NULL, + `baseline_prs` int unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`project`), + KEY `initiative_projects_initiative` (`initiative_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; /*!50112 SET @disable_bulk_load = IF (@is_rocksdb_supported, 'SET SESSION rocksdb_bulk_load = @old_rocksdb_bulk_load', 'SET @dummy_rocksdb_bulk_load = 0') */; /*!50112 PREPARE s FROM @disable_bulk_load */; /*!50112 EXECUTE s */; diff --git a/models/db_pull.js b/models/db_pull.js index 374e260b..a8e11fd3 100644 --- a/models/db_pull.js +++ b/models/db_pull.js @@ -52,9 +52,17 @@ DBPull.prototype.save = function () { // A webhook's body carries no push time; an upsert that leaves out the // column keeps the stored one, where REPLACE would blank it. if (pullData.date_pushed == null) delete pullData.date_pushed; - var q_update = 'INSERT INTO pulls SET ? ON DUPLICATE KEY UPDATE ?'; + // ready_at: when it last left draft, so the Projects windows count it + // from then. A stored draft saved as ready is stamped with its update + // time (a ready_for_review hook's own time; a refresh's is the latest + // change since, a little late), one saved as a draft clears it, and one + // never seen as a draft keeps none. It's assigned before `?` so `draft` + // still reads the stored flag: an upsert's assignments run left to right. + var q_update = + 'INSERT INTO pulls SET ? ON DUPLICATE KEY UPDATE ' + + '`ready_at` = IF(? = 1, NULL, IF(`draft` = 1, COALESCE(?, UNIX_TIMESTAMP()), `ready_at`)), ?'; - return db.query(q_update, [pullData, pullData]); + return db.query(q_update, [pullData, pullData.draft, pullData.date_updated, pullData]); }; export default DBPull; diff --git a/shared/index.ts b/shared/index.ts index f0e7b3c7..dc9e1300 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -117,11 +117,32 @@ export { } from './model/decide'; export type { ClosedIssue, + GrownProject, DecideProject, DecideReason, DecideRow, PlanCounts, } from './model/decide'; +export { + checkInitiativeFields, + goingProjects, + grew, + homesOf, + initiativeSpan, + GROWTH_FLOOR, + INITIATIVE_GOAL_MAX, + INITIATIVE_KINDS, + INITIATIVE_NAME_MAX, + INITIATIVE_STATUSES, +} from './model/initiatives'; +export type { + Filing, + Initiative, + InitiativeFields, + InitiativeKind, + InitiativeSpan, + InitiativeStatus, +} from './model/initiatives'; export { loadByWeek, mondaysBetween, peakFrom, spansFrom } from './model/load'; export type { InFlightSpan, LoadWeek, OriginCounts } from './model/load'; export { timeSpent } from './model/retro'; diff --git a/shared/model/decide.ts b/shared/model/decide.ts index 745af146..68a656d2 100644 --- a/shared/model/decide.ts +++ b/shared/model/decide.ts @@ -1,5 +1,6 @@ import { dayStart, firstOpenDay, targetOf, utcDay, type Project, type Today } from './projects'; -import { healthStanding, isUnderWay, planEnd, type RoadmapItem } from './roadmap'; +import { goingProjects, homesOf, type Initiative } from './initiatives'; +import { healthStanding, isUnderWay, planEnd, weeksPast, type RoadmapItem } from './roadmap'; import type { IssueCounts } from './work'; /** @@ -26,6 +27,17 @@ import type { IssueCounts } from './work'; * - `issues_done`: every issue in its project is closed (model/work.ts), * and the plan hasn't changed since the last one closed; only the plan * running today is asked + * Once initiatives are in use (model/initiatives.ts), five more: + * - `unfiled`: a tracked project with no home, and 3+ PRs or a plan under + * way; a project already asked `new` files from that row instead + * - `no_project`: an active plan with no project, which can't be filed in + * an initiative until it has one + * - `untracked_grew`: a not-tracked project that grew (initiatives.ts grew) + * - `goal_reached`: no project of an open initiative still going + * (initiatives.ts goingProjects), every plan in it stopped for + * GOAL_QUIET_DAYS, and no plan or filing in it changed since Keep open + * - `reopened_in_closed`: a project in a closed initiative still going, on + * the project's own row when it has one * Every plan still under way is judged on its own, so a project's finished * first phase can't hide its second running late. A call is a roadmap write * (commit, park, finish, drop); any change to a plan counts as one. @@ -38,6 +50,9 @@ export const DECIDE_MIN_PRS = 3; /** days a finished or dropped project can keep PRs open before it's asked * about again */ const REOPEN_DAYS = 7; +/** days every project of an initiative has been finished or quiet before + * Decide asks whether it reached its goal */ +const GOAL_QUIET_DAYS = 14; const DAY = 86400; export type DecideReason = @@ -60,7 +75,22 @@ export type DecideReason = } | { kind: 'moving' } /** `open`: the plan's PRs still open */ - | { kind: 'issues_done'; done: number; dropped: number; open: number }; + | { kind: 'issues_done'; done: number; dropped: number; open: number } + | { kind: 'unfiled' } + | { kind: 'no_project' } + /** `since`: PRs opened since it was set not tracked, against `baseline`, + * its PR count then */ + | { kind: 'untracked_grew'; since: number; baseline: number } + /** the row names no project: `initiative` is the one asked about */ + | { kind: 'goal_reached'; initiative: number } + | { kind: 'reopened_in_closed'; initiative: number; as: 'done' | 'dropped' }; + +/** A not-tracked project that grew (model/initiatives.ts grew). */ +export interface GrownProject { + slug: string; + since: number; + baseline: number; +} /** What the queue needs to know about a live project. */ export interface DecideProject { @@ -143,6 +173,14 @@ export const RANK: Record = { stalled: 8, at_risk: 9, new: 10, + // ahead of `reopened` on the row it joins: the initiative is asked first, + // and every view's worst call (the Roadmap row, the Plan cell) is the + // question Decide's sections ask + reopened_in_closed: -1, + goal_reached: 7, + unfiled: 10, + no_project: 10, + untracked_grew: 10, }; /** A plan's PRs, by the dates (model/work.ts PlanWork), as Decide counts them. */ @@ -191,6 +229,8 @@ export function decideQueue({ planCounts = new Map(), issues = new Map(), ongoing = new Set(), + initiatives = [], + grown = [], today, now, }: { @@ -205,6 +245,12 @@ export function decideQueue({ /** projects that run with no end: they never owe a first plan, and a * finished plan of theirs isn't reopened by the work that follows */ ongoing?: ReadonlySet; + /** every initiative, with the projects filed in it; none means the layer + * isn't in use yet, so no project is asked where it belongs. `live` and + * `items` must already leave not-tracked projects out */ + initiatives?: readonly Pick[]; + /** not-tracked projects that grew, which `live` leaves out */ + grown?: readonly GrownProject[]; /** the day `now` falls on where the team is (projects.ts dayIn): what * "overdue" is judged by, the vouch for an update included */ today: string; @@ -216,15 +262,18 @@ export function decideQueue({ if (item.project) plansOf.set(item.project, [...(plansOf.get(item.project) ?? []), item]); } const rows = new Map(); - const add = (slug: string | null, item: RoadmapItem | null, reason: DecideReason) => { - const key = `${slug ?? ''}:${item?.id ?? ''}`; + const add = ( + slug: string | null, + item: RoadmapItem | null, + reason: DecideReason, + key = `${slug ?? ''}:${item?.id ?? ''}` + ) => { const row = rows.get(key) ?? { slug, item, reasons: [] }; row.reasons.push(reason); rows.set(key, row); }; const decidedAt = (item: RoadmapItem) => item.updated_at ?? 0; - const weeksPast = (item: RoadmapItem) => - Math.ceil(((dayStart(today) as number) - (dayStart(planEnd(item)) as number)) / (7 * DAY)); + const weeksOver = (item: RoadmapItem) => weeksPast(planEnd(item), today); // what a plan under way owes; `project` is its live project, if any const planReasons = (item: RoadmapItem, project: DecideProject | null) => { @@ -245,7 +294,7 @@ export function decideQueue({ if (item.end_kind === 'hard' && planEnd(item) < today) { add(item.project, item, { kind: open > 0 ? 'over' : 'ended', - weeks: weeksPast(item), + weeks: weeksOver(item), since: counts?.afterEnd ?? 0, }); } @@ -342,7 +391,83 @@ export function decideQueue({ if (item.project && liveSlugs.has(item.project)) continue; if (isUnderWay(item.status)) planReasons(item, null); } + initiativeReasons(); return [...rows.values()].sort(compareRows); + + // where projects belong (model/initiatives.ts) + function initiativeReasons() { + for (const g of grown) { + add(g.slug, null, { kind: 'untracked_grew', since: g.since, baseline: g.baseline }); + } + if (!initiatives.length) return; + const homes = homesOf(initiatives); + const liveBySlug = new Map(live.map(p => [p.slug, p])); + const openOf = (slug: string) => liveBySlug.get(slug)?.open ?? 0; + const underWay = (slug: string) => + (plansOf.get(slug) ?? []).filter(p => isUnderWay(p.status)); + // on the project's own row when it has one (its worst, when two of + // its plans are asked about), so a project is asked about once; else + // on its first plan under way + const ask = (slug: string, reason: DecideReason) => { + const row = [...rows.values()].filter(r => r.slug === slug).sort(compareRows)[0]; + if (row) row.reasons.push(reason); + else add(slug, underWay(slug)[0] ?? null, reason); + }; + const unfiled = new Set([ + ...live.filter(p => p.prs >= DECIDE_MIN_PRS).map(p => p.slug), + ...[...plansOf.keys()].filter(slug => underWay(slug).length), + ]); + for (const slug of unfiled) { + if (homes.has(slug)) continue; + // the first call files it too + if ([...rows.values()].some(r => r.slug === slug && r.reasons.some(x => x.kind === 'new'))) + continue; + ask(slug, { kind: 'unfiled' }); + } + // a plan with no project has no home to give: once it's active, which + // project is it? + for (const item of items) { + if (!item.project && item.status === 'active') add(null, item, { kind: 'no_project' }); + } + for (const initiative of initiatives) { + if (initiative.kind !== 'initiative') continue; + const slugs = initiative.projects.map(f => f.project); + const going = goingProjects(slugs, items, openOf); + if (initiative.status !== 'open') { + const as = initiative.status; + for (const slug of going) { + ask(slug, { kind: 'reopened_in_closed', initiative: initiative.id, as }); + } + continue; + } + if (!slugs.length || going.length) continue; + // every plan stopped, and no project with no plan merged lately + // (Today's live holds the last LIVE_DAYS); when the last plan + // stopped or the last project was filed here (a project filed + // today hasn't settled in this initiative, so nothing asks on the + // load's day), and when a plan or filing in it last changed. PR + // activity on a finished project changes neither, so Keep open holds + let settled = 0; + let changed = 0; + let quiet = true; + for (const f of initiative.projects) { + const plans = plansOf.get(f.project) ?? []; + if (!plans.length && liveBySlug.has(f.project)) quiet = false; + const times = plans.map(p => Math.max(p.status_at ?? 0, decidedAt(p))); + settled = Math.max(settled, ...times, f.set_at); + changed = Math.max(changed, settled, f.set_at); + } + if (!quiet || now - settled < GOAL_QUIET_DAYS * DAY) continue; + // Keep open: asked again only once a plan or filing in it changes + if (initiative.status_at != null && changed <= initiative.status_at) continue; + add( + null, + null, + { kind: 'goal_reached', initiative: initiative.id }, + `initiative:${initiative.id}` + ); + } + } } const rank = (row: DecideRow) => Math.min(...row.reasons.map(r => RANK[r.kind])); diff --git a/shared/model/initiatives.ts b/shared/model/initiatives.ts new file mode 100644 index 00000000..c3be6f8a --- /dev/null +++ b/shared/model/initiatives.ts @@ -0,0 +1,211 @@ +import { endOf, isUnderWay, PROJECT_SLUG, type RoadmapItem } from './roadmap'; + +/** + * Initiatives: one layer above projects, kept in Pulldasher's own tables + * (initiatives, initiative_projects). Each has a name, a goal, one owner and + * its own rank, and holds projects by slug; a project has one home at most: + * an initiative, Keep the lights on (`upkeep`), Not tracked (`untracked`), or + * no row, which the board calls Unsorted. An initiative has no PRs, updates + * or dates of its own: its dates are read off its projects' plans + * (initiativeSpan). Pure, so the server and the board share it. + */ + +export type InitiativeKind = 'initiative' | 'upkeep' | 'untracked'; +export const INITIATIVE_KINDS: InitiativeKind[] = ['initiative', 'upkeep', 'untracked']; +export type InitiativeStatus = 'open' | 'done' | 'dropped'; +export const INITIATIVE_STATUSES: InitiativeStatus[] = ['open', 'done', 'dropped']; +export const INITIATIVE_NAME_MAX = 120; +export const INITIATIVE_GOAL_MAX = 255; +/** PRs a not-tracked project opens since it was set so, at least, before + * Decide asks whether it's still not tracked (grew) */ +export const GROWTH_FLOOR = 30; + +/** One project's home: when it was filed there and by whom, and for Not + * tracked, its PR count then (the growth check's starting point). */ +export interface Filing { + project: string; + set_by: string | null; + /** epoch secs */ + set_at: number; + baseline_prs: number; +} + +export interface Initiative { + id: number; + slug: string; + name: string; + kind: InitiativeKind; + goal: string; + owner: string | null; + /** its rank: lower comes first; never the plans' priority */ + priority: number; + status: InitiativeStatus; + /** when its status was last set (epoch secs), Keep open included; null + * before anyone has */ + status_at: number | null; + created_by: string | null; + created_at: number | null; + updated_by: string | null; + updated_at: number | null; + /** the projects filed in it, by slug */ + projects: Filing[]; +} + +/** An initiative's dates, read off its projects' plans (initiativeSpan). */ +export interface InitiativeSpan { + /** the earliest start of its plans, done ones included; null with none */ + start: string | null; + /** the latest end among its plans under way that have one */ + end: string | null; + /** its plans under way with an end */ + with_end: number; + /** its plans under way with no end */ + ongoing: number; + /** derived, never stored: soft when a plan under way has an end, ongoing + * when one runs with none, ended when only finished plans are left (on + * `ended`), null with no plans */ + end_kind: 'soft' | 'ongoing' | 'ended' | null; + /** the latest end of its done plans, when end_kind is ended */ + ended: string | null; +} + +/** + * An initiative's span from the plans of the projects in it. Parked and + * dropped plans don't count; done ones count toward the start and, when + * nothing is under way, say when it ended. Projects with no plan add + * nothing. + */ +export function initiativeSpan( + projects: readonly string[], + items: readonly Pick[] +): InitiativeSpan { + const mine = new Set(projects); + const plans = items.filter( + i => i.project && mine.has(i.project) && i.status !== 'parked' && i.status !== 'dropped' + ); + const going = plans.filter(i => isUnderWay(i.status)); + const ends = going.map(endOf).filter((e): e is string => e != null); + const doneEnds = plans + .filter(i => i.status === 'done') + .map(endOf) + .filter((e): e is string => e != null); + const latest = (days: string[]) => (days.length ? days.reduce((a, b) => (b > a ? b : a)) : null); + const starts = plans.map(i => i.start); + const ongoing = going.length - ends.length; + const end_kind = ends.length + ? 'soft' + : ongoing + ? 'ongoing' + : plans.length + ? 'ended' + : null; + return { + start: starts.length ? starts.reduce((a, b) => (b < a ? b : a)) : null, + end: latest(ends), + with_end: ends.length, + ongoing, + end_kind, + ended: end_kind === 'ended' ? latest(doneEnds) : null, + }; +} + +/** Each filed project's home, by slug. */ +export function homesOf>( + initiatives: readonly I[] +): Map { + const out = new Map(); + for (const initiative of initiatives) { + for (const filing of initiative.projects) out.set(filing.project, { initiative, filing }); + } + return out; +} + +/** Whether a not-tracked project has grown enough to ask about again: as + * many PRs since it was set not tracked as it had then, and GROWTH_FLOOR. */ +export function grew(since: number, baseline: number): boolean { + return since >= Math.max(baseline, GROWTH_FLOOR); +} + +/** + * The projects among `slugs` still going: a plan under way, or PRs open + * (`open`, by slug). The one rule for an initiative's work: it closes only + * once none is left, Decide asks whether it reached its goal only then, and + * a closed one with any is asked to reopen. + */ +export function goingProjects( + slugs: readonly string[], + items: readonly Pick[], + open: (slug: string) => number +): string[] { + const planned = new Set( + items.filter(i => i.project && isUnderWay(i.status)).map(i => i.project as string) + ); + return slugs.filter(slug => planned.has(slug) || open(slug) > 0); +} + +export type InitiativeFields = Pick; + +const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; + +/** + * Check what a person sent for an initiative, and clean it: the name and + * goal trimmed to one line. A new one (`partial: false`) needs a slug and a + * name; an edit may send any of name, goal, owner and status (the slug and + * kind stay as made). Returns the clean fields, or the first problem in + * words fit to show the person. + */ +export function checkInitiativeFields( + input: unknown, + { partial }: { partial: boolean } +): { fields: Partial } | { error: string } { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { error: 'send the initiative as a JSON object' }; + } + const raw = input as Record; + const fields: Partial = {}; + const has = (key: string) => Object.prototype.hasOwnProperty.call(raw, key); + const line = (v: string) => v.replace(/\s+/g, ' ').trim(); + if (!partial) { + if (typeof raw.slug !== 'string' || !PROJECT_SLUG.test(raw.slug)) { + return { error: 'an initiative needs a slug, like site-speed' }; + } + fields.slug = raw.slug; + if (has('kind')) { + if (!INITIATIVE_KINDS.includes(raw.kind as InitiativeKind)) { + return { error: `kind is one of ${INITIATIVE_KINDS.join(', ')}` }; + } + fields.kind = raw.kind as InitiativeKind; + } + } else if (has('slug') || has('kind')) { + return { error: 'an initiative keeps the slug and kind it was made with' }; + } + if (has('name') || !partial) { + const name = typeof raw.name === 'string' ? line(raw.name) : ''; + if (!name) return { error: 'an initiative needs a name' }; + if (name.length > INITIATIVE_NAME_MAX) { + return { error: `keep the name to ${INITIATIVE_NAME_MAX} characters` }; + } + fields.name = name; + } + if (has('goal')) { + if (raw.goal == null || raw.goal === '') fields.goal = ''; + else if (typeof raw.goal !== 'string') return { error: 'the goal is a line of text' }; + else if (line(raw.goal).length > INITIATIVE_GOAL_MAX) { + return { error: `keep the goal to ${INITIATIVE_GOAL_MAX} characters` }; + } else fields.goal = line(raw.goal); + } + if (has('owner')) { + if (raw.owner == null || raw.owner === '') fields.owner = null; + else if (typeof raw.owner !== 'string' || !LOGIN.test(raw.owner.trim())) { + return { error: 'the owner must be a GitHub login' }; + } else fields.owner = raw.owner.trim(); + } + if (has('status')) { + if (!partial) return { error: 'a new initiative starts open' }; + if (!INITIATIVE_STATUSES.includes(raw.status as InitiativeStatus)) { + return { error: `status is one of ${INITIATIVE_STATUSES.join(', ')}` }; + } + fields.status = raw.status as InitiativeStatus; + } + return { fields }; +} diff --git a/shared/model/load.ts b/shared/model/load.ts index 21a96b4a..c17d377b 100644 --- a/shared/model/load.ts +++ b/shared/model/load.ts @@ -89,20 +89,23 @@ export function weekMembers({ const onRoadmap = new Set(kept.flatMap(p => (p.project ? [p.project] : []))); const open = new Set(spans.filter(s => s.end == null).map(s => s.slug)); const undecided = [...(ahead ?? open)].filter(slug => open.has(slug) && !decided.has(slug)); - const running = (p: RoadmapItem, week: string) => - p.start <= sunday(week) && (endOf(p) ?? week) >= week; + // each plan's end once, not once a week: a five-year window asks + // hundreds of weeks of hundreds of plans + const ends = new Map(kept.map(p => [p.id, endOf(p)])); return week => { + const last = sunday(week); + const running = (p: RoadmapItem) => p.start <= last && (ends.get(p.id) ?? week) >= week; const projects = new Map(); const counted = new Set(); if (week <= thisWeek) { for (const s of spans) { - if (s.start <= sunday(week) && (s.end ?? today) >= week) { + if (s.start <= last && (s.end ?? today) >= week) { projects.set(s.slug, onRoadmap.has(s.slug) ? 'on' : 'off'); } } // a plan with no PRs yet is still work in flight once it's under way for (const p of kept) { - if (!p.project && (p.status === 'active' || p.status === 'done') && running(p, week)) { + if (!p.project && (p.status === 'active' || p.status === 'done') && running(p)) { counted.add(p.id); } } @@ -111,9 +114,9 @@ export function weekMembers({ for (const p of kept) { if (!isUnderWay(p.status)) continue; // past its end already and still open: it keeps going - const end = endOf(p); + const end = ends.get(p.id) ?? null; const overrun = !!p.project && open.has(p.project) && end != null && end < thisWeek; - if (running(p, week) || overrun) { + if (running(p) || overrun) { counted.add(p.id); if (p.project) projects.set(p.project, 'on'); } diff --git a/shared/model/projects.ts b/shared/model/projects.ts index c2614f70..b52d2466 100644 --- a/shared/model/projects.ts +++ b/shared/model/projects.ts @@ -76,8 +76,11 @@ export interface ProjectGroup { slug: string; /** the project's issue; null when a label names a project that has no issue yet */ project: Project | null; - /** open PRs, oldest first */ + /** open PRs, oldest first, drafts left out: a draft isn't work yet */ open: DerivedPull[]; + /** its open drafts, oldest first: never counted (not in people, idle + * days, flags or live), only listed where someone would look */ + drafts: DerivedPull[]; /** merged in the last LIVE_DAYS, newest first */ merged: PullData[]; /** closed without merging in the last LIVE_DAYS, which the board keeps @@ -104,8 +107,16 @@ export interface Today { /** open PRs in two projects: two project labels, or no label and issues of * two projects linked. Each counts under the first (projectOf). */ doubleLabeled: DerivedPull[]; + /** open drafts no live or quiet project lists: one-offs, ones in no + * project, and ones whose project is closed or has no issue and nothing + * else open. Never counted, oldest first. */ + drafts: DerivedPull[]; } +/** A draft isn't work yet: the Projects tab never counts one, and lists + * it in a closed fold where someone would look for it. */ +export const isDraft = (p: Pick): boolean => !!p.data.draft; + /** The projects each PR links, by issueKey of the PR: every project that has * an issue it links (by its label, by hand or by a link) or whose own issue * it links, sorted. What a PR with no project label joins (projectOf). The @@ -148,6 +159,7 @@ export function buildToday( slug, project: bySlug.get(slug) ?? null, open: [], + drafts: [], merged: [], closed: [], people: [], @@ -160,8 +172,20 @@ export function buildToday( return g; }; - const today: Today = { live: [], quiet: [], misc: [], unsorted: [], doubleLabeled: [] }; + const today: Today = { + live: [], + quiet: [], + misc: [], + unsorted: [], + doubleLabeled: [], + drafts: [], + }; + const drafts: [string | null, DerivedPull][] = []; for (const p of open) { + if (isDraft(p)) { + drafts.push([projectOf(p.data.labels, prefix, linked[issueKey(p.data)] ?? []), p]); + continue; + } const slugs = projectSlugs(p.data.labels, prefix); const links = linked[issueKey(p.data)] ?? []; // a label settles it; with none, links to two projects' issues don't @@ -175,7 +199,8 @@ export function buildToday( } for (const p of closed) { const at = p.merged_at ?? p.closed_at; - if (!at || now - epoch(at) > LIVE_DAYS * DAY) continue; + // a draft closed without merging was never counted open, so it never leaves + if (p.draft || !at || now - epoch(at) > LIVE_DAYS * DAY) continue; const slug = projectOf(p.labels, prefix, linked[issueKey(p)]); if (slug == null || slug === MISC_SLUG) continue; group(slug)[p.merged_at ? 'merged' : 'closed'].push(p); @@ -208,6 +233,14 @@ export function buildToday( g.open.length > 0 || (g.merged.length > 0 && g.project?.state !== 'closed'); today.live = [...groups.values()].filter(isLive); today.quiet = [...groups.values()].filter(g => !isLive(g) && g.project?.state === 'open'); + // a draft lists under its project when the project shows, else outside + const shown = new Set([...today.live, ...today.quiet].map(g => g.slug)); + const oldest = (a: DerivedPull, b: DerivedPull) => + epoch(a.data.created_at) - epoch(b.data.created_at); + for (const [slug, p] of drafts.sort(([, a], [, b]) => oldest(a, b))) { + if (slug != null && shown.has(slug)) groups.get(slug)?.drafts.push(p); + else today.drafts.push(p); + } for (const g of today.live) { if (g.people.length === 1 && g.open.length + g.merged.length >= ONE_PERSON_MIN_PRS) diff --git a/shared/model/roadmap.ts b/shared/model/roadmap.ts index 39d32271..ff60ebdc 100644 --- a/shared/model/roadmap.ts +++ b/shared/model/roadmap.ts @@ -104,6 +104,10 @@ export interface RoadmapItem { * item (never stored); absent or null when it has no project the board * can read */ lately?: PlanLately | null; + /** its project is Not tracked (model/initiatives.ts), as the server read + * it when it sent the item (never stored): it owes no update, and + * counting views leave it out */ + untracked?: boolean; } /** how many trailing days set the pace a project's issues are forecast at */ @@ -275,6 +279,13 @@ export function weeksThrough(start: string, end: string): number { return Math.min(MAX_WEEKS, Math.max(1, weeks)); } +/** Whole weeks, rounded up, from a last day `end` to `day` (both + * YYYY-MM-DD): "3 weeks past". The one count every view says, so a band, a + * bar and a row never disagree about the same end. */ +export function weeksPast(end: string, day: string): number { + return Math.ceil(((dayStart(day) as number) - (dayStart(end) as number)) / (7 * DAY)); +} + /** The last planned day of an item: its Sunday, `weeks` after it starts. */ export function planEnd(item: Pick): string { return utcDay((dayStart(item.start) as number) + (item.weeks * 7 - 1) * DAY); @@ -615,16 +626,16 @@ export function updatesOwed( } /** `today` is the day `now` falls on where the team is (dayIn), which says - * whether a plan is past its end. */ + * whether a plan is past its end. A not-tracked plan owes nothing. */ export function healthStanding( item: Pick< RoadmapItem, - 'status' | 'start' | 'weeks' | 'end_kind' | 'update' | 'created_at' | 'lately' + 'status' | 'start' | 'weeks' | 'end_kind' | 'update' | 'created_at' | 'lately' | 'untracked' >, now: number = Date.now() / 1000, today: string = dayIn(now) ): HealthStanding { - if (isStopped(item.status)) return { kind: 'quiet' }; + if (isStopped(item.status) || item.untracked) return { kind: 'quiet' }; const active = inProgress(item); const u = item.update; if (!u) { diff --git a/shared/model/work.ts b/shared/model/work.ts index 35c85250..03d98279 100644 --- a/shared/model/work.ts +++ b/shared/model/work.ts @@ -123,14 +123,19 @@ export interface WorkPull { number: number; title: string; author: string; - /** epoch secs */ + /** epoch secs it opened */ createdAt: number; + /** epoch secs a former draft was marked ready, when the server knows: + * as work it starts then (planWork), not when it opened */ + readyAt?: number | null; mergedAt: number | null; /** epoch secs it closed, merged or not; null while open */ closedAt: number | null; state: 'open' | 'closed'; /** the issues its body links on purpose (bodyLinks) */ links: IssueRef[]; + /** a draft: it isn't work yet, so no count takes it (projects.ts isDraft) */ + draft?: boolean; } /** A PR that links an issue, as the issue's row shows it: its title and @@ -140,8 +145,14 @@ export interface IssuePull { number: number; title: string | null; author: string | null; + /** epoch secs it opened */ createdAt: number | null; + /** epoch secs a former draft was marked ready (WorkPull's) */ + readyAt?: number | null; state: 'open' | 'merged' | 'closed' | null; + /** an open draft: listed, never counted (projects.ts isDraft). The board + * reads it off its live row when it has one, which is newer. */ + draft?: true; /** on a PR that does none of the project's issues: the issues it does * that other projects have (their slugs), so the page can say where the * work may belong. Absent when there are none. */ @@ -318,13 +329,17 @@ export function planWork(inputs: WorkInputs): PlanWork[] { const mine = byProject.get(slug) ?? []; if (!mine.length) continue; for (const pr of prs) { - const plan = planOfWork(mine, pr.createdAt); + // a draft isn't work yet: not open, not late, not a comeback + if (pr.draft) continue; + // a former draft starts as work the day it was marked ready + const at = pr.readyAt ?? pr.createdAt; + const plan = planOfWork(mine, at); if (!plan) continue; if (pr.state === 'open') openPulls.set(plan.id, (openPulls.get(plan.id) ?? 0) + 1); - if (pr.createdAt >= afterEndOf(plan)) push(afterEnd, plan.id, pr); + if (at >= afterEndOf(plan)) push(afterEnd, plan.id, pr); const stopped = plan.status === 'done' || plan.status === 'dropped'; const since = statusAt(plan); - if (stopped && since != null && pr.createdAt > since + TAIL_DAYS * DAY) { + if (stopped && since != null && at > since + TAIL_DAYS * DAY) { push(afterDone, plan.id, pr); } } @@ -345,13 +360,16 @@ export function projectCounts(inputs: Pick): Map { + const gated = new Set(); + const stub = new Proxy({}, { get: (_, method) => path => method === 'get' && gated.add(path) }); + authManager.setupRoutes(stub); + const source = readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + // the API checks its bearer token inline (apiAuth) instead + const reads = [...source.matchAll(/app\.get\('([^']+)',\s*(\w+)/g)] + .filter(m => m[2] !== 'apiAuth') + .map(m => m[1]); + assert.ok(reads.includes('/initiatives')); + assert.deepEqual( + reads.filter(path => !gated.has(path)), + [] + ); +}); diff --git a/test/db-pull.test.js b/test/db-pull.test.js new file mode 100644 index 00000000..39005381 --- /dev/null +++ b/test/db-pull.test.js @@ -0,0 +1,40 @@ +import { test, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import db from '../lib/db.js'; +import DBPull from '../models/db_pull.js'; + +const pull = draft => ({ + data: { + repo: 'o/r', + number: 1, + state: 'open', + title: 't', + body: '', + draft, + created_at: '2026-09-01T00:00:00Z', + updated_at: '2026-09-28T10:00:00Z', + milestone: {}, + head: { ref: 'f', sha: 'abc' }, + base: { ref: 'master' }, + user: { login: 'dana' }, + }, +}); + +test('saving a pull stamps ready_at as it leaves draft, before draft takes the new flag', async t => { + const sent = []; + t.mock.method(db, 'query', async (sql, params) => sent.push({ sql, params })); + await new DBPull(pull(false)).save(); + const [{ sql, params }] = sent; + // a fresh row has none: ready_at isn't in what's inserted + assert.equal('ready_at' in params[0], false); + // on an update it's assigned first, so `draft` still reads the stored flag: + // a stored draft saved as ready takes its update time, one saved as a + // draft clears it, and otherwise it keeps what it had + const update = sql.split('ON DUPLICATE KEY UPDATE ')[1]; + assert.equal( + update, + '`ready_at` = IF(? = 1, NULL, IF(`draft` = 1, COALESCE(?, UNIX_TIMESTAMP()), `ready_at`)), ?' + ); + assert.deepEqual(params.slice(1, 3), [0, Date.parse('2026-09-28T10:00:00Z') / 1000]); + mock.restoreAll(); +}); diff --git a/test/db-retry.test.js b/test/db-retry.test.js index 32a482d6..d918d474 100644 --- a/test/db-retry.test.js +++ b/test/db-retry.test.js @@ -1,6 +1,6 @@ -import { test } from 'node:test'; +import { test, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { retryIfIdleClosed } from '../lib/db.js'; +import db, { retryIfIdleClosed } from '../lib/db.js'; const idleClosed = Object.assign(new Error('disconnected by the server'), { errno: 4031 }); @@ -39,3 +39,10 @@ test('it retries only once', async () => { ); assert.equal(calls, 2); }); + +test('queries are sent without a timeout, so a long migration can finish', async () => { + mock.method(db.pool, 'query', async (sql, params) => [{ sql, params }]); + const sent = await db.query('SELECT ?', [1]); + mock.restoreAll(); + assert.deepEqual(sent, { sql: 'SELECT ?', params: [1] }); +}); diff --git a/test/hooks-label-rename.test.js b/test/hooks-label-rename.test.js index 62bf2ee0..df2f9699 100644 --- a/test/hooks-label-rename.test.js +++ b/test/hooks-label-rename.test.js @@ -96,10 +96,14 @@ test('a project moves once no repo has a PR or issue under its old label', async assert.equal(await renameProject(settings, 'audit', 'remediation'), true); const sql = queries.map(q => [q.sql.split(' ').slice(0, 3).join(' '), q.params]); assert.deepEqual( - sql.filter(([s]) => s.startsWith('UPDATE')), + sql.filter(([s]) => /^(UPDATE|DELETE)/.test(s)), [ ['UPDATE IGNORE `project_issues`', ['remediation', 'audit']], ['UPDATE `roadmap_items` SET', ['remediation', 'audit']], + // its initiative filing moves with the plan; the old row goes when + // the new slug was filed already (its home wins) + ['UPDATE IGNORE `initiative_projects`', ['remediation', 'audit']], + ['DELETE FROM `initiative_projects`', ['audit']], ] ); const ongoing = queries.find(q => q.sql.startsWith('REPLACE INTO `project_settings`')); @@ -112,9 +116,39 @@ test('a project another repo still labels the old way stays put', async () => { assert.equal(queries.length, 1); }); -test('a roadmap item already tracking the new slug keeps it', async () => { +test('a roadmap item already tracking the new slug keeps it, and the old plan its home', async () => { const queries = fakeDb({ '`roadmap_items` WHERE': [{ n: 1 }] }); await renameProject(settings, 'audit', 'remediation'); assert.ok(!queries.some(q => q.sql.startsWith('UPDATE `roadmap_items`'))); assert.ok(queries.some(q => q.sql.startsWith('UPDATE IGNORE `project_issues`'))); + // the plan left on the old slug keeps its filing; the new slug keeps its own + assert.ok(!queries.some(q => q.sql.includes('`initiative_projects`'))); +}); + +test('a project another repo still labels the old way keeps its filing too', async () => { + const queries = fakeDb({ '`pull_labels`': [{ n: 2 }] }); + await renameProject(settings, 'audit', 'remediation'); + assert.ok(!queries.some(q => q.sql.includes('`initiative_projects`'))); +}); + +test('a case-only label rename keeps the project filed', async () => { + // initiative_projects.project is utf8mb4_general_ci: '=' ignores case + // unless the column is compared BINARY, so this fake does the same + const same = (sql, a, b) => + sql.includes('BINARY') ? a === b : a.toLowerCase() === b.toLowerCase(); + let filings = [{ project: 'Docs', initiative_id: 3 }]; + mock.method(db, 'query', async (sql, params) => { + if (sql.startsWith('UPDATE IGNORE `initiative_projects`')) { + const [to, from] = params; + for (const f of filings) if (same(sql, f.project, from)) f.project = to; + return { affectedRows: 1 }; + } + if (sql.startsWith('DELETE FROM `initiative_projects`')) { + filings = filings.filter(f => !same(sql, f.project, params[0])); + return { affectedRows: 1 }; + } + return sql.includes('COUNT(*)') ? [{ n: 0 }] : []; + }); + assert.equal(await renameProject({ prefix: 'P-' }, 'Docs', 'docs'), true); + assert.deepEqual(filings, [{ project: 'docs', initiative_id: 3 }]); }); diff --git a/test/initiatives.test.js b/test/initiatives.test.js new file mode 100644 index 00000000..efe96508 --- /dev/null +++ b/test/initiatives.test.js @@ -0,0 +1,683 @@ +import { test, before, after, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import bodyParser from 'body-parser'; +import config from '../lib/config-loader.js'; +import db from '../lib/db.js'; +import Pull from '../models/pull.js'; +import Label from '../models/label.js'; +import pullManager from '../lib/pull-manager.js'; +import { fileProject, grownSlugs, prCount, trackedOnly } from '../lib/initiatives.js'; +import initiativesController from '../controllers/initiatives.js'; +import roadmapController, { canWrite } from '../controllers/roadmap.js'; +import { API_ROUTES, PROJECT_WRITES } from '../controllers/api-routes.js'; + +// In-memory initiatives and initiative_projects tables behind a stubbed +// db.query, so each write shows in the next read as it would in MySQL. They +// answer only the statements lib/initiatives.js sends, plus the roadmap's +// reads (its plans, for each initiative's span and the close check). +let initiatives = []; +let filings = []; +let plans = []; +// labeled PRs: { title, date, repo?, owner?, draft? } +let prs = []; +let nextId = 1; + +// project and slug columns are utf8mb4_general_ci: '=' ignores case +const same = (a, b) => a.toLowerCase() === b.toLowerCase(); + +const byRank = () => [...initiatives].sort((a, b) => a.priority - b.priority || a.id - b.id); + +function fakeQuery(sql, params = []) { + if (sql.includes('`roadmap_updates`')) return []; + if (sql.startsWith('UPDATE `roadmap_items` SET ?')) { + const plan = plans.find(p => p.id === params[1]); + if (plan) Object.assign(plan, params[0]); + return { affectedRows: plan ? 1 : 0 }; + } + if (sql.includes('FROM `roadmap_items`')) return plans; + if (sql.startsWith('SELECT `id`, `slug`')) return byRank(); + if (sql.startsWith('SELECT `project`, `initiative_id`')) { + return [...filings].sort((a, b) => a.project.localeCompare(b.project)); + } + if (sql.startsWith('SELECT MAX(`priority`)')) { + return [{ top: initiatives.length ? Math.max(...initiatives.map(i => i.priority)) : null }]; + } + if (sql.startsWith('INSERT INTO `initiatives` SET ?')) { + if (initiatives.some(i => i.slug === params[0].slug)) { + throw Object.assign(new Error('Duplicate entry'), { code: 'ER_DUP_ENTRY' }); + } + const row = { id: nextId++, ...params[0] }; + initiatives.push(row); + return { insertId: row.id, affectedRows: 1 }; + } + if (sql.startsWith('SELECT `status` FROM `initiatives`')) { + return initiatives.filter(i => i.id === params[0]); + } + if (sql.startsWith('UPDATE `initiatives` SET ? WHERE `id` = ?')) { + const row = initiatives.find(i => i.id === params[1]); + if (row) Object.assign(row, params[0]); + return { affectedRows: row ? 1 : 0 }; + } + if (sql.startsWith('DELETE FROM `initiatives`')) { + const [id] = params; + const held = filings.some(f => f.initiative_id === id); + const before = initiatives.length; + if (!held) initiatives = initiatives.filter(i => i.id !== id); + return { affectedRows: before - initiatives.length }; + } + if (sql.startsWith('UPDATE `initiatives` SET `priority` = CASE')) { + const pairs = params.slice(0, -1); + for (let i = 0; i < pairs.length; i += 2) { + initiatives.find(r => r.id === pairs[i]).priority = pairs[i + 1]; + } + return { affectedRows: pairs.length / 2 }; + } + if (sql.startsWith('SELECT f.`project`')) { + return filings + .filter(f => same(f.project, params[0])) + .map(f => { + const i = initiatives.find(r => r.id === f.initiative_id); + return { ...f, slug: i.slug, kind: i.kind }; + }); + } + if (sql.startsWith('SELECT DISTINCT p.`repo`, p.`number`, p.`owner`')) { + const [title, since] = params; + return prs + .filter(p => same(p.title, title) && p.date > since && !p.draft) + .map((p, number) => ({ repo: 'o/r', number, owner: p.owner ?? 'dana' })); + } + if (sql.startsWith('SELECT DISTINCT l.`title`, p.`repo`, p.`number`, p.`date`, p.`owner`')) { + return prs + .filter(p => params[0].some(t => same(t, p.title)) && !p.draft) + .map((p, number) => ({ + title: p.title, + repo: 'o/r', + number, + date: p.date, + owner: p.owner ?? 'dana', + })); + } + if (sql.startsWith('SELECT `title` FROM `pull_labels`')) { + return prs.filter(p => same(p.title, params[0])).slice(0, 1); + } + if (sql.startsWith('SELECT l.`title`, p.`repo`')) { + return prs.map(p => ({ title: p.title, repo: p.repo ?? 'o/r' })); + } + if (sql.startsWith('SELECT `id`, `kind`, `status`')) { + return initiatives.filter(i => same(i.slug, params[0])); + } + if (sql.startsWith('DELETE FROM `initiative_projects`')) { + filings = filings.filter(f => !same(f.project, params[0])); + return { affectedRows: 1 }; + } + if (sql.startsWith('REPLACE INTO `initiative_projects` SET ?')) { + filings = [...filings.filter(f => !same(f.project, params[0].project)), { ...params[0] }]; + return { affectedRows: 1 }; + } + // the board's projects and PR links (the close check's open PRs): none + if (/FROM issues i|FROM pull_labels l JOIN issues|FROM `project_issues`|FROM `issue_pull_links`/.test(sql)) return []; + throw new Error(`unexpected query: ${sql}`); +} + +function makeApp() { + const app = express(); + app.use(bodyParser.json()); + app.use((req, res, next) => { + req.isAuthenticated = () => true; + req.user = { username: 'alice' }; + next(); + }); + app.get('/initiatives', initiativesController.list); + app.post('/initiatives', canWrite, initiativesController.create); + app.put('/initiatives/file', canWrite, initiativesController.file); + app.patch('/initiatives/:id', canWrite, initiativesController.update); + app.delete('/initiatives/:id', canWrite, initiativesController.remove); + app.post('/initiatives/:id/move', canWrite, initiativesController.move); + app.patch('/roadmap/:id', canWrite, roadmapController.update); + return app; +} + +// holds one statement (`at`) until another (`release`) is sent or 200 ms +// pass, to make two writes interleave +let hold = null; +// every statement, in the order it ran (a held one when it's let go) +const ranInOrder = []; +function holdUntil(at, release) { + let open; + let reached; + const until = new Promise(r => (open = r)); + const arrived = new Promise(r => (reached = r)); + hold = { at, release, until, open, reached }; + return Promise.race([arrived, new Promise(r => setTimeout(r, 1000))]); +} + +let server; +let base; +before(async () => { + mock.method(db, 'query', async (sql, params) => { + // a release counts once the held statement is waiting + if (hold?.waiting && sql.startsWith(hold.release)) hold.open(); + if (hold && sql.startsWith(hold.at)) { + hold.waiting = true; + hold.reached(); + await Promise.race([hold.until, new Promise(r => setTimeout(r, 200))]); + } + ranInOrder.push(sql); + return fakeQuery(sql, params); + }); + await new Promise(resolve => { + server = makeApp().listen(0, () => { + base = `http://127.0.0.1:${server.address().port}`; + resolve(); + }); + }); +}); +after(() => { + mock.restoreAll(); + server.close(); +}); +beforeEach(() => { + initiatives = []; + filings = []; + plans = []; + prs = []; + nextId = 1; + hold = null; + ranInOrder.length = 0; +}); + +async function call(method, path, body) { + const res = await fetch(base + path, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +const make = (slug, over = {}) => call('POST', '/initiatives', { slug, name: slug, ...over }); + +/** A roadmap_items row, as lib/roadmap.js selects it. */ +const plan = (id, project, over = {}) => ({ + id, + name: `Plan ${id}`, + project, + team: null, + lead_login: null, + status: 'active', + origin: null, + start: '2026-09-07', + weeks: 4, + end_kind: 'soft', + done_when: '', + priority: id, + notes: '', + waits_on: null, + updated_by: null, + updated_at: 1, + created_at: 1, + status_at: 1, + ...over, +}); + +test('a new initiative is ranked last, open, and its slug is its own', async () => { + const first = await make('site-speed', { goal: 'Fast\npages', owner: 'dana' }); + assert.equal(first.status, 201); + assert.deepEqual( + [first.body.initiative.slug, first.body.initiative.goal, first.body.initiative.owner], + ['site-speed', 'Fast pages', 'dana'] + ); + assert.deepEqual( + [first.body.initiative.kind, first.body.initiative.status, first.body.initiative.created_by], + ['initiative', 'open', 'alice'] + ); + const second = await make('klo', { kind: 'upkeep' }); + assert.equal(second.body.initiative.priority, first.body.initiative.priority + 1); + assert.equal(second.body.initiative.kind, 'upkeep'); + assert.equal((await make('klo')).status, 409); + assert.equal((await call('POST', '/initiatives', { name: 'No slug' })).status, 400); +}); + +test('GET lists them in rank order with their projects and span', async () => { + await make('site-speed'); + await make('api'); + await call('PUT', '/initiatives/file', { project: 'bot-defense', initiative: 'site-speed' }); + plans = [ + plan(1, 'bot-defense', { start: '2026-07-13', weeks: 16 }), + plan(2, 'bot-defense', { start: '2026-08-03', end_kind: 'ongoing' }), + plan(3, 'elsewhere', { start: '2026-01-05' }), + ]; + const { status, body } = await call('GET', '/initiatives'); + assert.equal(status, 200); + assert.deepEqual( + body.initiatives.map(i => [i.slug, i.projects.map(f => f.project)]), + [ + ['site-speed', ['bot-defense']], + ['api', []], + ] + ); + assert.deepEqual(body.initiatives[0].span, { + start: '2026-07-13', + end: '2026-11-01', + with_end: 1, + ongoing: 1, + end_kind: 'soft', + ended: null, + }); + assert.equal(body.initiatives[1].span.end_kind, null); +}); + +test('a move changes only initiatives’ ranks', async () => { + const a = (await make('a')).body.initiative.id; + const b = (await make('b')).body.initiative.id; + const c = (await make('c')).body.initiative.id; + const moved = await call('POST', `/initiatives/${c}/move`, { before: a }); + assert.deepEqual( + moved.body.initiatives.map(i => i.slug), + ['c', 'a', 'b'] + ); + const bottom = await call('POST', `/initiatives/${c}/move`, { before: null }); + assert.deepEqual( + bottom.body.initiatives.map(i => i.slug), + ['a', 'b', 'c'] + ); + assert.equal((await call('POST', `/initiatives/${b}/move`, { before: 99 })).status, 404); + assert.equal((await call('POST', `/initiatives/${b}/move`, {})).status, 400); +}); + +test('an edit changes what was sent; status says when, Keep open restamps, undo puts times back', async () => { + const id = (await make('a')).body.initiative.id; + const renamed = await call('PATCH', `/initiatives/${id}`, { name: 'Alpha', owner: null }); + assert.deepEqual([renamed.body.initiative.name, renamed.body.initiative.owner], ['Alpha', null]); + assert.equal(renamed.body.initiative.status_at, null); + assert.equal((await call('PATCH', `/initiatives/${id}`, { slug: 'b' })).status, 400); + // Keep open: the status it has, said again + const kept = await call('PATCH', `/initiatives/${id}`, { status: 'open', restate: true }); + assert.ok(kept.body.initiative.status_at > 0); + const undone = await call('PATCH', `/initiatives/${id}`, { + status: 'open', + undo: { updated_at: 5, status_at: null }, + }); + assert.deepEqual([undone.body.initiative.status_at, undone.body.initiative.updated_at], [null, 5]); + assert.equal((await call('PATCH', '/initiatives/99', { name: 'X' })).status, 404); +}); + +test('an initiative closes only once nothing in it is going', async () => { + const id = (await make('firmware')).body.initiative.id; + await call('PUT', '/initiatives/file', { project: 'hub', initiative: 'firmware' }); + await call('PUT', '/initiatives/file', { project: 'bench', initiative: 'firmware' }); + plans = [plan(1, 'hub'), plan(2, 'bench', { status: 'done' })]; + const refused = await call('PATCH', `/initiatives/${id}`, { status: 'done' }); + assert.equal(refused.status, 409); + assert.deepEqual(refused.body.live, ['hub']); + plans = [plan(1, 'hub', { status: 'parked' }), plan(2, 'bench', { status: 'done' })]; + const closed = await call('PATCH', `/initiatives/${id}`, { status: 'done' }); + assert.equal(closed.status, 200); + assert.equal(closed.body.initiative.status, 'done'); + assert.ok(closed.body.initiative.status_at > 0); +}); + +test('Not tracked and upkeep are one of each and never close', async () => { + const shelf = (await make('shelf', { kind: 'untracked' })).body.initiative.id; + const klo = (await make('klo', { kind: 'upkeep' })).body.initiative.id; + assert.equal((await make('shelf-2', { kind: 'untracked' })).status, 409); + assert.equal((await make('klo-2', { kind: 'upkeep' })).status, 409); + assert.equal((await make('more')).status, 201); + for (const id of [shelf, klo]) { + const refused = await call('PATCH', `/initiatives/${id}`, { status: 'dropped' }); + assert.equal(refused.status, 409); + // still open, and its other fields still change + const renamed = await call('PATCH', `/initiatives/${id}`, { name: 'Renamed', status: 'open' }); + assert.deepEqual([renamed.status, renamed.body.initiative.status], [200, 'open']); + } +}); + +test('an initiative with a finished plan whose PRs are still open does not close', async () => { + const saved = config.projects; + config.projects = { repo: 'test/projects' }; + try { + const id = (await make('firmware')).body.initiative.id; + await call('PUT', '/initiatives/file', { project: 'hub', initiative: 'firmware' }); + plans = [plan(1, 'hub', { status: 'done' })]; + // hub's plan is done, but a PR labeled with it is open on the board + const now = Math.floor(Date.now() / 1000); + const row = { + repo: 'test/repo-a', + number: 41, + state: 'open', + title: 'PR 41', + body: '', + draft: 0, + date: now - 86400, + date_updated: now, + date_closed: null, + mergeable: 1, + date_merged: null, + difficulty: null, + additions: 1, + deletions: 0, + changed_files: 1, + milestone_title: null, + milestone_due_on: null, + head_branch: 'b41', + head_sha: 'sha41', + base_branch: 'main', + owner: 'alice', + assignees: [], + requested_reviewers: [], + cr_req: 1, + qa_req: 1, + }; + const labels = [new Label({ name: 'project:hub' }, 41, 'test/repo-a', 'alice')]; + pullManager.updatePull(Pull.getFromDB(row, [], [], [], [], labels)); + const refused = await call('PATCH', `/initiatives/${id}`, { status: 'done' }); + assert.equal(refused.status, 409); + assert.deepEqual(refused.body.live, ['hub']); + // closed, the PR no longer holds it open + pullManager.getPulls()[0].data.state = 'closed'; + pullManager.getPulls()[0].data.date_closed = now; + assert.equal((await call('PATCH', `/initiatives/${id}`, { status: 'done' })).status, 200); + } finally { + config.projects = saved; + pullManager.getPulls().length = 0; + } +}); + +test('only an empty initiative can be deleted', async () => { + const id = (await make('a')).body.initiative.id; + await call('PUT', '/initiatives/file', { project: 'p', initiative: 'a' }); + assert.equal((await call('DELETE', `/initiatives/${id}`)).status, 409); + await call('PUT', '/initiatives/file', { project: 'p', initiative: null }); + assert.equal((await call('DELETE', `/initiatives/${id}`)).status, 200); + assert.equal((await call('DELETE', `/initiatives/${id}`)).status, 404); + // its slug is free again + assert.equal((await make('a')).status, 201); +}); + +test('nothing is filed into a closed initiative, but an undo puts a filing back there', async () => { + const id = (await make('a')).body.initiative.id; + initiatives.find(i => i.id === id).status = 'done'; + const refused = await call('PUT', '/initiatives/file', { project: 'p', initiative: 'a' }); + assert.equal(refused.status, 409); + assert.match(refused.body.error, /closed/); + assert.deepEqual(filings, []); + // an undo restores the filing a move replaced, with its time + const back = await call('PUT', '/initiatives/file', { + project: 'p', + initiative: 'a', + set_at: 50, + baseline_prs: 0, + }); + assert.equal(back.status, 200); + assert.equal(back.body.home.initiative, 'a'); +}); + +test('filing moves a project’s one home, 409s a stale expect, and says what it replaced', async () => { + await make('a'); + await make('b'); + const first = await call('PUT', '/initiatives/file', { project: 'p', initiative: 'a', expect: null }); + assert.equal(first.status, 200); + assert.deepEqual(first.body.before, { initiative: null, set_by: null, set_at: null, baseline_prs: 0 }); + assert.equal(first.body.home.initiative, 'a'); + assert.equal(first.body.home.set_by, 'alice'); + const stale = await call('PUT', '/initiatives/file', { project: 'p', initiative: 'b', expect: null }); + assert.equal(stale.status, 409); + assert.equal(stale.body.home.initiative, 'a'); + const moved = await call('PUT', '/initiatives/file', { project: 'p', initiative: 'b', expect: 'a' }); + assert.equal(moved.body.before.initiative, 'a'); + assert.deepEqual( + filings.map(f => f.project), + ['p'] + ); + assert.equal((await call('PUT', '/initiatives/file', { project: 'p', initiative: 'nope' })).status, 404); + assert.equal((await call('PUT', '/initiatives/file', { project: 'bad slug', initiative: 'a' })).status, 400); + const unfiled = await call('PUT', '/initiatives/file', { project: 'p', initiative: null }); + assert.equal(unfiled.body.home, null); + assert.deepEqual(filings, []); +}); + +test('filing into Not tracked sets the growth check’s start, and its undo puts it back', async () => { + await make('not-tracked', { kind: 'untracked' }); + await make('a'); + const now = Math.floor(Date.now() / 1000); + const DAY = 86400; + prs = [ + ...Array.from({ length: 5 }, () => ({ title: 'project:ops-ci', date: now - 40 * DAY })), + // an issue's label isn't counted: lib/initiatives.js joins pulls + ]; + // with no baseline sent, its PR count now + const set = await call('PUT', '/initiatives/file', { project: 'ops-ci', initiative: 'not-tracked' }); + assert.equal(set.body.home.baseline_prs, 5); + // the load sends its count from GitHub + const loaded = await call('PUT', '/initiatives/file', { + project: 'ops-ci', + initiative: 'not-tracked', + baseline_prs: 40, + }); + assert.equal(loaded.body.home.baseline_prs, 40); + // three PRs later, Keep: the old baseline plus the PRs since it was set + filings[0].set_at = now - 10 * DAY; + prs.push(...Array.from({ length: 3 }, () => ({ title: 'project:ops-ci', date: now - DAY }))); + const keep = await call('PUT', '/initiatives/file', { + project: 'ops-ci', + initiative: 'not-tracked', + expect: 'not-tracked', + }); + assert.equal(keep.body.home.baseline_prs, 43); + assert.deepEqual(keep.body.before, { + initiative: 'not-tracked', + set_by: 'alice', + set_at: now - 10 * DAY, + baseline_prs: 40, + }); + // undoing the Keep puts the old start back, so the count doesn't restart + const undo = await call('PUT', '/initiatives/file', { + project: 'ops-ci', + initiative: keep.body.before.initiative, + expect: 'not-tracked', + set_at: keep.body.before.set_at, + baseline_prs: keep.body.before.baseline_prs, + }); + assert.deepEqual([undo.body.home.set_at, undo.body.home.baseline_prs], [now - 10 * DAY, 40]); + // a filing anywhere else keeps no baseline + const tracked = await call('PUT', '/initiatives/file', { project: 'ops-ci', initiative: 'a' }); + assert.equal(tracked.body.home.baseline_prs, 0); + // an undo time from the future, or a negative count, is refused + const later = await call('PUT', '/initiatives/file', { + project: 'ops-ci', + initiative: 'a', + set_at: now + 3600, + }); + assert.equal(later.status, 400); + const negative = await call('PUT', '/initiatives/file', { + project: 'ops-ci', + initiative: 'a', + baseline_prs: -1, + }); + assert.equal(negative.status, 400); +}); + +test('trackedOnly leaves not-tracked projects out of Today, the plans and by-slug numbers', () => { + const untracked = new Set(['ops']); + const out = trackedOnly(untracked, { + today: { live: [{ slug: 'ops' }, { slug: 'store' }], quiet: [{ slug: 'ops' }], misc: [1] }, + items: [{ project: 'ops' }, { project: 'store' }, { project: null }], + bySlug: { ops: 1, store: 2 }, + }); + assert.deepEqual(out.today, { live: [{ slug: 'store' }], quiet: [], misc: [1] }); + assert.deepEqual(out.items, [{ project: 'store' }, { project: null }]); + assert.deepEqual(out.bySlug, { store: 2 }); + assert.deepEqual(trackedOnly(untracked, { items: [] }), { today: undefined, items: [], bySlug: undefined }); +}); + +test('every API write is one open boards hear about, initiatives included', () => { + const writes = API_ROUTES.filter(r => r.method !== 'get'); + for (const { path } of writes) { + assert.ok( + PROJECT_WRITES.some(prefix => path === prefix || path.startsWith(`${prefix}/`)), + `${path} isn't in PROJECT_WRITES` + ); + } + for (const prefix of ['/initiatives', '/api/v1/initiatives']) assert.ok(PROJECT_WRITES.includes(prefix)); + const initiativeRoutes = API_ROUTES.filter(r => r.path.startsWith('/api/v1/initiatives')); + assert.deepEqual( + initiativeRoutes.map(r => `${r.method} ${r.path}`), + [ + 'get /api/v1/initiatives', + 'post /api/v1/initiatives', + 'put /api/v1/initiatives/file', + 'patch /api/v1/initiatives/:id', + 'delete /api/v1/initiatives/:id', + 'post /api/v1/initiatives/:id/move', + ] + ); + for (const r of initiativeRoutes) { + assert.ok(r.does.length > 20); + if (r.method !== 'get') assert.equal(r.handlers[0], canWrite); + } +}); + +test('a slug in other case files the project as its label spells it', async () => { + await make('site-speed'); + await make('b'); + prs = [{ title: 'project:site-speed', date: 1 }]; + const filed = await call('PUT', '/initiatives/file', { + project: 'Site-Speed', + initiative: 'Site-Speed', + expect: null, + }); + assert.equal(filed.status, 200); + assert.equal(filed.body.project, 'site-speed'); + assert.equal(filed.body.home.initiative, 'site-speed'); + assert.deepEqual( + filings.map(f => f.project), + ['site-speed'] + ); + // the home it has, in any case, is the home the person saw + const moved = await call('PUT', '/initiatives/file', { + project: 'site-speed', + initiative: 'b', + expect: 'SITE-speed', + }); + assert.equal(moved.status, 200); + assert.equal(moved.body.before.initiative, 'site-speed'); +}); + +test('a project Not tracked by its repos stays out of Unsorted, except on an undo', async () => { + await make('not-tracked', { kind: 'untracked' }); + prs = [{ title: 'project:deploys', date: 1, repo: 'iFixit/ops' }]; + await call('PUT', '/initiatives/file', { project: 'deploys', initiative: 'not-tracked' }); + const opts = { prefix: 'project:', expect: 'not-tracked', untrackedRepos: ['ops'] }; + const refused = await fileProject('deploys', null, 'kyle', opts); + assert.equal(refused.untracked, true); + assert.equal(filings.length, 1); + // one PR elsewhere ends the repo default, so Unsorted is real then + const undone = await fileProject('deploys', null, 'kyle', { ...opts, undo: true }); + assert.equal(undone.home, null); + assert.equal(filings.length, 0); +}); + +test('a baseline past what the column holds is refused', async () => { + await make('a'); + const big = await call('PUT', '/initiatives/file', { + project: 'p', + initiative: 'a', + baseline_prs: 5_000_000_000, + set_at: 50, + }); + assert.equal(big.status, 400); +}); + +test('the growth check counts no drafts and no bots, the same on both sides', async () => { + const now = Math.floor(Date.now() / 1000); + prs = [ + { title: 'project:ops', date: now, owner: 'dana' }, + { title: 'project:ops', date: now, owner: 'dana', draft: 1 }, + { title: 'project:ops', date: now, owner: 'fixture-bot' }, + ...Array.from({ length: 30 }, () => ({ + title: 'project:ops', + date: now, + owner: 'renovate[bot]', + })), + ]; + assert.equal(await prCount('project:', 'ops'), 1); + const grown = await grownSlugs({ prefix: 'project:' }, [], new Set(['ops'])); + assert.equal(grown.size, 0); +}); + +test('a delete racing a filing into it leaves no filing in a missing initiative', async () => { + const id = (await make('a')).body.initiative.id; + const reached = holdUntil('REPLACE INTO `initiative_projects`', 'DELETE FROM `initiatives`'); + const filing = call('PUT', '/initiatives/file', { project: 'p', initiative: 'a' }); + await reached; + const deleting = call('DELETE', `/initiatives/${id}`); + await Promise.all([filing, deleting]); + assert.ok( + filings.every(f => initiatives.some(i => i.id === f.initiative_id)), + 'a filing points at a deleted initiative' + ); +}); + +test('a close racing a filing of a live project into it is refused', async () => { + const id = (await make('a')).body.initiative.id; + plans = [plan(1, 'p')]; + const reached = holdUntil('REPLACE INTO `initiative_projects`', 'UPDATE `initiatives` SET ?'); + const filing = call('PUT', '/initiatives/file', { project: 'p', initiative: 'a' }); + await reached; + const closing = call('PATCH', `/initiatives/${id}`, { status: 'done' }); + const [, closed] = await Promise.all([filing, closing]); + assert.equal(closed.status, 409); + assert.equal(initiatives[0].status, 'open'); +}); + +test('an Undo of a Reopen closes it again, though a project in it is still going', async () => { + const id = (await make('a')).body.initiative.id; + await call('PUT', '/initiatives/file', { project: 'p', initiative: 'a' }); + const row = initiatives.find(i => i.id === id); + Object.assign(row, { status: 'done', status_at: 40, updated_at: 40 }); + // Decide asked to reopen it because p's plan is under way + plans = [plan(1, 'p')]; + const reopened = await call('PATCH', `/initiatives/${id}`, { status: 'open' }); + assert.equal(reopened.status, 200); + const undone = await call('PATCH', `/initiatives/${id}`, { + status: 'done', + undo: { updated_at: 40, status_at: 40 }, + }); + assert.equal(undone.status, 200); + assert.equal(row.status, 'done'); + // without undo, the same close is refused + row.status = 'open'; + assert.equal((await call('PATCH', `/initiatives/${id}`, { status: 'done' })).status, 409); + // nor does an undo with no times to put back skip it + for (const undo of [{}, true, 1]) { + assert.equal( + (await call('PATCH', `/initiatives/${id}`, { status: 'done', undo })).status, + 409 + ); + } + assert.equal(row.status, 'open'); +}); + +test('a plan’s status saved during a close lands after it, not between its check and its write', async () => { + const id = (await make('a')).body.initiative.id; + await call('PUT', '/initiatives/file', { project: 'p', initiative: 'a' }); + plans = [plan(1, 'p', { status: 'parked' })]; + const reached = holdUntil('UPDATE `initiatives` SET ?', 'UPDATE `roadmap_items`'); + const closing = call('PATCH', `/initiatives/${id}`, { status: 'done' }); + await reached; + const starting = call('PATCH', '/roadmap/1', { status: 'active' }); + await Promise.all([closing, starting]); + const at = start => ranInOrder.findIndex(sql => sql.startsWith(start)); + assert.ok(at('UPDATE `initiatives` SET ?') >= 0); + assert.ok(at('UPDATE `initiatives` SET ?') < at('UPDATE `roadmap_items`')); +}); + +test('two creates of Not tracked at once make one', async () => { + const reached = holdUntil('INSERT INTO `initiatives`', 'SELECT `id`, `slug`'); + const first = make('nt-a', { kind: 'untracked' }); + await reached; + const [a, b] = await Promise.all([first, make('nt-b', { kind: 'untracked' })]); + assert.deepEqual([a.status, b.status].sort(), [201, 409]); + assert.equal(initiatives.filter(i => i.kind === 'untracked').length, 1); +}); diff --git a/test/projects.test.js b/test/projects.test.js index 4f758a37..64c03b2f 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -9,6 +9,7 @@ import Issue from '../models/issue.js'; import pullManager from '../lib/pull-manager.js'; import { issueRepos, + loadTimeSpent, parseWindow, projectSettings, projectsFromRows, @@ -19,7 +20,9 @@ import { MAX_WINDOW_DAYS, } from '../lib/projects.js'; import projectsController from '../controllers/projects.js'; +import { forgetProjectCaches } from '../controllers/roadmap.js'; import { fieldsFromNodes } from '../lib/git-manager.js'; +import { windowStats } from '../shared/dist/index.js'; const DAY = 86400; // 2026-09-29 12:00 UTC @@ -256,6 +259,19 @@ test('spansFromRows files PRs by label, leaves bots out, falls back to the merge ]); }); +test('spansFromRows leaves drafts out, open or closed as one', () => { + const spans = spansFromRows( + [ + { repo: 'test/repo-a', number: 1, owner: 'alice', date: 100, date_closed: null, date_merged: null, draft: 1 }, + { repo: 'test/repo-a', number: 2, owner: 'alice', date: 100, date_closed: 150, date_merged: null, draft: 1 }, + { repo: 'test/repo-a', number: 3, owner: 'alice', date: 100, date_closed: null, date_merged: null, draft: 0 }, + ], + [], + 'project:' + ); + assert.deepEqual(spans, [{ author: 'alice', project: null, opened: 100, closed: null, merged: false }]); +}); + test('spansFromRows files a PR with no project label under the project whose issue it links', () => { const spans = spansFromRows( [ @@ -326,6 +342,14 @@ function pullRow(number, owner, over = {}) { // the roadmap_items rows the stubbed db answers with; a test sets its own let roadmapRows = []; +// the initiatives tables, and what the not-tracked reads find in +// pull_labels joined to pulls: each label's repos, and each PR's open time +let initiativeRows = []; +let filingRows = []; +let labelRepos = []; +let labelDates = []; +// how many times the initiatives table was read +let initiativeReads = 0; const label = (title, number) => new Label({ name: title }, number, 'test/repo-a', 'job-bot'); @@ -360,11 +384,14 @@ before(() => { seed(pullRow(12, 'alice'), [label('project:alpha', 12)]); seed(pullRow(13, 'bob'), []); seed(pullRow(14, 'fixture-bot'), [label('project:alpha', 14)]); + // a draft in alpha, and one in no project: listed, counted nowhere + seed(pullRow(16, 'alice', { draft: 1 }), [label('project:alpha', 16)]); + seed(pullRow(17, 'dave', { draft: 1 }), []); // merged yesterday in alpha: alice's third PR there seed(pullRow(15, 'alice', { state: 'closed', date_closed: now - DAY, date_merged: now - DAY }), [ label('project:alpha', 15), ]); - mock.method(db, 'query', async sql => { + mock.method(db, 'query', async (sql, params) => { if (sql.startsWith('SELECT i.* FROM issues')) { return [ issueRow({ number: 7, title: 'Alpha work', assignee: 'alice' }), @@ -383,6 +410,7 @@ before(() => { return [{ repo: 'test/repo-a', number: 11, login: 'alice', at: now - 2 * DAY, owner: 'alice' }]; } if (sql.startsWith('SELECT p.repo, p.number, p.owner AS login, p.date_merged AS at')) return []; + if (sql.startsWith('SELECT p.repo, p.number, p.owner AS login, p.ready_at AS at')) return []; if (sql.includes('FROM comments t')) { return [ { repo: 'test/repo-a', number: 11, login: 'carol', at: now - 2 * DAY, owner: 'alice' }, @@ -418,6 +446,17 @@ before(() => { { reviewer: 'alice', repo: 'test/repo-a', number: 11, author: 'alice', at: now - DAY }, ]; } + if (sql.includes('FROM `initiatives`')) { + initiativeReads++; + return initiativeRows; + } + if (sql.includes('FROM `initiative_projects`')) return filingRows; + if (sql.startsWith('SELECT l.`title`, p.`repo`')) return labelRepos; + if (sql.startsWith('SELECT DISTINCT l.`title`, p.`repo`, p.`number`, p.`date`')) { + return labelDates + .filter(r => params[0].includes(r.title)) + .map((r, number) => ({ repo: 'test/repo-a', number, owner: 'alice', ...r })); + } if (sql.includes('FROM `roadmap_items`')) return roadmapRows; if (sql.includes('FROM `roadmap_updates`')) return []; // alpha's own issue carries its label; its side names #11, on the @@ -479,6 +518,8 @@ test('/api/v1/projects lists each project with today and the window', async () = // ones still need their CR assert.deepEqual(alpha.open, ['test/repo-a#11', 'test/repo-a#12']); assert.deepEqual(alpha.merged_recently, ['test/repo-a#15']); + assert.deepEqual(alpha.drafts, ['test/repo-a#16']); + assert.deepEqual(body.drafts, ['test/repo-a#17']); assert.deepEqual(alpha.flags, ['one_person', 'waiting_on_review']); // both wait on a code review, in the project page's words assert.deepEqual(alpha.stages, { ready: 0, hold: 0, review: 2, work: 0 }); @@ -705,3 +746,253 @@ test('/api/v1/decide asks "past its end?" by the team’s day, as the board’s assert.equal(await pastEnd(Date.UTC(2026, 8, 28, 8)), true); roadmapRows = []; }); + +/** An initiatives row, and a filing in it, as lib/initiatives.js selects them. */ +const initiativeRow = (id, slug, kind = 'initiative', over = {}) => ({ + id, + slug, + name: slug, + kind, + goal: '', + owner_login: 'dana', + priority: id, + status: 'open', + status_at: null, + created_by: null, + created_at: null, + updated_by: null, + updated_at: null, + ...over, +}); +const filingRow = (project, initiative_id, over = {}) => ({ + project, + initiative_id, + set_by: 'dana', + set_at: 1000, + baseline_prs: 0, + ...over, +}); +/** `n` PRs labeled for `slug`, opened at `at` */ +const prsOf = (slug, n, at) => Array.from({ length: n }, () => ({ title: `project:${slug}`, date: at })); + +// what the controllers keep of initiatives (controllers/roadmap.js +// trackedNow) is read again for each fixture, as a write makes it be +function withInitiatives(rows, filings, run) { + initiativeRows = rows; + filingRows = filings; + forgetProjectCaches(); + return run().finally(() => { + initiativeRows = []; + filingRows = []; + labelDates = []; + labelRepos = []; + forgetProjectCaches(); + }); +} + +test('with no initiatives, nothing is not tracked and nothing changes', async () => { + const data = await get('/projects-data'); + assert.deepEqual([data.body.untracked, data.body.grew, data.body.grew_counts], [[], [], {}]); + const { body } = await get('/api/v1/projects'); + const alpha = body.projects.find(p => p.slug === 'alpha'); + assert.deepEqual([alpha.initiative, alpha.tracked, alpha.grew], [null, true, false]); +}); + +test('the board and API endpoints share one read of the initiatives until a write', async () => { + await withInitiatives([initiativeRow(2, 'not-tracked', 'untracked')], [filingRow('alpha', 2)], async () => { + initiativeReads = 0; + for (const path of ['/projects-data', '/api/v1/projects', '/api/v1/decide', '/api/v1/load']) { + await get(path); + } + assert.equal(initiativeReads, 1); + // a write forgets it (app.js projectsChanged), and the next read sees the change + filingRows = []; + forgetProjectCaches(); + const { body } = await get('/projects-data'); + assert.deepEqual([initiativeReads, body.untracked], [2, []]); + }); +}); + +test('/projects-data and /api/v1/projects say each project’s home, tracking and growth', async () => { + const now = Math.floor(Date.now() / 1000); + await withInitiatives( + [initiativeRow(1, 'site-speed'), initiativeRow(2, 'not-tracked', 'untracked')], + [filingRow('beta', 1), filingRow('alpha', 2, { set_at: now - 30 * DAY, baseline_prs: 30 })], + async () => { + // 29 PRs since it was set not tracked: short of its 30 then + labelDates = [...prsOf('alpha', 29, now - DAY), ...prsOf('alpha', 40, now - 60 * DAY)]; + let data = await get('/projects-data'); + assert.deepEqual([data.body.untracked, data.body.grew], [['alpha'], []]); + labelDates.push(...prsOf('alpha', 1, now - DAY)); + // a new PR reaches the count once what's kept is read again + forgetProjectCaches(); + data = await get('/projects-data'); + assert.deepEqual(data.body.grew, ['alpha']); + assert.deepEqual(data.body.grew_counts, { alpha: { since: 30, baseline: 30 } }); + const { body } = await get('/api/v1/projects'); + const record = slug => body.projects.find(p => p.slug === slug); + assert.deepEqual( + [record('alpha').initiative, record('alpha').tracked, record('alpha').grew], + ['not-tracked', false, true] + ); + assert.deepEqual([record('beta').initiative, record('beta').tracked], ['site-speed', true]); + } + ); +}); + +test('a project whose PRs are all in an untrackedRepos repo is not tracked until filed', async () => { + config.projects.untrackedRepos = ['repo-a']; + try { + labelRepos = [ + { title: 'project:alpha', repo: 'test/repo-a' }, + { title: 'project:beta', repo: 'test/repo-a' }, + { title: 'project:beta', repo: 'test/repo-b' }, + { title: 'project:misc', repo: 'test/repo-a' }, + { title: 'project:gamma', repo: 'test/repo-a' }, + ]; + await withInitiatives([initiativeRow(1, 'site-speed')], [filingRow('gamma', 1)], async () => { + const { body } = await get('/projects-data'); + // beta has a PR elsewhere; gamma is filed; misc is never a project + assert.deepEqual(body.untracked, ['alpha']); + }); + } finally { + delete config.projects.untrackedRepos; + } +}); + +test('/api/v1/decide leaves not-tracked projects out, but asks about the ones that grew', async () => { + const now = Math.floor(Date.now() / 1000); + // alpha has 2 open PRs and a merge: `new` while tracked + await withInitiatives( + [initiativeRow(2, 'not-tracked', 'untracked')], + [filingRow('alpha', 2, { set_at: now - DAY })], + async () => { + const quiet = await get('/api/v1/decide'); + assert.ok(!quiet.body.decisions.some(d => d.project === 'alpha')); + // with a plan that ended long ago it still asks nothing + roadmapRows = [roadmapRow()]; + const planned = await get('/api/v1/decide'); + assert.ok(!planned.body.decisions.some(d => d.project === 'alpha')); + roadmapRows = []; + labelDates = prsOf('alpha', 30, now); + forgetProjectCaches(); + const grown = await get('/api/v1/decide'); + const row = grown.body.decisions.find(d => d.project === 'alpha'); + assert.equal(row.name, 'Alpha work'); + assert.equal(row.open, 2); + assert.deepEqual(row.reasons, [{ kind: 'untracked_grew', since: 30, baseline: 0 }]); + assert.deepEqual(row.initiative, { id: 2, slug: 'not-tracked', name: 'not-tracked', owner: 'dana' }); + } + ); +}); + +test('/api/v1/decide asks where an unfiled project goes once an initiative exists', async () => { + roadmapRows = [roadmapRow({ end_kind: 'soft', updated_at: Math.floor(Date.now() / 1000) })]; + await withInitiatives([initiativeRow(1, 'site-speed')], [], async () => { + const { body } = await get('/api/v1/decide'); + const row = body.decisions.find(d => d.project === 'alpha'); + assert.deepEqual(row.reasons, [{ kind: 'unfiled' }]); + assert.equal(row.initiative, null); + }); + roadmapRows = []; +}); + +test('/api/v1/load leaves not-tracked projects out, past and ahead', async () => { + roadmapRows = []; + await withInitiatives( + [initiativeRow(2, 'not-tracked', 'untracked')], + [filingRow('alpha', 2, { set_at: Math.floor(Date.now() / 1000) })], + async () => { + const { body } = await get('/api/v1/load'); + assert.deepEqual({ on: body.this_week.on_plan, off: body.this_week.off_plan }, { on: 0, off: 0 }); + assert.equal(body.weeks.at(-1).off_plan, 0); + assert.ok(body.weeks.every(w => w.on_plan === 0 && w.off_plan === 0)); + // and with a plan, it isn't on plan either, as it would be tracked + roadmapRows = [roadmapRow({ start: body.this_week.week, end_kind: 'soft' })]; + const planned = await get('/api/v1/load'); + assert.equal(planned.body.this_week.on_plan, 0); + filingRows = []; + forgetProjectCaches(); + const tracked = await get('/api/v1/load'); + assert.equal(tracked.body.this_week.on_plan, 1); + roadmapRows = []; + } + ); +}); + +test('a draft marked ready counts from the day it was marked ready', () => { + const t0 = Date.UTC(2026, 8, 1) / 1000; // opened as a draft on 9/1 + const ready = Date.UTC(2026, 8, 28) / 1000; // marked ready on 9/28 + const row = { + repo: 'o/r', + number: 1, + owner: 'dana', + date: t0, + date_closed: null, + date_merged: null, + }; + const labels = [{ repo: 'o/r', number: 1, title: 'project:site-speed' }]; + const [span] = spansFromRows([{ ...row, draft: 0, ready_at: ready }], labels, 'project:'); + assert.equal(span.opened, ready); + // a window that ended while it was still a draft never counts it + const stats = windowStats([span], '2026-09-01', '2026-09-20', { + teamOf: () => null, + reviews: [], + }); + assert.equal(stats.totals.opened, 0); + // one never a draft counts from its open day + assert.equal( + spansFromRows([{ ...row, draft: 0, ready_at: null }], labels, 'project:')[0].opened, + t0 + ); +}); + +test('time spent leaves out a ready PR’s days from before it was marked ready', async t => { + const DAY = 86400; + const ready = Date.UTC(2026, 8, 10) / 1000; + const pr = { + repo: 'test/repo-a', + number: 1, + owner: 'dana', + title: 'PR', + state: 'open', + draft: 0, + ready_at: ready, + }; + t.mock.method(db, 'query', async sql => { + if (sql.includes('FROM comments t')) { + return [ + // on the draft, then once it was ready + { ...pr, login: 'erin', at: ready - 5 * DAY }, + { ...pr, login: 'erin', at: ready + DAY }, + ]; + } + return []; + }); + const settings = { prefix: 'project:', teams: {}, teamOf: () => null }; + const spent = await loadTimeSpent(settings, '2026-09-01', '2026-09-20'); + assert.equal(spent.rows.length, 1); + assert.equal(spent.weeks.length, 1); +}); + +test('time spent gives a former draft its ready day as the day it opened', async t => { + const ready = Date.UTC(2026, 8, 10) / 1000; + const pr = { + repo: 'test/repo-a', + number: 1, + owner: 'dana', + title: 'PR', + state: 'open', + draft: 0, + ready_at: ready, + }; + t.mock.method(db, 'query', async sql => { + // opened as a draft before the window, marked ready in it, nothing else + if (sql.includes('p.ready_at AS at')) return [{ ...pr, login: 'dana', at: ready }]; + return []; + }); + const settings = { prefix: 'project:', teams: {}, teamOf: () => null }; + const spent = await loadTimeSpent(settings, '2026-09-07', '2026-09-20'); + assert.deepEqual(spent.people, ['dana']); + assert.equal(spent.rows.length, 1); +}); diff --git a/test/roadmap-serial.test.js b/test/roadmap-serial.test.js new file mode 100644 index 00000000..73b8be56 --- /dev/null +++ b/test/roadmap-serial.test.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { decidesGoing } from '../controllers/roadmap.js'; + +test('a status or project edit runs with initiative closes; others do not', () => { + assert.equal(decidesGoing({ status: 'active' }), true); + assert.equal(decidesGoing({ project: 'workbench' }), true); + assert.equal(decidesGoing({ project: null }), true); + assert.equal(decidesGoing({ name: 'New name', weeks: 4 }), false); +}); diff --git a/test/roadmap.test.js b/test/roadmap.test.js index b349352d..0f8bc1f9 100644 --- a/test/roadmap.test.js +++ b/test/roadmap.test.js @@ -561,6 +561,37 @@ async function withProjects(issues, run) { } } +test('the roadmap still loads when the initiatives can’t be read, and reads them again after', async () => { + const errors = mock.method(console, 'error', () => {}); + await withProjects([issueRow(1, 'alpha')], async () => { + const inner = projectQuery; + projectQuery = sql => { + if (sql.includes('FROM `initiatives`')) throw new Error('table gone'); + return inner(sql); + }; + const made = await call('POST', '/roadmap', { name: 'Alpha', project: 'alpha' }); + assert.equal(made.status, 201); + const { status, body } = await call('GET', '/roadmap', undefined, {}); + assert.equal(status, 200); + assert.equal(body.items[0].untracked, false); + // the failure isn't kept: once the table reads again, a plan whose + // project is Not tracked says so on the next read + projectQuery = sql => { + if (sql.includes('FROM `initiatives`')) { + return [{ id: 1, slug: 'shelf', name: 'Not tracked', kind: 'untracked', priority: 1, status: 'open' }]; + } + if (sql.includes('FROM `initiative_projects`')) { + return [{ project: 'alpha', initiative_id: 1, set_by: null, set_at: 1, baseline_prs: 0 }]; + } + return inner(sql); + }; + const again = await call('GET', '/roadmap', undefined, {}); + assert.equal(again.body.items[0].untracked, true); + }); + assert.ok(errors.mock.calls.some(c => String(c.arguments[0]).includes('initiatives failed'))); + errors.mock.restore(); +}); + test('a new plan starts on its issue’s Start date, placed by its Priority', async () => { await withProjects( [ @@ -830,3 +861,47 @@ test('the project standing needs a signed-in session, like the board’s other r authManager.setupRoutes({ get: path => gated.push(path) }); assert.ok(gated.includes('/project-standing')); }); + +test('a not-tracked plan says so, owes no update, and leaves updates-owed', async () => { + const old = Date.UTC(2026, 0, 5) / 1000; + const plan = (id, project) => ({ + id, + name: `Plan ${id}`, + project, + team: null, + lead_login: 'dana', + status: 'active', + origin: null, + start: '2026-01-05', + weeks: 60, + priority: id, + notes: '', + waits_on: null, + updated_by: 'dana', + updated_at: old, + created_at: old, + }); + rows.push(plan(1, 'alpha'), plan(2, 'beta')); + await withProjects([], async () => { + const base = projectQuery; + projectQuery = sql => { + if (sql.includes('FROM `initiatives`')) { + return [{ id: 2, slug: 'not-tracked', name: 'Not tracked', kind: 'untracked', priority: 0, status: 'open' }]; + } + if (sql.includes('FROM `initiative_projects`')) { + return [{ project: 'alpha', initiative_id: 2, set_by: null, set_at: 1, baseline_prs: 0 }]; + } + return base(sql); + }; + forgetProjectCaches(); + const { body } = await call('GET', '/roadmap', undefined, {}); + const by = id => body.items.find(i => i.id === id); + assert.deepEqual([by(1).untracked, by(1).standing], [true, null]); + assert.deepEqual([by(2).untracked, by(2).standing], [false, 'owed']); + const owed = await call('GET', '/updates-owed', undefined, {}); + assert.deepEqual( + owed.body.leads.flatMap(l => l.plans.map(p => p.id)), + [2] + ); + }); +}); diff --git a/test/schema-check.test.js b/test/schema-check.test.js index 453bd929..6bbd438f 100644 --- a/test/schema-check.test.js +++ b/test/schema-check.test.js @@ -128,3 +128,21 @@ test('names the roadmap and settings tables a database from before them lacks, i ]); mock.restoreAll(); }); + +test('names the initiatives file until both its tables are in', async () => { + mock.method(db, 'query', async () => all.filter(r => r.t !== 'initiative_projects')); + assert.deepEqual(await missingMigrations(), ['0039-initiatives--add-tables.sql']); + mock.restoreAll(); +}); + +test('names the ready_at migration until pulls has the column', async () => { + mock.method(db, 'query', async () => all.filter(r => !(r.t === 'pulls' && r.c === 'ready_at'))); + assert.deepEqual(await missingMigrations(), ['0040-pulls--add-ready-at.sql']); + mock.restoreAll(); +}); + +test('names the ready_at index until pulls has it', async () => { + mock.method(db, 'query', async () => all.filter(r => r.c !== 'key:pulls_ready_at')); + assert.deepEqual(await missingMigrations(), ['0041-pulls--add-ready-at-index.sql']); + mock.restoreAll(); +}); diff --git a/test/serially.test.js b/test/serially.test.js new file mode 100644 index 00000000..ae11c2b5 --- /dev/null +++ b/test/serially.test.js @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { serially } from '../lib/initiatives.js'; + +test('writes run one at a time, in order', async () => { + const seen = []; + const slow = () => new Promise(resolve => setTimeout(() => resolve(seen.push('a')), 20)); + await Promise.all([serially(slow), serially(async () => seen.push('b'))]); + assert.deepEqual(seen, ['a', 'b']); +}); + +test('a failed write fails only its own request', async () => { + await assert.rejects( + serially(async () => { + throw new Error('no'); + }), + /no/ + ); + assert.equal(await serially(async () => 'next'), 'next'); +}); + +test('a hung write fails after the limit and the next one still runs', async () => { + const hung = serially(() => new Promise(() => {}), 30); + const next = serially(async () => 'next', 30); + await assert.rejects(hung, /timed out/); + assert.equal(await next, 'next'); +}); diff --git a/test/work.test.js b/test/work.test.js index 976b4c6b..d6cf2ec8 100644 --- a/test/work.test.js +++ b/test/work.test.js @@ -879,3 +879,15 @@ test('a search that fails for another reason is the caller’s, asked once and n await assert.rejects(() => searchIssues(settings, 'stickers limited'), /rate limit/); assert.equal(calls, first * 2); }); + +test('a PR opened as a draft before a plan’s end and marked ready after it counts as after the end', async () => { + fresh(); + // opened Aug 10 as a draft, before the plan's end (Aug 30), ready Sep 15 + LABELED_PULLS.push(pullRow(220, 'dana', '2026-08-10', { draft: 0, ready_at: at('2026-09-15') })); + try { + const work = await loadWork(settings); + assert.ok(work.plans[0].afterEnd.some(p => p.number === 220)); + } finally { + LABELED_PULLS.pop(); + } +});